/** * One conversation per assistant, shared by every panel on the page. * * ## Why this exists * * `sessionId` and the transcript used to live inside `Assistant.svelte`. That * made them per-COMPONENT, which is why `createAssistant` had to hand back the * existing instance for an id: mounting a second component gave one assistant * two conversations on one page. A visitor could ask a question in the corner * bubble, open the drawer from the header, and find an empty panel, their * conversation simply gone. That is the divergence the instance registry was * written to prevent, and it is the reason "one page, one panel" was a rule * rather than a preference. * * Lifting the two of them here inverts it: the conversation is the durable * thing, and a panel is a *view* onto it. Any number of shapes may now mount for * the same assistant, and they all read and write the same messages. * * ## What is here, and what is deliberately not * * Here: the things that ARE the conversation, the backend session and the * transcript. Two panels showing different transcripts is the bug; two panels * disagreeing about whether a send is currently in flight is not, because only * one panel is open at a time (see the mutual exclusion in `index.ts`). * * Not here: `isSending`, streaming buffers, typewriter timers, scroll position, * the composer's draft text. Those are properties of a VIEW. Hoisting them would * mean a panel adopting another panel's half-finished animation, and would make * every panel re-render on every token of a stream it is not showing. * * A completed answer lands in `messages`, so a visitor who switches panels * mid-stream sees the finished answer arrive in the panel they moved to. * * ## Reactivity * * `.svelte.ts` so `$state` works outside a component (Svelte 5 runes). The * object identity is stable per assistant id, components hold a reference and * read through it, so a write from one panel is seen by all of them. */ /** * Generic in the message type, and that is the point: this module holds the * transcript, it does not interpret it. * * The first version declared a structural stand-in here, `{ id, role: string, * text: string, [key: string]: unknown }`, to avoid importing the component's * internals. That was a SECOND definition of what a message is, and it silently * widened every message the component read back out of the store: `role` came * back as `string` rather than the union, and every optional field * (`citations`, `grounding`, `actionResult`, …) came back as `unknown`, which * narrows to `{}` under a truthy check. It cost ~25 type errors in * `Assistant.svelte` that neither `tsc --noEmit` nor `vite build` can see. * * A parameter has no such opinion. `getConversation(id)` hands back the * component's own type, unwidened, and this file stays free of the component's * type graph, which is what the stand-in was trying and failing to buy. */ export type Conversation = { /** Backend session. `null` until the first `ensureSession()`. */ sessionId: string | null; /** The transcript every panel for this assistant renders. */ messages: M[]; /** * Which shape currently holds the screen, by resolved mode, `null` if none. * * This is NOT the view state the block above refuses, and the distinction is * the whole reason it can live here. `isSending` describes what one panel is * doing; this describes which ONE of them the page belongs to right now. * There is exactly one such value per assistant, no panel can answer it from * its own fields, and every panel needs the answer, that is the definition * of shared state rather than hoisted state. * * Two panels read it for two different purposes: * * - the holder's siblings CLOSE, which is the mutual exclusion that used to * live in `index.ts`. It sat in the public `open()` there, so it only ever * covered host-triggered opens: a visitor clicking the corner launcher * while the drawer stood open went through the component's own `toggle()` * and left both panels open, the exact duplicated transcript the rule * exists to prevent. Written here, every path is covered, because every * path ends in `openAssistant()`. * - the siblings also hide their RESTING AFFORDANCE. A launcher is an * invitation to open a conversation that is already on screen, and while * the drawer is out the corner bubble's launcher is left sitting under it, * it is `position: fixed`, so even the drawer's push does not move it. */ openPanel: string | null; }; /** * The conversation for an assistant, created on first ask. * * Keyed by assistant id and NOT by shape: that is the entire point. Two shapes * on one page resolve the same object. */ export declare function getConversation(assistantId: string): Conversation; /** * Drop an assistant's conversation. * * Called when the LAST panel for an id is destroyed, not on every destroy, or * tearing down one of two panels would wipe the transcript the other is still * showing. `index.ts` owns that counting because it owns the registry. */ export declare function forgetConversation(assistantId: string): void;