Show latest todowrite plan in control panel
This commit is contained in:
@@ -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<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 connectionStatusClass = () => {
|
||||
const status = connectionStatus()
|
||||
@@ -709,11 +735,23 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
)
|
||||
|
||||
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 = [
|
||||
{
|
||||
id: "lsp",
|
||||
label: "LSP Servers",
|
||||
content: (
|
||||
render: () => (
|
||||
<InstanceServiceStatus
|
||||
instanceId={props.instance.id}
|
||||
initialInstance={props.instance}
|
||||
@@ -726,7 +764,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
{
|
||||
id: "mcp",
|
||||
label: "MCP Servers",
|
||||
content: (
|
||||
render: () => (
|
||||
<InstanceServiceStatus
|
||||
instanceId={props.instance.id}
|
||||
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[]) => {
|
||||
setRightPanelExpandedItems(values)
|
||||
}
|
||||
@@ -786,7 +835,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
||||
</Accordion.Trigger>
|
||||
</Accordion.Header>
|
||||
<Accordion.Content class="w-full px-3 pb-3 text-sm text-primary">
|
||||
{section.content}
|
||||
{section.render()}
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
)}
|
||||
|
||||
@@ -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 <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 {
|
||||
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 <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>
|
||||
)
|
||||
return <TodoListView state={state} />
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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<InstanceMessageState>(createInitialState(instanceId))
|
||||
|
||||
const TODO_TOOL_NAME = "todowrite"
|
||||
|
||||
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) {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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<string, SessionRecord>
|
||||
@@ -99,6 +105,7 @@ export interface InstanceMessageState {
|
||||
permissions: InstancePermissionState
|
||||
usage: Record<string, SessionUsageState>
|
||||
scrollState: Record<string, ScrollSnapshot>
|
||||
latestTodos: Record<string, LatestTodoSnapshot | undefined>
|
||||
}
|
||||
|
||||
export interface SessionUpsertInput {
|
||||
|
||||
Reference in New Issue
Block a user