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) <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-03-28 19:11:13 +00:00
parent 977e5a8ddb
commit 80af408f2d
5 changed files with 69 additions and 3 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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`)
},

View File

@@ -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(() => {

View File

@@ -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 {