From 80af408f2d51a30d416cfc391659d08248c7af8b Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 28 Mar 2026 19:11:13 +0000 Subject: [PATCH] feat: persist task lane user responses to backend Add PUT /ai-sessions/{id}/task-lane endpoint that saves the full task lane state (AI questions/actions + user's in-progress responses) to the pending_task_lane JSONB column. TaskLane debounce-saves to the backend every 2s after changes. On session load, user responses are restored from the backend into sessionStorage so TaskLane picks them up on mount. Users can now close the browser, come back later, and find their task lane exactly where they left it. Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/api/endpoints/ai_sessions.py | 28 +++++++++++++++++++ backend/app/schemas/ai_session.py | 10 +++++++ frontend/src/api/aiSessions.ts | 8 ++++++ .../src/components/assistant/TaskLane.tsx | 19 +++++++++++-- frontend/src/pages/AssistantChatPage.tsx | 7 +++++ 5 files changed, 69 insertions(+), 3 deletions(-) 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 {