diff --git a/backend/app/api/endpoints/ai_sessions.py b/backend/app/api/endpoints/ai_sessions.py index 722f54b7..b5420787 100644 --- a/backend/app/api/endpoints/ai_sessions.py +++ b/backend/app/api/endpoints/ai_sessions.py @@ -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) diff --git a/backend/app/schemas/ai_session.py b/backend/app/schemas/ai_session.py index 0039653e..23bfd652 100644 --- a/backend/app/schemas/ai_session.py +++ b/backend/app/schemas/ai_session.py @@ -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 diff --git a/frontend/src/api/aiSessions.ts b/frontend/src/api/aiSessions.ts index 39259c1b..08aae5bc 100644 --- a/frontend/src/api/aiSessions.ts +++ b/frontend/src/api/aiSessions.ts @@ -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>; + }): Promise { + await apiClient.put(`/ai-sessions/${sessionId}/task-lane`, data) + }, + async pauseSession(sessionId: string): Promise { await apiClient.post(`/ai-sessions/${sessionId}/pause`) }, diff --git a/frontend/src/components/assistant/TaskLane.tsx b/frontend/src/components/assistant/TaskLane.tsx index ddd60d79..791eba74 100644 --- a/frontend/src/components/assistant/TaskLane.tsx +++ b/frontend/src/components/assistant/TaskLane.tsx @@ -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 | 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>, + }).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(() => { diff --git a/frontend/src/pages/AssistantChatPage.tsx b/frontend/src/pages/AssistantChatPage.tsx index 45ce031b..3a95f680 100644 --- a/frontend/src/pages/AssistantChatPage.tsx +++ b/frontend/src/pages/AssistantChatPage.tsx @@ -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).responses as unknown[] | undefined + if (responses && responses.length > 0) { + try { + sessionStorage.setItem(`rf-tasklane-state:${chatId}`, JSON.stringify(responses)) + } catch { /* ignore */ } + } } } } catch {