From e9f96474e0814a844ea6d4805f129cc63601538b Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sat, 28 Mar 2026 04:21:42 +0000 Subject: [PATCH] feat: persist task lane state across reloads and session switches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TaskLane now saves user's in-progress answers (typed text, checked items) to sessionStorage keyed by session ID. On reload or session switch, the full task lane state restores — including partial work. - TaskLane: saves tasks array to sessionStorage on every change, restores from sessionStorage on mount - AssistantChatPage: saves task lane metadata (visibility, questions, actions, chatId) to sessionStorage, restores on mount - Closing the task lane clears its sessionStorage entry Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/assistant/TaskLane.tsx | 52 +++++++++++++++---- frontend/src/pages/AssistantChatPage.tsx | 44 ++++++++++++++-- 2 files changed, 82 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/assistant/TaskLane.tsx b/frontend/src/components/assistant/TaskLane.tsx index e9972ca6..ddd60d79 100644 --- a/frontend/src/components/assistant/TaskLane.tsx +++ b/frontend/src/components/assistant/TaskLane.tsx @@ -33,22 +33,51 @@ type TaskResponse = QuestionResponse | ActionResponse interface TaskLaneProps { questions: QuestionItem[] actions: ActionItem[] + sessionId?: string | null onSubmit: (responses: TaskResponse[]) => void onClose: () => void loading?: boolean } +// ── Storage helpers ── + +const TASK_LANE_STORAGE_KEY = 'rf-tasklane-state' + +function saveTaskState(sessionId: string, tasks: TaskResponse[]) { + try { + sessionStorage.setItem(`${TASK_LANE_STORAGE_KEY}:${sessionId}`, JSON.stringify(tasks)) + } catch { /* quota exceeded — ignore */ } +} + +function loadTaskState(sessionId: string): TaskResponse[] | null { + try { + const stored = sessionStorage.getItem(`${TASK_LANE_STORAGE_KEY}:${sessionId}`) + return stored ? JSON.parse(stored) : null + } catch { return null } +} + +export function clearTaskState(sessionId: string) { + try { sessionStorage.removeItem(`${TASK_LANE_STORAGE_KEY}:${sessionId}`) } catch { /* ignore */ } +} + // ── Component ── -export function TaskLane({ questions, actions, onSubmit, onClose, loading }: TaskLaneProps) { - const [tasks, setTasks] = useState(() => [ - ...questions.map((q): QuestionResponse => ({ - type: 'question', text: q.text, context: q.context, state: 'pending', value: '', - })), - ...actions.map((a): ActionResponse => ({ - type: 'action', label: a.label, command: a.command, description: a.description, state: 'pending', value: '', - })), - ]) +export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loading }: TaskLaneProps) { + const [tasks, setTasks] = useState(() => { + // Try to restore saved state for this session (preserves user's in-progress answers) + if (sessionId) { + const saved = loadTaskState(sessionId) + if (saved && saved.length > 0) return saved + } + return [ + ...questions.map((q): QuestionResponse => ({ + type: 'question', text: q.text, context: q.context, state: 'pending', value: '', + })), + ...actions.map((a): ActionResponse => ({ + type: 'action', label: a.label, command: a.command, description: a.description, state: 'pending', value: '', + })), + ] + }) const [submitting, setSubmitting] = useState(false) const [showRunAll, setShowRunAll] = useState(false) const [showPreview, setShowPreview] = useState(false) @@ -100,6 +129,11 @@ export function TaskLane({ questions, actions, onSubmit, onClose, loading }: Tas } }, [handleMouseMove, handleMouseUp]) + // Save task state to sessionStorage on every change + useEffect(() => { + if (sessionId) saveTaskState(sessionId, tasks) + }, [sessionId, tasks]) + // Reset when new tasks come in from AI response useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: syncs derived state from prop changes diff --git a/frontend/src/pages/AssistantChatPage.tsx b/frontend/src/pages/AssistantChatPage.tsx index 61b665d0..18b7d218 100644 --- a/frontend/src/pages/AssistantChatPage.tsx +++ b/frontend/src/pages/AssistantChatPage.tsx @@ -12,7 +12,7 @@ import { analytics } from '@/lib/analytics' import { toast } from '@/lib/toast' import { ChatSidebar, ChatSidebarCollapsedBar } from '@/components/assistant/ChatSidebar' import { ChatMessage } from '@/components/assistant/ChatMessage' -import { TaskLane } from '@/components/assistant/TaskLane' +import { TaskLane, clearTaskState } from '@/components/assistant/TaskLane' import { ConcludeSessionModal } from '@/components/assistant/ConcludeSessionModal' import type { ChatListItem, ConclusionOutcome } from '@/types/assistant-chat' import type { SuggestedFlow } from '@/types/copilot' @@ -42,9 +42,27 @@ export default function AssistantChatPage() { const [logContent, setLogContent] = useState('') const [pendingUploads, setPendingUploads] = useState([]) const [isDragOver, setIsDragOver] = useState(false) - const [activeQuestions, setActiveQuestions] = useState([]) - const [activeActions, setActiveActions] = useState([]) - const [showTaskLane, setShowTaskLane] = useState(false) + const [activeQuestions, setActiveQuestions] = useState(() => { + try { + const saved = sessionStorage.getItem('rf-tasklane-meta') + if (saved) { const d = JSON.parse(saved); return d.questions || [] } + } catch { /* ignore */ } + return [] + }) + const [activeActions, setActiveActions] = useState(() => { + try { + const saved = sessionStorage.getItem('rf-tasklane-meta') + if (saved) { const d = JSON.parse(saved); return d.actions || [] } + } catch { /* ignore */ } + return [] + }) + const [showTaskLane, setShowTaskLane] = useState(() => { + try { + const saved = sessionStorage.getItem('rf-tasklane-meta') + if (saved) { const d = JSON.parse(saved); return d.show === true && d.chatId === (urlSessionId || null) } + } catch { /* ignore */ } + return false + }) const [sidebarCollapsed, setSidebarCollapsed] = useState(() => localStorage.getItem('rf-chat-sidebar-collapsed') === 'true' ) @@ -137,6 +155,18 @@ export default function AssistantChatPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []) + // Persist task lane metadata to sessionStorage + useEffect(() => { + try { + sessionStorage.setItem('rf-tasklane-meta', JSON.stringify({ + show: showTaskLane, + chatId: activeChatId, + questions: activeQuestions, + actions: activeActions, + })) + } catch { /* ignore */ } + }, [showTaskLane, activeChatId, activeQuestions, activeActions]) + // Auto-scroll useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) @@ -737,8 +767,12 @@ export default function AssistantChatPage() { setShowTaskLane(false)} + onClose={() => { + setShowTaskLane(false) + if (activeChatId) clearTaskState(activeChatId) + }} loading={loading} /> )}