Skip to content

Commit 5c3f413

Browse files
alari76claude
andcommitted
fix: make todo panel updates reliable and close it when work stops
TaskUpdate calls with ids the server didn't know (taskSeq reset on TodoWrite, empty task map after process restart) were silently dropped, leaving the chat todo panel stale and preventing it from ever reaching all-done. Server now upserts unknown ids, keeps task ids monotonic, honors explicit ids, and seeds restarted processes from the last todo_update in history. Frontend panel is now turn-aware: dismisses 3s after completion once the turn ends (10s while processing), collapses to the pill when a turn ends with unfinished tasks, and detects replaced lists by id signature so a dismissed panel re-shows for new task lists. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 577c695 commit 5c3f413

10 files changed

Lines changed: 307 additions & 29 deletions

server/claude-process.test.ts

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -133,14 +133,16 @@ describe('handleTaskTool', () => {
133133
expect(cp.tasks.size).toBe(1)
134134
})
135135

136-
it('clears existing tasks and resets ID sequence', () => {
136+
it('clears existing tasks but keeps the ID sequence monotonic', () => {
137137
cp.handleTaskTool('TaskCreate', { subject: 'Old task' })
138138
expect(cp.tasks.size).toBe(1)
139139
cp.handleTaskTool('TodoWrite', {
140140
todos: [{ content: 'New', status: 'in_progress' }],
141141
})
142142
expect(cp.tasks.size).toBe(1)
143-
expect(cp.tasks.get('1')!.subject).toBe('New')
143+
// New list gets a fresh id (not reused '1') so the frontend can detect
144+
// a replaced list and later TaskCreate/TaskUpdate ids never collide.
145+
expect(cp.tasks.get('2')!.subject).toBe('New')
144146
})
145147

146148
it('returns false for non-array todos', () => {
@@ -182,8 +184,18 @@ describe('handleTaskTool', () => {
182184
expect(cp.tasks.has('1')).toBe(false)
183185
})
184186

185-
it('returns false for nonexistent task', () => {
186-
expect(cp.handleTaskTool('TaskUpdate', { taskId: '999', status: 'completed' })).toBe(false)
187+
it('upserts a task for an unknown id instead of dropping the update', () => {
188+
expect(cp.handleTaskTool('TaskUpdate', { taskId: '999', status: 'completed' })).toBe(true)
189+
expect(cp.tasks.get('999')!.status).toBe('completed')
190+
expect(cp.tasks.get('999')!.subject).toBe('Task 999')
191+
})
192+
193+
it('returns false for unknown id with deleted status', () => {
194+
expect(cp.handleTaskTool('TaskUpdate', { taskId: '999', status: 'deleted' })).toBe(false)
195+
})
196+
197+
it('returns false for missing taskId', () => {
198+
expect(cp.handleTaskTool('TaskUpdate', { status: 'completed' })).toBe(false)
187199
})
188200

189201
it('updates subject and activeForm', () => {
@@ -196,6 +208,23 @@ describe('handleTaskTool', () => {
196208
it('returns false for unknown tool', () => {
197209
expect(cp.handleTaskTool('UnknownTool', {})).toBe(false)
198210
})
211+
212+
describe('seedTasks', () => {
213+
it('restores tasks and continues the id sequence past seeded ids', () => {
214+
cp.seedTasks([
215+
{ id: '3', subject: 'Restored A', status: 'completed' },
216+
{ id: '4', subject: 'Restored B', status: 'in_progress' },
217+
])
218+
expect(cp.tasks.size).toBe(2)
219+
// TaskUpdate against a pre-restart id now resolves to the real task
220+
cp.handleTaskTool('TaskUpdate', { taskId: '4', status: 'completed' })
221+
expect(cp.tasks.get('4')!.status).toBe('completed')
222+
expect(cp.tasks.get('4')!.subject).toBe('Restored B')
223+
// New TaskCreate ids do not collide with seeded ids
224+
cp.handleTaskTool('TaskCreate', { subject: 'Next' })
225+
expect(cp.tasks.get('5')!.subject).toBe('Next')
226+
})
227+
})
199228
})
200229

201230
describe('handleStreamEvent (via handleLine)', () => {

server/claude-process.ts

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -447,7 +447,9 @@ export class ClaudeProcess extends EventEmitter<ClaudeProcessEvents> implements
447447
if (isTask && TOOL_DEBUG) console.log('[task-debug] tool:', this.tool.name, 'input:', JSON.stringify(parsed).slice(0, 200))
448448
if (this.handleTaskTool(this.tool.name!, parsed)) {
449449
if (TOOL_DEBUG) console.log('[task-debug] emitting todo_update, tasks:', this.tasks.size)
450-
this.emit('todo_update', Array.from(this.tasks.values()))
450+
// Copy items so later in-place TaskUpdate mutations don't alias into
451+
// previously broadcast/history-stored snapshots.
452+
this.emit('todo_update', Array.from(this.tasks.values(), t => ({ ...t })))
451453
}
452454
} catch (err) {
453455
console.warn(`[claude] Failed to parse tool input for ${this.tool.name}:`, err instanceof Error ? err.message : err)
@@ -605,14 +607,17 @@ export class ClaudeProcess extends EventEmitter<ClaudeProcessEvents> implements
605607
* Returns true if the task list changed (caller should emit todo_update).
606608
*/
607609
private handleTaskTool(toolName: string, input: Record<string, unknown>): boolean {
608-
// TodoWrite sends the entire list at once
610+
// TodoWrite sends the entire list at once.
611+
// Note: taskSeq is intentionally NOT reset — keeping ids monotonic across
612+
// list generations lets the frontend detect a brand-new list by id and
613+
// avoids id collisions with later TaskCreate/TaskUpdate calls.
609614
if (toolName === 'TodoWrite') {
610615
const todos = input.todos as Array<Record<string, unknown>> | undefined
611616
if (!Array.isArray(todos)) return false
612617
this.tasks.clear()
613-
this.taskSeq = 0
614618
for (const item of todos) {
615619
const id = String(item.id || ++this.taskSeq)
620+
this.syncTaskSeq(id)
616621
const status = item.status as string
617622
if (status !== 'pending' && status !== 'in_progress' && status !== 'completed') continue
618623
this.tasks.set(id, {
@@ -626,7 +631,8 @@ export class ClaudeProcess extends EventEmitter<ClaudeProcessEvents> implements
626631
}
627632
// TaskCreate/TaskUpdate are the newer tool names
628633
if (toolName === 'TaskCreate') {
629-
const id = String(++this.taskSeq)
634+
const id = String(input.taskId || input.id || ++this.taskSeq)
635+
this.syncTaskSeq(id)
630636
this.tasks.set(id, {
631637
id,
632638
subject: String(input.subject || ''),
@@ -637,8 +643,17 @@ export class ClaudeProcess extends EventEmitter<ClaudeProcessEvents> implements
637643
}
638644
if (toolName === 'TaskUpdate') {
639645
const id = String(input.taskId || '')
640-
const task = this.tasks.get(id)
641-
if (!task) return false
646+
if (!id) return false
647+
let task = this.tasks.get(id)
648+
if (!task) {
649+
// Unknown id — our in-memory map can diverge from the CLI's real task
650+
// list (e.g. after a process restart mid-session). Upsert instead of
651+
// dropping the update, otherwise the UI shows a stale list forever.
652+
if (input.status === 'deleted') return false
653+
task = { id, subject: String(input.subject || `Task ${id}`), status: 'pending' }
654+
this.tasks.set(id, task)
655+
this.syncTaskSeq(id)
656+
}
642657
const status = input.status as string | undefined
643658
if (status === 'deleted') {
644659
this.tasks.delete(id)
@@ -654,6 +669,25 @@ export class ClaudeProcess extends EventEmitter<ClaudeProcessEvents> implements
654669
return false
655670
}
656671

672+
/** Keep taskSeq ahead of any numeric id we have seen, so generated ids never collide. */
673+
private syncTaskSeq(id: string): void {
674+
const n = Number(id)
675+
if (Number.isInteger(n) && n > this.taskSeq) this.taskSeq = n
676+
}
677+
678+
/**
679+
* Seed task state from a previous process's last known list (session restore).
680+
* Without this, a restarted process starts with an empty map and TaskUpdate
681+
* calls referencing pre-restart task ids would otherwise be lost.
682+
*/
683+
seedTasks(tasks: TaskItem[]): void {
684+
this.tasks.clear()
685+
for (const t of tasks) {
686+
this.tasks.set(t.id, { ...t })
687+
this.syncTaskSeq(t.id)
688+
}
689+
}
690+
657691
/**
658692
* Extract a short summary from extended thinking text.
659693
* Tries to grab the first sentence (up to 120 chars), or truncates at a

server/coding-process.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import type { EventEmitter } from 'events'
1414
import type { ClaudeProcessEvents } from './claude-process.js'
15+
import type { TaskItem } from './types.js'
1516

1617
/**
1718
* Supported AI coding assistant providers.
@@ -73,6 +74,9 @@ export interface CodingProcess extends EventEmitter<ClaudeProcessEvents> {
7374
*/
7475
sendControlResponse(requestId: string, behavior: 'allow' | 'deny', updatedInput?: Record<string, unknown>, message?: string): void
7576

77+
/** Seed task/todo state from a previous process's last known list (session restore). */
78+
seedTasks(tasks: TaskItem[]): void
79+
7680
/** Whether the process is currently running and accepting input. */
7781
isAlive(): boolean
7882

server/opencode-process.ts

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -618,13 +618,13 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
618618
this.emit('tool_active', toolName, inputStr)
619619
// Detect task/todo tool calls and emit todo_update
620620
if (part.state?.input && this.handleTaskTool(toolName, part.state.input)) {
621-
this.emit('todo_update', Array.from(this.tasks.values()))
621+
this.emit('todo_update', Array.from(this.tasks.values(), t => ({ ...t })))
622622
}
623623
} else if (status === 'completed') {
624624
// Also check for task tools at completion (some providers only
625625
// populate input at this stage, not during 'running')
626626
if (part.state?.input && this.handleTaskTool(toolName, part.state.input)) {
627-
this.emit('todo_update', Array.from(this.tasks.values()))
627+
this.emit('todo_update', Array.from(this.tasks.values(), t => ({ ...t })))
628628
}
629629
const output = part.state?.output
630630
const summary = output ? output.slice(0, 200) : undefined
@@ -796,13 +796,16 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
796796
private handleTaskTool(toolName: string, input: Record<string, unknown>): boolean {
797797
// Normalize tool name — OpenCode may report as 'todowrite', 'TodoWrite', 'todo_write', etc.
798798
const normalized = toolName.toLowerCase().replace(/_/g, '')
799+
// Note: taskSeq is intentionally NOT reset on TodoWrite — keeping ids
800+
// monotonic across list generations lets the frontend detect a brand-new
801+
// list by id and avoids collisions with later TaskCreate/TaskUpdate calls.
799802
if (normalized === 'todowrite') {
800803
const todos = input.todos as Array<Record<string, unknown>> | undefined
801804
if (!Array.isArray(todos)) return false
802805
this.tasks.clear()
803-
this.taskSeq = 0
804806
for (const item of todos) {
805807
const id = String(item.id || ++this.taskSeq)
808+
this.syncTaskSeq(id)
806809
const status = item.status as string
807810
if (status !== 'pending' && status !== 'in_progress' && status !== 'completed') continue
808811
this.tasks.set(id, {
@@ -815,7 +818,8 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
815818
return true
816819
}
817820
if (normalized === 'taskcreate') {
818-
const id = String(++this.taskSeq)
821+
const id = String(input.taskId || input.id || ++this.taskSeq)
822+
this.syncTaskSeq(id)
819823
this.tasks.set(id, {
820824
id,
821825
subject: String(input.subject || ''),
@@ -826,8 +830,17 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
826830
}
827831
if (normalized === 'taskupdate') {
828832
const id = String(input.taskId || '')
829-
const task = this.tasks.get(id)
830-
if (!task) return false
833+
if (!id) return false
834+
let task = this.tasks.get(id)
835+
if (!task) {
836+
// Unknown id — our in-memory map can diverge from the provider's real
837+
// task list (e.g. after a process restart mid-session). Upsert instead
838+
// of dropping the update, otherwise the UI shows a stale list forever.
839+
if (input.status === 'deleted') return false
840+
task = { id, subject: String(input.subject || `Task ${id}`), status: 'pending' }
841+
this.tasks.set(id, task)
842+
this.syncTaskSeq(id)
843+
}
831844
const status = input.status as string | undefined
832845
if (status === 'deleted') {
833846
this.tasks.delete(id)
@@ -843,6 +856,25 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
843856
return false
844857
}
845858

859+
/** Keep taskSeq ahead of any numeric id we have seen, so generated ids never collide. */
860+
private syncTaskSeq(id: string): void {
861+
const n = Number(id)
862+
if (Number.isInteger(n) && n > this.taskSeq) this.taskSeq = n
863+
}
864+
865+
/**
866+
* Seed task state from a previous process's last known list (session restore).
867+
* Without this, a restarted process starts with an empty map and TaskUpdate
868+
* calls referencing pre-restart task ids would otherwise be lost.
869+
*/
870+
seedTasks(tasks: TaskItem[]): void {
871+
this.tasks.clear()
872+
for (const t of tasks) {
873+
this.tasks.set(t.id, { ...t })
874+
this.syncTaskSeq(t.id)
875+
}
876+
}
877+
846878
/** Send a user message to the OpenCode session. */
847879
sendMessage(content: string): void {
848880
if (!this.alive || !this.opencodeSessionId) {

server/session-lifecycle.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,18 @@ export class SessionLifecycle {
171171

172172
this.wireClaudeEvents(cp, session, sessionId)
173173

174+
// On resume, carry the last known task list into the new process so
175+
// TaskUpdate calls referencing pre-restart task ids aren't dropped.
176+
if (resume) {
177+
for (let i = session.outputHistory.length - 1; i >= 0; i--) {
178+
const msg = session.outputHistory[i]
179+
if (msg.type === 'todo_update') {
180+
cp.seedTasks(msg.tasks)
181+
break
182+
}
183+
}
184+
}
185+
174186
cp.start()
175187
session.claudeProcess = cp
176188
this.deps.globalBroadcast?.({ type: 'sessions_updated' })

src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -599,6 +599,7 @@ export default function App() {
599599
planningMode={planningMode}
600600
activityLabel={activityLabel}
601601
tasks={tasks}
602+
isProcessing={isProcessing}
602603
activePrompt={activePrompt}
603604
sendPromptResponse={sendPromptResponse}
604605
inputBarRef={inputBarRef}
@@ -666,6 +667,7 @@ export default function App() {
666667
planningMode={planningMode}
667668
activityLabel={activityLabel}
668669
tasks={tasks}
670+
isProcessing={isProcessing}
669671
disabled={!settings.token}
670672
hasFileChanges={hasFileChanges}
671673
diffPanelOpen={diffPanelOpen}

src/components/OrchestratorContent.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export interface OrchestratorContentProps {
2727
planningMode: boolean
2828
activityLabel?: string
2929
tasks: TaskItem[]
30+
isProcessing: boolean
3031
activePrompt: PromptEntry | null
3132
sendPromptResponse: (value: string | string[], requestId?: string) => void
3233
inputBarRef: RefObject<InputBarHandle | null>
@@ -55,6 +56,7 @@ export function OrchestratorContent({
5556
planningMode,
5657
activityLabel,
5758
tasks,
59+
isProcessing,
5860
activePrompt,
5961
sendPromptResponse,
6062
inputBarRef,
@@ -93,7 +95,7 @@ export function OrchestratorContent({
9395
variant="orchestrator"
9496
agentName={agentName}
9597
/>
96-
<TodoPanel tasks={tasks} />
98+
<TodoPanel tasks={tasks} isProcessing={isProcessing} />
9799
</div>
98100
{activePrompt && (
99101
<PromptButtons

src/components/SessionContent.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export interface SessionContentProps {
2626
planningMode: boolean
2727
activityLabel?: string
2828
tasks: TaskItem[]
29+
isProcessing: boolean
2930
disabled: boolean
3031
hasFileChanges: boolean
3132
diffPanelOpen: boolean
@@ -70,6 +71,7 @@ export function SessionContent({
7071
planningMode,
7172
activityLabel,
7273
tasks,
74+
isProcessing,
7375
disabled,
7476
hasFileChanges,
7577
diffPanelOpen,
@@ -116,7 +118,7 @@ export function SessionContent({
116118
activityLabel={isProviderDisabled ? undefined : activityLabel}
117119
isMobile={isMobile}
118120
/>
119-
<TodoPanel tasks={tasks} />
121+
<TodoPanel tasks={tasks} isProcessing={isProcessing} />
120122

121123
{/* Claude Code disabled banner */}
122124
{claudeDisabled && (

0 commit comments

Comments
 (0)