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

@ -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

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

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.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")

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)

View file

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

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()}>

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,

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

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")