mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-23 02:36:57 -04:00
feat(tui): move assistant content into per-part quark slots
This commit is contained in:
@@ -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.
|
||||
@@ -1,10 +1,29 @@
|
||||
import { For, from, type Accessor, type JSX } from "solid-js"
|
||||
import { createMemo, For, from, type Accessor, type JSX } from "solid-js"
|
||||
import type { Keyed } from "./keyed"
|
||||
import type { Readable } from "./reactivity"
|
||||
|
||||
export function useValue<A>(readable: Readable<A>): Accessor<A> {
|
||||
return from(readable, readable())
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive accessor for one keyed slot. Tracks structure only until the slot
|
||||
* exists; afterwards value changes flow through the slot alone, so unrelated
|
||||
* structural churn cannot re-render the consumer.
|
||||
*/
|
||||
export function useSlot<A, Key>(keyed: Keyed.Keyed<A, Key>, key: () => Key): Accessor<A | undefined> {
|
||||
const structure = useValue(keyed.slots)
|
||||
const slot = createMemo(() => {
|
||||
structure()
|
||||
return keyed.get(key())
|
||||
})
|
||||
const value = createMemo(() => {
|
||||
const current = slot()
|
||||
return current ? useValue(current) : undefined
|
||||
})
|
||||
return () => value()?.()
|
||||
}
|
||||
|
||||
export function KeyedFor<A>(props: {
|
||||
readonly each: Accessor<readonly Readable<A>[]>
|
||||
readonly fallback?: JSX.Element
|
||||
|
||||
+112
-114
@@ -19,9 +19,6 @@ import type {
|
||||
ReferenceInfo,
|
||||
SessionMessageInfo,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageAssistantReasoning,
|
||||
SessionMessageAssistantText,
|
||||
SessionMessageAssistantTool,
|
||||
SessionInfo,
|
||||
SessionPendingInfo,
|
||||
ShellInfo,
|
||||
@@ -33,6 +30,7 @@ import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useClient } from "./client"
|
||||
import { createEffect, createSignal, onCleanup } from "solid-js"
|
||||
import { SessionContent } from "../routes/session/content"
|
||||
|
||||
export type DataSessionStatus = "idle" | "running"
|
||||
|
||||
@@ -146,6 +144,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
directory: process.cwd(),
|
||||
})
|
||||
const messageIndex = new Map<string, Map<string, number>>()
|
||||
// Assistant content lives in per-message keyed part slots, not the Solid
|
||||
// store: streaming deltas publish one slot instead of reconciling arrays.
|
||||
const content = SessionContent.make()
|
||||
const sync = createSync()
|
||||
|
||||
function setSessionActive(sessionID: string, status: DataSessionStatus) {
|
||||
@@ -199,20 +200,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
const item = messages.findLast((item) => item.type === "compaction" && item.status === "running")
|
||||
return item?.type === "compaction" ? item : undefined
|
||||
},
|
||||
latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) {
|
||||
return assistant?.content.findLast(
|
||||
(item): item is SessionMessageAssistantTool =>
|
||||
item.type === "tool" && (callID === undefined || item.id === callID),
|
||||
)
|
||||
},
|
||||
latestText(assistant: SessionMessageAssistant | undefined) {
|
||||
return assistant?.content.findLast((item): item is SessionMessageAssistantText => item.type === "text")
|
||||
},
|
||||
latestReasoning(assistant: SessionMessageAssistant | undefined) {
|
||||
return assistant?.content.findLast(
|
||||
(item): item is SessionMessageAssistantReasoning => item.type === "reasoning" && !item.time?.completed,
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
function index(sessionID: string) {
|
||||
@@ -269,6 +256,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
|
||||
function removeSession(sessionID: string) {
|
||||
messageIndex.delete(sessionID)
|
||||
content.drop(sessionID)
|
||||
sync.invalidate(`session:${sessionID}`)
|
||||
sync.invalidate(`session.pending:${sessionID}`)
|
||||
sync.invalidate(`session.message:${sessionID}`)
|
||||
@@ -353,6 +341,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
void client.api.session
|
||||
.message({ sessionID: event.data.sessionID, messageID: messageIDFromEvent(event.id) })
|
||||
.then((item) => {
|
||||
if (item.type === "assistant") content.seed(event.data.sessionID, item.id, item.content)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const position = index.get(item.id)
|
||||
if (position === undefined) return message.append(draft, index, item)
|
||||
@@ -540,143 +529,142 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
}
|
||||
})
|
||||
break
|
||||
case "session.text.started":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
|
||||
type: "text",
|
||||
text: "",
|
||||
})
|
||||
})
|
||||
case "session.text.started": {
|
||||
const parts = content.ensure(event.data.sessionID, event.data.assistantMessageID)
|
||||
const partID = SessionContent.textID(event.data.ordinal)
|
||||
if (!parts.has(partID)) parts.insert({ type: "text", text: "", partID })
|
||||
break
|
||||
}
|
||||
case "session.text.delta":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID))
|
||||
if (match) match.text += event.data.delta
|
||||
})
|
||||
content
|
||||
.get(event.data.sessionID, event.data.assistantMessageID)
|
||||
?.modify(SessionContent.textID(event.data.ordinal), (part) =>
|
||||
part.type === "text" ? { ...part, text: part.text + event.data.delta } : part,
|
||||
)
|
||||
break
|
||||
case "session.text.ended":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestText(message.assistant(draft, index, event.data.assistantMessageID))
|
||||
if (match) match.text = event.data.text
|
||||
})
|
||||
content
|
||||
.get(event.data.sessionID, event.data.assistantMessageID)
|
||||
?.modify(SessionContent.textID(event.data.ordinal), (part) =>
|
||||
part.type === "text" ? { ...part, text: event.data.text } : part,
|
||||
)
|
||||
break
|
||||
case "session.tool.input.started":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
|
||||
case "session.tool.input.started": {
|
||||
const parts = content.ensure(event.data.sessionID, event.data.assistantMessageID)
|
||||
if (!parts.has(event.data.callID))
|
||||
parts.insert({
|
||||
type: "tool",
|
||||
id: event.data.callID,
|
||||
name: event.data.name,
|
||||
time: { created: event.created },
|
||||
state: { status: "streaming", input: "" },
|
||||
partID: event.data.callID,
|
||||
})
|
||||
})
|
||||
break
|
||||
}
|
||||
case "session.tool.input.delta":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestTool(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.callID,
|
||||
)
|
||||
if (match?.state.status === "streaming") match.state.input += event.data.delta
|
||||
content.get(event.data.sessionID, event.data.assistantMessageID)?.modify(event.data.callID, (part) => {
|
||||
if (part.type !== "tool" || part.state.status !== "streaming") return part
|
||||
return { ...part, state: { ...part.state, input: part.state.input + event.data.delta } }
|
||||
})
|
||||
break
|
||||
case "session.tool.input.ended":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestTool(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.callID,
|
||||
)
|
||||
if (match?.state.status === "streaming") match.state.input = event.data.text
|
||||
content.get(event.data.sessionID, event.data.assistantMessageID)?.modify(event.data.callID, (part) => {
|
||||
if (part.type !== "tool" || part.state.status !== "streaming") return part
|
||||
return { ...part, state: { ...part.state, input: event.data.text } }
|
||||
})
|
||||
break
|
||||
case "session.tool.called":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestTool(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.callID,
|
||||
)
|
||||
if (!match) return
|
||||
match.time.ran = event.created
|
||||
match.executed = event.data.executed
|
||||
match.providerState = event.data.state
|
||||
match.state = { status: "running", input: event.data.input, structured: {}, content: [] }
|
||||
content.get(event.data.sessionID, event.data.assistantMessageID)?.modify(event.data.callID, (part) => {
|
||||
if (part.type !== "tool") return part
|
||||
return {
|
||||
...part,
|
||||
time: { ...part.time, ran: event.created },
|
||||
executed: event.data.executed,
|
||||
providerState: event.data.state,
|
||||
state: { status: "running", input: event.data.input, structured: {}, content: [] },
|
||||
}
|
||||
})
|
||||
break
|
||||
case "session.tool.progress":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestTool(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.callID,
|
||||
)
|
||||
if (match?.state.status !== "running") return
|
||||
match.state.structured = event.data.structured
|
||||
match.state.content = [...event.data.content]
|
||||
content.get(event.data.sessionID, event.data.assistantMessageID)?.modify(event.data.callID, (part) => {
|
||||
if (part.type !== "tool" || part.state.status !== "running") return part
|
||||
return {
|
||||
...part,
|
||||
state: { ...part.state, structured: event.data.structured, content: [...event.data.content] },
|
||||
}
|
||||
})
|
||||
break
|
||||
case "session.tool.success":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestTool(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.callID,
|
||||
)
|
||||
if (match?.state.status !== "running") return
|
||||
match.state = {
|
||||
status: "completed",
|
||||
input: match.state.input,
|
||||
structured: event.data.structured,
|
||||
content: [...event.data.content],
|
||||
result: event.data.result,
|
||||
content.get(event.data.sessionID, event.data.assistantMessageID)?.modify(event.data.callID, (part) => {
|
||||
if (part.type !== "tool" || part.state.status !== "running") return part
|
||||
return {
|
||||
...part,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: part.state.input,
|
||||
structured: event.data.structured,
|
||||
content: [...event.data.content],
|
||||
result: event.data.result,
|
||||
},
|
||||
executed: event.data.executed || part.executed === true,
|
||||
providerResultState: event.data.resultState,
|
||||
time: { ...part.time, completed: event.created },
|
||||
}
|
||||
match.executed = event.data.executed || match.executed === true
|
||||
match.providerResultState = event.data.resultState
|
||||
match.time.completed = event.created
|
||||
})
|
||||
break
|
||||
case "session.tool.failed":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestTool(
|
||||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.callID,
|
||||
)
|
||||
if (!match || (match.state.status !== "streaming" && match.state.status !== "running")) return
|
||||
match.state = {
|
||||
status: "error",
|
||||
error: event.data.error,
|
||||
input: typeof match.state.input === "string" ? {} : match.state.input,
|
||||
structured: match.state.status === "running" ? match.state.structured : {},
|
||||
content: match.state.status === "running" ? match.state.content : [],
|
||||
result: event.data.result,
|
||||
content.get(event.data.sessionID, event.data.assistantMessageID)?.modify(event.data.callID, (part) => {
|
||||
if (part.type !== "tool" || (part.state.status !== "streaming" && part.state.status !== "running"))
|
||||
return part
|
||||
return {
|
||||
...part,
|
||||
state: {
|
||||
status: "error",
|
||||
error: event.data.error,
|
||||
input: typeof part.state.input === "string" ? {} : part.state.input,
|
||||
structured: part.state.status === "running" ? part.state.structured : {},
|
||||
content: part.state.status === "running" ? part.state.content : [],
|
||||
result: event.data.result,
|
||||
},
|
||||
executed: event.data.executed || part.executed === true,
|
||||
providerResultState: event.data.resultState,
|
||||
time: { ...part.time, completed: event.created },
|
||||
}
|
||||
match.executed = event.data.executed || match.executed === true
|
||||
match.providerResultState = event.data.resultState
|
||||
match.time.completed = event.created
|
||||
})
|
||||
break
|
||||
case "session.reasoning.started":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
message.assistant(draft, index, event.data.assistantMessageID)?.content.push({
|
||||
case "session.reasoning.started": {
|
||||
const parts = content.ensure(event.data.sessionID, event.data.assistantMessageID)
|
||||
const partID = SessionContent.reasoningID(event.data.ordinal)
|
||||
if (!parts.has(partID))
|
||||
parts.insert({
|
||||
type: "reasoning",
|
||||
text: "",
|
||||
state: event.data.state,
|
||||
time: { created: event.created },
|
||||
partID,
|
||||
})
|
||||
})
|
||||
break
|
||||
}
|
||||
case "session.reasoning.delta":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestReasoning(message.assistant(draft, index, event.data.assistantMessageID))
|
||||
if (match) match.text += event.data.delta
|
||||
})
|
||||
content
|
||||
.get(event.data.sessionID, event.data.assistantMessageID)
|
||||
?.modify(SessionContent.reasoningID(event.data.ordinal), (part) =>
|
||||
part.type === "reasoning" ? { ...part, text: part.text + event.data.delta } : part,
|
||||
)
|
||||
break
|
||||
case "session.reasoning.ended":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const match = message.latestReasoning(message.assistant(draft, index, event.data.assistantMessageID))
|
||||
if (match) {
|
||||
match.text = event.data.text
|
||||
match.time = { created: match.time?.created ?? event.created, completed: event.created }
|
||||
if (event.data.state !== undefined) match.state = event.data.state
|
||||
}
|
||||
})
|
||||
content
|
||||
.get(event.data.sessionID, event.data.assistantMessageID)
|
||||
?.modify(SessionContent.reasoningID(event.data.ordinal), (part) => {
|
||||
if (part.type !== "reasoning") return part
|
||||
return {
|
||||
...part,
|
||||
text: event.data.text,
|
||||
time: { created: part.time?.created ?? event.created, completed: event.created },
|
||||
state: event.data.state !== undefined ? event.data.state : part.state,
|
||||
}
|
||||
})
|
||||
break
|
||||
case "session.retry.scheduled":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
@@ -745,7 +733,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const position = draft.findIndex((item) => item.id >= event.data.to)
|
||||
if (position === -1) return
|
||||
for (const item of draft.splice(position)) index.delete(item.id)
|
||||
for (const item of draft.splice(position)) {
|
||||
index.delete(item.id)
|
||||
content.drop(event.data.sessionID, item.id)
|
||||
}
|
||||
})
|
||||
break
|
||||
case "session.compaction.delta":
|
||||
@@ -964,9 +955,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
||||
await client.api.message.list({ sessionID, limit: 200, order: "desc" })
|
||||
).data.toReversed()
|
||||
messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])))
|
||||
content.drop(sessionID)
|
||||
for (const item of messages) {
|
||||
if (item.type === "assistant") content.seed(sessionID, item.id, item.content)
|
||||
}
|
||||
setStore("session", "message", sessionID, reconcile(messages))
|
||||
})
|
||||
},
|
||||
parts(sessionID: string, messageID: string) {
|
||||
return content.ensure(sessionID, messageID)
|
||||
},
|
||||
invalidate(sessionID: string) {
|
||||
sync.invalidate(`session.message:${sessionID}`)
|
||||
},
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { SessionMessageAssistant } from "@opencode-ai/client"
|
||||
import { Keyed, Layout } from "effect-quark"
|
||||
|
||||
/**
|
||||
* Stable per-part reactive slots for assistant message content.
|
||||
*
|
||||
* Streaming events name their part on the wire (text/reasoning by ordinal,
|
||||
* tools by callID); each assistant message owns one keyed collection so a
|
||||
* delta publishes exactly one slot instead of reconciling a content array.
|
||||
* See docs/design/quark-message-content.md.
|
||||
*/
|
||||
export namespace SessionContent {
|
||||
type ContentPart = SessionMessageAssistant["content"][number]
|
||||
export type Part = ContentPart & { readonly partID: string }
|
||||
export type Parts = Keyed.Keyed<Part, string>
|
||||
|
||||
// Streamed sub-objects are replaced immutably on change, so reference
|
||||
// equality is the correct (and cheapest) field comparator for them.
|
||||
const reference: Layout.Field<unknown> = { equivalent: Object.is }
|
||||
|
||||
const PartLayout = Layout.keyedUnion({
|
||||
key: Layout.key("partID", Layout.string),
|
||||
tag: "type",
|
||||
variants: {
|
||||
text: Layout.struct({ text: Layout.string }),
|
||||
reasoning: Layout.struct({ text: Layout.string, state: reference, time: reference }),
|
||||
tool: Layout.struct({
|
||||
id: Layout.immutable(Layout.string),
|
||||
name: Layout.immutable(Layout.string),
|
||||
executed: reference,
|
||||
providerState: reference,
|
||||
providerResultState: reference,
|
||||
state: reference,
|
||||
time: reference,
|
||||
}),
|
||||
},
|
||||
})
|
||||
// The layout describes the reactive fields of the client content shapes;
|
||||
// the plan is typed against those shapes at this single boundary.
|
||||
const PartPlan = Layout.compile(PartLayout) as unknown as Layout.Plan<Part, string>
|
||||
|
||||
export function textID(ordinal: number) {
|
||||
return `text:${ordinal}`
|
||||
}
|
||||
|
||||
export function reasoningID(ordinal: number) {
|
||||
return `reasoning:${ordinal}`
|
||||
}
|
||||
|
||||
/** Assign the synthetic part IDs used across the TUI to a fetched content array. */
|
||||
export function withPartIDs(content: readonly ContentPart[]): Part[] {
|
||||
const ordinals = { text: 0, reasoning: 0 }
|
||||
return content.map((part) => {
|
||||
if (part.type === "tool") return { ...part, partID: part.id }
|
||||
return { ...part, partID: `${part.type}:${ordinals[part.type]++}` }
|
||||
})
|
||||
}
|
||||
|
||||
export function make(options?: { readonly metrics?: Keyed.Metrics }) {
|
||||
const collections = new Map<string, Parts>()
|
||||
const id = (sessionID: string, messageID: string) => `${sessionID}\u0000${messageID}`
|
||||
|
||||
return {
|
||||
get(sessionID: string, messageID: string) {
|
||||
return collections.get(id(sessionID, messageID))
|
||||
},
|
||||
ensure(sessionID: string, messageID: string) {
|
||||
const key = id(sessionID, messageID)
|
||||
const existing = collections.get(key)
|
||||
if (existing) return existing
|
||||
const created = PartPlan.make([], options)
|
||||
collections.set(key, created)
|
||||
return created
|
||||
},
|
||||
seed(sessionID: string, messageID: string, content: readonly ContentPart[]) {
|
||||
this.ensure(sessionID, messageID).set(withPartIDs(content))
|
||||
},
|
||||
/** Drop one message's collection, or every collection for a session. */
|
||||
drop(sessionID: string, messageID?: string) {
|
||||
if (messageID !== undefined) {
|
||||
collections.delete(id(sessionID, messageID))
|
||||
return
|
||||
}
|
||||
const prefix = `${sessionID}\u0000`
|
||||
for (const key of [...collections.keys()]) {
|
||||
if (key.startsWith(prefix)) collections.delete(key)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,9 @@ export function DialogMessage(props: {
|
||||
value.type === "user"
|
||||
? value.text
|
||||
: value.type === "assistant"
|
||||
? value.content
|
||||
? data.session.message
|
||||
.parts(props.sessionID, props.messageID)
|
||||
.values()
|
||||
.filter((content) => content.type === "text")
|
||||
.map((content) => content.text)
|
||||
.join("\n")
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
Switch,
|
||||
useContext,
|
||||
} from "solid-js"
|
||||
import { KeyedFor } from "effect-quark/solid"
|
||||
import { KeyedFor, useSlot, useValue } from "effect-quark/solid"
|
||||
import { SessionContent } from "./content"
|
||||
import path from "node:path"
|
||||
import { EOL, tmpdir } from "node:os"
|
||||
import { mkdir, writeFile } from "node:fs/promises"
|
||||
@@ -74,7 +75,7 @@ import { Keymap, type KeymapCommand } from "../../context/keymap"
|
||||
import { usePathFormatter } from "../../context/path-format"
|
||||
import { useLocation } from "../../context/location"
|
||||
import { createSessionRows } from "./rows"
|
||||
import { messageBoundaryIDs, resolvePart, type PartRef, type SessionRow } from "./timeline"
|
||||
import { messageBoundaryIDs, type PartRef, type SessionRow } from "./timeline"
|
||||
import { switchLabel } from "../../util/model"
|
||||
import { findMessageBoundary, messageNavigationSlack } from "./message-navigation"
|
||||
import { stringWidth } from "../../util/string-width"
|
||||
@@ -312,6 +313,11 @@ export function Session() {
|
||||
direction,
|
||||
children: scroll.getChildren(),
|
||||
messages: messages(),
|
||||
hasText: (messageID) =>
|
||||
data.session.message
|
||||
.parts(route.sessionID, messageID)
|
||||
.values()
|
||||
.some((part) => part.type === "text" && part.text.trim()),
|
||||
scrollTop: scroll.scrollTop,
|
||||
viewportY: scroll.viewport.y,
|
||||
currentID: navigationMessage(),
|
||||
@@ -681,7 +687,10 @@ export function Session() {
|
||||
return
|
||||
}
|
||||
|
||||
const textParts = lastAssistantMessage.content.filter((part) => part.type === "text")
|
||||
const textParts = data.session.message
|
||||
.parts(route.sessionID, lastAssistantMessage.id)
|
||||
.values()
|
||||
.filter((part) => part.type === "text")
|
||||
if (textParts.length === 0) {
|
||||
toast.show({ message: "No text parts found in last assistant message", variant: "error" })
|
||||
dialog.clear()
|
||||
@@ -719,7 +728,9 @@ export function Session() {
|
||||
try {
|
||||
const sessionData = session()
|
||||
if (!sessionData) return
|
||||
const transcript = formatSessionTranscript(sessionData, messages(), showThinking())
|
||||
const transcript = formatSessionTranscript(sessionData, messages(), showThinking(), (messageID) =>
|
||||
data.session.message.parts(route.sessionID, messageID).values(),
|
||||
)
|
||||
await clipboard.write?.(transcript)
|
||||
toast.show({ message: "Session transcript copied to clipboard!", variant: "success" })
|
||||
} catch {
|
||||
@@ -746,7 +757,9 @@ export function Session() {
|
||||
|
||||
const content =
|
||||
options.format === "markdown"
|
||||
? formatSessionTranscript(sessionData, messages(), options.thinking)
|
||||
? formatSessionTranscript(sessionData, messages(), options.thinking, (messageID) =>
|
||||
data.session.message.parts(route.sessionID, messageID).values(),
|
||||
)
|
||||
: await (async () => {
|
||||
if (options.debug) {
|
||||
const events: { readonly created: number }[] = []
|
||||
@@ -934,11 +947,15 @@ export function Session() {
|
||||
<SessionRowView
|
||||
row={row()}
|
||||
message={(messageID) => data.session.message.get(route.sessionID, messageID)}
|
||||
parts={(messageID) => data.session.message.parts(route.sessionID, messageID)}
|
||||
boundaryID={boundaries()[index()]}
|
||||
/>
|
||||
)}
|
||||
</KeyedFor>
|
||||
<BackgroundToolHint messages={messages()} />
|
||||
<BackgroundToolHint
|
||||
messages={messages()}
|
||||
parts={(messageID) => data.session.message.parts(route.sessionID, messageID)}
|
||||
/>
|
||||
<Show when={session()?.revert?.messageID}>
|
||||
<RevertMessage
|
||||
count={
|
||||
@@ -1028,6 +1045,7 @@ export function Session() {
|
||||
function SessionRowView(props: {
|
||||
row: SessionRow
|
||||
message: (messageID: string) => SessionMessageInfo | undefined
|
||||
parts: (messageID: string) => SessionContent.Parts
|
||||
boundaryID?: string
|
||||
}) {
|
||||
return (
|
||||
@@ -1042,10 +1060,17 @@ function SessionRowView(props: {
|
||||
<CompactionQueued />
|
||||
</Match>
|
||||
<Match when={props.row.type === "part" ? props.row : undefined}>
|
||||
{(row) => <SessionPartView partRef={row().ref} message={props.message} />}
|
||||
{(row) => <SessionPartView partRef={row().ref} message={props.message} parts={props.parts} />}
|
||||
</Match>
|
||||
<Match when={props.row.type === "group" && props.row.kind === "reasoning" ? props.row : undefined}>
|
||||
{(row) => <SessionReasoningGroupView refs={row().refs} completed={row().completed} message={props.message} />}
|
||||
{(row) => (
|
||||
<SessionReasoningGroupView
|
||||
refs={row().refs}
|
||||
completed={row().completed}
|
||||
message={props.message}
|
||||
parts={props.parts}
|
||||
/>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={props.row.type === "group" && props.row.kind === "exploration" ? props.row : undefined}>
|
||||
{(row) => (
|
||||
@@ -1054,6 +1079,7 @@ function SessionRowView(props: {
|
||||
pending={row().pending}
|
||||
completed={row().completed}
|
||||
message={props.message}
|
||||
parts={props.parts}
|
||||
/>
|
||||
)}
|
||||
</Match>
|
||||
@@ -1073,21 +1099,29 @@ function SessionRowView(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function BackgroundToolHint(props: { messages: SessionMessageInfo[] }) {
|
||||
function BackgroundToolHint(props: {
|
||||
messages: SessionMessageInfo[]
|
||||
parts: (messageID: string) => SessionContent.Parts
|
||||
}) {
|
||||
const { themeV2 } = useTheme()
|
||||
const shortcut = Keymap.useShortcut("session.background")
|
||||
const visible = createMemo(() => {
|
||||
const current = props.messages.findLast(
|
||||
const current = createMemo(() =>
|
||||
props.messages.findLast(
|
||||
(message): message is SessionMessageAssistant => message.type === "assistant" && !message.time.completed,
|
||||
)
|
||||
return (
|
||||
current?.content.some((part) => {
|
||||
),
|
||||
)
|
||||
const parts = createMemo(() => {
|
||||
const message = current()
|
||||
return message ? useValue(props.parts(message.id).values) : undefined
|
||||
})
|
||||
const visible = createMemo(
|
||||
() =>
|
||||
parts()?.()?.some((part) => {
|
||||
if (part.type !== "tool" || part.state.status !== "running") return false
|
||||
const display = toolDisplay(part.name)
|
||||
return display === "shell" || display === "subagent"
|
||||
}) ?? false
|
||||
)
|
||||
})
|
||||
}) ?? false,
|
||||
)
|
||||
return (
|
||||
<Show when={visible() && shortcut()}>
|
||||
{(value) => (
|
||||
@@ -1127,13 +1161,15 @@ function SessionMessageView(props: { message: SessionMessageInfo }) {
|
||||
)
|
||||
}
|
||||
|
||||
function SessionPartView(props: { partRef: PartRef; message: (messageID: string) => SessionMessageInfo | undefined }) {
|
||||
function SessionPartView(props: {
|
||||
partRef: PartRef
|
||||
message: (messageID: string) => SessionMessageInfo | undefined
|
||||
parts: (messageID: string) => SessionContent.Parts
|
||||
}) {
|
||||
const message = createMemo(() => props.message(props.partRef.messageID))
|
||||
const part = createMemo(() => {
|
||||
const item = message()
|
||||
if (item?.type !== "assistant") return
|
||||
return resolvePart(item, props.partRef.partID)
|
||||
})
|
||||
// One reactive slot per part: unrelated deltas in the same message cannot
|
||||
// re-render this row.
|
||||
const part = useSlot(props.parts(props.partRef.messageID), () => props.partRef.partID)
|
||||
return (
|
||||
<Show when={part()}>
|
||||
{(item) => (
|
||||
@@ -1161,17 +1197,26 @@ function SessionReasoningGroupView(props: {
|
||||
refs: readonly PartRef[]
|
||||
completed: boolean
|
||||
message: (messageID: string) => SessionMessageInfo | undefined
|
||||
parts: (messageID: string) => SessionContent.Parts
|
||||
}) {
|
||||
const ctx = use()
|
||||
const { themeV2, syntax } = useTheme()
|
||||
const renderer = useRenderer()
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
const [hover, setHover] = createSignal(false)
|
||||
// Slot accessors are created per ref list; value changes flow through the
|
||||
// individual slots without re-running the accessor construction.
|
||||
const accessors = createMemo(() =>
|
||||
props.refs.map((ref) => ({
|
||||
ref,
|
||||
part: useSlot(props.parts(ref.messageID), () => ref.partID),
|
||||
})),
|
||||
)
|
||||
const parts = createMemo(() =>
|
||||
props.refs.flatMap((ref) => {
|
||||
const message = props.message(ref.messageID)
|
||||
accessors().flatMap((entry) => {
|
||||
const message = props.message(entry.ref.messageID)
|
||||
if (message?.type !== "assistant") return []
|
||||
const part = resolvePart(message, ref.partID)
|
||||
const part = entry.part()
|
||||
if (part?.type !== "reasoning" || !reasoningContent(part)) return []
|
||||
return [{ message, part }]
|
||||
}),
|
||||
@@ -1196,7 +1241,11 @@ function SessionReasoningGroupView(props: {
|
||||
<Show when={parts().length > 0}>
|
||||
<Show
|
||||
when={ctx.thinkingMode() === "hide"}
|
||||
fallback={<For each={props.refs}>{(ref) => <SessionPartView partRef={ref} message={props.message} />}</For>}
|
||||
fallback={
|
||||
<For each={props.refs}>
|
||||
{(ref) => <SessionPartView partRef={ref} message={props.message} parts={props.parts} />}
|
||||
</For>
|
||||
}
|
||||
>
|
||||
<box flexDirection="column" flexShrink={0}>
|
||||
<InlineToolRow
|
||||
@@ -1232,15 +1281,14 @@ function SessionReasoningGroupView(props: {
|
||||
<box paddingLeft={3}>
|
||||
<For each={props.refs}>
|
||||
{(ref) => {
|
||||
const message = createMemo(() => {
|
||||
const item = props.message(ref.messageID)
|
||||
return item?.type === "assistant" ? item : undefined
|
||||
})
|
||||
const slot = useSlot(props.parts(ref.messageID), () => ref.partID)
|
||||
const part = createMemo(() => {
|
||||
const item = message()
|
||||
if (!item) return undefined
|
||||
const part = resolvePart(item, ref.partID)
|
||||
return part?.type === "reasoning" ? part : undefined
|
||||
const item = slot()
|
||||
return item?.type === "reasoning" ? item : undefined
|
||||
})
|
||||
const messageCompleted = createMemo(() => {
|
||||
const item = props.message(ref.messageID)
|
||||
return item?.type === "assistant" && item.time.completed !== undefined
|
||||
})
|
||||
const content = createMemo(() => {
|
||||
const item = part()
|
||||
@@ -1258,7 +1306,7 @@ function SessionReasoningGroupView(props: {
|
||||
<code
|
||||
filetype="markdown"
|
||||
drawUnstyledText={false}
|
||||
streaming={part()?.time?.completed === undefined && message()?.time.completed === undefined}
|
||||
streaming={part()?.time?.completed === undefined && !messageCompleted()}
|
||||
syntaxStyle={syntax()}
|
||||
content={content()}
|
||||
conceal={ctx.markdownMode() === "rendered"}
|
||||
@@ -1283,22 +1331,27 @@ function SessionGroupView(props: {
|
||||
pending: readonly PartRef[]
|
||||
completed: boolean
|
||||
message: (messageID: string) => SessionMessageInfo | undefined
|
||||
parts: (messageID: string) => SessionContent.Parts
|
||||
}) {
|
||||
const { themeV2 } = useTheme()
|
||||
const ctx = use()
|
||||
const renderer = useRenderer()
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
const [hover, setHover] = createSignal(false)
|
||||
const parts = (refs: readonly PartRef[]) =>
|
||||
refs.flatMap((ref) => {
|
||||
const message = props.message(ref.messageID)
|
||||
if (message?.type !== "assistant") return []
|
||||
const part = resolvePart(message, ref.partID)
|
||||
// Slot accessors per ref list: tool state changes flow through individual
|
||||
// slots; the accessor lists rebuild only when the refs themselves change.
|
||||
const slots = (refs: () => readonly PartRef[]) =>
|
||||
createMemo(() => refs().map((ref) => useSlot(props.parts(ref.messageID), () => ref.partID)))
|
||||
const groupedSlots = slots(() => props.refs)
|
||||
const pendingSlots = slots(() => props.pending)
|
||||
const parts = (accessors: readonly (() => SessionContent.Part | undefined)[]) =>
|
||||
accessors.flatMap((accessor) => {
|
||||
const part = accessor()
|
||||
if (part?.type !== "tool") return []
|
||||
return [part]
|
||||
})
|
||||
const grouped = createMemo(() => parts(props.refs))
|
||||
const pending = createMemo(() => parts(props.pending))
|
||||
const grouped = createMemo(() => parts(groupedSlots()))
|
||||
const pending = createMemo(() => parts(pendingSlots()))
|
||||
const label = createMemo(() => {
|
||||
const counts = grouped().reduce<Record<string, number>>((result, part) => {
|
||||
const tool = toolDisplay(part.name)
|
||||
@@ -1713,124 +1766,6 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
||||
)
|
||||
}
|
||||
|
||||
function AssistantMessage(props: { message: SessionMessageAssistant; last: boolean }) {
|
||||
const ctx = use()
|
||||
const local = useLocal()
|
||||
const { themeV2 } = useTheme().contextual("elevated")
|
||||
const model = createMemo(
|
||||
() =>
|
||||
ctx
|
||||
.models()
|
||||
.find((model) => model.providerID === props.message.model.providerID && model.id === props.message.model.id)
|
||||
?.name ?? `${props.message.model.providerID}/${props.message.model.id}`,
|
||||
)
|
||||
|
||||
const final = createMemo(() => {
|
||||
return props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish)
|
||||
})
|
||||
|
||||
const duration = createMemo(() => {
|
||||
if (!final()) return 0
|
||||
if (!props.message.time.completed) return 0
|
||||
return props.message.time.completed - props.message.time.created
|
||||
})
|
||||
|
||||
const exploration = createMemo(() => {
|
||||
const grouped = new Map<string, { first: boolean; parts: SessionMessageAssistantTool[]; active: boolean }>()
|
||||
if (!ctx.groupExploration()) return grouped
|
||||
const runs = props.message.content
|
||||
.map((part) =>
|
||||
part.type === "tool" &&
|
||||
["read", "glob", "grep"].includes(toolDisplay(part.name)) &&
|
||||
part.state.status !== "streaming"
|
||||
? part
|
||||
: undefined,
|
||||
)
|
||||
.reduce<SessionMessageAssistantTool[][]>(
|
||||
(runs, part) => {
|
||||
if (part) runs[runs.length - 1].push(part)
|
||||
if (!part && runs[runs.length - 1].length) runs.push([])
|
||||
return runs
|
||||
},
|
||||
[[]],
|
||||
)
|
||||
.filter((run) => run.length > 0)
|
||||
for (const run of runs) {
|
||||
const summary = {
|
||||
parts: run,
|
||||
active: false,
|
||||
}
|
||||
run.forEach((part, index) => grouped.set(part.id, { ...summary, first: index === 0 }))
|
||||
}
|
||||
return grouped
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<For each={props.message.content}>
|
||||
{(content, index) => (
|
||||
<Switch>
|
||||
<Match when={content.type === "text"}>
|
||||
<TextPart
|
||||
part={content as SessionMessageAssistantText}
|
||||
last={index() === props.message.content.length - 1}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={content.type === "reasoning"}>
|
||||
<ReasoningPart
|
||||
part={content as SessionMessageAssistantReasoning}
|
||||
message={props.message}
|
||||
last={index() === props.message.content.length - 1}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={content.type === "tool"}>
|
||||
<Show when={exploration().get((content as SessionMessageAssistantTool).id)?.first !== false}>
|
||||
<Show
|
||||
when={exploration().get((content as SessionMessageAssistantTool).id)}
|
||||
fallback={<ToolPart part={content as SessionMessageAssistantTool} />}
|
||||
>
|
||||
{(summary) => <ExplorationSummary {...summary()} />}
|
||||
</Show>
|
||||
</Show>
|
||||
</Match>
|
||||
</Switch>
|
||||
)}
|
||||
</For>
|
||||
<Show when={props.message.error}>
|
||||
<box
|
||||
border={["left"]}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
backgroundColor={themeV2.background()}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={themeV2.text.feedback.error()}
|
||||
>
|
||||
<text fg={themeV2.text.subdued()}>{errorMessage(props.message.error)}</text>
|
||||
</box>
|
||||
</Show>
|
||||
<AssistantRetry retry={props.message.retry} />
|
||||
<Switch>
|
||||
<Match when={props.last || final() || props.message.error}>
|
||||
<box paddingLeft={3}>
|
||||
<text>
|
||||
<span
|
||||
style={{ fg: props.message.error ? themeV2.text.subdued() : local.agent.color(props.message.agent) }}
|
||||
>
|
||||
{Locale.titlecase(props.message.agent)}
|
||||
</span>
|
||||
<span style={{ fg: themeV2.text.subdued() }}> · {model()}</span>
|
||||
<Show when={duration()}>
|
||||
<span style={{ fg: themeV2.text.subdued() }}> · {Locale.duration(duration())}</span>
|
||||
</Show>
|
||||
</text>
|
||||
</box>
|
||||
</Match>
|
||||
</Switch>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function AssistantRetry(props: { retry: SessionMessageAssistant["retry"] }) {
|
||||
const { themeV2 } = useTheme()
|
||||
return (
|
||||
@@ -3035,13 +2970,18 @@ function recordValue(value: unknown): Record<string, unknown> | undefined {
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
function formatSessionTranscript(session: SessionInfo, messages: SessionMessageInfo[], thinking: boolean) {
|
||||
function formatSessionTranscript(
|
||||
session: SessionInfo,
|
||||
messages: SessionMessageInfo[],
|
||||
thinking: boolean,
|
||||
partsOf: (messageID: string) => readonly SessionContent.Part[],
|
||||
) {
|
||||
const body = messages.flatMap((message) => {
|
||||
if (message.type === "user") return [`## User\n\n${message.text}`]
|
||||
if (message.type === "shell")
|
||||
return [`## Shell\n\n\`\`\`\n$ ${message.command}\n${message.output?.output ?? ""}\n\`\`\``]
|
||||
if (message.type !== "assistant") return []
|
||||
const content = message.content.flatMap((item) => {
|
||||
const content = partsOf(message.id).flatMap((item) => {
|
||||
if (item.type === "text") return [item.text]
|
||||
if (item.type === "reasoning") return thinking ? [`_Thinking:_\n\n${item.text}`] : []
|
||||
const input = typeof item.state.input === "string" ? item.state.input : JSON.stringify(item.state.input, null, 2)
|
||||
|
||||
@@ -19,6 +19,7 @@ export function findMessageBoundary(input: {
|
||||
direction: "next" | "prev"
|
||||
children: readonly MessageChild[]
|
||||
messages: readonly SessionMessageInfo[]
|
||||
hasText?: (messageID: string) => boolean
|
||||
scrollTop: number
|
||||
viewportY: number
|
||||
currentID?: string
|
||||
@@ -35,7 +36,9 @@ export function findMessageBoundary(input: {
|
||||
return [{ id: child.id, y, top: y }]
|
||||
}
|
||||
if (input.userOnly || message.type !== "assistant") return []
|
||||
if (!message.content.some((content) => content.type === "text" && content.text.trim())) return []
|
||||
const hasText =
|
||||
input.hasText?.(message.id) ?? message.content.some((content) => content.type === "text" && content.text.trim())
|
||||
if (!hasText) return []
|
||||
const y = input.scrollTop + child.y - input.viewportY
|
||||
return [{ id: child.id, y, top: Math.max(0, y - 1) }]
|
||||
})
|
||||
|
||||
@@ -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()}>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user