// Framework-agnostic view controller behind the /svelte and /vue adapters // (0.31.0). One place owns the subscription wiring + snapshot bookkeeping the // adapters share, so each adapter stays a logic-free ~40-line binding to its // framework's reactivity primitive — and THIS file is where the behavior gets // unit-tested once instead of per-framework. (React hosts use the standalone // `@pouchy_ai/react` package instead — it owns the client LIFECYCLE and a // voice-call hook, which this controller deliberately doesn't; conversely the // render-state here — streamState, streamed draft, pendingConfirms — is richer // than the react hooks. Complementary shapes, not a parity pair.) // // Contract (deliberately useSyncExternalStore-shaped, the strictest of the // three): `getSnapshot()` returns the SAME object reference until something // changes, and every change produces a new frozen snapshot + one listener // sweep. Svelte's store contract and Vue's shallowRef both accept that shape // as-is. import type { CompanionClient } from './client'; import type { CompanionStreamState } from './client'; import type { ConfirmRequestPayload, MessageAttachment, TypingPayload } from './protocol'; import { CompanionError } from './errors'; /** One rendered line of the conversation, in arrival order. `'assistant'` * matches the industry role vocabulary and this package's own voice-call * transcript + `@pouchy_ai/react`'s ChatMessage (0.33.0 — was 'companion'). */ export interface CompanionTranscriptEntry { role: 'user' | 'assistant'; text: string; /** Client wall-clock ms when the entry was appended. */ at: number; /** Files an assistant reply produced (CR-731) — from the live * `companion.message` envelope, or from `history()` on a restore (CR-729). * Absent when the reply produced none; render a download link from it. */ attachments?: readonly MessageAttachment[]; } /** The well-formed attachments of an unknown value (structural read, so an * older server or a hand-rolled payload adds none rather than throwing). */ function attachmentsOf(v: unknown): MessageAttachment[] | undefined { if (!Array.isArray(v)) return undefined; const out = v.filter( (a): a is MessageAttachment => !!a && typeof (a as MessageAttachment).fileId === 'string' && typeof (a as MessageAttachment).url === 'string' ); return out.length ? out : undefined; } /** Immutable view snapshot. A new object per change — safe to hand to * useSyncExternalStore / $state / shallowRef without defensive copies. */ export interface CompanionViewSnapshot { streamState: CompanionStreamState; /** Rolling transcript, oldest first, capped at `transcriptCap`. */ transcript: readonly CompanionTranscriptEntry[]; /** The in-flight streamed reply (token deltas), '' when none. */ draft: string; typing: boolean; /** Confirm requests that arrived on the stream and haven't been resolved * through this view yet. Rebuild after a reload via client.pendingConfirms() * — confirm_request events are not replayed. */ pendingConfirms: readonly ConfirmRequestPayload[]; /** Sends queued while offline (0.34.0 — the client's `queueOffline` * option): `client.pendingOutbox().length`, live-updated. Always 0 on a * client from an older SDK bundle without the offline queue. */ outbox: number; /** Set when the `restore` history load REJECTED (CR-784). A brand-new * session resolves with no turns and leaves this unset — so a value here is * always a real failure: `not_connected` (restore ran before `connect()`), * `missing_scope` (the token lacks `chat`), a 429 or a 5xx. `code` is the * CompanionError code, or `'unknown'` for an uncoded rejection. */ restoreError?: { code: string; message: string }; } export interface CompanionViewOptions { /** Max transcript entries kept (oldest dropped). Default 200. */ transcriptCap?: number; /** Restore up to this many recent turns from `client.history()` on * creation, so a reloaded tab isn't blank (same semantic as * `@pouchy_ai/react`'s useMessages). Requires a connected client (a * session must exist). Restored turns land ONLY while the live transcript * is still empty — they never clobber messages that arrived first. * Default 0 (off). */ restore?: number; } export interface CompanionView { /** Stable-reference snapshot (useSyncExternalStore contract). */ getSnapshot(): CompanionViewSnapshot; /** Subscribe to snapshot changes. Returns unsubscribe. */ subscribe(listener: () => void): () => void; /** sendText that also appends the user turn to the transcript. Typed as * the client's own overloaded signature (CR-1054): `Parameters` / * `ReturnType` see only the LAST overload, so an `awaitReply: true` send * was typed as the plain ack and its reply `text` did not type-check. */ sendText: CompanionClient['sendText']; /** confirmAction that also removes the confirm from `pendingConfirms` * (on approve AND deny — both resolve the card server-side). */ confirmAction( confirmId: string, approve: boolean, opts?: { signal?: AbortSignal } ): ReturnType; /** Detach every client subscription. The view goes inert (snapshot frozen, * listeners dropped); the client itself is NOT closed — it belongs to the * host. Idempotent. */ dispose(): void; /** The underlying client, for everything the view doesn't wrap. */ readonly client: CompanionClient; } const DEFAULT_TRANSCRIPT_CAP = 200; /** Wire a client into an immutable-snapshot view. Adapters bind this to their * framework; hosts using none of the adapters can consume it directly. */ export function createCompanionView( client: CompanionClient, opts?: CompanionViewOptions ): CompanionView { const cap = Math.max(1, opts?.transcriptCap ?? DEFAULT_TRANSCRIPT_CAP); const listeners = new Set<() => void>(); let disposed = false; /** sendText calls from this view not yet settled (0.63.1). */ let inFlight = 0; let snapshot: CompanionViewSnapshot = Object.freeze({ streamState: client.streamState, transcript: Object.freeze([]) as readonly CompanionTranscriptEntry[], draft: '', typing: false, pendingConfirms: Object.freeze([]) as readonly ConfirmRequestPayload[], // Feature-detect (0.34.0): a host may hand this view a client built from // an older SDK bundle without the offline queue — permanently-empty then. outbox: typeof client.pendingOutbox === 'function' ? client.pendingOutbox().length : 0 }); function commit(patch: Partial): void { if (disposed) return; snapshot = Object.freeze({ ...snapshot, ...patch }); // Snapshot-first, then notify: a listener that re-reads getSnapshot() // synchronously must see the new value. Handler isolation matches the // client's emit() doctrine — one throwing listener must not starve the rest. for (const l of [...listeners]) { try { l(); } catch { /* listener errors are the listener's problem */ } } } function appendTranscript(entry: CompanionTranscriptEntry): void { const next = [...snapshot.transcript, entry]; commit({ transcript: Object.freeze(next.slice(-cap)) }); } const restore = opts?.restore ?? 0; if (restore > 0) { void client .history({ limit: restore }) .then((turns) => { // Only fill a still-empty transcript — live traffic wins. if (disposed || snapshot.transcript.length) return; const restored: CompanionTranscriptEntry[] = []; for (const t of turns) { if (t.user) restored.push({ role: 'user', text: t.user, at: t.ts }); if (t.assistant) { const attachments = attachmentsOf((t as { attachments?: unknown }).attachments); restored.push({ role: 'assistant', text: t.assistant, at: t.ts, ...(attachments ? { attachments } : {}) }); } } if (restored.length) commit({ transcript: Object.freeze(restored.slice(-cap)) }); }) .catch((err: unknown) => { // NOT "a new session has no history": that case RESOLVES with []. // A rejection is always a coded error the integrator must see (CR-784). if (disposed) return; const code = (err as { code?: unknown } | null)?.code; commit({ restoreError: { code: typeof code === 'string' && code ? code : 'unknown', message: err instanceof Error ? err.message : String(err) } }); }); } const unsubs: Array<() => void> = [ client.onStreamStateChange((next: CompanionStreamState) => commit({ streamState: next })), client.onMessage((text: string, envelope?: { payload?: unknown }) => { // The authoritative full reply replaces any streamed draft; typing is // definitionally over once the reply lands. The envelope's files ride // along (CR-731), read structurally so an older core adds none. const attachments = attachmentsOf((envelope?.payload as { attachments?: unknown } | undefined)?.attachments); const next = [ ...snapshot.transcript, { role: 'assistant' as const, text, at: Date.now(), ...(attachments ? { attachments } : {}) } ]; commit({ transcript: Object.freeze(next.slice(-cap)), draft: '', typing: false }); }), client.onDelta((chunk: string, meta: { reset?: boolean }) => { commit({ draft: meta.reset ? chunk : snapshot.draft + chunk }); }), client.onTyping((payload: TypingPayload) => commit({ typing: !!payload.active })), client.onConfirmRequest((payload: ConfirmRequestPayload) => { // The stream can redeliver on reconnect replay — dedupe by confirmId. if (snapshot.pendingConfirms.some((c) => c.confirmId === payload.confirmId)) return; commit({ pendingConfirms: Object.freeze([...snapshot.pendingConfirms, payload]) }); }), // Same feature-detect as the initial snapshot (0.34.0). ...(typeof client.onOutboxChange === 'function' ? [client.onOutboxChange((items) => commit({ outbox: items.length }))] : []) ]; return { client, getSnapshot: () => snapshot, subscribe(listener: () => void) { listeners.add(listener); return () => listeners.delete(listener); }, async sendText(text, sendOpts) { const entry: CompanionTranscriptEntry = { role: 'user', text, at: Date.now() }; appendTranscript(entry); inFlight++; try { const res = await client.sendText(text, sendOpts as never); inFlight--; return res; } catch (err) { inFlight--; // The optimistic line must not survive a send the server never saw // (network drop / 429 turn-claim) — roll it back by identity so the // host's retry re-appends cleanly (0.32.1). The exceptions are the // codes that say the text is NOT lost: `queued_offline` (0.34.0 — // queued, replayed on reconnect, mirroring the resolved // `{ queued: true }` shape), and `reply_timeout` / // `needs_event_stream` (0.63.1 — the POST was accepted, so the reply // can still land through onMessage; rolling the line back left that // reply under no question). const lineStands = err instanceof CompanionError && (err.code === 'queued_offline' || err.code === 'reply_timeout' || err.code === 'needs_event_stream'); // A failed turn's half-streamed draft and typing flag are dead: only // onMessage or a `reset` delta used to clear them, and neither comes // after a failure (0.63.1). Cleared only when no other send from this // view is still in flight — a 409 `turn_pending` rejects WHILE the // turn it lost to is streaming, and that draft is live. commit({ ...(lineStands ? {} : { transcript: Object.freeze(snapshot.transcript.filter((e) => e !== entry)) }), ...(inFlight === 0 ? { draft: '', typing: false } : {}) }); throw err; } }, async confirmAction(confirmId, approve, confirmOpts) { let res; try { res = await client.confirmAction(confirmId, approve, confirmOpts); } catch (err) { // The same confirm can be resolved OUT FROM UNDER this card — the // hosted confirm page, or (since round 13) a channel keyword reply. // A 409 confirm_resolved / 404 confirm_not_found means the card is // dead either way; keeping it would strand a permanently-stale // approve button (0.32.1). Other rejections (network) keep the card. if ( err instanceof CompanionError && (err.code === 'confirm_resolved' || err.code === 'confirm_not_found') ) { commit({ pendingConfirms: Object.freeze( snapshot.pendingConfirms.filter((c) => c.confirmId !== confirmId) ) }); } throw err; } // Remove on any settled resolution; exec_failed with retryable keeps the // card — the host may re-approve the SAME confirmId (documented server // semantic), so dropping it would strand the retry affordance. if (!(res.status === 'exec_failed' && res.retryable)) { commit({ pendingConfirms: Object.freeze( snapshot.pendingConfirms.filter((c) => c.confirmId !== confirmId) ) }); } return res; }, dispose() { if (disposed) return; for (const u of unsubs) u(); listeners.clear(); disposed = true; } }; }