feat: persist task lane state across reloads and session switches
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) <noreply@anthropic.com>
This commit is contained in:
@@ -33,22 +33,51 @@ type TaskResponse = QuestionResponse | ActionResponse
|
|||||||
interface TaskLaneProps {
|
interface TaskLaneProps {
|
||||||
questions: QuestionItem[]
|
questions: QuestionItem[]
|
||||||
actions: ActionItem[]
|
actions: ActionItem[]
|
||||||
|
sessionId?: string | null
|
||||||
onSubmit: (responses: TaskResponse[]) => void
|
onSubmit: (responses: TaskResponse[]) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
loading?: boolean
|
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 ──
|
// ── Component ──
|
||||||
|
|
||||||
export function TaskLane({ questions, actions, onSubmit, onClose, loading }: TaskLaneProps) {
|
export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loading }: TaskLaneProps) {
|
||||||
const [tasks, setTasks] = useState<TaskResponse[]>(() => [
|
const [tasks, setTasks] = useState<TaskResponse[]>(() => {
|
||||||
...questions.map((q): QuestionResponse => ({
|
// Try to restore saved state for this session (preserves user's in-progress answers)
|
||||||
type: 'question', text: q.text, context: q.context, state: 'pending', value: '',
|
if (sessionId) {
|
||||||
})),
|
const saved = loadTaskState(sessionId)
|
||||||
...actions.map((a): ActionResponse => ({
|
if (saved && saved.length > 0) return saved
|
||||||
type: 'action', label: a.label, command: a.command, description: a.description, state: 'pending', value: '',
|
}
|
||||||
})),
|
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 [submitting, setSubmitting] = useState(false)
|
||||||
const [showRunAll, setShowRunAll] = useState(false)
|
const [showRunAll, setShowRunAll] = useState(false)
|
||||||
const [showPreview, setShowPreview] = useState(false)
|
const [showPreview, setShowPreview] = useState(false)
|
||||||
@@ -100,6 +129,11 @@ export function TaskLane({ questions, actions, onSubmit, onClose, loading }: Tas
|
|||||||
}
|
}
|
||||||
}, [handleMouseMove, handleMouseUp])
|
}, [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
|
// Reset when new tasks come in from AI response
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: syncs derived state from prop changes
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: syncs derived state from prop changes
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { analytics } from '@/lib/analytics'
|
|||||||
import { toast } from '@/lib/toast'
|
import { toast } from '@/lib/toast'
|
||||||
import { ChatSidebar, ChatSidebarCollapsedBar } from '@/components/assistant/ChatSidebar'
|
import { ChatSidebar, ChatSidebarCollapsedBar } from '@/components/assistant/ChatSidebar'
|
||||||
import { ChatMessage } from '@/components/assistant/ChatMessage'
|
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 { ConcludeSessionModal } from '@/components/assistant/ConcludeSessionModal'
|
||||||
import type { ChatListItem, ConclusionOutcome } from '@/types/assistant-chat'
|
import type { ChatListItem, ConclusionOutcome } from '@/types/assistant-chat'
|
||||||
import type { SuggestedFlow } from '@/types/copilot'
|
import type { SuggestedFlow } from '@/types/copilot'
|
||||||
@@ -42,9 +42,27 @@ export default function AssistantChatPage() {
|
|||||||
const [logContent, setLogContent] = useState('')
|
const [logContent, setLogContent] = useState('')
|
||||||
const [pendingUploads, setPendingUploads] = useState<PendingUpload[]>([])
|
const [pendingUploads, setPendingUploads] = useState<PendingUpload[]>([])
|
||||||
const [isDragOver, setIsDragOver] = useState(false)
|
const [isDragOver, setIsDragOver] = useState(false)
|
||||||
const [activeQuestions, setActiveQuestions] = useState<QuestionItem[]>([])
|
const [activeQuestions, setActiveQuestions] = useState<QuestionItem[]>(() => {
|
||||||
const [activeActions, setActiveActions] = useState<ActionItem[]>([])
|
try {
|
||||||
const [showTaskLane, setShowTaskLane] = useState(false)
|
const saved = sessionStorage.getItem('rf-tasklane-meta')
|
||||||
|
if (saved) { const d = JSON.parse(saved); return d.questions || [] }
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
const [activeActions, setActiveActions] = useState<ActionItem[]>(() => {
|
||||||
|
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(() =>
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(() =>
|
||||||
localStorage.getItem('rf-chat-sidebar-collapsed') === 'true'
|
localStorage.getItem('rf-chat-sidebar-collapsed') === 'true'
|
||||||
)
|
)
|
||||||
@@ -137,6 +155,18 @@ export default function AssistantChatPage() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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
|
// Auto-scroll
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
@@ -737,8 +767,12 @@ export default function AssistantChatPage() {
|
|||||||
<TaskLane
|
<TaskLane
|
||||||
questions={activeQuestions}
|
questions={activeQuestions}
|
||||||
actions={activeActions}
|
actions={activeActions}
|
||||||
|
sessionId={activeChatId}
|
||||||
onSubmit={handleTaskSubmit}
|
onSubmit={handleTaskSubmit}
|
||||||
onClose={() => setShowTaskLane(false)}
|
onClose={() => {
|
||||||
|
setShowTaskLane(false)
|
||||||
|
if (activeChatId) clearTaskState(activeChatId)
|
||||||
|
}}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user