fix: resolve task lane stale state, partial submit, and closure bugs

- Import and call clearTaskState before updating questions/actions in
  handleSend and handleTaskSubmit so new AI tasks always replace stale
  sessionStorage cache instead of being overridden by it
- Include pending (not yet completed) tasks in the AI message on partial
  submit so the AI knows which tasks were left unanswered
- Fix stale closure in TaskLane saveTaskLane useEffect — use refs for
  questions/actions so the debounced backend save always uses current values
- Add responses field to pending_task_lane TypeScript type, removing the
  unsafe double-cast in selectChat
- Instruct the AI to re-surface incomplete tasks unless ≥75% confident
  the information is no longer needed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-04-06 16:53:48 +00:00
parent 58fe3574bf
commit 8bd395a0c7
4 changed files with 25 additions and 7 deletions

View File

@@ -77,6 +77,9 @@ scope narrows it to this endpoint.
- JSON array of objects with `text` (required) and `context` (optional, 1 sentence) - JSON array of objects with `text` (required) and `context` (optional, 1 sentence)
- 1-3 questions per response - 1-3 questions per response
- Do NOT ask questions inline in your prose. ALL questions go in the marker. - Do NOT ask questions inline in your prose. ALL questions go in the marker.
- If the engineer's message contains tasks marked `_(not yet completed)_`, re-include \
those as questions/actions in your next response UNLESS you are ≥75% confident the \
information is no longer needed to resolve the issue. Default to keeping them.
**[ACTIONS] marker format:** **[ACTIONS] marker format:**
- JSON array of objects with `label` (required), `command` (optional), `description` (required) - JSON array of objects with `label` (required), `command` (optional), `description` (required)
@@ -155,6 +158,8 @@ To create a fork, append this marker AFTER your [QUESTIONS]/[ACTIONS] markers:
Every single response MUST contain [QUESTIONS] and/or [ACTIONS] markers with valid JSON. \ Every single response MUST contain [QUESTIONS] and/or [ACTIONS] markers with valid JSON. \
No exceptions. Not even when forking. A response without at least one of these markers \ No exceptions. Not even when forking. A response without at least one of these markers \
will crash the UI. If you are unsure, include both. The markers are REQUIRED output, not optional. will crash the UI. If you are unsure, include both. The markers are REQUIRED output, not optional.
If any tasks in the engineer's message are marked `_(not yet completed)_`, re-include them \
in your markers unless you are ≥75% confident that information is no longer relevant.
""" """

View File

@@ -130,6 +130,14 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
} }
}, [handleMouseMove, handleMouseUp]) }, [handleMouseMove, handleMouseUp])
// Refs so the debounced save always uses the latest questions/actions/tasks
const questionsRef = useRef(questions)
const actionsRef = useRef(actions)
const tasksRef = useRef(tasks)
useEffect(() => { questionsRef.current = questions }, [questions])
useEffect(() => { actionsRef.current = actions }, [actions])
useEffect(() => { tasksRef.current = tasks }, [tasks])
// Save task state to sessionStorage on every change + debounce to backend // Save task state to sessionStorage on every change + debounce to backend
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => { useEffect(() => {
@@ -139,9 +147,9 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
if (saveTimerRef.current) clearTimeout(saveTimerRef.current) if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
saveTimerRef.current = setTimeout(() => { saveTimerRef.current = setTimeout(() => {
aiSessionsApi.saveTaskLane(sessionId, { aiSessionsApi.saveTaskLane(sessionId, {
questions: questions.map(q => ({ text: q.text, context: q.context })), questions: questionsRef.current.map(q => ({ text: q.text, context: q.context })),
actions: actions.map(a => ({ label: a.label, command: a.command, description: a.description })), actions: actionsRef.current.map(a => ({ label: a.label, command: a.command, description: a.description })),
responses: tasks as unknown as Array<Record<string, unknown>>, responses: tasksRef.current as unknown as Array<Record<string, unknown>>,
}).catch(() => { /* silent — best-effort save */ }) }).catch(() => { /* silent — best-effort save */ })
}, 2000) }, 2000)
return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current) } return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current) }

View File

