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
commit 6d043d9155
11 changed files with 547 additions and 328 deletions

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.

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" import type { Readable } from "./reactivity"
export function useValue<A>(readable: Readable<A>): Accessor<A> { export function useValue<A>(readable: Readable<A>): Accessor<A> {
return from(readable, readable()) 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: { export function KeyedFor<A>(props: {
readonly each: Accessor<readonly Readable<A>[]> readonly each: Accessor<readonly Readable<A>[]>
readonly fallback?: JSX.Element readonly fallback?: JSX.Element

View file

@ -19,9 +19,6 @@ import type {
ReferenceInfo, ReferenceInfo,
SessionMessageInfo, SessionMessageInfo,
SessionMessageAssistant, SessionMessageAssistant,
SessionMessageAssistantReasoning,
SessionMessageAssistantText,
SessionMessageAssistantTool,
SessionInfo, SessionInfo,
SessionPendingInfo, SessionPendingInfo,
ShellInfo, ShellInfo,
@ -33,6 +30,7 @@ import { createStore, produce, reconcile } from "solid-js/store"
import { createSimpleContext } from "./helper" import { createSimpleContext } from "./helper"
import { useClient } from "./client" import { useClient } from "./client"
import { createEffect, createSignal, onCleanup } from "solid-js" import { createEffect, createSignal, onCleanup } from "solid-js"
import { SessionContent } from "../routes/session/content"
export type DataSessionStatus = "idle" | "running" export type DataSessionStatus = "idle" | "running"
@ -146,6 +144,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
directory: process.cwd(), directory: process.cwd(),
}) })
const messageIndex = new Map<string, Map<string, number>>() 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() const sync = createSync()
function setSessionActive(sessionID: string, status: DataSessionStatus) { 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") const item = messages.findLast((item) => item.type === "compaction" && item.status === "running")
return item?.type === "compaction" ? item : undefined 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) { function index(sessionID: string) {
@ -269,6 +256,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
function removeSession(sessionID: string) { function removeSession(sessionID: string) {
messageIndex.delete(sessionID) messageIndex.delete(sessionID)
content.drop(sessionID)
sync.invalidate(`session:${sessionID}`) sync.invalidate(`session:${sessionID}`)
sync.invalidate(`session.pending:${sessionID}`) sync.invalidate(`session.pending:${sessionID}`)
sync.invalidate(`session.message:${sessionID}`) sync.invalidate(`session.message:${sessionID}`)
@ -353,6 +341,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
void client.api.session void client.api.session
.message({ sessionID: event.data.sessionID, messageID: messageIDFromEvent(event.id) }) .message({ sessionID: event.data.sessionID, messageID: messageIDFromEvent(event.id) })
.then((item) => { .then((item) => {
if (item.type === "assistant") content.seed(event.data.sessionID, item.id, item.content)
message.update(event.data.sessionID, (draft, index) => { message.update(event.data.sessionID, (draft, index) => {
const position = index.get(item.id) const position = index.get(item.id)
if (position === undefined) return message.append(draft, index, item) if (position === undefined) return message.append(draft, index, item)
@ -540,143 +529,142 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
} }
}) })
break break
case "session.text.started": case "session.text.started": {
message.update(event.data.sessionID, (draft, index) => { const parts = content.ensure(event.data.sessionID, event.data.assistantMessageID)
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({ const partID = SessionContent.textID(event.data.ordinal)
type: "text", if (!parts.has(partID)) parts.insert({ type: "text", text: "", partID })
text: "",
})
})
break break
}
case "session.text.delta": case "session.text.delta":
message.update(event.data.sessionID, (draft, index) => { content
const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID)) .get(event.data.sessionID, event.data.assistantMessageID)
if (match) match.text += event.data.delta ?.modify(SessionContent.textID(event.data.ordinal), (part) =>
}) part.type === "text" ? { ...part, text: part.text + event.data.delta } : part,
)
break break
case "session.text.ended": case "session.text.ended":
message.update(event.data.sessionID, (draft, index) => { content
const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID)) .get(event.data.sessionID, event.data.assistantMessageID)
if (match) match.text = event.data.text ?.modify(SessionContent.textID(event.data.ordinal), (part) =>
}) part.type === "text" ? { ...part, text: event.data.text } : part,
)
break break
case "session.tool.input.started": case "session.tool.input.started": {
message.update(event.data.sessionID, (draft, index) => { const parts = content.ensure(event.data.sessionID, event.data.assistantMessageID)
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({ if (!parts.has(event.data.callID))
parts.insert({
type: "tool", type: "tool",
id: event.data.callID, id: event.data.callID,
name: event.data.name, name: event.data.name,
time: { created: event.created }, time: { created: event.created },
state: { status: "streaming", input: "" }, state: { status: "streaming", input: "" },
partID: event.data.callID,
}) })
})
break break
}
case "session.tool.input.delta": case "session.tool.input.delta":
message.update(event.data.sessionID, (draft, index) => { content.get(event.data.sessionID, event.data.assistantMessageID)?.modify(event.data.callID, (part) => {
const match = message.latestTool( if (part.type !== "tool" || part.state.status !== "streaming") return part
message.assistant(draft, index, event.data.assistantMessageID), return { ...part, state: { ...part.state, input: part.state.input + event.data.delta } }
event.data.callID,
)
if (match?.state.status === "streaming") match.state.input += event.data.delta
}) })
break break
case "session.tool.input.ended": case "session.tool.input.ended":
message.update(event.data.sessionID, (draft, index) => { content.get(event.data.sessionID, event.data.assistantMessageID)?.modify(event.data.callID, (part) => {
const match = message.latestTool( if (part.type !== "tool" || part.state.status !== "streaming") return part
message.assistant(draft, index, event.data.assistantMessageID), return { ...part, state: { ...part.state, input: event.data.text } }
event.data.callID,
)
if (match?.state.status === "streaming") match.state.input = event.data.text
}) })
break break
case "session.tool.called": case "session.tool.called":
message.update(event.data.sessionID, (draft, index) => { content.get(event.data.sessionID, event.data.assistantMessageID)?.modify(event.data.callID, (part) => {
const match = message.latestTool( if (part.type !== "tool") return part
message.assistant(draft, index, event.data.assistantMessageID), return {
event.data.callID, ...part,
) time: { ...part.time, ran: event.created },
if (!match) return executed: event.data.executed,
match.time.ran = event.created providerState: event.data.state,
match.executed = event.data.executed state: { status: "running", input: event.data.input, structured: {}, content: [] },
match.providerState = event.data.state }
match.state = { status: "running", input: event.data.input, structured: {}, content: [] }
}) })
break break
case "session.tool.progress": case "session.tool.progress":
message.update(event.data.sessionID, (draft, index) => { content.get(event.data.sessionID, event.data.assistantMessageID)?.modify(event.data.callID, (part) => {
const match = message.latestTool( if (part.type !== "tool" || part.state.status !== "running") return part
message.assistant(draft, index, event.data.assistantMessageID), return {
event.data.callID, ...part,
) state: { ...part.state, structured: event.data.structured, content: [...event.data.content] },
if (match?.state.status !== "running") return }
match.state.structured = event.data.structured
match.state.content = [...event.data.content]
}) })
break break
case "session.tool.success": case "session.tool.success":
message.update(event.data.sessionID, (draft, index) => { content.get(event.data.sessionID, event.data.assistantMessageID)?.modify(event.data.callID, (part) => {
const match = message.latestTool( if (part.type !== "tool" || part.state.status !== "running") return part
message.assistant(draft, index, event.data.assistantMessageID), return {
event.data.callID, ...part,
) state: {
if (match?.state.status !== "running") return status: "completed",
match.state = { input: part.state.input,
status: "completed", structured: event.data.structured,
input: match.state.input, content: [...event.data.content],
structured: event.data.structured, result: event.data.result,
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 break
case "session.tool.failed": case "session.tool.failed":
message.update(event.data.sessionID, (draft, index) => { content.get(event.data.sessionID, event.data.assistantMessageID)?.modify(event.data.callID, (part) => {
const match = message.latestTool( if (part.type !== "tool" || (part.state.status !== "streaming" && part.state.status !== "running"))
message.assistant(draft, index, event.data.assistantMessageID), return part
event.data.callID, return {
) ...part,
if (!match || (match.state.status !== "streaming" && match.state.status !== "running")) return state: {
match.state = { status: "error",
status: "error", error: event.data.error,
error: event.data.error, input: typeof part.state.input === "string" ? {} : part.state.input,
input: typeof match.state.input === "string" ? {} : match.state.input, structured: part.state.status === "running" ? part.state.structured : {},
structured: match.state.status === "running" ? match.state.structured : {}, content: part.state.status === "running" ? part.state.content : [],
content: match.state.status === "running" ? match.state.content : [], result: event.data.result,
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 break
case "session.reasoning.started": case "session.reasoning.started": {
message.update(event.data.sessionID, (draft, index) => { const parts = content.ensure(event.data.sessionID, event.data.assistantMessageID)
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({ const partID = SessionContent.reasoningID(event.data.ordinal)
if (!parts.has(partID))
parts.insert({
type: "reasoning", type: "reasoning",
text: "", text: "",
state: event.data.state, state: event.data.state,
time: { created: event.created }, time: { created: event.created },
partID,
}) })
})
break break
}
case "session.reasoning.delta": case "session.reasoning.delta":
message.update(event.data.sessionID, (draft, index) => { content
const match = message.latestReasoning(message.assistant(draft, index, event.data.assistantMessageID)) .get(event.data.sessionID, event.data.assistantMessageID)
if (match) match.text += event.data.delta ?.modify(SessionContent.reasoningID(event.data.ordinal), (part) =>
}) part.type === "reasoning" ? { ...part, text: part.text + event.data.delta } : part,
)
break break
case "session.reasoning.ended": case "session.reasoning.ended":
message.update(event.data.sessionID, (draft, index) => { content
const match = message.latestReasoning(message.assistant(draft, index, event.data.assistantMessageID)) .get(event.data.sessionID, event.data.assistantMessageID)
if (match) { ?.modify(SessionContent.reasoningID(event.data.ordinal), (part) => {
match.text = event.data.text if (part.type !== "reasoning") return part
match.time = { created: match.time?.created ?? event.created, completed: event.created } return {
if (event.data.state !== undefined) match.state = event.data.state ...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 break
case "session.retry.scheduled": case "session.retry.scheduled":
message.update(event.data.sessionID, (draft, index) => { 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) => { message.update(event.data.sessionID, (draft, index) => {
const position = draft.findIndex((item) => item.id >= event.data.to) const position = draft.findIndex((item) => item.id >= event.data.to)
if (position === -1) return 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 break
case "session.compaction.delta": 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" }) await client.api.message.list({ sessionID, limit: 200, order: "desc" })
).data.toReversed() ).data.toReversed()
messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index]))) 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)) setStore("session", "message", sessionID, reconcile(messages))
}) })
}, },
parts(sessionID: string, messageID: string) {
return content.ensure(sessionID, messageID)
},
invalidate(sessionID: string) { invalidate(sessionID: string) {
sync.invalidate(`session.message:${sessionID}`) sync.invalidate(`session.message:${sessionID}`)
}, },

View file

@ -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)
}
},
}
}
}

View file

@ -62,7 +62,9 @@ export function DialogMessage(props: {
value.type === "user" value.type === "user"
? value.text ? value.text
: value.type === "assistant" : value.type === "assistant"
? value.content ? data.session.message
.parts(props.sessionID, props.messageID)
.values()
.filter((content) => content.type === "text") .filter((content) => content.type === "text")
.map((content) => content.text) .map((content) => content.text)
.join("\n") .join("\n")

View file

@ -13,7 +13,8 @@ import {
Switch, Switch,
useContext, useContext,
} from "solid-js" } 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 path from "node:path"
import { EOL, tmpdir } from "node:os" import { EOL, tmpdir } from "node:os"
import { mkdir, writeFile } from "node:fs/promises" 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 { usePathFormatter } from "../../context/path-format"
import { useLocation } from "../../context/location" import { useLocation } from "../../context/location"
import { createSessionRows } from "./rows" 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 { switchLabel } from "../../util/model"
import { findMessageBoundary, messageNavigationSlack } from "./message-navigation" import { findMessageBoundary, messageNavigationSlack } from "./message-navigation"
import { stringWidth } from "../../util/string-width" import { stringWidth } from "../../util/string-width"
@ -312,6 +313,11 @@ export function Session() {
direction, direction,
children: scroll.getChildren(), children: scroll.getChildren(),
messages: messages(), messages: messages(),
hasText: (messageID) =>
data.session.message
.parts(route.sessionID, messageID)
.values()
.some((part) => part.type === "text" && part.text.trim()),
scrollTop: scroll.scrollTop, scrollTop: scroll.scrollTop,
viewportY: scroll.viewport.y, viewportY: scroll.viewport.y,
currentID: navigationMessage(), currentID: navigationMessage(),
@ -681,7 +687,10 @@ export function Session() {
return 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) { if (textParts.length === 0) {
toast.show({ message: "No text parts found in last assistant message", variant: "error" }) toast.show({ message: "No text parts found in last assistant message", variant: "error" })
dialog.clear() dialog.clear()
@ -719,7 +728,9 @@ export function Session() {
try { try {
const sessionData = session() const sessionData = session()
if (!sessionData) return 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) await clipboard.write?.(transcript)
toast.show({ message: "Session transcript copied to clipboard!", variant: "success" }) toast.show({ message: "Session transcript copied to clipboard!", variant: "success" })
} catch { } catch {
@ -746,7 +757,9 @@ export function Session() {
const content = const content =
options.format === "markdown" options.format === "markdown"
? formatSessionTranscript(sessionData, messages(), options.thinking) ? formatSessionTranscript(sessionData, messages(), options.thinking, (messageID) =>
data.session.message.parts(route.sessionID, messageID).values(),
)
: await (async () => { : await (async () => {
if (options.debug) { if (options.debug) {
const events: { readonly created: number }[] = [] const events: { readonly created: number }[] = []
@ -934,11 +947,15 @@ export function Session() {
<SessionRowView <SessionRowView
row={row()} row={row()}
message={(messageID) => data.session.message.get(route.sessionID, messageID)} message={(messageID) => data.session.message.get(route.sessionID, messageID)}
parts={(messageID) => data.session.message.parts(route.sessionID, messageID)}
boundaryID={boundaries()[index()]} boundaryID={boundaries()[index()]}
/> />
)} )}
</KeyedFor> </KeyedFor>
<BackgroundToolHint messages={messages()} /> <BackgroundToolHint
messages={messages()}
parts={(messageID) => data.session.message.parts(route.sessionID, messageID)}
/>
<Show when={session()?.revert?.messageID}> <Show when={session()?.revert?.messageID}>
<RevertMessage <RevertMessage
count={ count={
@ -1028,6 +1045,7 @@ export function Session() {
function SessionRowView(props: { function SessionRowView(props: {
row: SessionRow row: SessionRow
message: (messageID: string) => SessionMessageInfo | undefined message: (messageID: string) => SessionMessageInfo | undefined
parts: (messageID: string) => SessionContent.Parts
boundaryID?: string boundaryID?: string
}) { }) {
return ( return (
@ -1042,10 +1060,17 @@ function SessionRowView(props: {
<CompactionQueued /> <CompactionQueued />
</Match> </Match>
<Match when={props.row.type === "part" ? props.row : undefined}> <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>
<Match when={props.row.type === "group" && props.row.kind === "reasoning" ? props.row : undefined}> <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>
<Match when={props.row.type === "group" && props.row.kind === "exploration" ? props.row : undefined}> <Match when={props.row.type === "group" && props.row.kind === "exploration" ? props.row : undefined}>
{(row) => ( {(row) => (
@ -1054,6 +1079,7 @@ function SessionRowView(props: {
pending={row().pending} pending={row().pending}
completed={row().completed} completed={row().completed}
message={props.message} message={props.message}
parts={props.parts}
/> />
)} )}
</Match> </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 { themeV2 } = useTheme()
const shortcut = Keymap.useShortcut("session.background") const shortcut = Keymap.useShortcut("session.background")
const visible = createMemo(() => { const current = createMemo(() =>
const current = props.messages.findLast( props.messages.findLast(
(message): message is SessionMessageAssistant => message.type === "assistant" && !message.time.completed, (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 if (part.type !== "tool" || part.state.status !== "running") return false
const display = toolDisplay(part.name) const display = toolDisplay(part.name)
return display === "shell" || display === "subagent" return display === "shell" || display === "subagent"
}) ?? false }) ?? false,
) )
})
return ( return (
<Show when={visible() && shortcut()}> <Show when={visible() && shortcut()}>
{(value) => ( {(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 message = createMemo(() => props.message(props.partRef.messageID))
const part = createMemo(() => { // One reactive slot per part: unrelated deltas in the same message cannot
const item = message() // re-render this row.
if (item?.type !== "assistant") return const part = useSlot(props.parts(props.partRef.messageID), () => props.partRef.partID)
return resolvePart(item, props.partRef.partID)
})
return ( return (
<Show when={part()}> <Show when={part()}>
{(item) => ( {(item) => (
@ -1161,17 +1197,26 @@ function SessionReasoningGroupView(props: {
refs: readonly PartRef[] refs: readonly PartRef[]
completed: boolean completed: boolean
message: (messageID: string) => SessionMessageInfo | undefined message: (messageID: string) => SessionMessageInfo | undefined
parts: (messageID: string) => SessionContent.Parts
}) { }) {
const ctx = use() const ctx = use()
const { themeV2, syntax } = useTheme() const { themeV2, syntax } = useTheme()
const renderer = useRenderer() const renderer = useRenderer()
const [expanded, setExpanded] = createSignal(false) const [expanded, setExpanded] = createSignal(false)
const [hover, setHover] = 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(() => const parts = createMemo(() =>
props.refs.flatMap((ref) => { accessors().flatMap((entry) => {
const message = props.message(ref.messageID) const message = props.message(entry.ref.messageID)
if (message?.type !== "assistant") return [] if (message?.type !== "assistant") return []
const part = resolvePart(message, ref.partID) const part = entry.part()
if (part?.type !== "reasoning" || !reasoningContent(part)) return [] if (part?.type !== "reasoning" || !reasoningContent(part)) return []
return [{ message, part }] return [{ message, part }]
}), }),
@ -1196,7 +1241,11 @@ function SessionReasoningGroupView(props: {
<Show when={parts().length > 0}> <Show when={parts().length > 0}>
<Show <Show
when={ctx.thinkingMode() === "hide"} 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}> <box flexDirection="column" flexShrink={0}>
<InlineToolRow <InlineToolRow
@ -1232,15 +1281,14 @@ function SessionReasoningGroupView(props: {
<box paddingLeft={3}> <box paddingLeft={3}>
<For each={props.refs}> <For each={props.refs}>
{(ref) => { {(ref) => {
const message = createMemo(() => { const slot = useSlot(props.parts(ref.messageID), () => ref.partID)
const item = props.message(ref.messageID)
return item?.type === "assistant" ? item : undefined
})
const part = createMemo(() => { const part = createMemo(() => {
const item = message() const item = slot()
if (!item) return undefined return item?.type === "reasoning" ? item : undefined
const part = resolvePart(item, ref.partID) })
return part?.type === "reasoning" ? part : undefined const messageCompleted = createMemo(() => {
const item = props.message(ref.messageID)
return item?.type === "assistant" && item.time.completed !== undefined
}) })
const content = createMemo(() => { const content = createMemo(() => {
const item = part() const item = part()
@ -1258,7 +1306,7 @@ function SessionReasoningGroupView(props: {
<code <code
filetype="markdown" filetype="markdown"
drawUnstyledText={false} drawUnstyledText={false}
streaming={part()?.time?.completed === undefined && message()?.time.completed === undefined} streaming={part()?.time?.completed === undefined && !messageCompleted()}
syntaxStyle={syntax()} syntaxStyle={syntax()}
content={content()} content={content()}
conceal={ctx.markdownMode() === "rendered"} conceal={ctx.markdownMode() === "rendered"}
@ -1283,22 +1331,27 @@ function SessionGroupView(props: {
pending: readonly PartRef[] pending: readonly PartRef[]
completed: boolean completed: boolean
message: (messageID: string) => SessionMessageInfo | undefined message: (messageID: string) => SessionMessageInfo | undefined
parts: (messageID: string) => SessionContent.Parts
}) { }) {
const { themeV2 } = useTheme() const { themeV2 } = useTheme()
const ctx = use() const ctx = use()
const renderer = useRenderer() const renderer = useRenderer()
const [expanded, setExpanded] = createSignal(false) const [expanded, setExpanded] = createSignal(false)
const [hover, setHover] = createSignal(false) const [hover, setHover] = createSignal(false)
const parts = (refs: readonly PartRef[]) => // Slot accessors per ref list: tool state changes flow through individual
refs.flatMap((ref) => { // slots; the accessor lists rebuild only when the refs themselves change.
const message = props.message(ref.messageID) const slots = (refs: () => readonly PartRef[]) =>
if (message?.type !== "assistant") return [] createMemo(() => refs().map((ref) => useSlot(props.parts(ref.messageID), () => ref.partID)))
const part = resolvePart(message, 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 [] if (part?.type !== "tool") return []
return [part] return [part]
}) })
const grouped = createMemo(() => parts(props.refs)) const grouped = createMemo(() => parts(groupedSlots()))
const pending = createMemo(() => parts(props.pending)) const pending = createMemo(() => parts(pendingSlots()))
const label = createMemo(() => { const label = createMemo(() => {
const counts = grouped().reduce<Record<string, number>>((result, part) => { const counts = grouped().reduce<Record<string, number>>((result, part) => {
const tool = toolDisplay(part.name) 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"] }) { function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
const { themeV2 } = useTheme() const { themeV2 } = useTheme()
return ( return (
@ -3035,13 +2970,18 @@ function recordValue(value: unknown): Record<string, unknown> | undefined {
return value as Record<string, unknown> 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) => { const body = messages.flatMap((message) => {
if (message.type === "user") return [`## User\n\n${message.text}`] if (message.type === "user") return [`## User\n\n${message.text}`]
if (message.type === "shell") if (message.type === "shell")
return [`## Shell\n\n\`\`\`\n$ ${message.command}\n${message.output?.output ?? ""}\n\`\`\``] return [`## Shell\n\n\`\`\`\n$ ${message.command}\n${message.output?.output ?? ""}\n\`\`\``]
if (message.type !== "assistant") return [] 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 === "text") return [item.text]
if (item.type === "reasoning") return thinking ? [`_Thinking:_\n\n${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) const input = typeof item.state.input === "string" ? item.state.input : JSON.stringify(item.state.input, null, 2)

View file

@ -19,6 +19,7 @@ export function findMessageBoundary(input: {
direction: "next" | "prev" direction: "next" | "prev"
children: readonly MessageChild[] children: readonly MessageChild[]
messages: readonly SessionMessageInfo[] messages: readonly SessionMessageInfo[]
hasText?: (messageID: string) => boolean
scrollTop: number scrollTop: number
viewportY: number viewportY: number
currentID?: string currentID?: string
@ -35,7 +36,9 @@ export function findMessageBoundary(input: {
return [{ id: child.id, y, top: y }] return [{ id: child.id, y, top: y }]
} }
if (input.userOnly || message.type !== "assistant") return [] 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 const y = input.scrollTop + child.y - input.viewportY
return [{ id: child.id, y, top: Math.max(0, y - 1) }] return [{ id: child.id, y, top: Math.max(0, y - 1) }]
}) })

View file

@ -146,9 +146,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
const input = createMemo(() => { const input = createMemo(() => {
const tool = props.request.source const tool = props.request.source
if (!tool) return {} if (!tool) return {}
const message = data.session.message.get(props.request.sessionID, tool.messageID) const part = data.session.message.parts(props.request.sessionID, tool.messageID).get(tool.callID)?.()
if (message?.type !== "assistant") return {}
const part = message.content.find((part) => part.type === "tool" && part.id === tool.callID)
if (part?.type === "tool" && part.state.status !== "streaming") return part.state.input if (part?.type === "tool" && part.state.status !== "streaming") return part.state.input
return {} return {}
}) })
@ -167,7 +165,9 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
</Match> </Match>
<Match when={true}> <Match when={true}>
<box paddingLeft={1} gap={1}> <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> <box>
<For each={props.request.save ?? []}> <For each={props.request.save ?? []}>
{(pattern) => ( {(pattern) => (
@ -643,10 +643,7 @@ function Prompt<const T extends Record<string, string>>(props: {
] ]
: []), : []),
], ],
bindings: [ bindings: [...(props.escapeKey ? ["app.exit"] : []), ...(props.fullscreen ? ["permission.prompt.fullscreen"] : [])],
...(props.escapeKey ? ["app.exit"] : []),
...(props.fullscreen ? ["permission.prompt.fullscreen"] : []),
],
})) }))
const hint = createMemo(() => (store.expanded ? "minimize" : "fullscreen")) const hint = createMemo(() => (store.expanded ? "minimize" : "fullscreen"))
@ -703,20 +700,14 @@ function Prompt<const T extends Record<string, string>>(props: {
<box <box
paddingLeft={1} paddingLeft={1}
paddingRight={1} paddingRight={1}
backgroundColor={themeV2.background.action( backgroundColor={themeV2.background.action(option === store.selected ? "focused" : "default")}
option === store.selected ? "focused" : "default",
)}
onMouseOver={() => setStore("selected", option)} onMouseOver={() => setStore("selected", option)}
onMouseUp={() => { onMouseUp={() => {
setStore("selected", option) setStore("selected", option)
props.onSelect(option) props.onSelect(option)
}} }}
> >
<text <text fg={themeV2.text.action(option === store.selected ? "focused" : "default")}>
fg={themeV2.text.action(
option === store.selected ? "focused" : "default",
)}
>
{props.options[option]} {props.options[option]}
</text> </text>
</box> </box>
@ -726,7 +717,8 @@ function Prompt<const T extends Record<string, string>>(props: {
<box flexDirection="row" gap={2} flexShrink={0}> <box flexDirection="row" gap={2} flexShrink={0}>
<Show when={props.fullscreen}> <Show when={props.fullscreen}>
<text fg={themeV2.text()}> <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> </text>
</Show> </Show>
<text fg={themeV2.text()}> <text fg={themeV2.text()}>

View file

@ -42,7 +42,11 @@ export function createSessionRows(sessionID: Accessor<string>, options?: { reado
const messages = data.session.message.list(sessionID()) const messages = data.session.message.list(sessionID())
const inputs = new Set(data.session.input.list(sessionID())) const inputs = new Set(data.session.input.list(sessionID()))
const boundary = revertBoundary() 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)) const position = rows.findIndex((row) => row.type === "message" && inputs.has(row.messageID))
rows.splice( rows.splice(
position === -1 ? rows.length : position, position === -1 ? rows.length : position,

View file

@ -1,5 +1,6 @@
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client" import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
import { Keyed, Layout, Transaction } from "effect-quark" import { Keyed, Layout, Transaction } from "effect-quark"
import { SessionContent } from "./content"
const PartRefLayout = Layout.struct({ const PartRefLayout = Layout.struct({
messageID: Layout.string, 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 isInput = (message: SessionMessageInfo) => inputs.has(message.id)
const pendingCompactions = messages.filter((message) => message.type === "compaction" && message.status === "running") const pendingCompactions = messages.filter((message) => message.type === "compaction" && message.status === "running")
const pending = new Set([...pendingCompactions.map((message) => message.id), ...inputs]) 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)) rows.push(messageRow(message.id))
return rows return rows
} }
const ordinals = { text: 0, reasoning: 0 } const parts = partsOf ? partsOf(message) : SessionContent.withPartIDs(message.content)
message.content.forEach((part) => { parts.forEach((part) => {
const partID = part.type === "tool" ? part.id : `${part.type}:${ordinals[part.type]++}`
if ((part.type === "text" || part.type === "reasoning") && !part.text.trim()) return 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) { if (isTerminalFinish(message.finish) || message.error || message.retry) {
completePrevious(rows) completePrevious(rows)
@ -239,15 +243,6 @@ function rowBoundaryMessageID(row: SessionRow, messages: Map<string, SessionMess
if (message?.type === "assistant") return message.id 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) { export function isTerminalFinish(finish: string | undefined) {
return !!finish && !["tool-calls", "unknown"].includes(finish) return !!finish && !["tool-calls", "unknown"].includes(finish)
} }

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 () => { test("does not publish timeline rows for duplicate streaming deltas", async () => {
const events = createEventStream() const events = createEventStream()
const sessionID = "session-stream-metrics" 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" }, data: { sessionID, assistantMessageID: "message-assistant", ordinal: 0, delta: "two" },
}) })
await wait(() => { await wait(() => {
const message = data.session.message.get(sessionID, "message-assistant") const part = data.session.message.parts(sessionID, "message-assistant").get("text:0")?.()
return ( return part?.type === "text" && part.text === "onetwo"
message?.type === "assistant" && message.content.some((part) => part.type === "text" && part.text === "onetwo")
)
}) })
expect(afterFirst).toEqual({ slotPublications: 0, structuralPublications: 1, equivalenceSuppressions: 0 }) 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(() => { 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 ( return (
assistant?.type === "assistant" && part?.type === "tool" && part.state.status === "running" && part.state.structured.sessionID === "session-child"
assistant.content[0]?.type === "tool" &&
assistant.content[0].state.status === "running" &&
assistant.content[0].state.structured.sessionID === "session-child"
) )
}) })
@ -2507,19 +2592,15 @@ test("settles pending tools when a live failure arrives", async () => {
}) })
await wait(() => { 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 ( return part?.type === "tool" && part.state.status === "error"
assistant?.type === "assistant" &&
assistant.content[0]?.type === "tool" &&
assistant.content[0].state.status === "error"
)
}) })
const assistant = sync.session.message.get("session-1", "msg_explicit_assistant_9") const assistant = sync.session.message.get("session-1", "msg_explicit_assistant_9")
expect(assistant?.type).toBe("assistant") expect(assistant?.type).toBe("assistant")
if (assistant?.type !== "assistant") return if (assistant?.type !== "assistant") return
expect(assistant.id).toBe("msg_explicit_assistant_9") 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") expect(tool?.type).toBe("tool")
if (tool?.type !== "tool") return if (tool?.type !== "tool") return
expect(tool.state.status).toBe("error") expect(tool.state.status).toBe("error")