refactor(tui): project production subgroups through tree engine (#48399)

This commit is contained in:
James Long
2026-09-10 23:15:58 -04:00
committed by GitHub
Unverified
parent 70afbac80c
commit 4d12e01824
5 changed files with 229 additions and 133 deletions
@@ -0,0 +1,141 @@
import type { SessionMessageAssistant } from "@opencode/client"
import { groupEntries, mergeGroups, splitGroups, type GroupNode } from "./tree"
export type PartRef = {
messageID: string
partID: string
}
export type CacheUsage = {
read: number
model: SessionMessageAssistant["model"]
}
export type SessionEntry =
| { type: "message"; messageID: string }
| { type: "compaction-queued"; inboxID: string }
| { type: "part"; ref: PartRef }
| { type: "assistant-footer"; messageID: string }
| { type: "turn-usage"; messageIDs: string[]; previousCache?: CacheUsage }
type GroupKind = "reasoning" | "exploration"
type SessionGroup = {
type: "group"
children: readonly GroupNode<SessionEntry, GroupKind>[]
size: number
completed: boolean
} & ({ kind: "reasoning" } | { kind: "exploration"; pending: PartRef[] })
export type SessionRow = SessionEntry | SessionGroup
export type AppendPart =
| { type: "text" }
| { type: "reasoning"; time?: { completed?: number } }
| { type: "tool"; name: string }
export type ProjectionEntry = {
entry: SessionEntry
part?: AppendPart
closesPrevious?: boolean
}
/** Hydrate a fresh history batch in one pass rather than merging one leaf at a time. */
export function projectEntries(entries: ProjectionEntry[]): SessionRow[] {
const nodes = groupEntries(entries, (item) => (item.part ? partPath(item.part) : []))
return nodes.map((node, index) => {
if (node.type === "entry") return node.entry.entry
const next = nodes[index + 1]
const completed =
(next !== undefined && (next.type === "group" || next.entry.closesPrevious !== false)) ||
(node.kind === "reasoning" &&
node.children.every(
(child) =>
child.type === "entry" &&
child.entry.part?.type === "reasoning" &&
child.entry.part.time?.completed !== undefined,
))
const group = { ...node, children: node.children.map(unwrap), completed }
return node.kind === "reasoning" ? { ...group, kind: "reasoning" } : { ...group, kind: "exploration", pending: [] }
})
}
function unwrap(node: GroupNode<ProjectionEntry, GroupKind>): GroupNode<SessionEntry, GroupKind> {
if (node.type === "entry") return { ...node, entry: node.entry.entry }
return { ...node, children: node.children.map(unwrap) }
}
function partPath(part: AppendPart): readonly GroupKind[] {
if (part.type === "reasoning") return ["reasoning"]
if (part.type === "tool" && ["read", "glob", "grep"].includes(part.name.toLowerCase())) return ["exploration"]
return []
}
/** Production rules only: keep lifecycle/status decisions outside the tree engine. */
export function append(rows: SessionRow[], ref: PartRef, part: AppendPart, index = rows.length) {
const [node] = groupEntries<SessionEntry, GroupKind>([{ type: "part", ref }], () => partPath(part))
if (node.type === "entry") {
completePrevious(rows, index)
rows.splice(index, 0, node.entry)
return
}
const previous = rows[index - 1]
if (previous?.type === "group" && previous.kind === node.kind) {
// Permission-blocked tools remain at the end, just as the former refs/pending
// partition did. Inserting a new ref must precede those blocked tools.
const pending = previous.kind === "exploration" ? previous.pending.length : 0
const [left, right] = splitGroups([previous], previous.size - pending)
const [merged] = mergeGroups(mergeGroups(left, [node]), right)
if (merged.type !== "group") throw new Error("Expected merged session group")
previous.children = merged.children
previous.size = merged.size
if (part.type === "reasoning") previous.completed &&= part.time?.completed !== undefined
return
}
completePrevious(rows, index)
rows.splice(
index,
0,
node.kind === "reasoning"
? { ...node, kind: "reasoning", completed: part.type === "reasoning" && part.time?.completed !== undefined }
: { ...node, kind: "exploration", pending: [], completed: false },
)
}
export function completePrevious(rows: SessionRow[], index = rows.length) {
const previous = rows[index - 1]
if (previous?.type === "group") previous.completed = true
}
/** Part references for an existing production subgroup, not a flat timeline. */
export function groupRefs(row: SessionGroup, includePending = false): PartRef[] {
const pending = !includePending && row.kind === "exploration" ? row.pending : []
const visit = (nodes: readonly GroupNode<SessionEntry, GroupKind>[]): PartRef[] =>
nodes.flatMap((node) => {
if (node.type === "group") return visit(node.children)
if (node.entry.type !== "part") return []
const ref = node.entry.ref
if (pending.some((item) => item.messageID === ref.messageID && item.partID === ref.partID)) return []
return [ref]
})
return visit(row.children)
}
export function partitionPending(rows: SessionRow[], pending: Set<string>) {
rows.forEach((row) => {
if (row.type !== "group" || row.kind !== "exploration") return
// The production exploration rule creates direct part children. Preserve the
// existing stable partition order when permissions are admitted or dismissed.
const blocked = (node: GroupNode<SessionEntry, GroupKind>) =>
node.type === "entry" && node.entry.type === "part" && pending.has(node.entry.ref.partID)
row.children = [...row.children.filter((node) => !blocked(node)), ...row.children.filter(blocked)]
row.pending = groupRefs(row, true).filter((ref) => pending.has(ref.partID))
})
}
export function hasPart(rows: SessionRow[], ref: PartRef) {
return rows.some((row) => {
if (row.type === "part") return row.ref.messageID === ref.messageID && row.ref.partID === ref.partID
if (row.type !== "group") return false
return groupRefs(row, true).some((item) => item.messageID === ref.messageID && item.partID === ref.partID)
})
}
+5 -2
View File
@@ -110,6 +110,7 @@ import { isRecord } from "../../util/record"
import { createHistoryPrepend } from "./history"
import { context, use, type PendingAction } from "./render-context"
import { INLINE_TOOL_ICON_WIDTH, InlineToolRow, ReasoningPart, reasoningContent, TextPart } from "./message-parts"
import { groupRefs } from "./grouping/session"
export { InlineToolRow } from "./message-parts"
addDefaultParsers(parsers.parsers)
@@ -1442,12 +1443,14 @@ function SessionRowView(props: SessionRowViewProps) {
{(row) => <SessionPartView partRef={row().ref} message={props.message} />}
</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={groupRefs(row())} completed={row().completed} message={props.message} />
)}
</Match>
<Match when={props.row.type === "group" && props.row.kind === "exploration" ? props.row : undefined}>
{(row) => (
<SessionGroupView
refs={row().refs}
refs={groupRefs(row())}
pending={row().pending}
completed={row().completed}
message={props.message}
+27 -105
View File
@@ -4,36 +4,20 @@ import { createStore, produce, reconcile } from "solid-js/store"
import { useConfig } from "../../config"
import { useData } from "../../context/data"
import { useClient } from "../../context/client"
export type PartRef = {
messageID: string
partID: string
}
export type CacheUsage = {
read: number
model: SessionMessageAssistant["model"]
}
export type SessionRow =
| { type: "message"; messageID: string }
| { type: "compaction-queued"; inboxID: string }
| { type: "part"; ref: PartRef }
| {
type: "group"
kind: "reasoning"
refs: PartRef[]
completed: boolean
}
| {
type: "group"
kind: "exploration"
refs: PartRef[]
pending: PartRef[]
completed: boolean
}
| { type: "assistant-footer"; messageID: string }
| { type: "turn-usage"; messageIDs: string[]; previousCache?: CacheUsage }
import {
append,
completePrevious,
groupRefs,
hasPart,
partitionPending,
projectEntries,
type AppendPart,
type CacheUsage,
type PartRef,
type ProjectionEntry,
type SessionRow,
} from "./grouping/session"
export type { CacheUsage, PartRef, SessionRow } from "./grouping/session"
export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessionID: string) => void) {
const data = useData()
@@ -180,7 +164,7 @@ export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessi
(row) =>
row.type === "group" &&
row.kind === "reasoning" &&
row.refs.some((item) => item.messageID === ref.messageID && item.partID === ref.partID),
groupRefs(row).some((item) => item.messageID === ref.messageID && item.partID === ref.partID),
)
if (row?.type === "group" && row.kind === "reasoning") row.completed = true
}),
@@ -299,16 +283,15 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S
const usage = turnTokens
? { steps: [] as SessionMessageAssistant[], previousTurnCache: undefined as CacheUsage | undefined }
: undefined
return [
const entries = [
...messages.filter((message) => !pending.has(message.id)),
...pendingCompactions,
...messages.filter(isInput),
].reduce<SessionRow[]>((rows, message) => {
].reduce<ProjectionEntry[]>((rows, message) => {
if (message.type !== "assistant") {
if (message.type === "synthetic" && !message.description?.trim()) return rows
if (message.type === "compaction" && message.status === "completed" && usage) usage.previousTurnCache = undefined
if (!pending.has(message.id)) completePrevious(rows)
rows.push({ type: "message", messageID: message.id })
rows.push({ entry: { type: "message", messageID: message.id }, closesPrevious: !pending.has(message.id) })
return rows
}
usage?.steps.push(message)
@@ -316,21 +299,22 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S
message.content.forEach((part) => {
const partID = part.type === "tool" ? part.id : `${part.type}:${ordinals[part.type]++}`
if ((part.type === "text" || part.type === "reasoning") && !part.text.trim()) return
append(rows, { messageID: message.id, partID }, part)
rows.push({ entry: { type: "part", ref: { messageID: message.id, partID } }, part })
})
const terminal = (message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error
if (terminal || message.retry) {
completePrevious(rows)
rows.push({ type: "assistant-footer", messageID: message.id })
rows.push({ entry: { type: "assistant-footer", messageID: message.id } })
}
if (terminal && usage) {
const stepsWithUsage = usage.steps.filter(hasTokenUsage)
const last = stepsWithUsage.at(-1)
if (last) {
rows.push({
type: "turn-usage",
messageIDs: stepsWithUsage.map((step) => step.id),
...(usage.previousTurnCache === undefined ? {} : { previousCache: usage.previousTurnCache }),
entry: {
type: "turn-usage",
messageIDs: stepsWithUsage.map((step) => step.id),
...(usage.previousTurnCache === undefined ? {} : { previousCache: usage.previousTurnCache }),
},
})
usage.previousTurnCache = { read: last.tokens.cache.read, model: last.model }
}
@@ -338,6 +322,7 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S
}
return rows
}, [])
return projectEntries(entries)
}
export function cacheReuseDrop(previous: CacheUsage | undefined, current: CacheUsage) {
@@ -427,7 +412,7 @@ function rowBoundaryMessageID(row: SessionRow, messages: Map<string, SessionMess
row.type === "part"
? row.ref.messageID
: row.type === "group"
? row.refs[0]?.messageID
? groupRefs(row)[0]?.messageID
: row.type === "assistant-footer"
? row.messageID
: row.type === "turn-usage"
@@ -446,66 +431,3 @@ export function resolvePart(message: SessionMessageAssistant, partID: string) {
const ordinal = Number(match[2])
return message.content.filter((part) => part.type === match[1])[ordinal]
}
type AppendPart =
| { type: "text" }
| { type: "reasoning"; time?: { completed?: number } }
| { type: "tool"; name: string }
function append(rows: SessionRow[], ref: PartRef, part: AppendPart, index = rows.length) {
if (part.type === "reasoning") {
const previous = rows[index - 1]
if (previous?.type === "group" && previous.kind === "reasoning") {
previous.refs.push(ref)
previous.completed &&= part.time?.completed !== undefined
return
}
completePrevious(rows, index)
rows.splice(index, 0, {
type: "group",
kind: "reasoning",
refs: [ref],
completed: part.time?.completed !== undefined,
})
return
}
if (part.type === "tool" && exploration(part.name)) {
const previous = rows[index - 1]
if (previous?.type === "group" && previous.kind === "exploration") {
previous.refs.push(ref)
return
}
completePrevious(rows, index)
rows.splice(index, 0, { type: "group", kind: "exploration", refs: [ref], pending: [], completed: false })
return
}
completePrevious(rows, index)
rows.splice(index, 0, { type: "part", ref })
}
function completePrevious(rows: SessionRow[], index = rows.length) {
const previous = rows[index - 1]
if (previous?.type === "group") previous.completed = true
}
function partitionPending(rows: SessionRow[], pending: Set<string>) {
rows.forEach((row) => {
if (row.type !== "group" || row.kind !== "exploration") return
const refs = [...row.refs, ...row.pending]
row.refs = refs.filter((ref) => !pending.has(ref.partID))
row.pending = refs.filter((ref) => pending.has(ref.partID))
})
}
function exploration(name: string) {
return ["read", "glob", "grep"].includes(name.toLowerCase())
}
function hasPart(rows: SessionRow[], ref: PartRef) {
return rows.some((row) => {
if (row.type === "part") return row.ref.messageID === ref.messageID && row.ref.partID === ref.partID
if (row.type !== "group") return false
const refs = row.kind === "exploration" ? [...row.refs, ...row.pending] : row.refs
return refs.some((item) => item.messageID === ref.messageID && item.partID === ref.partID)
})
}
@@ -0,0 +1,21 @@
import { expect, test } from "bun:test"
import { append, groupRefs, partitionPending, type SessionRow } from "../../../src/routes/session/grouping/session"
test("a pending tool does not hide a later tool reusing its call ID in another message", () => {
const rows: SessionRow[] = []
const blocked = { messageID: "assistant-a", partID: "call-reused" }
const later = { messageID: "assistant-b", partID: "call-reused" }
append(rows, blocked, { type: "tool", name: "read" })
partitionPending(rows, new Set([blocked.partID]))
append(rows, later, { type: "tool", name: "read" })
const group = rows[0]
if (group.type !== "group" || group.kind !== "exploration") throw new Error("Expected exploration group")
expect(groupRefs(group)).toEqual([later])
expect(group.pending).toEqual([blocked])
expect(groupRefs(group, true)).toEqual([later, blocked])
partitionPending(rows, new Set())
expect(groupRefs(group)).toEqual([later, blocked])
expect(group.pending).toEqual([])
})
+35 -26
View File
@@ -280,10 +280,11 @@ test("groups exploration parts across assistant messages until a delimiter", ()
kind: "exploration",
pending: [],
completed: true,
refs: [
{ messageID: "assistant-1", partID: "read-1" },
{ messageID: "assistant-1", partID: "glob-1" },
{ messageID: "assistant-2", partID: "grep-1" },
size: 3,
children: [
partChild("assistant-1", "read-1"),
partChild("assistant-1", "glob-1"),
partChild("assistant-2", "grep-1"),
],
},
{ type: "part", ref: { messageID: "assistant-2", partID: "text:0" } },
@@ -305,7 +306,8 @@ test("keeps non-exploration tools as individual part rows", () => {
kind: "exploration",
pending: [],
completed: true,
refs: [{ messageID: "assistant-1", partID: "read-1" }],
size: 1,
children: [partChild("assistant-1", "read-1")],
},
{ type: "part", ref: { messageID: "assistant-1", partID: "reasoning:0" } },
{
@@ -313,7 +315,8 @@ test("keeps non-exploration tools as individual part rows", () => {
kind: "exploration",
pending: [],
completed: false,
refs: [{ messageID: "assistant-1", partID: "grep-1" }],
size: 1,
children: [partChild("assistant-1", "grep-1")],
},
])
})
@@ -334,14 +337,16 @@ test("assigns stable kind ordinals within an assistant message", () => {
type: "group",
kind: "reasoning",
completed: true,
refs: [{ messageID: "assistant-1", partID: "reasoning:0" }],
size: 1,
children: [partChild("assistant-1", "reasoning:0")],
},
{ type: "part", ref: { messageID: "assistant-1", partID: "text:1" } },
{
type: "group",
kind: "reasoning",
completed: false,
refs: [{ messageID: "assistant-1", partID: "reasoning:1" }],
size: 1,
children: [partChild("assistant-1", "reasoning:1")],
},
])
})
@@ -361,17 +366,16 @@ test("groups adjacent reasoning parts until a visible boundary", () => {
type: "group",
kind: "reasoning",
completed: true,
refs: [
{ messageID: "assistant-1", partID: "reasoning:0" },
{ messageID: "assistant-1", partID: "reasoning:1" },
],
size: 2,
children: [partChild("assistant-1", "reasoning:0"), partChild("assistant-1", "reasoning:1")],
},
{ type: "part", ref: { messageID: "assistant-1", partID: "text:0" } },
{
type: "group",
kind: "reasoning",
completed: false,
refs: [{ messageID: "assistant-1", partID: "reasoning:2" }],
size: 1,
children: [partChild("assistant-1", "reasoning:2")],
},
])
})
@@ -393,17 +397,16 @@ test("groups across empty assistant reasoning parts", () => {
type: "group",
kind: "reasoning",
completed: true,
refs: [{ messageID: "assistant-1", partID: "reasoning:0" }],
size: 1,
children: [partChild("assistant-1", "reasoning:0")],
},
{
type: "group",
kind: "exploration",
pending: [],
completed: false,
refs: [
{ messageID: "assistant-1", partID: "read-1" },
{ messageID: "assistant-2", partID: "grep-1" },
],
size: 2,
children: [partChild("assistant-1", "read-1"), partChild("assistant-2", "grep-1")],
},
])
})
@@ -425,7 +428,8 @@ test("completes exploration groups when another row follows", () => {
kind: "exploration",
pending: [],
completed: true,
refs: [{ messageID: "assistant-1", partID: "read-1" }],
size: 1,
children: [partChild("assistant-1", "read-1")],
},
{ type: "message", messageID: "user-1" },
{
@@ -433,7 +437,8 @@ test("completes exploration groups when another row follows", () => {
kind: "exploration",
pending: [],
completed: true,
refs: [{ messageID: "assistant-2", partID: "grep-1" }],
size: 1,
children: [partChild("assistant-2", "grep-1")],
},
{ type: "assistant-footer", messageID: "assistant-2" },
])
@@ -468,10 +473,8 @@ test("hides synthetic messages without descriptions", () => {
kind: "exploration",
pending: [],
completed: false,
refs: [
{ messageID: "assistant-1", partID: "read-1" },
{ messageID: "assistant-2", partID: "grep-1" },
],
size: 2,
children: [partChild("assistant-1", "read-1"), partChild("assistant-2", "grep-1")],
},
])
expect(reduceSessionRows(messages, new Set(["synthetic-1"]))).toEqual(rows)
@@ -496,7 +499,8 @@ test("renders synthetic messages with descriptions", () => {
kind: "exploration",
pending: [],
completed: true,
refs: [{ messageID: "assistant-1", partID: "read-1" }],
size: 1,
children: [partChild("assistant-1", "read-1")],
},
{ type: "message", messageID: "synthetic-1" },
{
@@ -504,11 +508,16 @@ test("renders synthetic messages with descriptions", () => {
kind: "exploration",
pending: [],
completed: false,
refs: [{ messageID: "assistant-2", partID: "grep-1" }],
size: 1,
children: [partChild("assistant-2", "grep-1")],
},
])
})
function partChild(messageID: string, partID: string) {
return { type: "entry" as const, entry: { type: "part" as const, ref: { messageID, partID } }, size: 1 as const }
}
test("renders a footer for a pre-output retry assistant after replay", () => {
const message = assistant("assistant-retry", [])
message.retry = {