@@ -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 { StatusUpdateModal } from '@/components/flowpilot/StatusUpdateModal' import { StatusUpdateModal } from '@/components/flowpilot/StatusUpdateModal'
import type { ChatListItem, ConclusionOutcome } from '@/types/assistant-chat' import type { ChatListItem, ConclusionOutcome } from '@/types/assistant-chat'
@@ -242,7 +242,7 @@ export default function AssistantChatPage() {
if (q.length > 0 || a.length > 0) { if (q.length > 0 || a.length > 0) {
// Pre-load user's saved responses into sessionStorage BEFORE setting props // Pre-load user's saved responses into sessionStorage BEFORE setting props
// so TaskLane can restore them on mount/prop-change // so TaskLane can restore them on mount/prop-change
const responses = (detail.pending_task_lane as Record<string, unknown>).responses as unknown[] | undefined const responses = detail.pending_task_lane.responses
if (responses && responses.length > 0) { if (responses && responses.length > 0) {
try { try {
sessionStorage.setItem(`rf-tasklane-state:${chatId}`, JSON.stringify(responses)) sessionStorage.setItem(`rf-tasklane-state:${chatId}`, JSON.stringify(responses))
@@ -340,6 +340,7 @@ export default function AssistantChatPage() {
const hasQuestions = response.questions && response.questions.length > 0 const hasQuestions = response.questions && response.questions.length > 0
const hasActions = response.actions && response.actions.length > 0 const hasActions = response.actions && response.actions.length > 0
if (hasQuestions || hasActions) { if (hasQuestions || hasActions) {
if (activeChatId) clearTaskState(activeChatId)
setActiveQuestions(response.questions || []) setActiveQuestions(response.questions || [])
setActiveActions(response.actions || []) setActiveActions(response.actions || [])
setShowTaskLane(true) setShowTaskLane(true)
@@ -358,7 +359,8 @@ export default function AssistantChatPage() {
const handleTaskSubmit = async (responses: Array<{ type: string; state: string; value: string; text?: string; label?: string }>) => { const handleTaskSubmit = async (responses: Array<{ type: string; state: string; value: string; text?: string; label?: string }>) => {
if (!activeChatId || loading) return if (!activeChatId || loading) return
// Format task responses into a structured message for the AI // Format task responses into a structured message for the AI.
// Pending tasks are included so the AI knows they weren't completed yet.
const parts: string[] = [] const parts: string[] = []
for (const r of responses) { for (const r of responses) {
const name = r.type === 'question' ? `Q: ${r.text}` : r.label || 'Check' const name = r.type === 'question' ? `Q: ${r.text}` : r.label || 'Check'
@@ -366,6 +368,8 @@ export default function AssistantChatPage() {
parts.push(`**${name}:**\n\`\`\`\n${r.value.trim()}\n\`\`\``) parts.push(`**${name}:**\n\`\`\`\n${r.value.trim()}\n\`\`\``)
} else if (r.state === 'skipped') { } else if (r.state === 'skipped') {
parts.push(`**${name}:** _(skipped)_`) parts.push(`**${name}:** _(skipped)_`)
} else {
parts.push(`**${name}:** _(not yet completed)_`)
} }
} }
const userMessage = parts.join('\n\n') const userMessage = parts.join('\n\n')
@@ -385,6 +389,7 @@ export default function AssistantChatPage() {
// Update task lane based on AI response // Update task lane based on AI response
const hasQuestions = response.questions && response.questions.length > 0 const hasQuestions = response.questions && response.questions.length > 0
const hasActions = response.actions && response.actions.length > 0 const hasActions = response.actions && response.actions.length > 0
if (activeChatId) clearTaskState(activeChatId)
if (hasQuestions || hasActions) { if (hasQuestions || hasActions) {
setActiveQuestions(response.questions || []) setActiveQuestions(response.questions || [])
setActiveActions(response.actions || []) setActiveActions(response.actions || [])

View File

@@ -196,7 +196,7 @@ export interface AISessionDetail extends AISessionSummary {
ticket_data: Record<string, unknown> | null ticket_data: Record<string, unknown> | null
steps: AISessionStepResponse[] steps: AISessionStepResponse[]
conversation_messages: Array<{ role: string; content: string }> conversation_messages: Array<{ role: string; content: string }>
pending_task_lane: { questions: QuestionItem[]; actions: ActionItem[] } | null pending_task_lane: { questions: QuestionItem[]; actions: ActionItem[]; responses?: Array<Record<string, unknown>> } | null
is_branching: boolean is_branching: boolean
active_branch_id: string | null active_branch_id: string | null
} }