Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions packages/app/src/pages/session/composer/session-todo-dock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Index, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { composerEnabled, composerProbe } from "@/testing/session-composer"
import { useLanguage } from "@/context/language"
import type { SessionTodoItem } from "@/pages/session/todos/todo-model"

const currentToken = "\u0000current\u0000"
const totalToken = "\u0000total\u0000"
Expand Down Expand Up @@ -42,7 +43,7 @@ function dot(status: Todo["status"]) {

export function SessionTodoDock(props: {
sessionID?: string
todos: Todo[]
todos: SessionTodoItem[]
collapseLabel: string
expandLabel: string
dockProgress: number
Expand Down Expand Up @@ -230,7 +231,7 @@ export function SessionTodoDock(props: {
)
}

function TodoList(props: { todos: Todo[] }) {
function TodoList(props: { todos: SessionTodoItem[] }) {
const [store, setStore] = createStore({
stuck: false,
})
Expand Down
24 changes: 24 additions & 0 deletions packages/app/src/pages/session/session-status-extractors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,30 @@ describe("extractTodos", () => {
)
expect(extractTodos([part])).toEqual([{ content: "ok", status: "pending", priority: "low" }])
})

it("prefers resolved metadata todos with ids over idless tool input", () => {
const part = toolPart(
"todowrite",
completedState({
input: { todos: [{ content: "A", status: "pending", priority: "medium" }] },
metadata: { todos: [{ id: "todo_1", content: "A", status: "pending", priority: "medium" }] },
}),
)

expect(extractTodos([part])).toEqual([{ id: "todo_1", content: "A", status: "pending", priority: "medium" }])
})

it("falls back to tool input when metadata todos are malformed", () => {
const part = toolPart(
"todowrite",
completedState({
input: { todos: [{ content: "A", status: "pending", priority: "medium" }] },
metadata: { todos: [{ id: "todo_1", content: "A", status: "pending" }] },
}),
)

expect(extractTodos([part])).toEqual([{ content: "A", status: "pending", priority: "medium" }])
})
})

const webfetchPart = (url: string): Part => toolPart("webfetch", completedState({ input: { url } }))
Expand Down
22 changes: 21 additions & 1 deletion packages/app/src/pages/session/session-status-extractors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const TOOL_WEBFETCH = "webfetch"
export const TOOL_WEBSEARCH = "websearch"

export interface TodoItem {
id?: string
content: string
status: string
priority: string
Expand All @@ -29,7 +30,20 @@ function isToolPart(part: Part): part is Extract<Part, { type: "tool" }> {
function isValidTodo(value: unknown): value is TodoItem {
if (typeof value !== "object" || value === null) return false
const v = value as Partial<TodoItem>
return typeof v.content === "string" && typeof v.status === "string" && typeof v.priority === "string"
return (
(v.id === undefined || typeof v.id === "string") &&
typeof v.content === "string" &&
typeof v.status === "string" &&
typeof v.priority === "string"
)
}

function todosFromMetadata(part: Extract<Part, { type: "tool" }>): TodoItem[] | undefined {
const metadata = part.state.status === "completed" ? part.state.metadata : undefined
const todos = (metadata as { todos?: unknown } | undefined)?.todos
if (!Array.isArray(todos)) return undefined
const valid = todos.filter(isValidTodo)
return valid.length === todos.length ? valid : undefined
}

export function extractTodos(parts: Part[]): TodoItem[] {
Expand All @@ -38,6 +52,12 @@ export function extractTodos(parts: Part[]): TodoItem[] {
if (!isToolPart(part)) continue
if (part.tool !== TOOL_TODOWRITE) continue
if (part.state.status !== "completed") continue
const metadataTodos = todosFromMetadata(part)
if (metadataTodos) {
latest = metadataTodos
continue
}

const rawInput = part.state.input
if (typeof rawInput !== "object" || rawInput === null) continue
const todos = (rawInput as { todos?: unknown }).todos
Expand Down
12 changes: 10 additions & 2 deletions packages/app/src/pages/session/session-todos.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
import type { Part, ToolState } from "@opencode-ai/sdk/v2"
import type { Todo } from "@opencode-ai/sdk/v2/client"
import { selectSessionTodos } from "./session-todos"
import type { SessionTodoItem } from "./todos/todo-model"

const completedState = (
overrides: Partial<Extract<ToolState, { status: "completed" }>> = {},
Expand All @@ -26,7 +27,14 @@ const toolPart = (tool: string, state: ToolState = completedState()): Part =>
state,
}) as Part

const todo = (content: string, status: Todo["status"] = "pending"): Todo => ({
const todo = (content: string, status: SessionTodoItem["status"] = "pending"): SessionTodoItem => ({
content,
status,
priority: "medium",
})

const backendTodo = (content: string, status: Todo["status"] = "pending"): Todo => ({
id: `todo_${content}`,
content,
status,
priority: "medium",
Expand All @@ -36,7 +44,7 @@ describe("selectSessionTodos", () => {
test("prefers message-derived todos over lagging backend todos", () => {
const parts = [toolPart("todowrite", completedState({ input: { todos: [todo("from parts", "in_progress")] } }))]

expect(selectSessionTodos({ backend: [todo("from backend", "pending")], parts })).toEqual([
expect(selectSessionTodos({ backend: [backendTodo("from backend", "pending")], parts })).toEqual([
todo("from parts", "in_progress"),
])
})
Expand Down
1 change: 0 additions & 1 deletion packages/app/src/pages/session/session-todos.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
export {
selectSessionTodoDataSnapshot,
selectSessionTodoDockSnapshot,
selectSessionTodoSnapshot,
selectSessionTodos,
type SessionTodoSource,
} from "./todos/todo-source"
18 changes: 18 additions & 0 deletions packages/app/src/pages/session/todos/todo-dock-machine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,24 @@ describe("reduceTodoDockState", () => {
expect(reduceTodoDockState(completing, { type: "snapshot", input: terminal("s", "completed") })).toBe(completing)
})

test("terminal replacement with same statuses but new lifecycle signature updates completing state", () => {
const shown = reduceTodoDockState(todoDockHiddenState(), { type: "snapshot", input: active("s") })
const completing = reduceTodoDockState(shown, {
type: "snapshot",
input: terminal("s", JSON.stringify([["todo_1", "completed"]])),
})

const replaced = reduceTodoDockState(completing, {
type: "snapshot",
input: terminal("s", JSON.stringify([["todo_2", "completed"]])),
})

expect(replaced).toMatchObject({
kind: "visible-completing",
lifecycleSignature: JSON.stringify([["todo_2", "completed"]]),
})
})

test("empty hides immediately", () => {
const shown = reduceTodoDockState(todoDockHiddenState(), { type: "snapshot", input: active() })

Expand Down
46 changes: 43 additions & 3 deletions packages/app/src/pages/session/todos/todo-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,24 @@ import { describe, expect, test } from "bun:test"
import type { Todo } from "@opencode-ai/sdk/v2/client"
import { todoDisplaySignature, todoLifecycleSignature, todoPhase } from "./todo-model"

const todo = (content: string, status: Todo["status"] = "pending", priority: Todo["priority"] = "medium"): Todo => ({
const todo = (
content: string,
status: Todo["status"] = "pending",
priority: Todo["priority"] = "medium",
id?: string,
): Todo =>
({
id,
content,
status,
priority,
}) as Todo

const idlessTodo = (
content: string,
status: Todo["status"] = "pending",
priority: Todo["priority"] = "medium",
): Pick<Todo, "content" | "status" | "priority"> => ({
content,
status,
priority,
Expand All @@ -25,8 +42,8 @@ describe("todoPhase", () => {

describe("todoLifecycleSignature", () => {
test("ignores content and priority refreshes", () => {
expect(todoLifecycleSignature([todo("first", "completed", "high")])).toBe(
todoLifecycleSignature([todo("first refreshed", "completed", "low")]),
expect(todoLifecycleSignature([todo("first", "completed", "high", "todo_1")])).toBe(
todoLifecycleSignature([todo("first refreshed", "completed", "low", "todo_1")]),
)
})

Expand All @@ -38,6 +55,29 @@ describe("todoLifecycleSignature", () => {
todoLifecycleSignature([todo("first", "completed"), todo("second", "completed")]),
)
})

test("changes when stable ids change with the same statuses", () => {
expect(todoLifecycleSignature([todo("first", "pending", "medium", "todo_1")])).not.toBe(
todoLifecycleSignature([todo("second", "pending", "medium", "todo_2")]),
)
})

test("falls back to status-only signatures when ids are missing", () => {
expect(todoLifecycleSignature([idlessTodo("first", "completed", "high")])).toBe(
todoLifecycleSignature([idlessTodo("first refreshed", "completed", "low")]),
)
})

test("falls back to status-only signatures when any todo is missing an id", () => {
expect(
todoLifecycleSignature([todo("first", "pending", "medium", "todo_1"), idlessTodo("second", "completed")]),
).toBe(
todoLifecycleSignature([
todo("first refreshed", "pending", "low", "todo_2"),
idlessTodo("second refreshed", "completed"),
]),
)
})
})

describe("todoDisplaySignature", () => {
Expand Down
10 changes: 7 additions & 3 deletions packages/app/src/pages/session/todos/todo-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ export type TodoPhase = "empty" | "active" | "terminal"

export type TodoSourceKind = "primary-backend" | "primary-parts" | "fallback-backend" | "fallback-parts" | "none"

export type SessionTodoItem = Pick<Todo, "content" | "priority" | "status"> & Partial<Pick<Todo, "id">>

export type TodoSnapshot = {
sessionID?: string
source: TodoSourceKind
items: Todo[]
items: SessionTodoItem[]
phase: TodoPhase
lifecycleSignature: string
displaySignature: string
Expand All @@ -24,7 +26,9 @@ export function todoPhase(todos: readonly Pick<Todo, "status">[]): TodoPhase {
return todos.every(isTerminalTodo) ? "terminal" : "active"
}

export function todoLifecycleSignature(todos: readonly Pick<Todo, "status">[]): string {
export function todoLifecycleSignature(todos: readonly Pick<SessionTodoItem, "id" | "status">[]): string {
const hasStableIDs = todos.every((todo) => typeof todo.id === "string" && todo.id.length > 0)
if (hasStableIDs) return JSON.stringify(todos.map((todo) => [todo.id, todo.status]))
return JSON.stringify(todos.map((todo) => [todo.status]))
}

Expand All @@ -35,7 +39,7 @@ export function todoDisplaySignature(todos: readonly Pick<Todo, "content" | "pri
export function todoSnapshot(input: {
sessionID?: string
source: TodoSourceKind
items: Todo[]
items: SessionTodoItem[]
dockEligible?: boolean
historicalTerminal?: boolean
}): TodoSnapshot {
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/pages/session/todos/todo-source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const todo = (content: string, status: Todo["status"] = "pending"): Todo => ({
content,
status,
priority: "medium",
})
}) as Todo

describe("selectSessionTodoDataSnapshot", () => {
test("returns completed-only parts for status summary display", () => {
Expand Down
9 changes: 3 additions & 6 deletions packages/app/src/pages/session/todos/todo-source.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Part, Todo } from "@opencode-ai/sdk/v2"
import { extractTodos } from "@/pages/session/session-status-extractors"
import { todoPhase, todoSnapshot, type TodoSnapshot } from "./todo-model"
import { todoPhase, todoSnapshot, type SessionTodoItem, type TodoSnapshot } from "./todo-model"

export type SessionTodoSource = {
sessionID?: string
Expand All @@ -13,7 +13,7 @@ export type SelectSessionTodosInput = {
fallback?: SessionTodoSource
}

const partTodos = (parts: Part[]) => extractTodos(parts) as Todo[]
const partTodos = (parts: Part[]) => extractTodos(parts)

// Data snapshots are for status displays and should preserve the latest todo
// list even when it is terminal. Dock snapshots below apply the stricter UI
Expand Down Expand Up @@ -101,9 +101,6 @@ export function selectSessionTodoDockSnapshot(input: SelectSessionTodosInput): T
return todoSnapshot({ sessionID: input.primary.sessionID, source: "none", items: [], dockEligible: false })
}

// Deprecated compatibility alias. Prefer explicit data or dock snapshot names.
export const selectSessionTodoSnapshot = selectSessionTodoDockSnapshot

export function selectSessionTodos(input: SessionTodoSource & { fallback?: SessionTodoSource }): Todo[] {
export function selectSessionTodos(input: SessionTodoSource & { fallback?: SessionTodoSource }): SessionTodoItem[] {
return selectSessionTodoDataSnapshot({ primary: input, fallback: input.fallback }).items
}
5 changes: 2 additions & 3 deletions packages/app/src/pages/session/todos/use-session-todos.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { createEffect, createMemo, on, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import type { Todo } from "@opencode-ai/sdk/v2"
import { useGlobalSync } from "@/context/global-sync"
import { useSync } from "@/context/sync"
import { composerDriver, composerEnabled, composerEvent } from "@/testing/session-composer"
import { reduceTodoDockState, TODO_DOCK_COMPLETING_DELAY_MS, todoDockHiddenState } from "./todo-dock-machine"
import { todoSnapshot, type TodoSnapshot } from "./todo-model"
import { todoSnapshot, type SessionTodoItem, type TodoSnapshot } from "./todo-model"
import { selectSessionTodoDockSnapshot } from "./todo-source"

const dockInput = (snapshot: TodoSnapshot, sessionID?: string) => ({
Expand All @@ -27,7 +26,7 @@ export function createSessionTodoModel(input: {

const [test, setTest] = createStore({
on: false,
todos: undefined as Todo[] | undefined,
todos: undefined as SessionTodoItem[] | undefined,
})

const pull = () => {
Expand Down
20 changes: 20 additions & 0 deletions packages/opencode/migration/20260503025430_todo_ids/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_todo` (
`id` text PRIMARY KEY NOT NULL,
`session_id` text NOT NULL,
`content` text NOT NULL,
`status` text NOT NULL,
`priority` text NOT NULL,
`position` integer NOT NULL,
`time_created` integer NOT NULL,
`time_updated` integer NOT NULL,
CONSTRAINT `fk_todo_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
INSERT INTO `__new_todo`(`id`, `session_id`, `content`, `status`, `priority`, `position`, `time_created`, `time_updated`)
SELECT 'todo_' || printf('%012x', (`time_created` * 4096) + `position`) || lower(hex(randomblob(7))), `session_id`, `content`, `status`, `priority`, `position`, `time_created`, `time_updated` FROM `todo`;--> statement-breakpoint
DROP TABLE `todo`;--> statement-breakpoint
ALTER TABLE `__new_todo` RENAME TO `todo`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE INDEX `todo_session_idx` ON `todo` (`session_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `todo_session_position_idx` ON `todo` (`session_id`,`position`);
Loading
Loading