import { ReactNode } from 'react'; import { AssistantToolStep } from '../ui/AssistantToolActivity'; /** * One logical run. * * ★★ `runId` is `${conversationId}:${messageId}`, and that pair is chosen * because it is the only identity that SURVIVES THE INTERNAL RETRY. The * assistant message id is minted once per turn (AssistantChatArea.tsx:995), * outside `sendWithRealBackend` (:1079), which the failure path re-enters as * `sendWithRealBackend(2)` (:1477) — and that retry swaps the sandbox id and * the session id. A payload keyed on either would split one logical run in two, * and a late callback from the first attempt could be mistaken for a new run. */ export interface AssistantPreviewRun { readonly runId: string; readonly conversationId: string; readonly messageId: string; /** * ★ The workspace as it was when the run STARTED, not as it is now. * `getWorkspaceId()` reads the URL live and the panel survives in-app * navigation, so re-deriving this per event would file a run's output under a * workspace it never ran in. */ readonly workspaceId: string | null; /** * ★★★ The ACTOR as it was when the run started, for the same reason — and * because the workspace alone is not an identity. * * Added after a surface that validated only `workspaceId` was found to let a * second account in the SAME workspace read the first one's answer: a late * `start` from the previous actor was adopted under the new one, and every * snapshot after it matched the run id. That case could not be fixed in the * consuming app at all, because the run carried no actor to compare against. * * `null` where there is no signed-in actor, which is a real state rather than * a missing value — two runs both lacking an actor are the same identity. */ readonly actorId: string | null; } /** * ★★ SPOKEN TURNS ARE INCLUDED, deliberately. Voice and typed turns share one * message array and are separated by `isVoiceTurn`, which every reader of the * TRANSCRIPT applies (`turnVisibility.ts:83`, `useConversationTurns.ts:46`, * `AssistantConversationList.tsx:13`, `exportConversation.ts:1`) so that spoken * turns stay out of the typed thread. * * The preview is not a transcript. It follows whatever turn the assistant is * running, and a spoken turn is a turn — filtering it would leave a product's * surface frozen for the whole of a voice answer, which is worse than showing * it. Stated here because the omission otherwise reads as an oversight next to * four readers that all filter, and because a product wanting to distinguish * modality will need a flag on this type: add one when a product actually needs * it, rather than guessing the shape before there is a consumer. */ /** * ★★ `snapshot` REPLACES, it never appends. The orchestrator's poll loop * rewrites the whole message content on every tick * (AssistantChatArea.tsx:1329), so a consumer that appended would accumulate * duplicates of the same answer. The `kind` says so at the type level rather * than in a comment a consumer never reads. */ export type AssistantPreviewEvent = { kind: "start"; run: AssistantPreviewRun; } | { kind: "snapshot"; run: AssistantPreviewRun; /** * ★★ THE PROSE, with the tool-activity lines REMOVED. * * The assistant's raw message interleaves `Running: ` / * `Completed: ` lines with the answer; the transcript splits them * out with `parseToolActivity` and renders them as an activity card. The * preview used to receive the raw content and render those lines as if * they were part of the answer. */ content: string; /** * ★★ The same lines, structured — so a surface can show PROGRESS as well * as prose. * * Without this a product could only ever render the answer, which is why * a "watch it build" surface (BigConsole has one) could not be based on * this capability at all. Empty for a run that has called no tools. */ steps: readonly AssistantToolStep[]; } | { kind: "end"; run: AssistantPreviewRun; /** * ★★ `abandoned` is not a failure and not a completion — it is a run the * bridge can no longer follow. It covers a turn the user cancelled while * a correction took over, a turn interrupted with nothing after it, and a * conversation switched away from mid-turn. In every one of those the * turn writes NO terminal status (`turnState.ts:70`), so without this * outcome the run would simply never end and a preview would sit showing * progress forever. The turn may or may not still be executing. */ outcome: "complete" | "error" | "abandoned"; /** * ★ NON-optional. Every terminal branch sets this — `complete` and * `error` from the store, `abandoned` explicitly as null — so leaving it * optional let a future branch omit it and hand a consumer `undefined` * where it had been promised `null`. The type now enforces at compile * time what the tests pin at runtime. */ error: string | null; }; /** * ★★ KEEP THIS OBJECT STABLE across renders — memoise it, or hold it in a ref. * * Two things depend on it, and both fail quietly rather than loudly: * * - The bridge's effect depends on the controller. It deliberately does NOT * reset its run state when the identity changes, precisely so that the * natural `preview={{ render, onEvent }}` — a new object every render — does * not re-announce `start` on every poll tick. The cost of that choice is that * REPLACING one working controller with another mid-run leaves the newcomer * without a `start` for the run already in flight. Swap controllers between * runs, not during one. * * - `render()` is invoked inside a single `PreviewSurface` component, so its * hooks belong to that component's fiber. Swapping in a renderer with a * DIFFERENT hook layout while the overlay is open is the classic * "rendered more hooks than during the previous render" error. Keep one * renderer per mounted preview, and branch inside it rather than swapping it. * * Neither is enforced by the type system: a named function passed as a callback * satisfies the hooks lint, so nothing warns. */ export interface AssistantPreviewController { /** * The product's own preview surface. Returns JSX and is called during render, * so it may use hooks like any component body. * * ★ It is rendered as an OVERLAY over a still-mounted chat area — never as an * alternate view. Swapping the panel's body would unmount `AssistantChatArea` * and take the voice session, the in-flight turn, draft attachments and the * composer draft with it, while turn status lives on in the zustand store: * split-brain rather than a clean stop. */ render: () => ReactNode; /** * Run-scoped progress. Optional: a product may want the surface without the * stream. * * ★ Non-blocking and error-contained by the bridge — a throwing observer must * never disturb the assistant, and must never cause a retry. */ onEvent?: (event: AssistantPreviewEvent) => void; /** Toggle label. Product-neutral English fallback if omitted. */ label?: string; } //# sourceMappingURL=types.d.ts.map