Show latest todowrite plan in control panel

This commit is contained in:
Shantur Rathore
2025-12-14 15:05:09 +00:00
parent a6404f25d9
commit 75b3699649
4 changed files with 187 additions and 48 deletions

View File

@@ -10,6 +10,7 @@ import {
type Accessor, type Accessor,
type Component, type Component,
} from "solid-js" } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk"
import { Accordion } from "@kobalte/core" import { Accordion } from "@kobalte/core"
import { ChevronDown } from "lucide-solid" import { ChevronDown } from "lucide-solid"
import AppBar from "@suid/material/AppBar" import AppBar from "@suid/material/AppBar"
@@ -49,6 +50,7 @@ import AgentSelector from "../agent-selector"
import ModelSelector from "../model-selector" import ModelSelector from "../model-selector"
import CommandPalette from "../command-palette" import CommandPalette from "../command-palette"
import Kbd from "../kbd" import Kbd from "../kbd"
import { TodoListView } from "../tool-call/renderers/todo"
import ContextUsagePanel from "../session/context-usage-panel" import ContextUsagePanel from "../session/context-usage-panel"
import SessionView from "../session/session-view" import SessionView from "../session/session-view"
import { formatTokenTotal } from "../../lib/formatters" import { formatTokenTotal } from "../../lib/formatters"
@@ -273,6 +275,30 @@ const InstanceShell2: Component<InstanceShellProps> = (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<ToolState | null>(() => {
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 connectionStatus = () => sseManager.getStatus(props.instance.id)
const connectionStatusClass = () => { const connectionStatusClass = () => {
const status = connectionStatus() const status = connectionStatus()
@@ -709,11 +735,23 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
) )
const RightDrawerContent = () => { const RightDrawerContent = () => {
const renderPlanSectionContent = () => {
const sessionId = activeSessionIdForInstance()
if (!sessionId || sessionId === "info") {
return <p class="text-xs text-secondary">Select a session to view plan.</p>
}
const todoState = latestTodoState()
if (!todoState) {
return <p class="text-xs text-secondary">Nothing planned yet.</p>
}
return <TodoListView state={todoState} emptyLabel="Nothing planned yet." />
}
const sections = [ const sections = [
{ {
id: "lsp", id: "lsp",
label: "LSP Servers", label: "LSP Servers",
content: ( render: () => (
<InstanceServiceStatus <InstanceServiceStatus
instanceId={props.instance.id} instanceId={props.instance.id}
initialInstance={props.instance} initialInstance={props.instance}
@@ -726,7 +764,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
{ {
id: "mcp", id: "mcp",
label: "MCP Servers", label: "MCP Servers",
content: ( render: () => (
<InstanceServiceStatus <InstanceServiceStatus
instanceId={props.instance.id} instanceId={props.instance.id}
initialInstance={props.instance} initialInstance={props.instance}
@@ -736,8 +774,19 @@ const InstanceShell2: Component<InstanceShellProps> = (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[]) => { const handleAccordionChange = (values: string[]) => {
setRightPanelExpandedItems(values) setRightPanelExpandedItems(values)
} }
@@ -786,7 +835,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
</Accordion.Trigger> </Accordion.Trigger>
</Accordion.Header> </Accordion.Header>
<Accordion.Content class="w-full px-3 pb-3 text-sm text-primary"> <Accordion.Content class="w-full px-3 pb-3 text-sm text-primary">
{section.content} {section.render()}
</Accordion.Content> </Accordion.Content>
</Accordion.Item> </Accordion.Item>
)} )}

View File

@@ -5,7 +5,7 @@ import { readToolStatePayload } from "../utils"
export type TodoViewStatus = "pending" | "in_progress" | "completed" | "cancelled" export type TodoViewStatus = "pending" | "in_progress" | "completed" | "cancelled"
interface TodoViewItem { export interface TodoViewItem {
id: string id: string
content: string content: string
status: TodoViewStatus 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 <div class="tool-call-todo-empty">{props.emptyLabel ?? "No plan items yet."}</div>
}
return (
<div class="tool-call-todo-region">
<div class="tool-call-todos" role="list">
<For each={todos}>
{(todo) => {
const label = getTodoStatusLabel(todo.status)
return (
<div
class="tool-call-todo-item"
classList={{
"tool-call-todo-item-completed": todo.status === "completed",
"tool-call-todo-item-cancelled": todo.status === "cancelled",
"tool-call-todo-item-active": todo.status === "in_progress",
}}
role="listitem"
>
<span class="tool-call-todo-checkbox" data-status={todo.status} aria-label={label}></span>
<div class="tool-call-todo-body">
<div class="tool-call-todo-heading">
<span class="tool-call-todo-text">{todo.content}</span>
<span class={`tool-call-todo-status tool-call-todo-status-${todo.status}`}>{label}</span>
</div>
</div>
</div>
)
}}
</For>
</div>
</div>
)
}
export function getTodoTitle(state?: ToolState): string { export function getTodoTitle(state?: ToolState): string {
if (!state) return "Plan" if (!state) return "Plan"
@@ -80,42 +125,6 @@ export const todoRenderer: ToolRenderer = {
const state = toolState() const state = toolState()
if (!state) return null if (!state) return null
const todos = extractTodosFromState(state) return <TodoListView state={state} />
const counts = summarizeTodos(todos)
if (counts.total === 0) {
return <div class="tool-call-todo-empty">No plan items yet.</div>
}
return (
<div class="tool-call-todo-region">
<div class="tool-call-todos" role="list">
<For each={todos}>
{(todo) => {
const label = getTodoStatusLabel(todo.status)
return (
<div
class="tool-call-todo-item"
classList={{
"tool-call-todo-item-completed": todo.status === "completed",
"tool-call-todo-item-cancelled": todo.status === "cancelled",
"tool-call-todo-item-active": todo.status === "in_progress",
}}
role="listitem"
>
<span class="tool-call-todo-checkbox" data-status={todo.status} aria-label={label}></span>
<div class="tool-call-todo-body">
<div class="tool-call-todo-heading">
<span class="tool-call-todo-text">{todo.content}</span>
<span class={`tool-call-todo-status tool-call-todo-status-${todo.status}`}>{label}</span>
</div>
</div>
</div>
)
}}
</For>
</div>
</div>
)
}, },
} }

View File

@@ -6,6 +6,7 @@ import type { ClientPart, MessageInfo } from "../../types/message"
import { clearRecordDisplayCacheForMessages } from "./record-display-cache" import { clearRecordDisplayCacheForMessages } from "./record-display-cache"
import type { import type {
InstanceMessageState, InstanceMessageState,
LatestTodoSnapshot,
MessageRecord, MessageRecord,
MessageUpsertInput, MessageUpsertInput,
PartUpdateInput, PartUpdateInput,
@@ -41,6 +42,7 @@ function createInitialState(instanceId: string): InstanceMessageState {
}, },
usage: {}, usage: {},
scrollState: {}, scrollState: {},
latestTodos: {},
} }
} }
@@ -206,6 +208,7 @@ export interface InstanceMessageStore {
getSessionRevision: (sessionId: string) => number getSessionRevision: (sessionId: string) => number
getSessionMessageIds: (sessionId: string) => string[] getSessionMessageIds: (sessionId: string) => string[]
getMessage: (messageId: string) => MessageRecord | undefined getMessage: (messageId: string) => MessageRecord | undefined
getLatestTodoSnapshot: (sessionId: string) => LatestTodoSnapshot | undefined
clearSession: (sessionId: string) => void clearSession: (sessionId: string) => void
clearInstance: () => void clearInstance: () => void
} }
@@ -213,9 +216,55 @@ export interface InstanceMessageStore {
export function createInstanceMessageStore(instanceId: string, hooks?: MessageStoreHooks): InstanceMessageStore { export function createInstanceMessageStore(instanceId: string, hooks?: MessageStoreHooks): InstanceMessageStore {
const [state, setState] = createStore<InstanceMessageState>(createInitialState(instanceId)) const [state, setState] = createStore<InstanceMessageState>(createInitialState(instanceId))
const TODO_TOOL_NAME = "todowrite"
const messageInfoCache = new Map<string, MessageInfo>() const messageInfoCache = new Map<string, MessageInfo>()
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) { function bumpSessionRevision(sessionId: string) {
if (!sessionId) return if (!sessionId) return
setState("sessionRevisions", sessionId, (value = 0) => value + 1) setState("sessionRevisions", sessionId, (value = 0) => value + 1)
@@ -365,6 +414,10 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
updatedAt: Date.now(), updatedAt: Date.now(),
})) }))
Object.values(normalizedRecords).forEach((record) => {
maybeUpdateLatestTodoFromRecord(record)
})
bumpSessionRevision(sessionId) bumpSessionRevision(sessionId)
}) })
} }
@@ -405,9 +458,11 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
const shouldBump = Boolean(input.bumpRevision || normalizedParts) const shouldBump = Boolean(input.bumpRevision || normalizedParts)
const now = Date.now() const now = Date.now()
let nextRecord: MessageRecord | undefined
setState("messages", input.id, (previous) => { setState("messages", input.id, (previous) => {
const revision = previous ? previous.revision + (shouldBump ? 1 : 0) : 0 const revision = previous ? previous.revision + (shouldBump ? 1 : 0) : 0
return { const record: MessageRecord = {
id: input.id, id: input.id,
sessionId: input.sessionId, sessionId: input.sessionId,
role: input.role, role: input.role,
@@ -419,8 +474,14 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
partIds: normalizedParts ? normalizedParts.ids : previous?.partIds ?? [], partIds: normalizedParts ? normalizedParts.ids : previous?.partIds ?? [],
parts: normalizedParts ? normalizedParts.map : previous?.parts ?? {}, parts: normalizedParts ? normalizedParts.map : previous?.parts ?? {},
} }
nextRecord = record
return record
}) })
if (nextRecord) {
maybeUpdateLatestTodoFromRecord(nextRecord)
}
insertMessageIntoSession(input.sessionId, input.id) insertMessageIntoSession(input.sessionId, input.id)
flushPendingParts(input.id) flushPendingParts(input.id)
bumpSessionRevision(input.sessionId) bumpSessionRevision(input.sessionId)
@@ -472,6 +533,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 // Any part update can change the rendered height of the message
// list, so we treat it as a session revision for scroll purposes. // list, so we treat it as a session revision for scroll purposes.
bumpSessionRevision(message.sessionId) bumpSessionRevision(message.sessionId)
@@ -557,6 +626,7 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
setState("pendingParts", options.newId, pending) setState("pendingParts", options.newId, pending)
} }
clearPendingPartsForMessage(options.oldId) clearPendingPartsForMessage(options.oldId)
maybeUpdateLatestTodoFromRecord(cloned)
} }
function setMessageInfo(messageId: string, info: MessageInfo) { function setMessageInfo(messageId: string, info: MessageInfo) {
@@ -779,6 +849,8 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
setState("sessionOrder", (ids) => ids.filter((id) => id !== sessionId)) setState("sessionOrder", (ids) => ids.filter((id) => id !== sessionId))
}) })
clearLatestTodoSnapshot(sessionId)
hooks?.onSessionCleared?.(instanceId, sessionId) hooks?.onSessionCleared?.(instanceId, sessionId)
} }
@@ -812,10 +884,12 @@ export function createInstanceMessageStore(instanceId: string, hooks?: MessageSt
setScrollSnapshot, setScrollSnapshot,
getScrollSnapshot, getScrollSnapshot,
getSessionRevision: getSessionRevisionValue, getSessionRevision: getSessionRevisionValue,
getSessionMessageIds: (sessionId: string) => state.sessions[sessionId]?.messageIds ?? [], getSessionMessageIds: (sessionId: string) => state.sessions[sessionId]?.messageIds ?? [],
getMessage: (messageId: string) => state.messages[messageId], getMessage: (messageId: string) => state.messages[messageId],
clearSession, getLatestTodoSnapshot: (sessionId: string) => state.latestTodos[sessionId],
clearInstance, clearSession,
} clearInstance,
} }
}

View File

@@ -88,6 +88,12 @@ export interface SessionUsageState {
latestMessageId?: string latestMessageId?: string
} }
export interface LatestTodoSnapshot {
messageId: string
partId: string
timestamp: number
}
export interface InstanceMessageState { export interface InstanceMessageState {
instanceId: string instanceId: string
sessions: Record<string, SessionRecord> sessions: Record<string, SessionRecord>
@@ -99,6 +105,7 @@ export interface InstanceMessageState {
permissions: InstancePermissionState permissions: InstancePermissionState
usage: Record<string, SessionUsageState> usage: Record<string, SessionUsageState>
scrollState: Record<string, ScrollSnapshot> scrollState: Record<string, ScrollSnapshot>
latestTodos: Record<string, LatestTodoSnapshot | undefined>
} }
export interface SessionUpsertInput { export interface SessionUpsertInput {