feat: task lane persistence + sidebar cleanup #121
@@ -48,6 +48,7 @@ from app.schemas.ai_session import (
|
||||
ChatSessionCreateResponse,
|
||||
ChatMessageRequest,
|
||||
ChatMessageResponse,
|
||||
SaveTaskLaneRequest,
|
||||
)
|
||||
from app.services import flowpilot_engine
|
||||
from app.services import unified_chat_service
|
||||
@@ -497,6 +498,33 @@ async def pause_session(
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── Save Task Lane ──
|
||||
|
||||
@router.put("/{session_id}/task-lane", status_code=204)
|
||||
@limiter.limit("30/minute")
|
||||
async def save_task_lane(
|
||||
request: Request,
|
||||
session_id: UUID,
|
||||
body: SaveTaskLaneRequest,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
):
|
||||
"""Save the current task lane state including user's in-progress responses."""
|
||||
session = await db.get(AISession, session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
if session.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not your session")
|
||||
|
||||
session.pending_task_lane = {
|
||||
"questions": [q.model_dump() for q in body.questions],
|
||||
"actions": [a.model_dump() for a in body.actions],
|
||||
"responses": body.responses,
|
||||
}
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ── Resume ──
|
||||
|
||||
@router.post("/{session_id}/resume", status_code=204)
|
||||
|
||||
@@ -287,6 +287,16 @@ class ChatMessageResponse(BaseModel):
|
||||
questions: list[QuestionItem] | None = None
|
||||
|
||||
|
||||
class SaveTaskLaneRequest(BaseModel):
|
||||
"""Save the full task lane state (AI items + user responses)."""
|
||||
questions: list[QuestionItem] = []
|
||||
actions: list[ActionItem] = []
|
||||
responses: list[dict[str, Any]] = Field(
|
||||
default_factory=list,
|
||||
description="User's in-progress task responses with state/value",
|
||||
)
|
||||
|
||||
|
||||
class AISessionSearchResult(BaseModel):
|
||||
"""Lightweight session result for Command Palette / autocomplete."""
|
||||
id: UUID
|
||||
|
||||
@@ -110,6 +110,14 @@ export const aiSessionsApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
async saveTaskLane(sessionId: string, data: {
|
||||
questions: Array<{ text: string; context?: string }>;
|
||||
actions: Array<{ label: string; command?: string | null; description?: string }>;
|
||||
responses: Array<Record<string, unknown>>;
|
||||
}): Promise<void> {
|
||||
await apiClient.put(`/ai-sessions/${sessionId}/task-lane`, data)
|
||||
},
|
||||
|
||||
async pauseSession(sessionId: string): Promise<void> {
|
||||
await apiClient.post(`/ai-sessions/${sessionId}/pause`)
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { aiSessionsApi } from '@/api/aiSessions'
|
||||
import type { ActionItem, QuestionItem } from '@/types/ai-session'
|
||||
|
||||
// ── Types ──
|
||||
@@ -129,10 +130,22 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
}
|
||||
}, [handleMouseMove, handleMouseUp])
|
||||
|
||||
// Save task state to sessionStorage on every change
|
||||
// Save task state to sessionStorage on every change + debounce to backend
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
useEffect(() => {
|
||||
if (sessionId) saveTaskState(sessionId, tasks)
|
||||
}, [sessionId, tasks])
|
||||
if (!sessionId) return
|
||||
saveTaskState(sessionId, tasks)
|
||||
// Debounce save to backend (2s after last change)
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
aiSessionsApi.saveTaskLane(sessionId, {
|
||||
questions: questions.map(q => ({ text: q.text, context: q.context })),
|
||||
actions: actions.map(a => ({ label: a.label, command: a.command, description: a.description })),
|
||||
responses: tasks as unknown as Array<Record<string, unknown>>,
|
||||
}).catch(() => { /* silent — best-effort save */ })
|
||||
}, 2000)
|
||||
return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current) }
|
||||
}, [sessionId, tasks]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Reset when new tasks come in from AI response
|
||||
useEffect(() => {
|
||||
|
||||
@@ -228,6 +228,13 @@ export default function AssistantChatPage() {
|
||||
setActiveQuestions(q)
|
||||
setActiveActions(a)
|
||||
setShowTaskLane(true)
|
||||
// Pre-load user's saved responses into sessionStorage so TaskLane restores them
|
||||
const responses = (detail.pending_task_lane as Record<string, unknown>).responses as unknown[] | undefined
|
||||
if (responses && responses.length > 0) {
|
||||
try {
|
||||
sessionStorage.setItem(`rf-tasklane-state:${chatId}`, JSON.stringify(responses))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
||||
Reference in New Issue
Block a user