diff --git a/docs/design/quark-message-content.md b/docs/design/quark-message-content.md new file mode 100644 index 0000000000..ea6344f6c3 --- /dev/null +++ b/docs/design/quark-message-content.md @@ -0,0 +1,94 @@ +# Quark-Owned Message Content Slots + +Status: implemented; drive parity pending + +## Summary + +Assistant message content currently lives as unkeyed arrays inside the +DataProvider Solid store. Streaming deltas mutate those arrays through +`produce`, and every consumer that resolves a part re-scans `content` +(`findLast`, `filter(type)[ordinal]`), so per-delta work scales with message +size instead of delta size. + +This experiment changes only content ownership: each assistant message's parts +become one Quark keyed collection with stable per-part slots. Streaming events +already name the slot on the wire — `ordinal` for text and reasoning, `callID` +for tools — so deltas address slots directly instead of being rediscovered. + +Message metadata (finish, usage, retry, error), the event protocol, timeline +rows, and view components' rendering logic remain unchanged. + +The experiment succeeds only if all checks pass: + +1. Existing TUI data and row tests preserve their behavior. +2. Per-delta work is O(1) in message size: one slot publication per delta, + zero structural publications, one markdown recompute scoped to the part. +3. The drive script passes unchanged. + +## Ownership Boundary + +``` +server events + | + v +DataProvider + ├── Solid store: session/message metadata (unchanged) + └── SessionContent: Keyed per assistant message <- this experiment + | | + | slots (structure) | slot (value channel) + v v + part row resolution TextPart / ReasoningPart / ToolPart +``` + +- Part identity: `text:{ordinal}` and `reasoning:{ordinal}` from event + ordinals, tool parts by `callID`. These match the synthetic part IDs the + timeline already uses, so `resolvePart`'s positional bridge disappears. +- Deltas are value-channel publications: `content.modify(key, ...)`. The + parts ``/group structure never re-runs on a delta. +- Part starts are structural insertions; `ended` events publish the + authoritative final value into the same slot. +- Full sync (`message.sync`) seeds each message's collection with `set(parts)` + derived from the fetched payload, assigning ordinals per type in order. +- Revert, session removal, and model-switch refetch drop or reseed collections. +- Cold consumers (transcript export, copy-last-response, message navigation, + permission lookup) read `values()` — plain arrays, same shapes as before. + +## Part Layout + +Tool identity fields are immutable; streamed fields are mutable. The field +diff mask means a tool status change skips text-driven work and vice versa. + +```ts +const PartLayout = Layout.keyedUnion({ + key: Layout.key("partID", Layout.string), + tag: "type", + variants: { + text: Layout.struct({ text: Layout.string }), + reasoning: Layout.struct({ text: Layout.string, state: ..., time: ... }), + tool: Layout.struct({ + id: Layout.immutable(Layout.string), + name: Layout.immutable(Layout.string), + state: ..., time: ..., executed: ..., providerState: ..., + }), + }, +}) +``` + +Store values are the client `SessionMessageAssistant*` content shapes plus the +synthetic `partID`; `values()` strips nothing because consumers already ignore +unknown fields. + +## What Is Not In Scope + +- Message metadata stays in the Solid store. +- No changes to server events or the client package types. +- No lazy or pull-based consumers; slots publish eagerly as today. +- Timeline row ownership is the previous experiment and is unchanged. + +## Measurement + +Instrument with `Keyed.Metrics`: a streaming test drives N deltas into a +message with M existing parts and asserts slot publications == N and +structural publications == parts started, independent of M. Markdown +recompute counts are asserted through the part component render counters in +the existing test harness. diff --git a/packages/quark/src/solid.ts b/packages/quark/src/solid.ts index 2ca1ecf7bd..6be648be09 100644 --- a/packages/quark/src/solid.ts +++ b/packages/quark/src/solid.ts @@ -1,10 +1,29 @@ -import { For, from, type Accessor, type JSX } from "solid-js" +import { createMemo, For, from, type Accessor, type JSX } from "solid-js" +import type { Keyed } from "./keyed" import type { Readable } from "./reactivity" export function useValue(readable: Readable): Accessor { return from(readable, readable()) } +/** + * Reactive accessor for one keyed slot. Tracks structure only until the slot + * exists; afterwards value changes flow through the slot alone, so unrelated + * structural churn cannot re-render the consumer. + */ +export function useSlot(keyed: Keyed.Keyed, key: () => Key): Accessor { + const structure = useValue(keyed.slots) + const slot = createMemo(() => { + structure() + return keyed.get(key()) + }) + const value = createMemo(() => { + const current = slot() + return current ? useValue(current) : undefined + }) + return () => value()?.() +} + export function KeyedFor(props: { readonly each: Accessor[]> readonly fallback?: JSX.Element diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 14990fb933..3dc776beee 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -19,9 +19,6 @@ import type { ReferenceInfo, SessionMessageInfo, SessionMessageAssistant, - SessionMessageAssistantReasoning, - SessionMessageAssistantText, - SessionMessageAssistantTool, SessionInfo, SessionPendingInfo, ShellInfo, @@ -33,6 +30,7 @@ import { createStore, produce, reconcile } from "solid-js/store" import { createSimpleContext } from "./helper" import { useClient } from "./client" import { createEffect, createSignal, onCleanup } from "solid-js" +import { SessionContent } from "../routes/session/content" export type DataSessionStatus = "idle" | "running" @@ -146,6 +144,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ directory: process.cwd(), }) const messageIndex = new Map>() + // Assistant content lives in per-message keyed part slots, not the Solid + // store: streaming deltas publish one slot instead of reconciling arrays. + const content = SessionContent.make() const sync = createSync() function setSessionActive(sessionID: string, status: DataSessionStatus) { @@ -199,20 +200,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ const item = messages.findLast((item) => item.type === "compaction" && item.status === "running") return item?.type === "compaction" ? item : undefined }, - latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) { - return assistant?.content.findLast( - (item): item is SessionMessageAssistantTool => - item.type === "tool" && (callID === undefined || item.id === callID), - ) - }, - latestText(assistant: SessionMessageAssistant | undefined) { - return assistant?.content.findLast((item): item is SessionMessageAssistantText => item.type === "text") - }, - latestReasoning(assistant: SessionMessageAssistant | undefined) { - return assistant?.content.findLast( - (item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && !item.time?.completed, - ) - }, } function index(sessionID: string) { @@ -269,6 +256,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ function removeSession(sessionID: string) { messageIndex.delete(sessionID) + content.drop(sessionID) sync.invalidate(`session:${sessionID}`) sync.invalidate(`session.pending:${sessionID}`) sync.invalidate(`session.message:${sessionID}`) @@ -353,6 +341,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ void client.api.session .message({ sessionID: event.data.sessionID, messageID: messageIDFromEvent(event.id) }) .then((item) => { + if (item.type === "assistant") content.seed(event.data.sessionID, item.id, item.content) message.update(event.data.sessionID, (draft, index) => { const position = index.get(item.id) if (position === undefined) return message.append(draft, index, item) @@ -540,143 +529,142 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ } }) break - case "session.text.started": - message.update(event.data.sessionID, (draft, index) => { - message.assistant(draft, index, event.data.assistantMessageID)?.content.push({ - type: "text", - text: "", - }) - }) + case "session.text.started": { + const parts = content.ensure(event.data.sessionID, event.data.assistantMessageID) + const partID = SessionContent.textID(event.data.ordinal) + if (!parts.has(partID)) parts.insert({ type: "text", text: "", partID }) break + } case "session.text.delta": - message.update(event.data.sessionID, (draft, index) => { - const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID)) - if (match) match.text += event.data.delta - }) + content + .get(event.data.sessionID, event.data.assistantMessageID) + ?.modify(SessionContent.textID(event.data.ordinal), (part) => + part.type === "text" ? { ...part, text: part.text + event.data.delta } : part, + ) break case "session.text.ended": - message.update(event.data.sessionID, (draft, index) => { - const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID)) - if (match) match.text = event.data.text - }) + content + .get(event.data.sessionID, event.data.assistantMessageID) + ?.modify(SessionContent.textID(event.data.ordinal), (part) => + part.type === "text" ? { ...part, text: event.data.text } : part, + ) break - case "session.tool.input.started": - message.update(event.data.sessionID, (draft, index) => { - message.assistant(draft, index, event.data.assistantMessageID)?.content.push({ + case "session.tool.input.started": { + const parts = content.ensure(event.data.sessionID, event.data.assistantMessageID) + if (!parts.has(event.data.callID)) + parts.insert({ type: "tool", id: event.data.callID, name: event.data.name, time: { created: event.created }, state: { status: "streaming", input: "" }, + partID: event.data.callID, }) - }) break + } case "session.tool.input.delta": - message.update(event.data.sessionID, (draft, index) => { - const match = message.latestTool( - message.assistant(draft, index, event.data.assistantMessageID), - event.data.callID, - ) - if (match?.state.status === "streaming") match.state.input += event.data.delta + content.get(event.data.sessionID, event.data.assistantMessageID)?.modify(event.data.callID, (part) => { + if (part.type !== "tool" || part.state.status !== "streaming") return part + return { ...part, state: { ...part.state, input: part.state.input + event.data.delta } } }) break case "session.tool.input.ended": - message.update(event.data.sessionID, (draft, index) => { - const match = message.latestTool( - message.assistant(draft, index, event.data.assistantMessageID), - event.data.callID, - ) - if (match?.state.status === "streaming") match.state.input = event.data.text + content.get(event.data.sessionID, event.data.assistantMessageID)?.modify(event.data.callID, (part) => { + if (part.type !== "tool" || part.state.status !== "streaming") return part + return { ...part, state: { ...part.state, input: event.data.text } } }) break case "session.tool.called": - message.update(event.data.sessionID, (draft, index) => { - const match = message.latestTool( - message.assistant(draft, index, event.data.assistantMessageID), - event.data.callID, - ) - if (!match) return - match.time.ran = event.created - match.executed = event.data.executed - match.providerState = event.data.state - match.state = { status: "running", input: event.data.input, structured: {}, content: [] } + content.get(event.data.sessionID, event.data.assistantMessageID)?.modify(event.data.callID, (part) => { + if (part.type !== "tool") return part + return { + ...part, + time: { ...part.time, ran: event.created }, + executed: event.data.executed, + providerState: event.data.state, + state: { status: "running", input: event.data.input, structured: {}, content: [] }, + } }) break case "session.tool.progress": - message.update(event.data.sessionID, (draft, index) => { - const match = message.latestTool( - message.assistant(draft, index, event.data.assistantMessageID), - event.data.callID, - ) - if (match?.state.status !== "running") return - match.state.structured = event.data.structured - match.state.content = [...event.data.content] + content.get(event.data.sessionID, event.data.assistantMessageID)?.modify(event.data.callID, (part) => { + if (part.type !== "tool" || part.state.status !== "running") return part + return { + ...part, + state: { ...part.state, structured: event.data.structured, content: [...event.data.content] }, + } }) break case "session.tool.success": - message.update(event.data.sessionID, (draft, index) => { - const match = message.latestTool( - message.assistant(draft, index, event.data.assistantMessageID), - event.data.callID, - ) - if (match?.state.status !== "running") return - match.state = { - status: "completed", - input: match.state.input, - structured: event.data.structured, - content: [...event.data.content], - result: event.data.result, + content.get(event.data.sessionID, event.data.assistantMessageID)?.modify(event.data.callID, (part) => { + if (part.type !== "tool" || part.state.status !== "running") return part + return { + ...part, + state: { + status: "completed", + input: part.state.input, + structured: event.data.structured, + content: [...event.data.content], + result: event.data.result, + }, + executed: event.data.executed || part.executed === true, + providerResultState: event.data.resultState, + time: { ...part.time, completed: event.created }, } - match.executed = event.data.executed || match.executed === true - match.providerResultState = event.data.resultState - match.time.completed = event.created }) break case "session.tool.failed": - message.update(event.data.sessionID, (draft, index) => { - const match = message.latestTool( - message.assistant(draft, index, event.data.assistantMessageID), - event.data.callID, - ) - if (!match || (match.state.status !== "streaming" && match.state.status !== "running")) return - match.state = { - status: "error", - error: event.data.error, - input: typeof match.state.input === "string" ? {} : match.state.input, - structured: match.state.status === "running" ? match.state.structured : {}, - content: match.state.status === "running" ? match.state.content : [], - result: event.data.result, + content.get(event.data.sessionID, event.data.assistantMessageID)?.modify(event.data.callID, (part) => { + if (part.type !== "tool" || (part.state.status !== "streaming" && part.state.status !== "running")) + return part + return { + ...part, + state: { + status: "error", + error: event.data.error, + input: typeof part.state.input === "string" ? {} : part.state.input, + structured: part.state.status === "running" ? part.state.structured : {}, + content: part.state.status === "running" ? part.state.content : [], + result: event.data.result, + }, + executed: event.data.executed || part.executed === true, + providerResultState: event.data.resultState, + time: { ...part.time, completed: event.created }, } - match.executed = event.data.executed || match.executed === true - match.providerResultState = event.data.resultState - match.time.completed = event.created }) break - case "session.reasoning.started": - message.update(event.data.sessionID, (draft, index) => { - message.assistant(draft, index, event.data.assistantMessageID)?.content.push({ + case "session.reasoning.started": { + const parts = content.ensure(event.data.sessionID, event.data.assistantMessageID) + const partID = SessionContent.reasoningID(event.data.ordinal) + if (!parts.has(partID)) + parts.insert({ type: "reasoning", text: "", state: event.data.state, time: { created: event.created }, + partID, }) - }) break + } case "session.reasoning.delta": - message.update(event.data.sessionID, (draft, index) => { - const match = message.latestReasoning(message.assistant(draft, index, event.data.assistantMessageID)) - if (match) match.text += event.data.delta - }) + content + .get(event.data.sessionID, event.data.assistantMessageID) + ?.modify(SessionContent.reasoningID(event.data.ordinal), (part) => + part.type === "reasoning" ? { ...part, text: part.text + event.data.delta } : part, + ) break case "session.reasoning.ended": - message.update(event.data.sessionID, (draft, index) => { - const match = message.latestReasoning(message.assistant(draft, index, event.data.assistantMessageID)) - if (match) { - match.text = event.data.text - match.time = { created: match.time?.created ?? event.created, completed: event.created } - if (event.data.state !== undefined) match.state = event.data.state - } - }) + content + .get(event.data.sessionID, event.data.assistantMessageID) + ?.modify(SessionContent.reasoningID(event.data.ordinal), (part) => { + if (part.type !== "reasoning") return part + return { + ...part, + text: event.data.text, + time: { created: part.time?.created ?? event.created, completed: event.created }, + state: event.data.state !== undefined ? event.data.state : part.state, + } + }) break case "session.retry.scheduled": message.update(event.data.sessionID, (draft, index) => { @@ -745,7 +733,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ message.update(event.data.sessionID, (draft, index) => { const position = draft.findIndex((item) => item.id >= event.data.to) if (position === -1) return - for (const item of draft.splice(position)) index.delete(item.id) + for (const item of draft.splice(position)) { + index.delete(item.id) + content.drop(event.data.sessionID, item.id) + } }) break case "session.compaction.delta": @@ -964,9 +955,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ await client.api.message.list({ sessionID, limit: 200, order: "desc" }) ).data.toReversed() messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index]))) + content.drop(sessionID) + for (const item of messages) { + if (item.type === "assistant") content.seed(sessionID, item.id, item.content) + } setStore("session", "message", sessionID, reconcile(messages)) }) }, + parts(sessionID: string, messageID: string) { + return content.ensure(sessionID, messageID) + }, invalidate(sessionID: string) { sync.invalidate(`session.message:${sessionID}`) }, diff --git a/packages/tui/src/routes/session/content.ts b/packages/tui/src/routes/session/content.ts new file mode 100644 index 0000000000..4e4e737da9 --- /dev/null +++ b/packages/tui/src/routes/session/content.ts @@ -0,0 +1,91 @@ +import type { SessionMessageAssistant } from "@opencode-ai/client" +import { Keyed, Layout } from "effect-quark" + +/** + * Stable per-part reactive slots for assistant message content. + * + * Streaming events name their part on the wire (text/reasoning by ordinal, + * tools by callID); each assistant message owns one keyed collection so a + * delta publishes exactly one slot instead of reconciling a content array. + * See docs/design/quark-message-content.md. + */ +export namespace SessionContent { + type ContentPart = SessionMessageAssistant["content"][number] + export type Part = ContentPart & { readonly partID: string } + export type Parts = Keyed.Keyed + + // Streamed sub-objects are replaced immutably on change, so reference + // equality is the correct (and cheapest) field comparator for them. + const reference: Layout.Field = { equivalent: Object.is } + + const PartLayout = Layout.keyedUnion({ + key: Layout.key("partID", Layout.string), + tag: "type", + variants: { + text: Layout.struct({ text: Layout.string }), + reasoning: Layout.struct({ text: Layout.string, state: reference, time: reference }), + tool: Layout.struct({ + id: Layout.immutable(Layout.string), + name: Layout.immutable(Layout.string), + executed: reference, + providerState: reference, + providerResultState: reference, + state: reference, + time: reference, + }), + }, + }) + // The layout describes the reactive fields of the client content shapes; + // the plan is typed against those shapes at this single boundary. + const PartPlan = Layout.compile(PartLayout) as unknown as Layout.Plan + + export function textID(ordinal: number) { + return `text:${ordinal}` + } + + export function reasoningID(ordinal: number) { + return `reasoning:${ordinal}` + } + + /** Assign the synthetic part IDs used across the TUI to a fetched content array. */ + export function withPartIDs(content: readonly ContentPart[]): Part[] { + const ordinals = { text: 0, reasoning: 0 } + return content.map((part) => { + if (part.type === "tool") return { ...part, partID: part.id } + return { ...part, partID: `${part.type}:${ordinals[part.type]++}` } + }) + } + + export function make(options?: { readonly metrics?: Keyed.Metrics }) { + const collections = new Map() + const id = (sessionID: string, messageID: string) => `${sessionID}\u0000${messageID}` + + return { + get(sessionID: string, messageID: string) { + return collections.get(id(sessionID, messageID)) + }, + ensure(sessionID: string, messageID: string) { + const key = id(sessionID, messageID) + const existing = collections.get(key) + if (existing) return existing + const created = PartPlan.make([], options) + collections.set(key, created) + return created + }, + seed(sessionID: string, messageID: string, content: readonly ContentPart[]) { + this.ensure(sessionID, messageID).set(withPartIDs(content)) + }, + /** Drop one message's collection, or every collection for a session. */ + drop(sessionID: string, messageID?: string) { + if (messageID !== undefined) { + collections.delete(id(sessionID, messageID)) + return + } + const prefix = `${sessionID}\u0000` + for (const key of [...collections.keys()]) { + if (key.startsWith(prefix)) collections.delete(key) + } + }, + } + } +} diff --git a/packages/tui/src/routes/session/dialog-message.tsx b/packages/tui/src/routes/session/dialog-message.tsx index 8935524634..e221619177 100644 --- a/packages/tui/src/routes/session/dialog-message.tsx +++ b/packages/tui/src/routes/session/dialog-message.tsx @@ -62,7 +62,9 @@ export function DialogMessage(props: { value.type === "user" ? value.text : value.type === "assistant" - ? value.content + ? data.session.message + .parts(props.sessionID, props.messageID) + .values() .filter((content) => content.type === "text") .map((content) => content.text) .join("\n") diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 942e173a84..d5967e239e 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -13,7 +13,8 @@ import { Switch, useContext, } from "solid-js" -import { KeyedFor } from "effect-quark/solid" +import { KeyedFor, useSlot, useValue } from "effect-quark/solid" +import { SessionContent } from "./content" import path from "node:path" import { EOL, tmpdir } from "node:os" import { mkdir, writeFile } from "node:fs/promises" @@ -74,7 +75,7 @@ import { Keymap, type KeymapCommand } from "../../context/keymap" import { usePathFormatter } from "../../context/path-format" import { useLocation } from "../../context/location" import { createSessionRows } from "./rows" -import { messageBoundaryIDs, resolvePart, type PartRef, type SessionRow } from "./timeline" +import { messageBoundaryIDs, type PartRef, type SessionRow } from "./timeline" import { switchLabel } from "../../util/model" import { findMessageBoundary, messageNavigationSlack } from "./message-navigation" import { stringWidth } from "../../util/string-width" @@ -312,6 +313,11 @@ export function Session() { direction, children: scroll.getChildren(), messages: messages(), + hasText: (messageID) => + data.session.message + .parts(route.sessionID, messageID) + .values() + .some((part) => part.type === "text" && part.text.trim()), scrollTop: scroll.scrollTop, viewportY: scroll.viewport.y, currentID: navigationMessage(), @@ -681,7 +687,10 @@ export function Session() { return } - const textParts = lastAssistantMessage.content.filter((part) => part.type === "text") + const textParts = data.session.message + .parts(route.sessionID, lastAssistantMessage.id) + .values() + .filter((part) => part.type === "text") if (textParts.length === 0) { toast.show({ message: "No text parts found in last assistant message", variant: "error" }) dialog.clear() @@ -719,7 +728,9 @@ export function Session() { try { const sessionData = session() if (!sessionData) return - const transcript = formatSessionTranscript(sessionData, messages(), showThinking()) + const transcript = formatSessionTranscript(sessionData, messages(), showThinking(), (messageID) => + data.session.message.parts(route.sessionID, messageID).values(), + ) await clipboard.write?.(transcript) toast.show({ message: "Session transcript copied to clipboard!", variant: "success" }) } catch { @@ -746,7 +757,9 @@ export function Session() { const content = options.format === "markdown" - ? formatSessionTranscript(sessionData, messages(), options.thinking) + ? formatSessionTranscript(sessionData, messages(), options.thinking, (messageID) => + data.session.message.parts(route.sessionID, messageID).values(), + ) : await (async () => { if (options.debug) { const events: { readonly created: number }[] = [] @@ -934,11 +947,15 @@ export function Session() { data.session.message.get(route.sessionID, messageID)} + parts={(messageID) => data.session.message.parts(route.sessionID, messageID)} boundaryID={boundaries()[index()]} /> )} - + data.session.message.parts(route.sessionID, messageID)} + /> SessionMessageInfo | undefined + parts: (messageID: string) => SessionContent.Parts boundaryID?: string }) { return ( @@ -1042,10 +1060,17 @@ function SessionRowView(props: { - {(row) => } + {(row) => } - {(row) => } + {(row) => ( + + )} {(row) => ( @@ -1054,6 +1079,7 @@ function SessionRowView(props: { pending={row().pending} completed={row().completed} message={props.message} + parts={props.parts} /> )} @@ -1073,21 +1099,29 @@ function SessionRowView(props: { ) } -function BackgroundToolHint(props: { messages: SessionMessageInfo[] }) { +function BackgroundToolHint(props: { + messages: SessionMessageInfo[] + parts: (messageID: string) => SessionContent.Parts +}) { const { themeV2 } = useTheme() const shortcut = Keymap.useShortcut("session.background") - const visible = createMemo(() => { - const current = props.messages.findLast( + const current = createMemo(() => + props.messages.findLast( (message): message is SessionMessageAssistant => message.type === "assistant" && !message.time.completed, - ) - return ( - current?.content.some((part) => { + ), + ) + const parts = createMemo(() => { + const message = current() + return message ? useValue(props.parts(message.id).values) : undefined + }) + const visible = createMemo( + () => + parts()?.()?.some((part) => { if (part.type !== "tool" || part.state.status !== "running") return false const display = toolDisplay(part.name) return display === "shell" || display === "subagent" - }) ?? false - ) - }) + }) ?? false, + ) return ( {(value) => ( @@ -1127,13 +1161,15 @@ function SessionMessageView(props: { message: SessionMessageInfo }) { ) } -function SessionPartView(props: { partRef: PartRef; message: (messageID: string) => SessionMessageInfo | undefined }) { +function SessionPartView(props: { + partRef: PartRef + message: (messageID: string) => SessionMessageInfo | undefined + parts: (messageID: string) => SessionContent.Parts +}) { const message = createMemo(() => props.message(props.partRef.messageID)) - const part = createMemo(() => { - const item = message() - if (item?.type !== "assistant") return - return resolvePart(item, props.partRef.partID) - }) + // One reactive slot per part: unrelated deltas in the same message cannot + // re-render this row. + const part = useSlot(props.parts(props.partRef.messageID), () => props.partRef.partID) return ( {(item) => ( @@ -1161,17 +1197,26 @@ function SessionReasoningGroupView(props: { refs: readonly PartRef[] completed: boolean message: (messageID: string) => SessionMessageInfo | undefined + parts: (messageID: string) => SessionContent.Parts }) { const ctx = use() const { themeV2, syntax } = useTheme() const renderer = useRenderer() const [expanded, setExpanded] = createSignal(false) const [hover, setHover] = createSignal(false) + // Slot accessors are created per ref list; value changes flow through the + // individual slots without re-running the accessor construction. + const accessors = createMemo(() => + props.refs.map((ref) => ({ + ref, + part: useSlot(props.parts(ref.messageID), () => ref.partID), + })), + ) const parts = createMemo(() => - props.refs.flatMap((ref) => { - const message = props.message(ref.messageID) + accessors().flatMap((entry) => { + const message = props.message(entry.ref.messageID) if (message?.type !== "assistant") return [] - const part = resolvePart(message, ref.partID) + const part = entry.part() if (part?.type !== "reasoning" || !reasoningContent(part)) return [] return [{ message, part }] }), @@ -1196,7 +1241,11 @@ function SessionReasoningGroupView(props: { 0}> {(ref) => }} + fallback={ + + {(ref) => } + + } > {(ref) => { - const message = createMemo(() => { - const item = props.message(ref.messageID) - return item?.type === "assistant" ? item : undefined - }) + const slot = useSlot(props.parts(ref.messageID), () => ref.partID) const part = createMemo(() => { - const item = message() - if (!item) return undefined - const part = resolvePart(item, ref.partID) - return part?.type === "reasoning" ? part : undefined + const item = slot() + return item?.type === "reasoning" ? item : undefined + }) + const messageCompleted = createMemo(() => { + const item = props.message(ref.messageID) + return item?.type === "assistant" && item.time.completed !== undefined }) const content = createMemo(() => { const item = part() @@ -1258,7 +1306,7 @@ function SessionReasoningGroupView(props: { SessionMessageInfo | undefined + parts: (messageID: string) => SessionContent.Parts }) { const { themeV2 } = useTheme() const ctx = use() const renderer = useRenderer() const [expanded, setExpanded] = createSignal(false) const [hover, setHover] = createSignal(false) - const parts = (refs: readonly PartRef[]) => - refs.flatMap((ref) => { - const message = props.message(ref.messageID) - if (message?.type !== "assistant") return [] - const part = resolvePart(message, ref.partID) + // Slot accessors per ref list: tool state changes flow through individual + // slots; the accessor lists rebuild only when the refs themselves change. + const slots = (refs: () => readonly PartRef[]) => + createMemo(() => refs().map((ref) => useSlot(props.parts(ref.messageID), () => ref.partID))) + const groupedSlots = slots(() => props.refs) + const pendingSlots = slots(() => props.pending) + const parts = (accessors: readonly (() => SessionContent.Part | undefined)[]) => + accessors.flatMap((accessor) => { + const part = accessor() if (part?.type !== "tool") return [] return [part] }) - const grouped = createMemo(() => parts(props.refs)) - const pending = createMemo(() => parts(props.pending)) + const grouped = createMemo(() => parts(groupedSlots())) + const pending = createMemo(() => parts(pendingSlots())) const label = createMemo(() => { const counts = grouped().reduce>((result, part) => { const tool = toolDisplay(part.name) @@ -1713,124 +1766,6 @@ function UserMessage(props: { message: SessionMessageUser }) { ) } -function AssistantMessage(props: { message: SessionMessageAssistant; last: boolean }) { - const ctx = use() - const local = useLocal() - const { themeV2 } = useTheme().contextual("elevated") - const model = createMemo( - () => - ctx - .models() - .find((model) => model.providerID === props.message.model.providerID && model.id === props.message.model.id) - ?.name ?? `${props.message.model.providerID}/${props.message.model.id}`, - ) - - const final = createMemo(() => { - return props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish) - }) - - const duration = createMemo(() => { - if (!final()) return 0 - if (!props.message.time.completed) return 0 - return props.message.time.completed - props.message.time.created - }) - - const exploration = createMemo(() => { - const grouped = new Map() - if (!ctx.groupExploration()) return grouped - const runs = props.message.content - .map((part) => - part.type === "tool" && - ["read", "glob", "grep"].includes(toolDisplay(part.name)) && - part.state.status !== "streaming" - ? part - : undefined, - ) - .reduce( - (runs, part) => { - if (part) runs[runs.length - 1].push(part) - if (!part && runs[runs.length - 1].length) runs.push([]) - return runs - }, - [[]], - ) - .filter((run) => run.length > 0) - for (const run of runs) { - const summary = { - parts: run, - active: false, - } - run.forEach((part, index) => grouped.set(part.id, { ...summary, first: index === 0 })) - } - return grouped - }) - - return ( - <> - - {(content, index) => ( - - - - - - - - - - } - > - {(summary) => } - - - - - )} - - - - {errorMessage(props.message.error)} - - - - - - - - - {Locale.titlecase(props.message.agent)} - - · {model()} - - · {Locale.duration(duration())} - - - - - - - ) -} - function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) { const { themeV2 } = useTheme() return ( @@ -3035,13 +2970,18 @@ function recordValue(value: unknown): Record | undefined { return value as Record } -function formatSessionTranscript(session: SessionInfo, messages: SessionMessageInfo[], thinking: boolean) { +function formatSessionTranscript( + session: SessionInfo, + messages: SessionMessageInfo[], + thinking: boolean, + partsOf: (messageID: string) => readonly SessionContent.Part[], +) { const body = messages.flatMap((message) => { if (message.type === "user") return [`## User\n\n${message.text}`] if (message.type === "shell") return [`## Shell\n\n\`\`\`\n$ ${message.command}\n${message.output?.output ?? ""}\n\`\`\``] if (message.type !== "assistant") return [] - const content = message.content.flatMap((item) => { + const content = partsOf(message.id).flatMap((item) => { if (item.type === "text") return [item.text] if (item.type === "reasoning") return thinking ? [`_Thinking:_\n\n${item.text}`] : [] const input = typeof item.state.input === "string" ? item.state.input : JSON.stringify(item.state.input, null, 2) diff --git a/packages/tui/src/routes/session/message-navigation.ts b/packages/tui/src/routes/session/message-navigation.ts index 5e75fd5df1..29c2125440 100644 --- a/packages/tui/src/routes/session/message-navigation.ts +++ b/packages/tui/src/routes/session/message-navigation.ts @@ -19,6 +19,7 @@ export function findMessageBoundary(input: { direction: "next" | "prev" children: readonly MessageChild[] messages: readonly SessionMessageInfo[] + hasText?: (messageID: string) => boolean scrollTop: number viewportY: number currentID?: string @@ -35,7 +36,9 @@ export function findMessageBoundary(input: { return [{ id: child.id, y, top: y }] } if (input.userOnly || message.type !== "assistant") return [] - if (!message.content.some((content) => content.type === "text" && content.text.trim())) return [] + const hasText = + input.hasText?.(message.id) ?? message.content.some((content) => content.type === "text" && content.text.trim()) + if (!hasText) return [] const y = input.scrollTop + child.y - input.viewportY return [{ id: child.id, y, top: Math.max(0, y - 1) }] }) diff --git a/packages/tui/src/routes/session/permission.tsx b/packages/tui/src/routes/session/permission.tsx index 3b3ecf41af..97c3852276 100644 --- a/packages/tui/src/routes/session/permission.tsx +++ b/packages/tui/src/routes/session/permission.tsx @@ -146,9 +146,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director const input = createMemo(() => { const tool = props.request.source if (!tool) return {} - const message = data.session.message.get(props.request.sessionID, tool.messageID) - if (message?.type !== "assistant") return {} - const part = message.content.find((part) => part.type === "tool" && part.id === tool.callID) + const part = data.session.message.parts(props.request.sessionID, tool.messageID).get(tool.callID)?.() if (part?.type === "tool" && part.state.status !== "streaming") return part.state.input return {} }) @@ -167,7 +165,9 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director - This will allow the following patterns until OpenCode is restarted + + This will allow the following patterns until OpenCode is restarted + {(pattern) => ( @@ -643,10 +643,7 @@ function Prompt>(props: { ] : []), ], - bindings: [ - ...(props.escapeKey ? ["app.exit"] : []), - ...(props.fullscreen ? ["permission.prompt.fullscreen"] : []), - ], + bindings: [...(props.escapeKey ? ["app.exit"] : []), ...(props.fullscreen ? ["permission.prompt.fullscreen"] : [])], })) const hint = createMemo(() => (store.expanded ? "minimize" : "fullscreen")) @@ -703,20 +700,14 @@ function Prompt>(props: { setStore("selected", option)} onMouseUp={() => { setStore("selected", option) props.onSelect(option) }} > - + {props.options[option]} @@ -726,7 +717,8 @@ function Prompt>(props: { - {shortcuts.get("permission.prompt.fullscreen")} {hint()} + {shortcuts.get("permission.prompt.fullscreen")}{" "} + {hint()} diff --git a/packages/tui/src/routes/session/rows.ts b/packages/tui/src/routes/session/rows.ts index 0af3239b0d..ec3c42c153 100644 --- a/packages/tui/src/routes/session/rows.ts +++ b/packages/tui/src/routes/session/rows.ts @@ -42,7 +42,11 @@ export function createSessionRows(sessionID: Accessor, options?: { reado const messages = data.session.message.list(sessionID()) const inputs = new Set(data.session.input.list(sessionID())) const boundary = revertBoundary() - const rows = reduceSessionRows(boundary ? messages.filter((message) => message.id < boundary) : messages, inputs) + const rows = reduceSessionRows( + boundary ? messages.filter((message) => message.id < boundary) : messages, + inputs, + (message) => data.session.message.parts(sessionID(), message.id).values(), + ) const position = rows.findIndex((row) => row.type === "message" && inputs.has(row.messageID)) rows.splice( position === -1 ? rows.length : position, diff --git a/packages/tui/src/routes/session/timeline.ts b/packages/tui/src/routes/session/timeline.ts index be59c6353d..1b70203622 100644 --- a/packages/tui/src/routes/session/timeline.ts +++ b/packages/tui/src/routes/session/timeline.ts @@ -1,5 +1,6 @@ import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client" import { Keyed, Layout, Transaction } from "effect-quark" +import { SessionContent } from "./content" const PartRefLayout = Layout.struct({ messageID: Layout.string, @@ -180,7 +181,11 @@ export namespace SessionTimeline { } } -export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new Set()) { +export function reduceSessionRows( + messages: SessionMessageInfo[], + inputs = new Set(), + partsOf?: (message: SessionMessageAssistant & { id: string }) => readonly SessionContent.Part[], +) { const isInput = (message: SessionMessageInfo) => inputs.has(message.id) const pendingCompactions = messages.filter((message) => message.type === "compaction" && message.status === "running") const pending = new Set([...pendingCompactions.map((message) => message.id), ...inputs]) @@ -195,11 +200,10 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S rows.push(messageRow(message.id)) return rows } - const ordinals = { text: 0, reasoning: 0 } - message.content.forEach((part) => { - const partID = part.type === "tool" ? part.id : `${part.type}:${ordinals[part.type]++}` + const parts = partsOf ? partsOf(message) : SessionContent.withPartIDs(message.content) + parts.forEach((part) => { if ((part.type === "text" || part.type === "reasoning") && !part.text.trim()) return - append(rows, { messageID: message.id, partID }, part) + append(rows, { messageID: message.id, partID: part.partID }, part) }) if (isTerminalFinish(message.finish) || message.error || message.retry) { completePrevious(rows) @@ -239,15 +243,6 @@ function rowBoundaryMessageID(row: SessionRow, messages: Map part.type === "tool" && part.id === partID) - if (tool) return tool - const match = /^(text|reasoning):(\d+)$/.exec(partID) - if (!match) return - const ordinal = Number(match[2]) - return message.content.filter((part) => part.type === match[1])[ordinal] -} - export function isTerminalFinish(finish: string | undefined) { return !!finish && !["tool-calls", "unknown"].includes(finish) } diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index fd39b0f744..a14a842846 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -883,6 +883,96 @@ test("classifies live tool rows independently of their call ID", async () => { } }) +test("publishes streaming deltas to one part slot without touching siblings", async () => { + const events = createEventStream() + const sessionID = "session-part-slots" + const calls = createFetch((url) => { + if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} }) + }, events) + let data!: ReturnType + let client!: ReturnType + + function Probe() { + data = useData() + client = useClient() + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await wait(() => client.connection.status() === "connected") + emitEvent(events, { + id: "evt_slots_step", + created: 1, + type: "session.step.started", + durable: durable(sessionID), + data: { + sessionID, + assistantMessageID: "message-assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + }, + }) + emitEvent(events, { + id: "evt_slots_tool", + created: 2, + type: "session.tool.input.started", + durable: durable(sessionID, 1), + data: { sessionID, assistantMessageID: "message-assistant", callID: "call-slots", name: "read" }, + }) + emitEvent(events, { + id: "evt_slots_text", + created: 3, + type: "session.text.started", + durable: durable(sessionID, 2), + data: { sessionID, assistantMessageID: "message-assistant", ordinal: 0 }, + }) + const parts = data.session.message.parts(sessionID, "message-assistant") + await wait(() => parts.get("text:0") !== undefined && parts.get("call-slots") !== undefined) + + const publications = { text: 0, tool: 0, structure: 0 } + const disposers = [ + parts.get("text:0")!.subscribe(() => publications.text++), + parts.get("call-slots")!.subscribe(() => publications.tool++), + parts.slots.subscribe(() => publications.structure++), + ] + emitEvent(events, { + id: "evt_slots_delta_1", + created: 4, + type: "session.text.delta", + data: { sessionID, assistantMessageID: "message-assistant", ordinal: 0, delta: "one" }, + }) + emitEvent(events, { + id: "evt_slots_delta_2", + created: 5, + type: "session.text.delta", + data: { sessionID, assistantMessageID: "message-assistant", ordinal: 0, delta: "two" }, + }) + await wait(() => { + const part = parts.get("text:0")?.() + return part?.type === "text" && part.text === "onetwo" + }) + + // One slot publication per delta; the sibling tool slot and the part + // structure never publish, independent of message size. + expect(publications).toEqual({ text: 2, tool: 0, structure: 0 }) + disposers.forEach((dispose) => dispose()) + } finally { + app.renderer.destroy() + } +}) + test("does not publish timeline rows for duplicate streaming deltas", async () => { const events = createEventStream() const sessionID = "session-stream-metrics" @@ -950,10 +1040,8 @@ test("does not publish timeline rows for duplicate streaming deltas", async () = data: { sessionID, assistantMessageID: "message-assistant", ordinal: 0, delta: "two" }, }) await wait(() => { - const message = data.session.message.get(sessionID, "message-assistant") - return ( - message?.type === "assistant" && message.content.some((part) => part.type === "text" && part.text === "onetwo") - ) + const part = data.session.message.parts(sessionID, "message-assistant").get("text:0")?.() + return part?.type === "text" && part.text === "onetwo" }) expect(afterFirst).toEqual({ slotPublications: 0, structuralPublications: 1, equivalenceSuppressions: 0 }) @@ -2482,12 +2570,9 @@ test("settles pending tools when a live failure arrives", async () => { }) await wait(() => { - const assistant = sync.session.message.get("session-1", "msg_explicit_assistant_9") + const part = sync.session.message.parts("session-1", "msg_explicit_assistant_9").get("call-1")?.() return ( - assistant?.type === "assistant" && - assistant.content[0]?.type === "tool" && - assistant.content[0].state.status === "running" && - assistant.content[0].state.structured.sessionID === "session-child" + part?.type === "tool" && part.state.status === "running" && part.state.structured.sessionID === "session-child" ) }) @@ -2507,19 +2592,15 @@ test("settles pending tools when a live failure arrives", async () => { }) await wait(() => { - const assistant = sync.session.message.get("session-1", "msg_explicit_assistant_9") - return ( - assistant?.type === "assistant" && - assistant.content[0]?.type === "tool" && - assistant.content[0].state.status === "error" - ) + const part = sync.session.message.parts("session-1", "msg_explicit_assistant_9").get("call-1")?.() + return part?.type === "tool" && part.state.status === "error" }) const assistant = sync.session.message.get("session-1", "msg_explicit_assistant_9") expect(assistant?.type).toBe("assistant") if (assistant?.type !== "assistant") return expect(assistant.id).toBe("msg_explicit_assistant_9") - const tool = assistant.content[0] + const tool = sync.session.message.parts("session-1", "msg_explicit_assistant_9").get("call-1")?.() expect(tool?.type).toBe("tool") if (tool?.type !== "tool") return expect(tool.state.status).toBe("error")