| 1 | // Session-scoped owner for host-verified answer references. |
| 2 | // |
| 3 | // One store lives per mounted chat session. The transcript reports the paths a |
| 4 | // committed markdown block named; the store batches them, deduplicates against |
| 5 | // what it already asked, and calls the host once per batch. Results are cached |
| 6 | // by session, turn, and candidate, so a stream update or a remount re-renders |
| 7 | // from the cache instead of asking again. |
| 8 | // |
| 9 | // Everything that can outlive its session is generation-bound: switching the |
| 10 | // session, closing the tab, or changing the working directory disposes the |
| 11 | // store, and a late reply from an older generation is dropped rather than |
| 12 | // written into the replacement conversation. |
| 13 | |
| 14 | import { app } from "./bridge"; |
| 15 | import type { ChatFileReference, ChatFileReferenceRequest, ChatFileReferenceResult } from "../generated/desktopContract.generated"; |
| 16 | |
| 17 | /** Host verdict for one candidate. */ |
| 18 | export interface ChatFileReferenceView { |
| 19 | key: string; |
| 20 | path: string; |
| 21 | status: "resolved" | "unavailable" | "unsupported"; |
| 22 | displayPath?: string; |
| 23 | kind?: string; |
| 24 | actions: readonly string[]; |
| 25 | reason?: string; |
| 26 | } |
| 27 | |
| 28 | /** Batches are capped by the host contract; the store splits larger sets. */ |
| 29 | export const CHAT_FILE_REFERENCE_BATCH_LIMIT = 64; |
| 30 | |
| 31 | type TurnState = { |
| 32 | factsVersion: number; |
| 33 | snapshot: ReadonlyMap<string, ChatFileReferenceView>; |
| 34 | requested: Set<string>; |
| 35 | queue: ChatFileReferenceRequest[]; |
| 36 | scheduled: boolean; |
| 37 | }; |
| 38 | |
| 39 | const emptyTurn: ReadonlyMap<string, ChatFileReferenceView> = new Map(); |
| 40 | |
| 41 | /** The wire type is a plain string; anything unrecognized is a refusal. */ |
| 42 | function normalizeStatus(value: string): ChatFileReferenceView["status"] { |
| 43 | return value === "resolved" || value === "unsupported" ? value : "unavailable"; |
| 44 | } |
| 45 | |
| 46 | export class ChatFileReferenceStore { |
| 47 | private turns = new Map<string, TurnState>(); |
| 48 | private listeners = new Set<() => void>(); |
| 49 | private generation = 0; |
| 50 | private disposed = false; |
| 51 | private attachments = 0; |
| 52 | |
| 53 | constructor(private readonly tabId: string, private readonly hostId: string = "local") {} |
| 54 | |
| 55 | /** Only the local host can verify a path today; a remote host degrades to text. */ |
| 56 | get supported(): boolean { return this.hostId === "local"; } |
| 57 | |
| 58 | subscribe = (listener: () => void): (() => void) => { |
| 59 | this.listeners.add(listener); |
| 60 | return () => { this.listeners.delete(listener); }; |
| 61 | }; |
| 62 | |
| 63 | getTurnSnapshot = (turnKey: string): ReadonlyMap<string, ChatFileReferenceView> => |
| 64 | this.turns.get(turnKey)?.snapshot ?? emptyTurn; |
| 65 | |
| 66 | /** |
| 67 | * Reports the candidates a committed block named. `factsVersion` identifies |
| 68 | * the turn's file facts: when it advances, earlier failures are re-asked, |
| 69 | * because a tool that just succeeded can turn a missing file into a real one. |
| 70 | */ |
| 71 | report(turnKey: string, factsVersion: number, candidates: readonly ChatFileReferenceRequest[]): void { |
| 72 | if (this.disposed || !this.supported || !candidates.length) return; |
| 73 | const state = this.turnState(turnKey, factsVersion); |
| 74 | if (state.factsVersion !== factsVersion) { |
| 75 | state.factsVersion = factsVersion; |
| 76 | for (const entry of state.snapshot.values()) { |
| 77 | if (entry.status !== "resolved") state.requested.delete(entry.key); |
| 78 | } |
| 79 | } |
| 80 | let queued = false; |
| 81 | for (const candidate of candidates) { |
| 82 | if (!candidate.key || state.requested.has(candidate.key)) continue; |
| 83 | state.requested.add(candidate.key); |
| 84 | state.queue.push(candidate); |
| 85 | queued = true; |
| 86 | } |
| 87 | if (queued) this.schedule(turnKey, state); |
| 88 | } |
| 89 | |
| 90 | /** |
| 91 | * React StrictMode replays mount effects, running a cleanup without a |
| 92 | * re-render. Disposal is therefore deferred and counted: a synchronous |
| 93 | * re-attach revives the same store instead of killing it for the session. |
| 94 | */ |
| 95 | attach(): void { |
| 96 | this.attachments++; |
| 97 | } |
| 98 | |
| 99 | detach(): void { |
| 100 | this.attachments--; |
| 101 | queueMicrotask(() => { if (this.attachments === 0) this.dispose(); }); |
| 102 | } |
| 103 | |
| 104 | dispose(): void { |
| 105 | if (this.disposed) return; |
| 106 | this.disposed = true; |
| 107 | this.generation++; |
| 108 | this.turns.clear(); |
| 109 | this.listeners.clear(); |
| 110 | } |
| 111 | |
| 112 | private turnState(turnKey: string, factsVersion: number): TurnState { |
| 113 | let state = this.turns.get(turnKey); |
| 114 | if (!state) { |
| 115 | state = { factsVersion, snapshot: new Map(), requested: new Set(), queue: [], scheduled: false }; |
| 116 | this.turns.set(turnKey, state); |
| 117 | } |
| 118 | return state; |
| 119 | } |
| 120 | |
| 121 | /** One flush per microtask keeps a burst of committed blocks in one batch. */ |
| 122 | private schedule(turnKey: string, state: TurnState): void { |
| 123 | if (state.scheduled) return; |
| 124 | state.scheduled = true; |
| 125 | queueMicrotask(() => { |
| 126 | state.scheduled = false; |
| 127 | if (!this.disposed) void this.flush(turnKey, state); |
| 128 | }); |
| 129 | } |
| 130 | |
| 131 | private async flush(turnKey: string, state: TurnState): Promise<void> { |
| 132 | while (state.queue.length) { |
| 133 | const batch = state.queue.splice(0, CHAT_FILE_REFERENCE_BATCH_LIMIT); |
| 134 | const generation = this.generation; |
| 135 | let verdicts: readonly ChatFileReference[]; |
| 136 | try { |
| 137 | verdicts = (await this.resolve(turnKey, batch)).references; |
| 138 | } catch { |
| 139 | verdicts = batch.map(item => ({ key: item.key, path: item.path, status: "unsupported", actions: [], reason: "unavailable-host" })); |
| 140 | } |
| 141 | // A reply from a replaced session must never reach the new one. |
| 142 | if (this.disposed || generation !== this.generation) return; |
| 143 | this.apply(state, verdicts); |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | private resolve(turnKey: string, batch: ChatFileReferenceRequest[]): Promise<ChatFileReferenceResult> { |
| 148 | return app.ResolveChatFileReferencesForTab(this.tabId, turnKey, batch); |
| 149 | } |
| 150 | |
| 151 | private apply(state: TurnState, references: readonly ChatFileReference[]): void { |
| 152 | if (!references.length) return; |
| 153 | const next = new Map(state.snapshot); |
| 154 | for (const reference of references) { |
| 155 | const previous = next.get(reference.path); |
| 156 | const status = normalizeStatus(reference.status); |
| 157 | // A resolved verdict outlives an earlier failure for the same file. |
| 158 | if (previous?.status === "resolved" && status !== "resolved") continue; |
| 159 | const value: ChatFileReferenceView = { |
| 160 | key: reference.key, |
| 161 | path: reference.path, |
| 162 | status, |
| 163 | displayPath: reference.displayPath, |
| 164 | kind: reference.kind, |
| 165 | actions: reference.actions ?? [], |
| 166 | reason: reference.reason, |
| 167 | }; |
| 168 | next.set(reference.path, value); |
| 169 | if (status === "resolved" && value.displayPath) next.set(value.displayPath, value); |
| 170 | } |
| 171 | state.snapshot = next; |
| 172 | this.publish(); |
| 173 | } |
| 174 | |
| 175 | private publish(): void { |
| 176 | for (const listener of this.listeners) listener(); |
| 177 | } |
| 178 | } |
| 179 |