Skip to content

Commit 47da8e1

Browse files
alari76claude
andauthored
fix: reliable todo panel updates + close when work stops (#511)
* 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> * fix: implement seedTasks no-op in CodexProcess Codex emits the full plan on every turn/plan/updated notification, so there is no incremental task state to seed after a restart. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 391c0ec commit 47da8e1

11 files changed

Lines changed: 313 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
@@ -486,7 +486,9 @@ export class ClaudeProcess extends EventEmitter<ClaudeProcessEvents> implements
486486
if (isTask && TOOL_DEBUG) console.log('[task-debug] tool:', this.tool.name, 'input:', JSON.stringify(parsed).slice(0, 200))
487487
if (this.handleTaskTool(this.tool.name!, parsed)) {
488488
if (TOOL_DEBUG) console.log('[task-debug] emitting todo_update, tasks:', this.tasks.size)
489-
this.emit('todo_update', Array.from(this.tasks.values()))
489+
// Copy items so later in-place TaskUpdate mutations don't alias into
490+
// previously broadcast/history-stored snapshots.
491+
this.emit('todo_update', Array.from(this.tasks.values(), t => ({ ...t })))
490492
}
491493
} catch (err) {
492494
console.warn(`[claude] Failed to parse tool input for ${this.tool.name}:`, err instanceof Error ? err.message : err)
@@ -648,14 +650,17 @@ export class ClaudeProcess extends EventEmitter<ClaudeProcessEvents> implements
648650
* Returns true if the task list changed (caller should emit todo_update).
649651
*/
650652
private handleTaskTool(toolName: string, input: Record<string, unknown>): boolean {
651-
// TodoWrite sends the entire list at once
653+
// TodoWrite sends the entire list at once.
654+
// Note: taskSeq is intentionally NOT reset — keeping ids monotonic across
655+
// list generations lets the frontend detect a brand-new list by id and
656+
// avoids id collisions with later TaskCreate/TaskUpdate calls.
652657
if (toolName === 'TodoWrite') {
653658
const todos = input.todos as Array<Record<string, unknown>> | undefined
654659
if (!Array.isArray(todos)) return false
655660
this.tasks.clear()
656-
this.taskSeq = 0
657661
for (const item of todos) {
658662
const id = String(item.id || ++this.taskSeq)
663+
this.syncTaskSeq(id)
659664
const status = item.status as string
660665
if (status !== 'pending' && status !== 'in_progress' && status !== 'completed') continue
661666
this.tasks.set(id, {
@@ -669,7 +674,8 @@ export class ClaudeProcess extends EventEmitter<ClaudeProcessEvents> implements
669674
}
670675
// TaskCreate/TaskUpdate are the newer tool names
671676
if (toolName === 'TaskCreate') {
672-
const id = String(++this.taskSeq)
677+
const id = String(input.taskId || input.id || ++this.taskSeq)
678+
this.syncTaskSeq(id)
673679
this.tasks.set(id, {
674680
id,
675681
subject: String(input.subject || ''),
@@ -680,8 +686,17 @@ export class ClaudeProcess extends EventEmitter<ClaudeProcessEvents> implements
680686
}
681687
if (toolName === 'TaskUpdate') {
682688
const id = String(input.taskId || '')
683-
const task = this.tasks.get(id)
684-
if (!task) return false
689+
if (!id) return false
690+
let task = this.tasks.get(id)
691+
if (!task) {
692+
// Unknown id — our in-memory map can diverge from the CLI's real task
693+
// list (e.g. after a process restart mid-session). Upsert instead of
694+
// dropping the update, otherwise the UI shows a stale list forever.
695+
if (input.status === 'deleted') return false
696+
task = { id, subject: String(input.subject || `Task ${id}`), status: 'pending' }
697+
this.tasks.set(id, task)
698+
this.syncTaskSeq(id)
699+
}
685700
const status = input.status as string | undefined
686701
if (status === 'deleted') {
687702
this.tasks.delete(id)
@@ -697,6 +712,25 @@ export class ClaudeProcess extends EventEmitter<ClaudeProcessEvents> implements
697712
return false
698713
}
699714

715+
/** Keep taskSeq ahead of any numeric id we have seen, so generated ids never collide. */
716+
private syncTaskSeq(id: string): void {
717+
const n = Number(id)
718+
if (Number.isInteger(n) && n > this.taskSeq) this.taskSeq = n
719+
}
720+
721+
/**
722+
* Seed task state from a previous process's last known list (session restore).
723+
* Without this, a restarted process starts with an empty map and TaskUpdate
724+
* calls referencing pre-restart task ids would otherwise be lost.
725+
*/
726+
seedTasks(tasks: TaskItem[]): void {
727+
this.tasks.clear()
728+
for (const t of tasks) {
729+
this.tasks.set(t.id, { ...t })
730+
this.syncTaskSeq(t.id)
731+
}
732+
}
733+
700734
/**
701735
* Extract a short summary from extended thinking text.
702736
* Tries to grab the first sentence (up to 120 chars), or truncates at a

server/codex-process.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -805,6 +805,12 @@ export class CodexProcess extends EventEmitter<ClaudeProcessEvents> implements C
805805
}
806806
}
807807

808+
/**
809+
* No-op: Codex emits the full plan on every turn/plan/updated notification,
810+
* so there is no incremental task state to seed after a restart.
811+
*/
812+
seedTasks(): void {}
813+
808814
isAlive(): boolean {
809815
return this.alive
810816
}

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.
@@ -81,6 +82,9 @@ export interface CodingProcess extends EventEmitter<ClaudeProcessEvents> {
8182
*/
8283
setPermissionMode?(mode: import('./types.js').PermissionMode): Promise<boolean>
8384

85+
/** Seed task/todo state from a previous process's last known list (session restore). */
86+
seedTasks(tasks: TaskItem[]): void
87+
8488
/** Whether the process is currently running and accepting input. */
8589
isAlive(): boolean
8690

server/opencode-process.ts

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -889,13 +889,13 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
889889
this.emit('tool_active', toolName, inputStr)
890890
// Detect task/todo tool calls and emit todo_update
891891
if (part.state?.input && this.handleTaskTool(toolName, part.state.input)) {
892-
this.emit('todo_update', Array.from(this.tasks.values()))
892+
this.emit('todo_update', Array.from(this.tasks.values(), t => ({ ...t })))
893893
}
894894
} else if (status === 'completed') {
895895
// Also check for task tools at completion (some providers only
896896
// populate input at this stage, not during 'running')
897897
if (part.state?.input && this.handleTaskTool(toolName, part.state.input)) {
898-
this.emit('todo_update', Array.from(this.tasks.values()))
898+
this.emit('todo_update', Array.from(this.tasks.values(), t => ({ ...t })))
899899
}
900900
const output = part.state?.output
901901
const summary = output ? output.slice(0, 200) : undefined
@@ -1265,13 +1265,16 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
12651265
private handleTaskTool(toolName: string, input: Record<string, unknown>): boolean {
12661266
// Normalize tool name — OpenCode may report as 'todowrite', 'TodoWrite', 'todo_write', etc.
12671267
const normalized = toolName.toLowerCase().replace(/_/g, '')
1268+
// Note: taskSeq is intentionally NOT reset on TodoWrite — keeping ids
1269+
// monotonic across list generations lets the frontend detect a brand-new
1270+
// list by id and avoids collisions with later TaskCreate/TaskUpdate calls.
12681271
if (normalized === 'todowrite') {
12691272
const todos = input.todos as Array<Record<string, unknown>> | undefined
12701273
if (!Array.isArray(todos)) return false
12711274
this.tasks.clear()
1272-
this.taskSeq = 0
12731275
for (const item of todos) {
12741276
const id = String(item.id || ++this.taskSeq)
1277+
this.syncTaskSeq(id)
12751278
const status = item.status as string
12761279
if (status !== 'pending' && status !== 'in_progress' && status !== 'completed') continue
12771280
this.tasks.set(id, {
@@ -1284,7 +1287,8 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
12841287
return true
12851288
}
12861289
if (normalized === 'taskcreate') {
1287-
const id = String(++this.taskSeq)
1290+
const id = String(input.taskId || input.id || ++this.taskSeq)
1291+
this.syncTaskSeq(id)
12881292
this.tasks.set(id, {
12891293
id,
12901294
subject: String(input.subject || ''),
@@ -1295,8 +1299,17 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
12951299
}
12961300
if (normalized === 'taskupdate') {
12971301
const id = String(input.taskId || '')
1298-
const task = this.tasks.get(id)
1299-
if (!task) return false
1302+
if (!id) return false
1303+
let task = this.tasks.get(id)
1304+
if (!task) {
1305+
// Unknown id — our in-memory map can diverge from the provider's real
1306+
// task list (e.g. after a process restart mid-session). Upsert instead
1307+
// of dropping the update, otherwise the UI shows a stale list forever.
1308+
if (input.status === 'deleted') return false
1309+
task = { id, subject: String(input.subject || `Task ${id}`), status: 'pending' }
1310+
this.tasks.set(id, task)
1311+
this.syncTaskSeq(id)
1312+
}
13001313
const status = input.status as string | undefined
13011314
if (status === 'deleted') {
13021315
this.tasks.delete(id)
@@ -1312,6 +1325,25 @@ export class OpenCodeProcess extends EventEmitter<ClaudeProcessEvents> implement
13121325
return false
13131326
}
13141327

1328+
/** Keep taskSeq ahead of any numeric id we have seen, so generated ids never collide. */
1329+
private syncTaskSeq(id: string): void {
1330+
const n = Number(id)
1331+
if (Number.isInteger(n) && n > this.taskSeq) this.taskSeq = n
1332+
}
1333+
1334+
/**
1335+
* Seed task state from a previous process's last known list (session restore).
1336+
* Without this, a restarted process starts with an empty map and TaskUpdate
1337+
* calls referencing pre-restart task ids would otherwise be lost.
1338+
*/
1339+
seedTasks(tasks: TaskItem[]): void {
1340+
this.tasks.clear()
1341+
for (const t of tasks) {
1342+
this.tasks.set(t.id, { ...t })
1343+
this.syncTaskSeq(t.id)
1344+
}
1345+
}
1346+
13151347
/** Send a user message to the OpenCode session. */
13161348
sendMessage(content: string): void {
13171349
if (!this.alive || !this.opencodeSessionId) {

server/session-lifecycle.ts

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

189189
this.wireClaudeEvents(cp, session, sessionId)
190190

191+
// On resume, carry the last known task list into the new process so
192+
// TaskUpdate calls referencing pre-restart task ids aren't dropped.
193+
if (resume) {
194+
for (let i = session.outputHistory.length - 1; i >= 0; i--) {
195+
const msg = session.outputHistory[i]
196+
if (msg.type === 'todo_update') {
197+
cp.seedTasks(msg.tasks)
198+
break
199+
}
200+
}
201+
}
202+
191203
cp.start()
192204
session.claudeProcess = cp
193205

src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,7 @@ export default function App() {
666666
planningMode={planningMode}
667667
activityLabel={activityLabel}
668668
tasks={tasks}
669+
isProcessing={isProcessing}
669670
activePrompt={activePrompt}
670671
sendPromptResponse={sendPromptResponse}
671672
inputBarRef={inputBarRef}
@@ -733,6 +734,7 @@ export default function App() {
733734
planningMode={planningMode}
734735
activityLabel={activityLabel}
735736
tasks={tasks}
737+
isProcessing={isProcessing}
736738
disabled={!settings.token}
737739
hasFileChanges={hasFileChanges}
738740
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
@@ -76,6 +77,7 @@ export function SessionContent({
7677
planningMode,
7778
activityLabel,
7879
tasks,
80+
isProcessing,
7981
disabled,
8082
hasFileChanges,
8183
diffPanelOpen,
@@ -126,7 +128,7 @@ export function SessionContent({
126128
activityLabel={isProviderDisabled ? undefined : activityLabel}
127129
isMobile={isMobile}
128130
/>
129-
<TodoPanel tasks={tasks} />
131+
<TodoPanel tasks={tasks} isProcessing={isProcessing} />
130132

131133
{/* Claude Code disabled banner */}
132134
{claudeDisabled && (

0 commit comments

Comments
 (0)