| 1 | import type { Item, LiveStream } from "./useController"; |
| 2 | import { canonicalUserConfirmations, matchLocalSubmissions, type LocalSubmission } from "./localSubmissionState"; |
| 3 | import type { PresentedFile } from "./types"; |
| 4 | import { deriveTurnFiles, fileIdentity, type TurnFileView } from "./turnFiles"; |
| 5 | import { recordFrontendDiagnostic } from "./frontendDiagnosticBridge"; |
| 6 | |
| 7 | export type PresentedFileView = PresentedFile & { toolCallId: string }; |
| 8 | |
| 9 | export type ChatNodeKey = string; |
| 10 | type Listener = () => void; |
| 11 | type ItemNode = { [K in Item["kind"]]: { kind: K; key: string; turnKey: string; item: Extract<Item, { kind: K }> } }[Item["kind"]]; |
| 12 | export type ChatNode = ItemNode |
| 13 | | { kind: "reasoning"; key: string; turnKey: string; item: Extract<Item, { kind: "assistant" }> } |
| 14 | | { kind: "process"; key: string; turnKey: string; members: readonly string[]; collapsed: boolean; foldable: boolean; toolCallCount: number; messageCount: number; subagentCount: number; failureCount: number } |
| 15 | | { kind: "tail"; key: string; turnKey: string; answerKey?: string; turn?: number; latest: boolean; presentedFiles: readonly PresentedFileView[]; modifiedFiles: readonly TurnFileView[] }; |
| 16 | export interface ChatStatus { running: boolean; hydrating: boolean; hasOlder: boolean; loadingOlder: boolean; error?: string; startedAt?: number } |
| 17 | export interface ChatInput extends ChatStatus { |
| 18 | items: readonly Item[]; |
| 19 | localSubmissions?: readonly LocalSubmission[]; |
| 20 | visibleSubmissionHandoffs?: Readonly<Record<string, { submissionId: string }>>; |
| 21 | live?: LiveStream; |
| 22 | historyStartTurn?: number; |
| 23 | } |
| 24 | export interface ChatViewSource { |
| 25 | getOrderSnapshot(): readonly ChatNodeKey[]; |
| 26 | subscribeOrder(listener: Listener): () => void; |
| 27 | getNodeSnapshot(key: ChatNodeKey): ChatNode | undefined; |
| 28 | subscribeNode(key: ChatNodeKey, listener: Listener): () => void; |
| 29 | getStatusSnapshot(): ChatStatus; |
| 30 | subscribeStatus(listener: Listener): () => void; |
| 31 | dispose(): void; |
| 32 | } |
| 33 | |
| 34 | const sameKeys = (a: readonly string[], b: readonly string[]) => a.length === b.length && a.every((key, i) => key === b[i]); |
| 35 | const sameReferences = (a: readonly unknown[] = [], b: readonly unknown[] = []) => |
| 36 | a.length === b.length && a.every((item, index) => item === b[index]); |
| 37 | const shallowSame = (a: object, b: object) => Object.keys(a).length === Object.keys(b).length |
| 38 | && Object.entries(a).every(([key, value]) => value === (b as Record<string, unknown>)[key]); |
| 39 | const foldViews = new Map<string, Set<string>>(); |
| 40 | const emptyChildren: readonly Extract<ChatNode, { kind: "tool" }>[] = []; |
| 41 | function proxyAuditCall(item: Item): string | undefined { |
| 42 | if (item.kind !== "notice" || item.code !== "capability_proxy_audit") return undefined; |
| 43 | try { return (JSON.parse(item.detail ?? "{}") as { callId?: string }).callId || undefined; } catch { return undefined; } |
| 44 | } |
| 45 | |
| 46 | function submissionItem(submission: LocalSubmission): Extract<Item, { kind: "user" }> { |
| 47 | return { |
| 48 | kind: "user", |
| 49 | id: submission.localId, |
| 50 | submissionId: submission.submissionId, |
| 51 | messageId: submission.messageId, |
| 52 | turnId: submission.turnId, |
| 53 | submissionState: submission.status === "accepted" ? "confirmed" : submission.status, |
| 54 | text: submission.text, |
| 55 | submitText: submission.submitText, |
| 56 | failed: submission.status === "failed", |
| 57 | createdAt: submission.createdAt, |
| 58 | checkpointTurn: submission.checkpointTurn, |
| 59 | }; |
| 60 | } |
| 61 | |
| 62 | type DisplayEntry = { item: Item; local?: LocalSubmission }; |
| 63 | function itemsWithLocalSubmissions(input: ChatInput): DisplayEntry[] { |
| 64 | const submissions = input.localSubmissions ?? []; |
| 65 | const items: DisplayEntry[] = uniqueUserItems(input.items).map(item => ({ item })); |
| 66 | const matched = new Set(matchLocalSubmissions(submissions, canonicalUserConfirmations(input.items)).map(match => match.submissionId)); |
| 67 | const insertedAfter = new Map<string, number>(); |
| 68 | for (const submission of [...submissions].sort((a, b) => a.sequence - b.sequence)) { |
| 69 | if (matched.has(submission.submissionId)) continue; |
| 70 | const item = { item: submissionItem(submission), local: submission }; |
| 71 | const turnIndex = submission.turnId ? items.findIndex(candidate => candidate.item.turnId === submission.turnId) : -1; |
| 72 | if (!submission.anchorItemId && submission.placement !== "latest" && !input.hasOlder && (input.historyStartTurn ?? 0) === 0) { |
| 73 | items.splice(insertedAfter.get("") ?? 0, 0, item); |
| 74 | insertedAfter.set("", (insertedAfter.get("") ?? 0) + 1); |
| 75 | continue; |
| 76 | } |
| 77 | const anchor = submission.anchorItemId ? items.findIndex(candidate => candidate.item.id === submission.anchorItemId) : -1; |
| 78 | if (anchor < 0) { |
| 79 | if (turnIndex >= 0) items.splice(turnIndex, 0, item); |
| 80 | continue; |
| 81 | } |
| 82 | const offset = insertedAfter.get(submission.anchorItemId!) ?? 0; |
| 83 | items.splice(anchor + 1 + offset, 0, item); |
| 84 | insertedAfter.set(submission.anchorItemId!, offset + 1); |
| 85 | } |
| 86 | return items; |
| 87 | } |
| 88 | |
| 89 | /** A reconstructable presentation projection. Controller/history remain authoritative. */ |
| 90 | export class ChatSource implements ChatViewSource { |
| 91 | private order: readonly string[] = []; |
| 92 | private nodes = new Map<string, ChatNode>(); |
| 93 | private children = new Map<string, readonly Extract<ChatNode, { kind: "tool" }>[]>(); |
| 94 | private orderListeners = new Set<Listener>(); |
| 95 | private statusListeners = new Set<Listener>(); |
| 96 | private nodeListeners = new Map<string, Set<Listener>>(); |
| 97 | private projectedGroups = new Map<string, { |
| 98 | user?: Extract<Item, { kind: "user" }>; |
| 99 | items: readonly Item[]; |
| 100 | active: boolean; |
| 101 | latest: boolean; |
| 102 | present: readonly string[]; |
| 103 | order: readonly string[]; |
| 104 | }>(); |
| 105 | private dirty = new Set<string>(); |
| 106 | private orderDirty = false; |
| 107 | private statusDirty = false; |
| 108 | private scheduled = false; |
| 109 | private epoch = 0; |
| 110 | private input?: ChatInput; |
| 111 | private status: ChatStatus = { running: false, hydrating: true, hasOlder: false, loadingOlder: false }; |
| 112 | private opened = new Set<string>(); |
| 113 | private displayKeyBySubmission = new Map<string, string>(); |
| 114 | private displayMessageBySubmission = new Map<string, string>(); |
| 115 | private displayKeyByMessage = new Map<string, string>(); |
| 116 | constructor(readonly sessionKey: string) { this.opened = new Set(foldViews.get(sessionKey)); } |
| 117 | getOrderSnapshot = () => this.order; |
| 118 | getNodeSnapshot = (key: string) => this.nodes.get(key); |
| 119 | getStatusSnapshot = () => this.status; |
| 120 | subscribeOrder = (listener: Listener) => { this.orderListeners.add(listener); return () => { this.orderListeners.delete(listener); }; }; |
| 121 | subscribeStatus = (listener: Listener) => { this.statusListeners.add(listener); return () => { this.statusListeners.delete(listener); }; }; |
| 122 | subscribeNode = (key: string, listener: Listener) => { |
| 123 | let listeners = this.nodeListeners.get(key); |
| 124 | if (!listeners) { listeners = new Set(); this.nodeListeners.set(key, listeners); } |
| 125 | listeners.add(listener); |
| 126 | return () => { listeners.delete(listener); if (!listeners.size) this.nodeListeners.delete(key); }; |
| 127 | }; |
| 128 | private put(node: ChatNode) { |
| 129 | const previous = this.nodes.get(node.key); |
| 130 | if (previous && shallowSame(previous, node)) return; |
| 131 | this.nodes.set(node.key, node); |
| 132 | this.dirty.add(node.key); |
| 133 | } |
| 134 | update(input: ChatInput) { |
| 135 | const previous = this.input; |
| 136 | this.input = input; |
| 137 | const { running, hydrating, hasOlder, loadingOlder, error, startedAt } = input; |
| 138 | const status = { running, hydrating, hasOlder, loadingOlder, error, startedAt }; |
| 139 | if (!shallowSame(status, this.status)) { this.status = status; this.statusDirty = true; } |
| 140 | if (!previous || previous.items !== input.items || previous.visibleSubmissionHandoffs !== input.visibleSubmissionHandoffs || !sameReferences(previous.localSubmissions, input.localSubmissions) |
| 141 | || previous.running !== running || previous.hasOlder !== hasOlder) this.project(input); |
| 142 | this.updateLive(input.live, false); |
| 143 | this.schedule(); |
| 144 | } |
| 145 | private project(input: ChatInput) { |
| 146 | for (const local of input.localSubmissions ?? []) if (local.messageId) this.displayMessageBySubmission.set(local.submissionId, local.messageId); |
| 147 | for (const [messageId, handoff] of Object.entries(input.visibleSubmissionHandoffs ?? {})) { |
| 148 | this.displayMessageBySubmission.set(handoff.submissionId, messageId); |
| 149 | } |
| 150 | const order: string[] = []; |
| 151 | const present = new Set<string>(); |
| 152 | const groups: Array<{ key: string; turn?: number; user?: Extract<Item, { kind: "user" }>; items: Item[] }> = []; |
| 153 | let group: (typeof groups)[number] = { key: "history-head", items: [] }; |
| 154 | groups.push(group); |
| 155 | for (const { item, local } of itemsWithLocalSubmissions(input)) { |
| 156 | if (item.kind === "user") { |
| 157 | const key = this.userDisplayKey(item, local, input.visibleSubmissionHandoffs); |
| 158 | group = { key, user: item, turn: item.checkpointTurn ?? item.historyTurn, items: [] }; |
| 159 | groups.push(group); |
| 160 | } else group.items.push(item); |
| 161 | } |
| 162 | const groupKeys = new Set(groups.map(current => current.key)); |
| 163 | for (const current of groups) { |
| 164 | if (!current.user && !current.items.length) continue; |
| 165 | const turnKey = current.key; |
| 166 | const active = current === groups[groups.length - 1] && input.running; |
| 167 | const latest = current === groups[groups.length - 1]; |
| 168 | const cached = this.projectedGroups.get(turnKey); |
| 169 | if (cached && cached.user === current.user && cached.active === active && cached.latest === latest && sameReferences(cached.items, current.items)) { |
| 170 | cached.present.forEach(key => present.add(key)); |
| 171 | order.push(...cached.order); |
| 172 | continue; |
| 173 | } |
| 174 | const groupPresent: string[] = []; |
| 175 | const groupOrderStart = order.length; |
| 176 | const add = (node: ChatNode, visible = true) => { |
| 177 | this.put(node); present.add(node.key); groupPresent.push(node.key); if (visible) order.push(node.key); |
| 178 | }; |
| 179 | if (current.user) add({ kind: "user", key: turnKey, turnKey, item: current.user }); |
| 180 | const answer = (current.items.find(item => item.kind === "assistant" && item.turnFinal) |
| 181 | ?? [...current.items].reverse().find(item => item.kind === "assistant" && item.turnFinal === undefined && item.text.trim())) as Extract<Item, { kind: "assistant" }> | undefined; |
| 182 | const answerIndex = answer ? current.items.indexOf(answer) : -1; |
| 183 | // Harness folds the completed process range, including recovered call errors. |
| 184 | // Terminal failures/recovery prompts remain independent and prevent auto-fold. |
| 185 | const failed = current.items.some((item, index) => item.kind === "assistant" && item.streaming |
| 186 | || item.kind === "tool" && item.status === "stopped" |
| 187 | || item.kind === "notice" && (item.action === "recover_context" |
| 188 | || index > answerIndex && !item.decisionReceipt && !item.completionSummary)); |
| 189 | const mergedAudits = new Set(current.items.filter(item => { |
| 190 | const call = proxyAuditCall(item); |
| 191 | return call && current.items.some(tool => tool.kind === "tool" && tool.id === call); |
| 192 | }).map(item => item.id)); |
| 193 | const members = current.items.flatMap(item => mergedAudits.has(item.id) ? [] : item.kind === "assistant" |
| 194 | ? [...(item !== answer ? [item.id] : []), `${item.id}:reasoning`] |
| 195 | : item.kind === "notice" && (item.level === "warn" || item.action === "recover_context") |
| 196 | || item.kind === "extension" && item.card.actions?.length ? [] : [item.id]); |
| 197 | const processKey = `${turnKey}:process`; |
| 198 | const old = this.nodes.get(processKey); |
| 199 | const stableMembers = old?.kind === "process" && sameKeys(old.members, members) ? old.members : members; |
| 200 | const hasProcess = current.items.some(item => item.kind === "tool" || item.kind === "phase" || |
| 201 | item.kind === "assistant" && (item !== answer && item.text.trim() || item.reasoning.trim())); |
| 202 | const hasTrailingWork = current.items.slice(answerIndex + 1).some(item => item.kind === "tool" || item.kind === "phase" || item.kind === "assistant"); |
| 203 | const foldable = Boolean(hasProcess && current.user && answer && !hasTrailingWork && !active && !failed && !current.user.failed); |
| 204 | const allCalls = current.items.filter((item): item is Extract<Item, { kind: "tool" }> => item.kind === "tool"); |
| 205 | const calls = allCalls.filter(item => !item.parentId); |
| 206 | const subagentCount = calls.filter(item => ["task", "read_only_task", "parallel_tasks", "fleet", "subagent"].includes(item.name)).length; |
| 207 | add({ kind: "process", key: processKey, turnKey, members: stableMembers, foldable, collapsed: foldable && !this.opened.has(turnKey), |
| 208 | toolCallCount: calls.length - subagentCount, subagentCount, |
| 209 | messageCount: current.items.filter(item => item.kind === "assistant" && item !== answer && item.text.trim()).length, |
| 210 | failureCount: calls.filter(item => item.status === "error" || item.error).length }); |
| 211 | for (const item of current.items) { |
| 212 | if (item.kind === "assistant") add({ kind: "reasoning", key: `${item.id}:reasoning`, turnKey, item }); |
| 213 | add({ kind: item.kind, key: item.id, turnKey, item } as ItemNode, !mergedAudits.has(item.id) && !(item.kind === "tool" && item.parentId)); |
| 214 | } |
| 215 | const declarations = allCalls.filter(item => item.name === "present" && item.status === "done" && !item.error && item.presentedFiles?.length); |
| 216 | const latestByPath = new Map<string, PresentedFileView>(); |
| 217 | for (const call of declarations) { |
| 218 | for (const file of call.presentedFiles ?? []) latestByPath.set(file.path, { ...file, toolCallId: call.id }); |
| 219 | } |
| 220 | const nextPresented = [...latestByPath.values()]; |
| 221 | const presentedPaths = new Set(nextPresented.map(file => fileIdentity(file.path))); |
| 222 | const nextModified = deriveTurnFiles(allCalls).filter(file => !presentedPaths.has(fileIdentity(file.path))); |
| 223 | const tailKey = `${turnKey}:tail`; |
| 224 | const oldTail = this.nodes.get(tailKey); |
| 225 | const stablePresented = oldTail?.kind === "tail" |
| 226 | && oldTail.presentedFiles.length === nextPresented.length |
| 227 | && oldTail.presentedFiles.every((file, index) => file.path === nextPresented[index]?.path && file.description === nextPresented[index]?.description && file.toolCallId === nextPresented[index]?.toolCallId) |
| 228 | ? oldTail.presentedFiles : nextPresented; |
| 229 | const stableModified = oldTail?.kind === "tail" |
| 230 | && oldTail.modifiedFiles.length === nextModified.length |
| 231 | && oldTail.modifiedFiles.every((file, index) => file.path === nextModified[index]?.path && file.operation === nextModified[index]?.operation && file.toolCallId === nextModified[index]?.toolCallId) |
| 232 | ? oldTail.modifiedFiles : nextModified; |
| 233 | add({ kind: "tail", key: tailKey, turnKey, answerKey: answer?.id, turn: current.turn, |
| 234 | latest, presentedFiles: stablePresented, modifiedFiles: stableModified }); |
| 235 | this.projectedGroups.set(turnKey, { |
| 236 | user: current.user, items: current.items, active, latest, present: groupPresent, order: order.slice(groupOrderStart), |
| 237 | }); |
| 238 | } |
| 239 | for (const key of this.nodes.keys()) if (!present.has(key)) { this.nodes.delete(key); this.dirty.add(key); } |
| 240 | for (const key of this.projectedGroups.keys()) if (!groupKeys.has(key)) this.projectedGroups.delete(key); |
| 241 | for (const [submissionId, key] of this.displayKeyBySubmission) if (!groupKeys.has(key)) this.displayKeyBySubmission.delete(submissionId); |
| 242 | for (const submissionId of this.displayMessageBySubmission.keys()) if (!this.displayKeyBySubmission.has(submissionId)) this.displayMessageBySubmission.delete(submissionId); |
| 243 | for (const [messageId, key] of this.displayKeyByMessage) if (!groupKeys.has(key)) this.displayKeyByMessage.delete(messageId); |
| 244 | const children = new Map<string, Extract<ChatNode, { kind: "tool" }>[]>(); |
| 245 | for (const node of this.nodes.values()) if (node.kind === "tool" && node.item.parentId) { |
| 246 | const list = children.get(node.item.parentId) ?? []; |
| 247 | list.push(node); children.set(node.item.parentId, list); |
| 248 | } |
| 249 | for (const key of new Set([...children.keys(), ...this.children.keys()])) { |
| 250 | const next = children.get(key) ?? emptyChildren; |
| 251 | const old = this.children.get(key) ?? emptyChildren; |
| 252 | if (next.length !== old.length || next.some((node, index) => node !== old[index])) { |
| 253 | this.children.set(key, next); this.dirty.add(`${key}:children`); |
| 254 | } |
| 255 | } |
| 256 | for (const key of this.opened) if (!groupKeys.has(key)) this.opened.delete(key); |
| 257 | if (!sameKeys(this.order, order)) { this.order = order; this.orderDirty = true; } |
| 258 | recordFrontendDiagnostic("transcript", "presentation", this.presentationStats()); |
| 259 | } |
| 260 | private userDisplayKey(item: Extract<Item, { kind: "user" }>, local?: LocalSubmission, handoffs?: ChatInput["visibleSubmissionHandoffs"]): string { |
| 261 | if (local) { |
| 262 | const key = `submission:${local.submissionId}`; |
| 263 | this.displayKeyBySubmission.set(local.submissionId, key); |
| 264 | return key; |
| 265 | } |
| 266 | let key = item.messageId ? this.displayKeyByMessage.get(item.messageId) : undefined; |
| 267 | const submissionId = item.messageId ? handoffs?.[item.messageId]?.submissionId ?? item.submissionId : undefined; |
| 268 | const boundMessageId = submissionId ? this.displayMessageBySubmission.get(submissionId) : undefined; |
| 269 | if (!key && submissionId && (!boundMessageId || boundMessageId === item.messageId)) { |
| 270 | key = this.displayKeyBySubmission.get(submissionId); |
| 271 | } |
| 272 | key ??= item.id; |
| 273 | if (submissionId && this.displayKeyBySubmission.get(submissionId) === key && item.messageId) { |
| 274 | this.displayMessageBySubmission.set(submissionId, item.messageId); |
| 275 | } |
| 276 | if (item.messageId) this.displayKeyByMessage.set(item.messageId, key); |
| 277 | return key; |
| 278 | } |
| 279 | presentationStats() { |
| 280 | return { nodes: this.nodes.size, submissions: this.displayKeyBySubmission.size, messages: this.displayKeyByMessage.size }; |
| 281 | } |
| 282 | /** Already frame-batched by the controller; no additional frame queue. */ |
| 283 | updateLive(live: LiveStream | undefined, publish = true) { |
| 284 | if (live && this.status.running) { |
| 285 | const node = this.nodes.get(live.id); |
| 286 | if (node?.kind === "assistant") { |
| 287 | const item = { ...node.item, text: live.text, reasoning: live.reasoning, reasoningComplete: live.reasoningComplete, streaming: true }; |
| 288 | if (!shallowSame(node.item, item)) { |
| 289 | this.put({ ...node, item }); |
| 290 | const reasoning = this.nodes.get(`${node.key}:reasoning`); |
| 291 | if (reasoning?.kind === "reasoning") this.put({ ...reasoning, item }); |
| 292 | } |
| 293 | } |
| 294 | } |
| 295 | if (publish) this.flush(); |
| 296 | } |
| 297 | toggleProcess(turnKey: string) { |
| 298 | const node = this.nodes.get(`${turnKey}:process`); |
| 299 | if (node?.kind !== "process") return; |
| 300 | if (node.collapsed) this.opened.add(turnKey); else this.opened.delete(turnKey); |
| 301 | foldViews.delete(this.sessionKey); foldViews.set(this.sessionKey, new Set(this.opened)); |
| 302 | if (foldViews.size > 100) foldViews.delete(foldViews.keys().next().value!); |
| 303 | this.put({ ...node, collapsed: node.foldable && !this.opened.has(turnKey) }); |
| 304 | this.flush(); |
| 305 | } |
| 306 | toolChildren(id: string) { return this.children.get(id) ?? emptyChildren; } |
| 307 | toolAudits(id: string): string[] { |
| 308 | return [...this.nodes.values()].flatMap(node => node.kind === "notice" && proxyAuditCall(node.item) === id |
| 309 | ? [`${node.item.text}\n${node.item.detail ?? ""}`] : []); |
| 310 | } |
| 311 | private schedule() { |
| 312 | if (this.scheduled) return; |
| 313 | this.scheduled = true; |
| 314 | const epoch = this.epoch; |
| 315 | queueMicrotask(() => { if (epoch === this.epoch) this.flush(); }); |
| 316 | } |
| 317 | private flush() { |
| 318 | this.scheduled = false; |
| 319 | const dirty = this.dirty; this.dirty = new Set(); |
| 320 | const orderDirty = this.orderDirty; this.orderDirty = false; |
| 321 | const statusDirty = this.statusDirty; this.statusDirty = false; |
| 322 | if (orderDirty) this.orderListeners.forEach(listener => listener()); |
| 323 | if (statusDirty) this.statusListeners.forEach(listener => listener()); |
| 324 | dirty.forEach(key => this.nodeListeners.get(key)?.forEach(listener => listener())); |
| 325 | } |
| 326 | dispose() { |
| 327 | this.epoch++; this.scheduled = false; this.dirty.clear(); |
| 328 | this.orderListeners.clear(); this.statusListeners.clear(); this.nodeListeners.clear(); |
| 329 | this.nodes.clear(); this.children.clear(); this.projectedGroups.clear(); |
| 330 | this.displayKeyBySubmission.clear(); this.displayMessageBySubmission.clear(); this.displayKeyByMessage.clear(); |
| 331 | this.order = []; this.input = undefined; |
| 332 | } |
| 333 | } |
| 334 | import { uniqueUserItems } from "./transcriptUserIdentity"; |
| 335 |