diff --git a/packages/ui/src/components/instance/instance-shell2.tsx b/packages/ui/src/components/instance/instance-shell2.tsx index 63616545..ce98e7da 100644 --- a/packages/ui/src/components/instance/instance-shell2.tsx +++ b/packages/ui/src/components/instance/instance-shell2.tsx @@ -10,6 +10,7 @@ import { type Accessor, type Component, } from "solid-js" +import type { ToolState } from "@opencode-ai/sdk" import { Accordion } from "@kobalte/core" import { ChevronDown } from "lucide-solid" import AppBar from "@suid/material/AppBar" @@ -49,6 +50,7 @@ import AgentSelector from "../agent-selector" import ModelSelector from "../model-selector" import CommandPalette from "../command-palette" import Kbd from "../kbd" +import { TodoListView } from "../tool-call/renderers/todo" import ContextUsagePanel from "../session/context-usage-panel" import SessionView from "../session/session-view" import { formatTokenTotal } from "../../lib/formatters" @@ -273,6 +275,30 @@ const InstanceShell2: Component = (props) => { } }) + const latestTodoSnapshot = createMemo(() => { + const sessionId = activeSessionIdForInstance() + if (!sessionId || sessionId === "info") return null + const store = messageStore() + if (!store) return null + const snapshot = store.state.latestTodos[sessionId] + return snapshot ?? null + }) + + const latestTodoState = createMemo(() => { + const snapshot = latestTodoSnapshot() + if (!snapshot) return null + const store = messageStore() + if (!store) return null + const message = store.getMessage(snapshot.messageId) + if (!message) return null + const partRecord = message.parts?.[snapshot.partId] + const part = partRecord?.data as { type?: string; tool?: string; state?: ToolState } + if (!part || part.type !== "tool" || part.tool !== "todowrite") return null + const state = part.state + if (!state || state.status !== "completed") return null + return state + }) + const connectionStatus = () => sseManager.getStatus(props.instance.id) const connectionStatusClass = () => { const status = connectionStatus() @@ -709,11 +735,23 @@ const InstanceShell2: Component = (props) => { ) const RightDrawerContent = () => { + const renderPlanSectionContent = () => { + const sessionId = activeSessionIdForInstance() + if (!sessionId || sessionId === "info") { + return

Select a session to view plan.

+ } + const todoState = latestTodoState() + if (!todoState) { + return

Nothing planned yet.

+ } + return + } + const sections = [ { id: "lsp", label: "LSP Servers", - content: ( + render: () => ( = (props) => { { id: "mcp", label: "MCP Servers", - content: ( + render: () => ( = (props) => { /> ), }, + { + id: "plan", + label: "Plan", + render: renderPlanSectionContent, + }, ] + createEffect(() => { + const currentExpanded = new Set(rightPanelExpandedItems()) + if (sections.every((section) => currentExpanded.has(section.id))) return + setRightPanelExpandedItems(sections.map((section) => section.id)) + }) + const handleAccordionChange = (values: string[]) => { setRightPanelExpandedItems(values) } @@ -786,7 +835,7 @@ const InstanceShell2: Component = (props) => { - {section.content} + {section.render()} )} diff --git a/packages/ui/src/components/tool-call/renderers/todo.tsx b/packages/ui/src/components/tool-call/renderers/todo.tsx index 8f507fee..2f276920 100644 --- a/packages/ui/src/components/tool-call/renderers/todo.tsx +++ b/packages/ui/src/components/tool-call/renderers/todo.tsx @@ -5,7 +5,7 @@ import { readToolStatePayload } from "../utils" export type TodoViewStatus = "pending" | "in_progress" | "completed" | "cancelled" -interface TodoViewItem { +export interface TodoViewItem { id: string content: string status: TodoViewStatus @@ -58,6 +58,51 @@ function getTodoStatusLabel(status: TodoViewStatus): string { } } +interface TodoListViewProps { + state?: ToolState + emptyLabel?: string +} + +export function TodoListView(props: TodoListViewProps) { + const todos = extractTodosFromState(props.state) + const counts = summarizeTodos(todos) + + if (counts.total === 0) { + return
{props.emptyLabel ?? "No plan items yet."}
+ } + + return ( +
+
+ + {(todo) => { + const label = getTodoStatusLabel(todo.status) + return ( +
+ +
+
+ {todo.content} + {label} +
+
+
+ ) + }} +
+
+
+ ) +} + export function getTodoTitle(state?: ToolState): string { if (!state) return "Plan" @@ -80,42 +125,6 @@ export const todoRenderer: ToolRenderer = { const state = toolState() if (!state) return null - const todos = extractTodosFromState(state) - const counts = summarizeTodos(todos) - - if (counts.total === 0) { - return
No plan items yet.
- } - - return ( -
-
- - {(todo) => { - const label = getTodoStatusLabel(todo.status) - return ( -
- -
-
- {todo.content} - {label} -
-
-
- ) - }} -
-
-
- ) + return }, } diff --git a/packages/ui/src/stores/message-v2/instance-store.ts b/packages/ui/src/stores/message-v2/instance-store.ts index 0bc681f1..cf6ad570 100644 --- a/packages/ui/src/stores/message-v2/instance-store.ts +++ b/packages/ui/src/stores/message-v2/instance-store.ts @@ -6,6 +6,7 @@ import type { ClientPart, MessageInfo } from "../../types/message" import { clearRecordDisplayCacheForMessages } from "./record-display-cache" import type { InstanceMessageState, + LatestTodoSnapshot, MessageRecord, MessageUpsertInput, PartUpdateInput, @@ -41,6 +42,7 @@ function createInitialState(instanceId: string): InstanceMessageState { }, usage: {}, scrollState: {}, + latestTodos: {}, } } @@ -206,6 +208,7 @@ export interface InstanceMessageStore { getSessionRevision: (sessionId: string) => number getSessionMessageIds: (sessionId: string) => string[] getMessage: (messageId: string) => MessageRecord | undefined + getLatestTodoSnapshot: (sessionId: string) => LatestTodoSnapshot | undefined clearSession: (sessionId: string) => void clearInstance: () => void } @@ -213,9 +216,55 @@ export interface InstanceMessageStore { export function createInstanceMessageStore(instanceId: string, hooks?: MessageStoreHooks): InstanceMessageStore { const [state, setState] = createStore(createInitialState(instanceId)) + const TODO_TOOL_NAME = "todowrite" const messageInfoCache = new Map() + function isCompletedTodoPart(part: ClientPart | undefined): boolean { + if (!part || (part as any).type !== "tool") { + return false + } + const toolName = typeof (part as any).tool === "string" ? (part as any).tool : "" + if (toolName !== TODO_TOOL_NAME) { + return false + } + const toolState = (part as any).state + if (!toolState || typeof toolState !== "object") { + return false + } + return (toolState as { status?: string }).status === "completed" + } + + function recordLatestTodoSnapshot(sessionId: string, snapshot: LatestTodoSnapshot) { + if (!sessionId) return + setState("latestTodos", sessionId, (existing) => { + if (existing && existing.timestamp > snapshot.timestamp) { + return existing + } + return snapshot + }) + } + + function maybeUpdateLatestTodoFromRecord(record: MessageRecord | undefined) { + if (!record || !Array.isArray(record.partIds) || record.partIds.length === 0) { + return + } + for (let index = record.partIds.length - 1; index >= 0; index -= 1) { + const partId = record.partIds[index] + const partRecord = record.parts[partId] + if (!partRecord) continue + if (isCompletedTodoPart(partRecord.data)) { + const timestamp = typeof record.updatedAt === "number" ? record.updatedAt : Date.now() + recordLatestTodoSnapshot(record.sessionId, { messageId: record.id, partId, timestamp }) + break + } + } + } + + function clearLatestTodoSnapshot(sessionId: string) { + setState("latestTodos", sessionId, undefined) + } + function bumpSessionRevision(sessionId: string) { if (!sessionId) return setState("sessionRevisions", sessionId, (value = 0) => value + 1) @@ -365,6 +414,10 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt updatedAt: Date.now(), })) + Object.values(normalizedRecords).forEach((record) => { + maybeUpdateLatestTodoFromRecord(record) + }) + bumpSessionRevision(sessionId) }) } @@ -405,9 +458,11 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt const shouldBump = Boolean(input.bumpRevision || normalizedParts) const now = Date.now() + let nextRecord: MessageRecord | undefined + setState("messages", input.id, (previous) => { const revision = previous ? previous.revision + (shouldBump ? 1 : 0) : 0 - return { + const record: MessageRecord = { id: input.id, sessionId: input.sessionId, role: input.role, @@ -419,8 +474,14 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt partIds: normalizedParts ? normalizedParts.ids : previous?.partIds ?? [], parts: normalizedParts ? normalizedParts.map : previous?.parts ?? {}, } + nextRecord = record + return record }) + if (nextRecord) { + maybeUpdateLatestTodoFromRecord(nextRecord) + } + insertMessageIntoSession(input.sessionId, input.id) flushPendingParts(input.id) bumpSessionRevision(input.sessionId) @@ -471,6 +532,14 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt } }), ) + + if (isCompletedTodoPart(cloned)) { + recordLatestTodoSnapshot(message.sessionId, { + messageId: input.messageId, + partId, + timestamp: Date.now(), + }) + } // Any part update can change the rendered height of the message // list, so we treat it as a session revision for scroll purposes. @@ -557,6 +626,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt setState("pendingParts", options.newId, pending) } clearPendingPartsForMessage(options.oldId) + maybeUpdateLatestTodoFromRecord(cloned) } function setMessageInfo(messageId: string, info: MessageInfo) { @@ -778,6 +848,8 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt setState("sessionOrder", (ids) => ids.filter((id) => id !== sessionId)) }) + + clearLatestTodoSnapshot(sessionId) hooks?.onSessionCleared?.(instanceId, sessionId) } @@ -812,10 +884,12 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt setScrollSnapshot, getScrollSnapshot, getSessionRevision: getSessionRevisionValue, - getSessionMessageIds: (sessionId: string) => state.sessions[sessionId]?.messageIds ?? [], - getMessage: (messageId: string) => state.messages[messageId], - clearSession, - clearInstance, - } - } + getSessionMessageIds: (sessionId: string) => state.sessions[sessionId]?.messageIds ?? [], + getMessage: (messageId: string) => state.messages[messageId], + getLatestTodoSnapshot: (sessionId: string) => state.latestTodos[sessionId], + clearSession, + clearInstance, + } + } + diff --git a/packages/ui/src/stores/message-v2/types.ts b/packages/ui/src/stores/message-v2/types.ts index e46b66b8..68bc72f2 100644 --- a/packages/ui/src/stores/message-v2/types.ts +++ b/packages/ui/src/stores/message-v2/types.ts @@ -88,6 +88,12 @@ export interface SessionUsageState { latestMessageId?: string } +export interface LatestTodoSnapshot { + messageId: string + partId: string + timestamp: number +} + export interface InstanceMessageState { instanceId: string sessions: Record @@ -99,6 +105,7 @@ export interface InstanceMessageState { permissions: InstancePermissionState usage: Record scrollState: Record + latestTodos: Record } export interface SessionUpsertInput {