refactor(tui): simplify quark migration after review

- fix permission input losing reactivity (raw slot read; regression test)
- BackgroundToolHint tracks structure + tool slots instead of whole-collection values
- group views share mapArray-backed usePartSlots (per-ref node reuse)
- dedupe streaming handlers behind variant-scoped modify/insert helpers
- part ID scheme has one definition; hasText required in message navigation
- expose read-only PartsView to view components; misc cleanups
This commit is contained in:
Kit Langton 2026-07-18 00:09:44 -04:00
commit 4027dbb4d8
11 changed files with 379 additions and 196 deletions

View file

@ -11,7 +11,7 @@ export function useValue<A>(readable: Readable<A>): Accessor<A> {
* slot, while memo equality prevents an unchanged slot from propagating to the
* consumer. Value changes flow through the slot itself.
*/
export function useSlot<A, Key>(keyed: Keyed.Keyed<A, Key>, key: () => Key): Accessor<A | undefined> {
export function useSlot<A, Key>(keyed: Pick<Keyed.Keyed<A, Key>, "slots" | "get">, key: () => Key): Accessor<A | undefined> {
const structure = useValue(keyed.slots)
const slot = createMemo(() => {
structure()

View file

@ -34,7 +34,7 @@ import { SessionContent } from "../routes/session/content"
export type DataSessionStatus = "idle" | "running"
const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
export const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
// Global MCP elicitations temporarily use "global" instead of a real session ID, so the
// server cannot recover their Location when settling them. Preserve the event Location
@ -150,6 +150,44 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
// 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()
// Variant-scoped slot operations: each owns its part address scheme and
// type guard so the streaming handlers below read as pure transforms.
// A missing collection or key is a normal straggler race and no-ops.
const modifyText = (
data: { sessionID: string; assistantMessageID: string; ordinal: number },
f: (part: Extract<SessionContent.Part, { type: "text" }>) => SessionContent.Part,
) =>
content
.get(data.sessionID, data.assistantMessageID)
?.modify(SessionContent.textID(data.ordinal), (part) => (part.type === "text" ? f(part) : part))
const modifyReasoning = (
data: { sessionID: string; assistantMessageID: string; ordinal: number },
f: (part: Extract<SessionContent.Part, { type: "reasoning" }>) => SessionContent.Part,
) =>
content
.get(data.sessionID, data.assistantMessageID)
?.modify(SessionContent.reasoningID(data.ordinal), (part) => (part.type === "reasoning" ? f(part) : part))
const modifyTool = (
data: { sessionID: string; assistantMessageID: string; callID: string },
f: (part: Extract<SessionContent.Part, { type: "tool" }>) => SessionContent.Part,
) =>
content
.get(data.sessionID, data.assistantMessageID)
?.modify(data.callID, (part) => (part.type === "tool" ? f(part) : part))
const insertPart = (data: { sessionID: string; assistantMessageID: string }, part: SessionContent.Part) => {
const parts = content.ensure(data.sessionID, data.assistantMessageID)
if (!parts.has(part.partID)) parts.insert(part)
}
// Shared settlement trailer for tool success and failure.
const settled = (
part: Extract<SessionContent.Part, { type: "tool" }>,
data: { executed: boolean; resultState?: Extract<SessionContent.Part, { type: "tool" }>["providerResultState"] },
completed: number,
) => ({
executed: data.executed || part.executed === true,
providerResultState: data.resultState,
time: { ...part.time, completed },
})
const sync = createSync()
const pendingOperations = new Map<string, PendingOperation[]>()
@ -251,6 +289,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
...item.data,
time: { created: item.timeCreated },
}
// Placeholder row until the server projects the real compaction;
// pending compactions carry no reason, so "manual" is a stand-in.
return {
id: item.id,
type: "compaction",
@ -566,142 +606,110 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
}
})
break
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 })
case "session.text.started":
insertPart(event.data, { type: "text", text: "", partID: SessionContent.textID(event.data.ordinal) })
break
}
case "session.text.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,
)
modifyText(event.data, (part) => ({ ...part, text: part.text + event.data.delta }))
break
case "session.text.ended":
content
.get(event.data.sessionID, event.data.assistantMessageID)
?.modify(SessionContent.textID(event.data.ordinal), (part) =>
part.type === "text" ? { ...part, text: event.data.text } : part,
)
modifyText(event.data, (part) => ({ ...part, text: event.data.text }))
break
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":
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 } }
case "session.tool.input.started":
insertPart(event.data, {
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":
modifyTool(event.data, (part) =>
part.state.status !== "streaming"
? part
: { ...part, state: { ...part.state, input: part.state.input + event.data.delta } },
)
break
case "session.tool.input.ended":
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 } }
})
modifyTool(event.data, (part) =>
part.state.status !== "streaming" ? part : { ...part, state: { ...part.state, input: event.data.text } },
)
break
case "session.tool.called":
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: [] },
}
})
modifyTool(event.data, (part) => ({
...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":
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] },
}
})
modifyTool(event.data, (part) =>
part.state.status !== "running"
? part
: {
...part,
state: { ...part.state, structured: event.data.structured, content: [...event.data.content] },
},
)
break
case "session.tool.success":
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 },
}
})
modifyTool(event.data, (part) =>
part.state.status !== "running"
? part
: {
...part,
state: {
status: "completed",
input: part.state.input,
structured: event.data.structured,
content: [...event.data.content],
result: event.data.result,
},
...settled(part, event.data, event.created),
},
)
break
case "session.tool.failed":
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 },
}
modifyTool(event.data, (part) =>
part.state.status !== "streaming" && part.state.status !== "running"
? part
: {
...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,
},
...settled(part, event.data, event.created),
},
)
break
case "session.reasoning.started":
insertPart(event.data, {
type: "reasoning",
text: "",
state: event.data.state,
time: { created: event.created },
partID: SessionContent.reasoningID(event.data.ordinal),
})
break
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":
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,
)
modifyReasoning(event.data, (part) => ({ ...part, text: part.text + event.data.delta }))
break
case "session.reasoning.ended":
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,
}
})
modifyReasoning(event.data, (part) => ({
...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) => {
@ -981,12 +989,17 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
sync(sessionID: string) {
return sync.run(`session.message:${sessionID}`, async () => {
await syncPending(sessionID)
const localInputs = pendingInputs(sessionID).map((item) => item.id)
const pendingMessages = [...(store.session.pending[sessionID] ?? [])].map(message.fromPending)
const inputIDs = () => pendingInputs(sessionID).map((item) => item.id)
// Snapshot pending state before the fetch: inputs admitted before
// the fetch must survive even if the server promotes them while
// the list request is in flight. The post-fetch snapshot covers
// inputs admitted during the fetch.
const localInputs = inputIDs()
const pendingMessages = (store.session.pending[sessionID] ?? []).map(message.fromPending)
const projected = await client.api.message.list({ sessionID, limit: 200, order: "desc" })
const next = projected.data.toReversed()
const index = new Map(next.map((message, index) => [message.id, index]))
const localInputIDs = new Set([...localInputs, ...pendingInputs(sessionID).map((item) => item.id)])
const localInputIDs = new Set([...localInputs, ...inputIDs()])
localInputIDs.forEach((messageID) => {
const position = messageIndex.get(sessionID)?.get(messageID)
const item = position === undefined ? undefined : store.session.message[sessionID]?.[position]
@ -1004,6 +1017,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
})
},
parts(sessionID: string, messageID: string) {
// Deliberately ensure-on-read: consumers capture the collection
// for their lifetime (see useSlot), so identity per (session,
// message) must be stable even when reads precede streaming.
// Stray collections are reclaimed by prune on the next sync.
return content.ensure(sessionID, messageID)
},
invalidate(sessionID: string) {

View file

@ -13,6 +13,8 @@ export namespace SessionContent {
type ContentPart = SessionMessageAssistant["content"][number]
export type Part = ContentPart & { readonly partID: string }
export type Parts = Keyed.Keyed<Part, string>
/** Read surface for view components; mutation stays with the data layer. */
export type PartsView = Pick<Parts, "slots" | "values" | "get" | "has">
// Streamed sub-objects are replaced immutably on change, so reference
// equality is the correct (and cheapest) field comparator for them.
@ -52,10 +54,28 @@ export namespace SessionContent {
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]++}` }
const id = part.type === "text" ? textID : reasoningID
return { ...part, partID: id(ordinals[part.type]++) }
})
}
/** Non-empty text parts of one message, in slot order. */
export function textParts(parts: PartsView) {
return parts
.values()
.filter((part): part is Extract<Part, { type: "text" }> => part.type === "text" && part.text.trim().length > 0)
}
export function hasText(parts: PartsView) {
return textParts(parts).length > 0
}
export function text(parts: PartsView) {
return textParts(parts)
.map((part) => part.text)
.join("\n")
}
export function make(options?: { readonly metrics?: Keyed.Metrics }) {
const collections = new Map<string, Parts>()
const id = (sessionID: string, messageID: string) => `${sessionID}\u0000${messageID}`

View file

@ -6,6 +6,7 @@ import { useToast } from "../../ui/toast"
import { useClient } from "../../context/client"
import { errorMessage } from "../../util/error"
import { DialogFork } from "./dialog-fork"
import { SessionContent } from "./content"
import type { PromptInfo } from "../../prompt/history"
export function DialogMessage(props: {
@ -62,12 +63,7 @@ export function DialogMessage(props: {
value.type === "user"
? value.text
: value.type === "assistant"
? data.session.message
.parts(props.sessionID, props.messageID)
.values()
.filter((content) => content.type === "text")
.map((content) => content.text)
.join("\n")
? SessionContent.text(data.session.message.parts(props.sessionID, props.messageID))
: "text" in value
? value.text
: ""

View file

@ -5,6 +5,7 @@ import {
createMemo,
createSignal,
For,
mapArray,
Match,
on,
onCleanup,
@ -208,6 +209,10 @@ export function Session() {
})
const editor = useEditorContext()
const rows = createSessionRows(() => route.sessionID)
const partsOf = (messageID: string) => data.session.message.parts(route.sessionID, messageID)
// Slot reads here are deliberately untracked: messageBoundaryIDs depends
// only on structurally-immutable row fields (id, type, origin), so this memo
// re-runs on structural changes (rows.slots) and message list changes only.
const boundaries = createMemo(() =>
messageBoundaryIDs(
rows.slots().map((slot) => slot()),
@ -313,11 +318,7 @@ 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()),
hasText: (messageID) => SessionContent.hasText(partsOf(messageID)),
scrollTop: scroll.scrollTop,
viewportY: scroll.viewport.y,
currentID: navigationMessage(),
@ -687,8 +688,7 @@ export function Session() {
return
}
const textParts = data.session.message
.parts(route.sessionID, lastAssistantMessage.id)
const textParts = partsOf(lastAssistantMessage.id)
.values()
.filter((part) => part.type === "text")
if (textParts.length === 0) {
@ -729,7 +729,7 @@ export function Session() {
const sessionData = session()
if (!sessionData) return
const transcript = formatSessionTranscript(sessionData, messages(), showThinking(), (messageID) =>
data.session.message.parts(route.sessionID, messageID).values(),
partsOf(messageID).values(),
)
await clipboard.write?.(transcript)
toast.show({ message: "Session transcript copied to clipboard!", variant: "success" })
@ -758,7 +758,7 @@ export function Session() {
const content =
options.format === "markdown"
? formatSessionTranscript(sessionData, messages(), options.thinking, (messageID) =>
data.session.message.parts(route.sessionID, messageID).values(),
partsOf(messageID).values(),
)
: await (async () => {
if (options.debug) {
@ -947,15 +947,12 @@ export function Session() {
<SessionRowView
row={row()}
message={(messageID) => data.session.message.get(route.sessionID, messageID)}
parts={(messageID) => data.session.message.parts(route.sessionID, messageID)}
parts={partsOf}
boundaryID={boundaries()[index()]}
/>
)}
</KeyedFor>
<BackgroundToolHint
messages={messages()}
parts={(messageID) => data.session.message.parts(route.sessionID, messageID)}
/>
<BackgroundToolHint messages={messages()} parts={partsOf} />
<Show when={session()?.revert?.messageID}>
<RevertMessage
count={
@ -1045,7 +1042,7 @@ export function Session() {
function SessionRowView(props: {
row: SessionRow
message: (messageID: string) => SessionMessageInfo | undefined
parts: (messageID: string) => SessionContent.Parts
parts: (messageID: string) => SessionContent.PartsView
boundaryID?: string
}) {
return (
@ -1101,7 +1098,7 @@ function SessionRowView(props: {
function BackgroundToolHint(props: {
messages: SessionMessageInfo[]
parts: (messageID: string) => SessionContent.Parts
parts: (messageID: string) => SessionContent.PartsView
}) {
const { themeV2 } = useTheme()
const shortcut = Keymap.useShortcut("session.background")
@ -1110,17 +1107,23 @@ function BackgroundToolHint(props: {
(message): message is SessionMessageAssistant => message.type === "assistant" && !message.time.completed,
),
)
const parts = createMemo(() => {
// Track the part structure (publishes only on part insert/remove) and the
// individual tool slots; text and reasoning deltas never reach this memo.
const toolSlots = createMemo(() => {
const message = current()
return message ? useValue(props.parts(message.id).values) : undefined
if (!message) return []
const parts = props.parts(message.id)
return useValue(parts.slots)()
.filter((slot) => slot().type === "tool")
.map((slot) => useValue(slot))
})
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,
const visible = createMemo(() =>
toolSlots().some((tool) => {
const part = tool()
if (part.type !== "tool" || part.state.status !== "running") return false
const display = toolDisplay(part.name)
return display === "shell" || display === "subagent"
}),
)
return (
<Show when={visible() && shortcut()}>
@ -1161,10 +1164,18 @@ function SessionMessageView(props: { message: SessionMessageInfo }) {
)
}
// Per-ref slot accessors for group views. mapArray reuses entries by ref
// identity, so appending one ref to a group creates one new slot subscription
// instead of rebuilding the whole accessor list; value changes flow through
// the individual slots.
function usePartSlots(parts: (messageID: string) => SessionContent.PartsView, refs: () => readonly PartRef[]) {
return mapArray(refs, (ref) => ({ ref, part: useSlot(parts(ref.messageID), () => ref.partID) }))
}
function SessionPartView(props: {
partRef: PartRef
message: (messageID: string) => SessionMessageInfo | undefined
parts: (messageID: string) => SessionContent.Parts
parts: (messageID: string) => SessionContent.PartsView
}) {
const message = createMemo(() => props.message(props.partRef.messageID))
// One reactive slot per part: unrelated deltas in the same message cannot
@ -1197,21 +1208,14 @@ function SessionReasoningGroupView(props: {
refs: readonly PartRef[]
completed: boolean
message: (messageID: string) => SessionMessageInfo | undefined
parts: (messageID: string) => SessionContent.Parts
parts: (messageID: string) => SessionContent.PartsView
}) {
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 accessors = usePartSlots(props.parts, () => props.refs)
const parts = createMemo(() =>
accessors().flatMap((entry) => {
const message = props.message(entry.ref.messageID)
@ -1331,22 +1335,18 @@ function SessionGroupView(props: {
pending: readonly PartRef[]
completed: boolean
message: (messageID: string) => SessionMessageInfo | undefined
parts: (messageID: string) => SessionContent.Parts
parts: (messageID: string) => SessionContent.PartsView
}) {
const { themeV2 } = useTheme()
const ctx = use()
const renderer = useRenderer()
const [expanded, setExpanded] = createSignal(false)
const [hover, setHover] = createSignal(false)
// 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()
const groupedSlots = usePartSlots(props.parts, () => props.refs)
const pendingSlots = usePartSlots(props.parts, () => props.pending)
const parts = (entries: readonly { readonly part: () => SessionContent.Part | undefined }[]) =>
entries.flatMap((entry) => {
const part = entry.part()
if (part?.type !== "tool") return []
return [part]
})

View file

@ -19,7 +19,9 @@ export function findMessageBoundary(input: {
direction: "next" | "prev"
children: readonly MessageChild[]
messages: readonly SessionMessageInfo[]
hasText?: (messageID: string) => boolean
// Assistant text lives in SessionContent slots; the store `content` field is
// fetch-time only, so callers must supply the live text predicate.
hasText: (messageID: string) => boolean
scrollTop: number
viewportY: number
currentID?: string
@ -36,9 +38,7 @@ export function findMessageBoundary(input: {
return [{ id: child.id, y, top: y }]
}
if (input.userOnly || message.type !== "assistant") return []
const hasText =
input.hasText?.(message.id) ?? message.content.some((content) => content.type === "text" && content.text.trim())
if (!hasText) return []
if (!input.hasText(message.id)) return []
const y = input.scrollTop + child.y - input.viewportY
return [{ id: child.id, y, top: Math.max(0, y - 1) }]
})

View file

@ -15,9 +15,23 @@ import { getScrollAcceleration } from "../../util/scroll"
import { useConfig } from "../../config"
import { Keymap } from "../../context/keymap"
import { usePathFormatter } from "../../context/path-format"
import { useSlot } from "@opencode-ai/quark/solid"
type PermissionStage = "permission" | "always" | "reject"
/** Resolved tool input for a permission request, once input streaming settles. */
export function usePermissionInput(request: PermissionV2Request): () => Record<string, unknown> {
const data = useData()
const tool = request.source
if (!tool) return () => ({})
const part = useSlot(data.session.message.parts(request.sessionID, tool.messageID), () => tool.callID)
return createMemo(() => {
const item = part()
if (item?.type === "tool" && item.state.status !== "streaming") return item.state.input
return {}
})
}
function EditBody(props: { request: PermissionV2Request; patch?: string }) {
const themeState = useTheme()
const themeV2 = themeState.themeV2
@ -143,13 +157,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
const pathFormatter = usePathFormatter()
const session = createMemo(() => data.session.get(props.request.sessionID))
const input = createMemo(() => {
const tool = props.request.source
if (!tool) return {}
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 {}
})
const input = usePermissionInput(props.request)
const { themeV2 } = useTheme()

View file

@ -1,8 +1,9 @@
import { Keyed } from "@opencode-ai/quark"
import { useValue } from "@opencode-ai/quark/solid"
import { batch, createEffect, on, onCleanup, type Accessor } from "solid-js"
import { useData } from "../../context/data"
import { messageIDFromEvent, useData } from "../../context/data"
import { useClient } from "../../context/client"
import { SessionContent } from "./content"
import {
SessionTimeline,
compactionQueuedRow,
@ -36,7 +37,6 @@ export function createSessionRows(sessionID: Accessor<string>, options?: { reado
state.replace(value, isPending, pendingPermissions())
})
}
const mutate = (f: () => void) => batch(f)
function reduce() {
const messages = data.session.message.list(sessionID())
@ -69,7 +69,7 @@ export function createSessionRows(sessionID: Accessor<string>, options?: { reado
createEffect(() => {
const pending = pendingPermissions()
mutate(() => state.repartition(pending))
batch(() => state.repartition(pending))
})
createEffect(
@ -132,20 +132,24 @@ export function createSessionRows(sessionID: Accessor<string>, options?: { reado
)
const appendMessage = (messageID: string) =>
mutate(() => {
batch(() => {
const pending = isPending(messageID)
const message = data.session.message.get(sessionID(), messageID)
state.appendMessage(messageID, { pending, compaction: message?.type === "compaction" })
})
const appendPart = (ref: PartRef, part: AppendPart) => mutate(() => state.appendPart(ref, part))
const appendPart = (ref: PartRef, part: AppendPart) => {
// Streaming deltas after the first are no-ops; skip the batch machinery.
if (state.hasPart(ref)) return
batch(() => state.appendPart(ref, part))
}
const appendFooter = (messageID: string) => mutate(() => state.appendFooter(messageID))
const appendFooter = (messageID: string) => batch(() => state.appendFooter(messageID))
const removeFooter = (messageID: string) => mutate(() => state.removeFooter(messageID))
const removeFooter = (messageID: string) => batch(() => state.removeFooter(messageID))
const message = (event: { id: string; data: { sessionID: string } }) => {
if (event.data.sessionID === sessionID()) appendMessage(event.id.replace(/^evt_/, "msg_"))
if (event.data.sessionID === sessionID()) appendMessage(messageIDFromEvent(event.id))
}
const input = (event: {
data: {
@ -163,35 +167,41 @@ export function createSessionRows(sessionID: Accessor<string>, options?: { reado
const subscriptions = [
data.on("session.input.admitted", input),
data.on("session.compaction.started", (event) => {
if (event.data.sessionID === sessionID()) appendMessage(event.data.inputID ?? event.id.replace(/^evt_/, "msg_"))
if (event.data.sessionID === sessionID()) appendMessage(event.data.inputID ?? messageIDFromEvent(event.id))
}),
data.on("session.instructions.updated", message),
data.on("session.synthetic", (event) => {
if (event.data.sessionID === sessionID() && event.data.description?.trim())
appendMessage(event.id.replace(/^evt_/, "msg_"))
appendMessage(messageIDFromEvent(event.id))
}),
data.on("session.shell.started", message),
data.on("session.agent.selected", message),
data.on("session.model.selected", message),
data.on("session.text.delta", (event) => {
if (event.data.sessionID === sessionID() && event.data.delta.trim())
appendPart({ messageID: event.data.assistantMessageID, partID: `text:${event.data.ordinal}` }, { type: "text" })
appendPart(
{ messageID: event.data.assistantMessageID, partID: SessionContent.textID(event.data.ordinal) },
{ type: "text" },
)
}),
data.on("session.text.ended", (event) => {
if (event.data.sessionID === sessionID() && event.data.text.trim())
appendPart({ messageID: event.data.assistantMessageID, partID: `text:${event.data.ordinal}` }, { type: "text" })
appendPart(
{ messageID: event.data.assistantMessageID, partID: SessionContent.textID(event.data.ordinal) },
{ type: "text" },
)
}),
data.on("session.reasoning.delta", (event) => {
if (event.data.sessionID === sessionID() && event.data.delta.trim())
appendPart(
{ messageID: event.data.assistantMessageID, partID: `reasoning:${event.data.ordinal}` },
{ messageID: event.data.assistantMessageID, partID: SessionContent.reasoningID(event.data.ordinal) },
{ type: "reasoning" },
)
}),
data.on("session.reasoning.ended", (event) => {
if (event.data.sessionID === sessionID() && event.data.text.trim())
appendPart(
{ messageID: event.data.assistantMessageID, partID: `reasoning:${event.data.ordinal}` },
{ messageID: event.data.assistantMessageID, partID: SessionContent.reasoningID(event.data.ordinal) },
{ type: "reasoning" },
)
}),

View file

@ -177,6 +177,8 @@ export namespace SessionTimeline {
appendFooter,
removeFooter,
repartition,
/** Cheap no-op check so per-delta callers can skip batching machinery. */
hasPart: (ref: PartRef) => state.hasMember("parts", partRowID(ref)),
}
}
}

View file

@ -7,6 +7,10 @@ const messages: SessionMessageInfo[] = [
assistant("assistant-1", "Response"),
{ type: "user", id: "user-2", text: "Second", time: { created: 2 } },
]
const hasText = (messageID: string) => {
const message = messages.find((item) => item.id === messageID)
return message?.type === "assistant" && message.content.some((part) => part.type === "text" && part.text.trim())
}
const children = [
{ id: "user-1", y: 0 },
{ id: "assistant-1", y: 20 },
@ -24,6 +28,7 @@ test("finds the next user message without stopping at an assistant message", ()
direction: "next",
children,
messages,
hasText,
scrollTop: 0,
viewportY: 0,
userOnly: true,
@ -37,6 +42,7 @@ test("finds the previous user message without stopping at an assistant message",
direction: "prev",
children: children.map((child) => ({ ...child, y: child.y - 35 })),
messages,
hasText,
scrollTop: 35,
viewportY: 0,
userOnly: true,
@ -50,6 +56,7 @@ test("preserves navigation across both user and assistant messages", () => {
direction: "next",
children,
messages,
hasText,
scrollTop: 0,
viewportY: 0,
}),
@ -59,6 +66,7 @@ test("preserves navigation across both user and assistant messages", () => {
direction: "prev",
children: children.map((child) => ({ ...child, y: child.y - 35 })),
messages,
hasText,
scrollTop: 35,
viewportY: 0,
}),
@ -71,6 +79,7 @@ test("uses the selected message when the viewport is too tall to scroll", () =>
direction: "next",
children,
messages,
hasText,
scrollTop: 0,
viewportY: 0,
currentID: "user-1",
@ -82,6 +91,7 @@ test("uses the selected message when the viewport is too tall to scroll", () =>
direction: "prev",
children,
messages,
hasText,
scrollTop: 0,
viewportY: 0,
currentID: "user-2",
@ -96,6 +106,7 @@ test("stops at the first and last selected user message", () => {
direction: "next",
children,
messages,
hasText,
scrollTop: 0,
viewportY: 0,
currentID: "user-2",
@ -107,6 +118,7 @@ test("stops at the first and last selected user message", () => {
direction: "prev",
children,
messages,
hasText,
scrollTop: 0,
viewportY: 0,
currentID: "user-1",
@ -121,6 +133,7 @@ test("keeps the logical boundary when layout temporarily moves it outside the vi
direction: "next",
children,
messages,
hasText,
scrollTop: 0,
viewportY: 0,
currentID: "user-2",
@ -135,6 +148,7 @@ test("stops at the first and last message", () => {
direction: "next",
children,
messages,
hasText,
scrollTop: 0,
viewportY: 0,
currentID: "user-2",
@ -145,6 +159,7 @@ test("stops at the first and last message", () => {
direction: "prev",
children,
messages,
hasText,
scrollTop: 0,
viewportY: 0,
currentID: "user-1",

View file

@ -0,0 +1,115 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import type { OpenCodeEvent, PermissionV2Request } from "@opencode-ai/client"
import { createEffect, type ParentProps } from "solid-js"
import { ClientProvider, useClient } from "../../../src/context/client"
import { DataProvider as DataProviderBase, useData } from "../../../src/context/data"
import { LocationProvider, useLocation } from "../../../src/context/location"
import { usePermissionInput } from "../../../src/routes/session/permission"
import { createApi, createEventStream, createFetch, directory, json } from "../../fixture/tui-client"
import { TestTuiContexts } from "../../fixture/tui-environment"
async function wait(fn: () => boolean, timeout = 2000) {
const start = Date.now()
while (!fn()) {
if (Date.now() - start > timeout) throw new Error("timed out waiting for condition")
await Bun.sleep(10)
}
}
function SyncLocation() {
const data = useData()
const location = useLocation()
createEffect(() => location.set(data.location.default()))
return null
}
function DataProvider(props: ParentProps) {
return (
<DataProviderBase>
<LocationProvider>
<SyncLocation />
{props.children}
</LocationProvider>
</DataProviderBase>
)
}
function emitEvent(events: ReturnType<typeof createEventStream>, event: OpenCodeEvent) {
events.emit({ ...event, location: { directory } })
}
test("permission input tracks the tool part slot as input settles", async () => {
const events = createEventStream()
const sessionID = "session-permission-input"
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>
let input!: () => unknown
const request = {
id: "perm_1",
sessionID,
permission: "shell",
resources: [],
metadata: {},
source: { messageID: "message-assistant", callID: "call-permission" },
time: { created: 1 },
} as unknown as PermissionV2Request
function Probe() {
data = useData()
client = useClient()
// Mounted like the permission dialog: before the tool call has settled.
input = usePermissionInput(request)
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<ClientProvider api={createApi(calls.fetch)}>
<DataProvider>
<Probe />
</DataProvider>
</ClientProvider>
</TestTuiContexts>
))
try {
await wait(() => client.connection.status() === "connected")
expect(input()).toEqual({})
emitEvent(events, {
id: "evt_perm_tool_started",
created: 1,
type: "session.tool.input.started",
durable: { aggregateID: `session_${sessionID}`, seq: 0, version: 1 },
data: { sessionID, assistantMessageID: "message-assistant", callID: "call-permission", name: "shell" },
} as unknown as OpenCodeEvent)
emitEvent(events, {
id: "evt_perm_tool_called",
created: 2,
type: "session.tool.called",
durable: { aggregateID: `session_${sessionID}`, seq: 1, version: 1 },
data: {
sessionID,
assistantMessageID: "message-assistant",
callID: "call-permission",
input: { command: "rm -rf ./dist" },
timestamp: 2,
},
} as unknown as OpenCodeEvent)
// The prompt is already mounted; the resolved input must flow through the
// part slot once the tool call settles out of input streaming.
await wait(() => {
const value = input()
return typeof value === "object" && value !== null && (value as { command?: string }).command === "rm -rf ./dist"
})
} finally {
app.renderer.destroy()
}
})