feat: add todo tracking and custom tool views

Adds todo tracking infrastructure with TodoWrite tool integration to extract
and persist todos in sessions. Refactors ToolCard component to support
custom tool view rendering and displays todo progress in SessionList.
This commit is contained in:
weishu
2025-12-17 11:45:17 +08:00
parent 031116bfce
commit 9cc13b51be
21 changed files with 1103 additions and 277 deletions
+9
View File
@@ -4,6 +4,7 @@ import { randomUUID } from 'node:crypto'
import type { Store } from '../../store'
import { RpcRegistry } from '../rpcRegistry'
import type { SyncEvent } from '../../sync/syncEngine'
import { extractTodoWriteTodosFromMessageContent } from '../../sync/todos'
type SessionAlivePayload = {
sid: string
@@ -125,6 +126,14 @@ export function registerCliHandlers(socket: Socket, deps: CliHandlersDeps): void
const msg = store.addMessage(sid, content, localId)
const todos = extractTodoWriteTodosFromMessageContent(content)
if (todos) {
const updated = store.setSessionTodos(sid, todos, msg.createdAt)
if (updated) {
onWebappEvent?.({ type: 'session-updated', sessionId: sid, data: { sid } })
}
}
// Broadcast to other CLI sockets interested in this session (skip sender).
const update = {
id: randomUUID(),
+43
View File
@@ -13,6 +13,8 @@ export type StoredSession = {
metadataVersion: number
agentState: unknown | null
agentStateVersion: number
todos: unknown | null
todosUpdatedAt: number | null
active: boolean
activeAt: number | null
seq: number
@@ -55,6 +57,8 @@ type DbSessionRow = {
metadata_version: number
agent_state: string | null
agent_state_version: number
todos: string | null
todos_updated_at: number | null
active: number
active_at: number | null
seq: number
@@ -102,6 +106,8 @@ function toStoredSession(row: DbSessionRow): StoredSession {
metadataVersion: row.metadata_version,
agentState: safeJsonParse(row.agent_state),
agentStateVersion: row.agent_state_version,
todos: safeJsonParse(row.todos),
todosUpdatedAt: row.todos_updated_at,
active: row.active === 1,
activeAt: row.active_at,
seq: row.seq
@@ -184,6 +190,8 @@ export class Store {
metadata_version INTEGER DEFAULT 1,
agent_state TEXT,
agent_state_version INTEGER DEFAULT 1,
todos TEXT,
todos_updated_at INTEGER,
active INTEGER DEFAULT 0,
active_at INTEGER,
seq INTEGER DEFAULT 0
@@ -215,6 +223,16 @@ export class Store {
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, seq);
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_local_id ON messages(session_id, local_id) WHERE local_id IS NOT NULL;
`)
const sessionColumns = this.db.prepare('PRAGMA table_info(sessions)').all() as Array<{ name: string }>
const sessionColumnNames = new Set(sessionColumns.map((c) => c.name))
if (!sessionColumnNames.has('todos')) {
this.db.exec('ALTER TABLE sessions ADD COLUMN todos TEXT')
}
if (!sessionColumnNames.has('todos_updated_at')) {
this.db.exec('ALTER TABLE sessions ADD COLUMN todos_updated_at INTEGER')
}
}
getOrCreateSession(tag: string, metadata: unknown, agentState: unknown): StoredSession {
@@ -237,11 +255,13 @@ export class Store {
id, tag, machine_id, created_at, updated_at,
metadata, metadata_version,
agent_state, agent_state_version,
todos, todos_updated_at,
active, active_at, seq
) VALUES (
@id, @tag, NULL, @created_at, @updated_at,
@metadata, 1,
@agent_state, 1,
NULL, NULL,
0, NULL, 0
)
`).run({
@@ -326,6 +346,29 @@ export class Store {
}
}
setSessionTodos(id: string, todos: unknown, todosUpdatedAt: number): boolean {
try {
const json = todos === null || todos === undefined ? null : JSON.stringify(todos)
const result = this.db.prepare(`
UPDATE sessions
SET todos = @todos,
todos_updated_at = @todos_updated_at,
updated_at = CASE WHEN updated_at > @updated_at THEN updated_at ELSE @updated_at END,
seq = seq + 1
WHERE id = @id AND (todos_updated_at IS NULL OR todos_updated_at < @todos_updated_at)
`).run({
id,
todos: json,
todos_updated_at: todosUpdatedAt,
updated_at: todosUpdatedAt
})
return result.changes === 1
} catch {
return false
}
}
getSession(id: string): StoredSession | null {
const row = this.db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as DbSessionRow | undefined
return row ? toStoredSession(row) : null
+27 -1
View File
@@ -11,6 +11,7 @@ import { z } from 'zod'
import type { Server } from 'socket.io'
import type { Store } from '../store'
import type { RpcRegistry } from '../socket/rpcRegistry'
import { extractTodoWriteTodosFromMessageContent, TodosSchema, type TodoItem } from './todos'
export type ConnectionStatus = 'disconnected' | 'connected'
@@ -73,6 +74,7 @@ export interface Session {
agentStateVersion: number
thinking: boolean
thinkingAt: number
todos?: TodoItem[]
permissionMode?: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | null
modelMode?: 'default' | 'sonnet' | 'opus' | null
}
@@ -143,6 +145,7 @@ export class SyncEngine {
private readonly lastBroadcastAtBySessionId: Map<string, number> = new Map()
private readonly lastBroadcastAtByMachineId: Map<string, number> = new Map()
private readonly todoBackfillAttemptedSessionIds: Set<string> = new Set()
private inactivityTimer: NodeJS.Timeout | null = null
constructor(
@@ -382,7 +385,7 @@ export class SyncEngine {
}
private refreshSession(sessionId: string): Session | null {
const stored = this.store.getSession(sessionId)
let stored = this.store.getSession(sessionId)
if (!stored) {
const existed = this.sessions.delete(sessionId)
if (existed) {
@@ -393,6 +396,22 @@ export class SyncEngine {
const existing = this.sessions.get(sessionId)
if (stored.todos === null && !this.todoBackfillAttemptedSessionIds.has(sessionId)) {
this.todoBackfillAttemptedSessionIds.add(sessionId)
const messages = this.store.getMessages(sessionId, 200)
for (let i = messages.length - 1; i >= 0; i -= 1) {
const message = messages[i]
const todos = extractTodoWriteTodosFromMessageContent(message.content)
if (todos) {
const updated = this.store.setSessionTodos(sessionId, todos, message.createdAt)
if (updated) {
stored = this.store.getSession(sessionId) ?? stored
}
break
}
}
}
const metadata = (() => {
const parsed = MetadataSchema.safeParse(stored.metadata)
return parsed.success ? parsed.data : null
@@ -403,6 +422,12 @@ export class SyncEngine {
return parsed.success ? parsed.data : null
})()
const todos = (() => {
if (stored.todos === null) return undefined
const parsed = TodosSchema.safeParse(stored.todos)
return parsed.success ? parsed.data : undefined
})()
const session: Session = {
id: stored.id,
seq: stored.seq,
@@ -416,6 +441,7 @@ export class SyncEngine {
agentStateVersion: stored.agentStateVersion,
thinking: existing?.thinking ?? false,
thinkingAt: existing?.thinkingAt ?? 0,
todos,
permissionMode: existing?.permissionMode ?? null,
modelMode: existing?.modelMode ?? null
}
+101
View File
@@ -0,0 +1,101 @@
import { z } from 'zod'
export const TodoItemSchema = z.object({
content: z.string(),
status: z.enum(['pending', 'in_progress', 'completed']),
priority: z.enum(['high', 'medium', 'low']),
id: z.string()
}).passthrough()
export type TodoItem = z.infer<typeof TodoItemSchema>
export const TodosSchema = z.array(TodoItemSchema)
function isObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object'
}
type RoleWrappedRecord = {
role: string
content: unknown
meta?: unknown
}
function isRoleWrappedRecord(value: unknown): value is RoleWrappedRecord {
if (!isObject(value)) return false
return typeof value.role === 'string' && 'content' in value
}
function unwrapRoleWrappedRecordEnvelope(value: unknown): RoleWrappedRecord | null {
if (isRoleWrappedRecord(value)) return value
if (!isObject(value)) return null
const direct = value.message
if (isRoleWrappedRecord(direct)) return direct
const data = value.data
if (isObject(data) && isRoleWrappedRecord(data.message)) return data.message as RoleWrappedRecord
const payload = value.payload
if (isObject(payload) && isRoleWrappedRecord(payload.message)) return payload.message as RoleWrappedRecord
return null
}
function extractTodosFromClaudeOutput(content: Record<string, unknown>): TodoItem[] | null {
if (content.type !== 'output') return null
const data = isObject(content.data) ? content.data : null
if (!data || data.type !== 'assistant') return null
const message = isObject(data.message) ? data.message : null
if (!message) return null
const modelContent = message.content
if (!Array.isArray(modelContent)) return null
for (const block of modelContent) {
if (!isObject(block) || block.type !== 'tool_use') continue
const name = typeof block.name === 'string' ? block.name : null
if (name !== 'TodoWrite') continue
const input = 'input' in block ? (block as Record<string, unknown>).input : null
if (!isObject(input)) continue
const todosCandidate = input.todos
const parsed = TodosSchema.safeParse(todosCandidate)
if (parsed.success) {
return parsed.data
}
}
return null
}
function extractTodosFromCodexMessage(content: Record<string, unknown>): TodoItem[] | null {
if (content.type !== 'codex') return null
const data = isObject(content.data) ? content.data : null
if (!data || data.type !== 'tool-call') return null
const name = typeof data.name === 'string' ? data.name : null
if (name !== 'TodoWrite') return null
const input = 'input' in data ? (data as Record<string, unknown>).input : null
if (!isObject(input)) return null
const todosCandidate = input.todos
const parsed = TodosSchema.safeParse(todosCandidate)
return parsed.success ? parsed.data : null
}
export function extractTodoWriteTodosFromMessageContent(messageContent: unknown): TodoItem[] | null {
const record = unwrapRoleWrappedRecordEnvelope(messageContent)
if (!record) return null
if (record.role !== 'agent' && record.role !== 'assistant') return null
if (!isObject(record.content) || typeof record.content.type !== 'string') return null
return extractTodosFromClaudeOutput(record.content) ?? extractTodosFromCodexMessage(record.content)
}
+2
View File
@@ -13,6 +13,7 @@ type SessionSummary = {
permissionMode: Session['permissionMode']
modelMode: Session['modelMode']
metadata: Session['metadata']
todos?: Session['todos']
pendingRequestsCount: number
}
@@ -27,6 +28,7 @@ function toSessionSummary(session: Session): SessionSummary {
permissionMode: session.permissionMode,
modelMode: session.modelMode,
metadata: session.metadata,
todos: session.todos,
pendingRequestsCount
}
}