feat: task lane persistence + sidebar cleanup #121
@@ -48,6 +48,7 @@ from app.schemas.ai_session import (
|
|||||||
ChatSessionCreateResponse,
|
ChatSessionCreateResponse,
|
||||||
ChatMessageRequest,
|
ChatMessageRequest,
|
||||||
ChatMessageResponse,
|
ChatMessageResponse,
|
||||||
|
SaveTaskLaneRequest,
|
||||||
)
|
)
|
||||||
from app.services import flowpilot_engine
|
from app.services import flowpilot_engine
|
||||||
from app.services import unified_chat_service
|
from app.services import unified_chat_service
|
||||||
@@ -497,6 +498,33 @@ async def pause_session(
|
|||||||
await db.commit()
|
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 ──
|
# ── Resume ──
|
||||||
|
|
||||||
@router.post("/{session_id}/resume", status_code=204)
|
@router.post("/{session_id}/resume", status_code=204)
|
||||||
|
|||||||
@@ -287,6 +287,16 @@ class ChatMessageResponse(BaseModel):
|
|||||||
questions: list[QuestionItem] | None = None
|
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):
|
class AISessionSearchResult(BaseModel):
|
||||||
"""Lightweight session result for Command Palette / autocomplete."""
|
"""Lightweight session result for Command Palette / autocomplete."""
|
||||||
id: UUID
|
id: UUID
|
||||||
|
|||||||
@@ -110,6 +110,14 @@ export const aiSessionsApi = {
|
|||||||
return response.data
|
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> {
|
async pauseSession(sessionId: string): Promise<void> {
|
||||||
await apiClient.post(`/ai-sessions/${sessionId}/pause`)
|
await apiClient.post(`/ai-sessions/${sessionId}/pause`)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { toast } from '@/lib/toast'
|
import { toast } from '@/lib/toast'
|
||||||
|
import { aiSessionsApi } from '@/api/aiSessions'
|
||||||
import type { ActionItem, QuestionItem } from '@/types/ai-session'
|
import type { ActionItem, QuestionItem } from '@/types/ai-session'
|
||||||
|
|
||||||
// ── Types ──
|
// ── Types ──
|
||||||
@@ -129,10 +130,22 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
|||||||
}
|
}
|
||||||
}, [handleMouseMove, handleMouseUp])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (sessionId) saveTaskState(sessionId, tasks)
|
if (!sessionId) return
|
||||||
}, [sessionId, tasks])
|
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
|
// Reset when new tasks come in from AI response
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -228,6 +228,13 @@ export default function AssistantChatPage() {
|
|||||||
setActiveQuestions(q)
|
setActiveQuestions(q)
|
||||||
setActiveActions(a)
|
setActiveActions(a)
|
||||||
setShowTaskLane(true)
|
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 {
|
} catch {
|
||||||
|
|||||||
Reference in New Issue
Block a user