feat(tui): move assistant content into per-part quark slots

This commit is contained in:
Kit Langton
2026-07-17 22:37:04 -04:00
parent 2635451bbf
commit 6d043d9155
11 changed files with 547 additions and 328 deletions
+94
View File
@@ -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<Part> 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 `<For>`/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.
+20 -1
View File
@@ -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<A>(readable: Readable<A>): Accessor<A> {
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<A, Key>(keyed: Keyed.Keyed<A, Key>, key: () => Key): Accessor<A | undefined> {
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<A>(props: {
readonly each: Accessor<readonly Readable<A>[]>
readonly fallback?: JSX.Element
+112 -114
View File
@@ -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<string, Map<string, number>>()
// 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}`)
},
@@ -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<Part, string>
// Streamed sub-objects are replaced immutably on change, so reference
// equality is the correct (and cheapest) field comparator for them.
const reference: Layout.Field<unknown> = { 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<Part, string>
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<string, Parts>()
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)
}
},
}
}
}
@@ -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")
+103 -163
View File
@@ -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() {
<SessionRowView
row={row()}
message={(messageID) => data.session.message.get(route.sessionID, messageID)}
parts={(messageID) => data.session.message.parts(route.sessionID, messageID)}
boundaryID={boundaries()[index()]}
/>
)}
</KeyedFor>
<BackgroundToolHint messages={messages()} />
<BackgroundToolHint
messages={messages()}
parts={(messageID) => data.session.message.parts(route.sessionID, messageID)}
/>
<Show when={session()?.revert?.messageID}>
<RevertMessage
count={
@@ -1028,6 +1045,7 @@ export function Session() {
function SessionRowView(props: {
row: SessionRow
message: (messageID: string) => SessionMessageInfo | undefined
parts: (messageID: string) => SessionContent.Parts
boundaryID?: string
}) {
return (
@@ -1042,10 +1060,17 @@ function SessionRowView(props: {
<CompactionQueued />
</Match>
<Match when={props.row.type === "part" ? props.row : undefined}>
{(row) => <SessionPartView partRef={row().ref} message={props.message} />}
{(row) => <SessionPartView partRef={row().ref} message={props.message} parts={props.parts} />}
</Match>
<Match when={props.row.type === "group" && props.row.kind === "reasoning" ? props.row : undefined}>
{(row) => <SessionReasoningGroupView refs={row().refs} completed={row().completed} message={props.message} />}
{(row) => (
<SessionReasoningGroupView
refs={row().refs}
completed={row().completed}
message={props.message}
parts={props.parts}
/>
)}
</Match>
<Match when={props.row.type === "group" && props.row.kind === "exploration" ? props.row : undefined}>
{(row) => (
@@ -1054,6 +1079,7 @@ function SessionRowView(props: {
pending={row().pending}
completed={row().completed}
message={props.message}
parts={props.parts}
/>
)}
</Match>
@@ -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 (
<Show when={visible() && shortcut()}>
{(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 (
<Show when={part()}>
{(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: {
<Show when={parts().length > 0}>
<Show
when={ctx.thinkingMode() === "hide"}
fallback={<For each={props.refs}>{(ref) => <SessionPartView partRef={ref} message={props.message} />}</For>}
fallback={
<For each={props.refs}>
{(ref) => <SessionPartView partRef={ref} message={props.message} parts={props.parts} />}
</For>
}
>
<box flexDirection="column" flexShrink={0}>
<InlineToolRow
@@ -1232,15 +1281,14 @@ function SessionReasoningGroupView(props: {
<box paddingLeft={3}>
<For each={props.refs}>
{(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: {
<code
filetype="markdown"
drawUnstyledText={false}
streaming={part()?.time?.completed === undefined && message()?.time.completed === undefined}
streaming={part()?.time?.completed === undefined && !messageCompleted()}
syntaxStyle={syntax()}
content={content()}
conceal={ctx.markdownMode() === "rendered"}
@@ -1283,22 +1331,27 @@ function SessionGroupView(props: {
pending: readonly PartRef[]
completed: boolean
message: (messageID: string) => 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<Record<string, number>>((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<string, { first: boolean; parts: SessionMessageAssistantTool[]; active: boolean }>()
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<SessionMessageAssistantTool[][]>(
(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 (
<>
<For each={props.message.content}>
{(content, index) => (
<Switch>
<Match when={content.type === "text"}>
<TextPart
part={content as SessionMessageAssistantText}
last={index() === props.message.content.length - 1}
/>
</Match>
<Match when={content.type === "reasoning"}>
<ReasoningPart
part={content as SessionMessageAssistantReasoning}
message={props.message}
last={index() === props.message.content.length - 1}
/>
</Match>
<Match when={content.type === "tool"}>
<Show when={exploration().get((content as SessionMessageAssistantTool).id)?.first !== false}>
<Show
when={exploration().get((content as SessionMessageAssistantTool).id)}
fallback={<ToolPart part={content as SessionMessageAssistantTool} />}
>
{(summary) => <ExplorationSummary {...summary()} />}
</Show>
</Show>
</Match>
</Switch>
)}
</For>
<Show when={props.message.error}>
<box
border={["left"]}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
backgroundColor={themeV2.background()}
customBorderChars={SplitBorder.customBorderChars}
borderColor={themeV2.text.feedback.error()}
>
<text fg={themeV2.text.subdued()}>{errorMessage(props.message.error)}</text>
</box>
</Show>
<AssistantRetry retry={props.message.retry} />
<Switch>
<Match when={props.last || final() || props.message.error}>
<box paddingLeft={3}>
<text>
<span
style={{ fg: props.message.error ? themeV2.text.subdued() : local.agent.color(props.message.agent) }}
>
{Locale.titlecase(props.message.agent)}
</span>
<span style={{ fg: themeV2.text.subdued() }}> · {model()}</span>
<Show when={duration()}>
<span style={{ fg: themeV2.text.subdued() }}> · {Locale.duration(duration())}</span>
</Show>
</text>
</box>
</Match>
</Switch>
</>
)
}
function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
const { themeV2 } = useTheme()
return (
@@ -3035,13 +2970,18 @@ function recordValue(value: unknown): Record<string, unknown> | undefined {
return value as Record<string, unknown>
}
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)
@@ -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) }]
})
+9 -17
View File
@@ -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
</Match>
<Match when={true}>
<box paddingLeft={1} gap={1}>
<text fg={themeV2.text.subdued()}>This will allow the following patterns until OpenCode is restarted</text>
<text fg={themeV2.text.subdued()}>
This will allow the following patterns until OpenCode is restarted
</text>
<box>
<For each={props.request.save ?? []}>
{(pattern) => (
@@ -643,10 +643,7 @@ function Prompt<const T extends Record<string, string>>(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<const T extends Record<string, string>>(props: {
<box
paddingLeft={1}
paddingRight={1}
backgroundColor={themeV2.background.action(
option === store.selected ? "focused" : "default",
)}
backgroundColor={themeV2.background.action(option === store.selected ? "focused" : "default")}
onMouseOver={() => setStore("selected", option)}
onMouseUp={() => {
setStore("selected", option)
props.onSelect(option)
}}
>
<text
fg={themeV2.text.action(
option === store.selected ? "focused" : "default",
)}
>
<text fg={themeV2.text.action(option === store.selected ? "focused" : "default")}>
{props.options[option]}
</text>
</box>
@@ -726,7 +717,8 @@ function Prompt<const T extends Record<string, string>>(props: {
<box flexDirection="row" gap={2} flexShrink={0}>
<Show when={props.fullscreen}>
<text fg={themeV2.text()}>
{shortcuts.get("permission.prompt.fullscreen")} <span style={{ fg: themeV2.text.subdued() }}>{hint()}</span>
{shortcuts.get("permission.prompt.fullscreen")}{" "}
<span style={{ fg: themeV2.text.subdued() }}>{hint()}</span>
</text>
</Show>
<text fg={themeV2.text()}>
+5 -1
View File
@@ -42,7 +42,11 @@ export function createSessionRows(sessionID: Accessor<string>, 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,
+9 -14
View File
@@ -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<string>()) {
export function reduceSessionRows(
messages: SessionMessageInfo[],
inputs = new Set<string>(),
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<string, SessionMess
if (message?.type === "assistant") return message.id
}
export function resolvePart(message: SessionMessageAssistant, partID: string) {
const tool = message.content.find((part) => 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)
}
+97 -16
View File
@@ -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<typeof useData>
let client!: ReturnType<typeof useClient>
function Probe() {
data = useData()
client = useClient()
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<ClientProvider api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</ClientProvider>
</TestTuiContexts>
))
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")