@theoremjs/web 0.3.4-next.373.gab46e23 → 0.3.4-next.377.g4dbec64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -150,6 +150,62 @@ for await (const event of session.events()) {
150
150
  - `open.host` is the `openMessage` that the page's `connection` returned.
151
151
  - Read later messages with `parseLiveClientMessage`. Give context messages to `session.sendContext({ client: message.context })`.
152
152
 
153
+ ## A chat with no framework
154
+
155
+ Use `createChatController` when you draw the chat yourself, in any framework or none. It holds all of the chat's logic, and it draws nothing:
156
+
157
+ - the transcript, and the reply as it streams;
158
+ - the composer's draft, files, and voice note;
159
+ - the pending messages: steer, queue, and stash;
160
+ - the gates, branching, and resuming a conversation.
161
+
162
+ `useTheoremChat` from `@theoremjs/react` is this controller, bound to React.
163
+
164
+ ```ts
165
+ import { createChatController, createHttpTransport } from '@theoremjs/web/client';
166
+
167
+ const transport = createHttpTransport({ endpoint: '/api/theorem' });
168
+ const iface = await transport.describe();
169
+ if (iface.type === 'live') throw new Error('Use LiveSessionClient for a live profile.');
170
+
171
+ const chat = createChatController({ transport, iface });
172
+ chat.subscribe(() => draw(chat.getState()));
173
+
174
+ chat.setDraftText('What is in my cart?');
175
+ await chat.submit();
176
+ ```
177
+
178
+ - `getState()` returns a `ChatState`. It is a new object after each change, and the same object between changes. So you can compare it by reference, as React's `useSyncExternalStore` does.
179
+ - `subscribe(listener)` calls the listener once for each group of changes, after the code that made them has run. It returns the function that unsubscribes.
180
+ - `update(options)` gives the controller your latest options, such as a new `context`, `slots`, or `pageTools`. Each turn reads the options as they are when it is sent. `conversation`, `conversationView`, and `initialText` are read only when the controller is created.
181
+ - Each method is bound to the controller. Pass it as a callback as it is.
182
+
183
+ `state.phase` is `'idle'`, `'streaming'`, or `'gated'`. The methods for the composer depend on it:
184
+
185
+ | Method | What it does |
186
+ | --- | --- |
187
+ | `submit()` | At `idle`, sends the draft. Otherwise, queues it. |
188
+ | `enqueue('steer')` | Sends the draft to the running turn with `transport.steer`. The steer leaves `pendingMessages` when the turn's `stage` event names it in `injected`. A steer the turn never takes waits at the front of the queue. |
189
+ | `enqueue('queue')` | Keeps the draft to send after the reply. When the reply ends without a failure and no gate waits, the first queued message is sent. |
190
+ | `enqueue('stash')` | Keeps the draft without sending it. `queuePending(id)` moves it to the queue. |
191
+ | `sendNow(message?)` | Sends a pending message, or the draft, at once. A streaming reply is stopped first. A reply that waits on gates is left. |
192
+ | `restorePending(id)` | Puts a pending message back in the composer. The draft that was there is stashed first, so the user loses nothing. |
193
+ | `stop()` | Stops the streaming reply. |
194
+ | `decide(action)`, `authenticate(secret?)` | Answer the gate that the reply waits on. |
195
+ | `branch(index)` | Cuts the conversation after transcript block `index`. |
196
+ | `sendText(text, files?)` | Sends a message from your code. It resolves with the blocks the turn added, or `null` when the chat cannot take a message now. |
197
+
198
+ The controller uses these methods of the transport: `turn`, `invoke` (to answer a gate), `steer`, `conversation`, and `rewind`. `conversation` and `rewind` are called only for a profile whose host keeps the conversation. `pageResult` is called when the reply hands a tool to the page.
199
+
200
+ The controller writes no English text. The helpers below give the composer's controls as values, and you choose the words:
201
+
202
+ | Helper | What it gives |
203
+ | --- | --- |
204
+ | `composerActionState(args)` | The primary button (`send`, `queue`, `stop`, or `none`), the send menu, and whether the button is off. |
205
+ | `composerDrawerSummary(args)` | The count of waiting messages by kind, for a collapsed drawer. |
206
+ | `resolveComposerHint(args)` | The stash hint, when the whole draft is selected. |
207
+ | `isStashShortcut(event)` | Whether a key press is the stash shortcut, `STASH_SHORTCUT`. |
208
+
153
209
  ## A custom browser transport
154
210
 
155
211
  Use `withPageToolDispatch` when your screen consumes turn events through a custom transport.
@@ -0,0 +1,147 @@
1
+ import { type AttachmentValidationIssue, type ComposerMenuAction, type ComposerPendingMessage, type ComposerProfileInterface, type ComposerRunPhase, type InterfaceTurnSession, type TranscriptBlock } from '@theoremjs/agents/interface';
2
+ import { type ClientFailure } from './failure.ts';
3
+ import { type PageTools } from './live/live-page-tool.ts';
4
+ import type { AnsweringGate, ToolDecisionAction } from './tool-resume.ts';
5
+ import type { ConversationView, TheoremTransport, TurnEventSink } from './transport.ts';
6
+ export type ChatOptions = {
7
+ transport: TheoremTransport;
8
+ /** The profile's composer interface, as `transport.describe()` returns it. `null` while it loads. */
9
+ iface: ComposerProfileInterface | null;
10
+ /**
11
+ * The ref of a conversation to resume, as `onConversationChange` reported it. Read once, when
12
+ * the chat is created: the chat draws the conversation as the host keeps it. A stateless profile
13
+ * keeps no conversation, so it has no ref.
14
+ */
15
+ conversation?: string;
16
+ /**
17
+ * The conversation under `conversation` as the host showed it with the ref, as
18
+ * `startConversation` returns it. Read once, when the chat is created: the chat draws it at
19
+ * once, and does not read it from the host again.
20
+ */
21
+ conversationView?: ConversationView;
22
+ /** The text the composer starts with. Read once, when the chat is created. */
23
+ initialText?: string;
24
+ /**
25
+ * Each new ref the host issues for the conversation: once the message posts, and again as the
26
+ * reply's runs are saved. Keep the latest to resume the chat later.
27
+ */
28
+ onConversationChange?: (ref: string) => void;
29
+ /**
30
+ * The value chosen for each of the profile's `inputs.slots`. Sent with every turn. A slot you
31
+ * name here is fixed to that value; one you leave out is chosen with `pickSlot`, starting at
32
+ * its first allowed value.
33
+ */
34
+ slots?: Record<string, string>;
35
+ /** Told each time the visitor picks a slot, with every slot's value. */
36
+ onSlotsChange?: (slots: Record<string, string>) => void;
37
+ /** What the page wants the agent to know: any JSON. The latest value goes with every turn. */
38
+ context?: unknown;
39
+ /** The page's tools, by name: each answers a tool the profile declares with `answeredBy: 'page'`. */
40
+ pageTools?: PageTools;
41
+ /**
42
+ * Every event of every turn, as it arrives and before the transcript takes it: the ones the
43
+ * transcript shows, and the ones it does not (a stage, a session, a guardrail decision).
44
+ */
45
+ onTurnEvent?: TurnEventSink;
46
+ };
47
+ /**
48
+ * How far the latest message has got: posted (`sending`), taken by the server
49
+ * (`sent`), reached the model (`delivered`), answered (`read`).
50
+ */
51
+ export type MessageDelivery = 'sending' | 'sent' | 'delivered' | 'read';
52
+ /** The slots the profile declares, and the value of each: the page's, else the picked one, else the first allowed. */
53
+ export type ChatSlots = {
54
+ declared: Record<string, string[]>;
55
+ values: Record<string, string>;
56
+ };
57
+ /** Everything a chat shows. A new object on each change, the same one between changes. */
58
+ export type ChatState = {
59
+ iface: ComposerProfileInterface | null;
60
+ /** The transcript. */
61
+ blocks: TranscriptBlock[];
62
+ /** The reply as it streams, or the reply that waits on a gate. */
63
+ streamBlocks: TranscriptBlock[];
64
+ chatStarted: boolean;
65
+ draftText: string;
66
+ pendingFiles: File[];
67
+ pendingVoice: File[];
68
+ /** Steered, queued, and stashed messages. */
69
+ pendingMessages: ComposerPendingMessage[];
70
+ issues: AttachmentValidationIssue[];
71
+ /** The failure the composer shows. */
72
+ failure: ClientFailure | null;
73
+ /** The answer on its way to a gate, shown on it until the answer settles or fails. */
74
+ answering: AnsweringGate | null;
75
+ phase: ComposerRunPhase;
76
+ session: InterfaceTurnSession;
77
+ /** The latest message's delivery; `null` before the first and once its turn failed. */
78
+ delivery: MessageDelivery | null;
79
+ streaming: boolean;
80
+ /** `undefined` when the profile declares no slots. */
81
+ slots: ChatSlots | undefined;
82
+ };
83
+ /** What `sendText` hands back: the blocks the turn added, its user message, and the reply. */
84
+ export type SentTurn = {
85
+ blocks: TranscriptBlock[];
86
+ };
87
+ /** A new value, or a function of the current one. */
88
+ export type ChatUpdate<T> = T | ((prev: T) => T);
89
+ /**
90
+ * A chat with no UI framework: transcript, streaming, the composer's draft, the pending
91
+ * steer / queue / stash, gates, branching, and resuming a conversation. Read it with `getState`,
92
+ * follow it with `subscribe`, and act through its methods. Every method is bound, so pass them
93
+ * as callbacks as they are.
94
+ */
95
+ export type ChatController = {
96
+ getState(): ChatState;
97
+ /**
98
+ * Called after each change, once per batch of changes. Returns the unsubscribe. A conversation
99
+ * resumed without its `conversationView` is read from the host once the first listener subscribes.
100
+ */
101
+ subscribe(listener: () => void): () => void;
102
+ /** The latest options. `conversation`, `conversationView` and `initialText` are read only at creation. */
103
+ update(options: ChatOptions): void;
104
+ setDraftText(value: ChatUpdate<string>): void;
105
+ setPendingFiles(value: ChatUpdate<File[]>): void;
106
+ setPendingVoice(value: ChatUpdate<File[]>): void;
107
+ setPendingMessages(value: ChatUpdate<ComposerPendingMessage[]>): void;
108
+ setIssues(value: ChatUpdate<AttachmentValidationIssue[]>): void;
109
+ /** Sends the composer's draft when idle; queues it while a reply streams or waits on a gate. */
110
+ submit(): Promise<void>;
111
+ /** Stops the reply that is streaming. */
112
+ stop(): void;
113
+ /** Steers, queues, or stashes the composer's draft. A steer goes to the running turn. */
114
+ enqueue(kind: 'queue' | 'steer' | 'stash'): Promise<void>;
115
+ /**
116
+ * Sends a pending message, or the composer's draft, now: a streaming reply stops first, and a
117
+ * reply that waits on gates is walked away from.
118
+ */
119
+ sendNow(source?: ComposerPendingMessage): Promise<void>;
120
+ menuAction(action: ComposerMenuAction): void;
121
+ /** Puts a pending message back in the composer. What the composer holds is stashed first. */
122
+ restorePending(id: string): Promise<void>;
123
+ /** Moves a stashed message into the queue. At idle, the queue starts sending. */
124
+ queuePending(id: string): void;
125
+ /** Answers the gate the reply waits on. */
126
+ decide(action: ToolDecisionAction): Promise<void>;
127
+ /** The visitor signed in for the gated call, or gave its secret. */
128
+ authenticate(secret?: string): Promise<void>;
129
+ /**
130
+ * Cuts the conversation after transcript block `index`. A host that keeps the conversation
131
+ * cuts it (walking away from a reply that waits on gates); a stateless chat cuts its own.
132
+ */
133
+ branch(index: number): Promise<void>;
134
+ /** The model, and its effort (its default when left out), for the next turn. */
135
+ setGeneration(next: {
136
+ modelId: string;
137
+ effort?: string;
138
+ }): void;
139
+ pickSlot(name: string, value: string): void;
140
+ /**
141
+ * Sends `text` and optional files as the composer would, and resolves once the reply is done;
142
+ * `null` when it can't go now (a reply is streaming, or waits on a gate).
143
+ */
144
+ sendText(text: string, files?: readonly File[]): Promise<SentTurn | null>;
145
+ };
146
+ /** A chat for `options`. Framework-free: see `ChatController`. */
147
+ export declare function createChatController(options: ChatOptions): ChatController;
package/client/chat.js ADDED
@@ -0,0 +1,765 @@
1
+ import { TheoremError } from '@theoremjs/agents';
2
+ import { branchInterfaceTurnSession, consumeNextComposerQueue, convertSteersToFrontQueued, createComposerPendingMessage, defaultInterfaceEffort, emptyInterfaceTurnSession, orderComposerPendingMessages, promoteComposerPendingKind, removeComposerPendingMessage, removeLandedSteers, userDraftHasPayload, userDraftToSteerInject, } from '@theoremjs/agents/interface';
3
+ import { composerHasPayload } from "./composer-primary.js";
4
+ import { composerFieldsFromDraft } from "./decode-composer-draft.js";
5
+ import { encodeComposerDraft } from "./encode-composer-draft.js";
6
+ import { clientFailure, turnFailure } from "./failure.js";
7
+ import { followGenerationDefaults } from "./generation-selection.js";
8
+ import { warnPageToolMismatch } from "./live/live-page-tool.js";
9
+ import { withPageToolDispatch } from "./page-dispatch.js";
10
+ import { sessionAfter, stampWorked } from "./run-commit.js";
11
+ import { keepsConversation, resumeInterfaceTool, streamInterfaceDraftTurn, streamInterfaceTurn, } from "./run-session.js";
12
+ import { foldAssistantTurn, issuedOperationId } from "./turn-client.js";
13
+ const DELIVERY_ORDER = ['sending', 'sent', 'delivered', 'read'];
14
+ /** The server's own reports (bookkeeping, a failure, the end), which say nothing of the model having the message. */
15
+ const SERVER_EVENTS = new Set(['stage', 'guardrail', 'session', 'error', 'done']);
16
+ /** The reply itself (its thinking and tool calls come before it). */
17
+ const REPLY_EVENTS = new Set(['text', 'structured', 'media']);
18
+ /**
19
+ * How far a line shows the message got: the host saving it, or any event,
20
+ * means the server took it; one from the model (thinking, a tool call) means
21
+ * the model has it, and the reply means it was read. A save without the
22
+ * message (a walk settling calls) says nothing of it.
23
+ */
24
+ function deliveryOf(line) {
25
+ if (line.type === 'conversation')
26
+ return line.posted ? 'sent' : undefined;
27
+ if (REPLY_EVENTS.has(line.type))
28
+ return 'read';
29
+ return SERVER_EVENTS.has(line.type) ? 'sent' : 'delivered';
30
+ }
31
+ /** The streamed blocks, ending on the turn's error unless the stream already carried it. */
32
+ function withTurnError(blocks, error) {
33
+ if (blocks.some((block) => block.kind === 'error'))
34
+ return blocks;
35
+ return [...blocks, { id: crypto.randomUUID(), kind: 'error', message: error }];
36
+ }
37
+ function slotsOf(iface, fixed, picked) {
38
+ const declared = iface?.inputs.slots ?? {};
39
+ const values = { ...fixed };
40
+ for (const [name, allowed] of Object.entries(declared)) {
41
+ const value = fixed?.[name] ?? picked[name] ?? allowed[0];
42
+ if (value !== undefined)
43
+ values[name] = value;
44
+ }
45
+ const chosen = Object.keys(declared).length > 0;
46
+ return {
47
+ values: chosen || fixed ? values : undefined,
48
+ slots: chosen ? { declared, values } : undefined,
49
+ };
50
+ }
51
+ function sameSlots(a, b) {
52
+ if (a === undefined || b === undefined)
53
+ return a === b;
54
+ const keys = Object.keys(a.values);
55
+ return (a.declared === b.declared &&
56
+ keys.length === Object.keys(b.values).length &&
57
+ keys.every((key) => a.values[key] === b.values[key]));
58
+ }
59
+ /** One frame later in a browser; a timer's tick elsewhere. Returns the cancel. */
60
+ function nextFrame(run) {
61
+ // why: The client also runs where there are no frames (a server, a test), so it asks before it uses them.
62
+ const frames = globalThis;
63
+ if (frames.requestAnimationFrame && frames.cancelAnimationFrame) {
64
+ const id = frames.requestAnimationFrame(run);
65
+ return () => frames.cancelAnimationFrame?.(id);
66
+ }
67
+ const id = setTimeout(run, 16);
68
+ return () => clearTimeout(id);
69
+ }
70
+ function newTurnId() {
71
+ return crypto.randomUUID();
72
+ }
73
+ class Chat {
74
+ #options;
75
+ #state;
76
+ #notified;
77
+ #listeners = new Set();
78
+ #flushQueued = false;
79
+ #busy = false;
80
+ #abort = null;
81
+ #turnId = null;
82
+ /** The run in flight; it settles, never rejects. */
83
+ #run = null;
84
+ // why: A stateless reply's work across its runs: a gate splits a reply into runs, and the wait between doesn't count.
85
+ #replyWorkedMs = 0;
86
+ /** The run that ended let the queue send its next message. */
87
+ #drainArmed = false;
88
+ #draining = false;
89
+ #conversation;
90
+ // why: One object per posted message, so a run can tell whether it posted one.
91
+ #delivery = null;
92
+ #picked = {};
93
+ #resumed = false;
94
+ #defaultsFrom;
95
+ #answeredPage = null;
96
+ #pageToolsWarned;
97
+ #frame = null;
98
+ #frameBlocks = null;
99
+ constructor(options) {
100
+ this.#options = options;
101
+ this.#conversation = options.conversation;
102
+ this.#state = {
103
+ iface: options.iface,
104
+ blocks: [],
105
+ streamBlocks: [],
106
+ chatStarted: false,
107
+ draftText: options.initialText ?? '',
108
+ pendingFiles: [],
109
+ pendingVoice: [],
110
+ pendingMessages: [],
111
+ issues: [],
112
+ failure: null,
113
+ answering: null,
114
+ phase: 'idle',
115
+ session: emptyInterfaceTurnSession(),
116
+ delivery: null,
117
+ streaming: false,
118
+ slots: slotsOf(options.iface, options.slots, {}).slots,
119
+ };
120
+ this.#notified = this.#state;
121
+ this.#warnPageTools();
122
+ this.#settle();
123
+ }
124
+ getState = () => this.#state;
125
+ subscribe = (listener) => {
126
+ this.#listeners.add(listener);
127
+ if (this.#listeners.size === 1)
128
+ this.#settle();
129
+ return () => this.#listeners.delete(listener);
130
+ };
131
+ update = (options) => {
132
+ this.#options = options;
133
+ this.#warnPageTools();
134
+ const { slots } = slotsOf(options.iface, options.slots, this.#picked);
135
+ const patch = {};
136
+ if (options.iface !== this.#state.iface)
137
+ patch.iface = options.iface;
138
+ if (!sameSlots(slots, this.#state.slots))
139
+ patch.slots = slots;
140
+ if (Object.keys(patch).length > 0)
141
+ this.#set(patch);
142
+ };
143
+ setDraftText = (value) => this.#apply('draftText', value);
144
+ setPendingFiles = (value) => this.#apply('pendingFiles', value);
145
+ setPendingVoice = (value) => this.#apply('pendingVoice', value);
146
+ setPendingMessages = (value) => this.#apply('pendingMessages', value);
147
+ setIssues = (value) => this.#apply('issues', value);
148
+ submit = async () => {
149
+ const { phase, draftText, pendingFiles, pendingVoice } = this.#state;
150
+ if (phase !== 'idle') {
151
+ await this.enqueue('queue');
152
+ return;
153
+ }
154
+ this.#set({ issues: [] });
155
+ await this.#sendFields({ text: draftText, files: [...pendingFiles], voice: [...pendingVoice] });
156
+ };
157
+ stop = () => {
158
+ this.#abort?.abort();
159
+ };
160
+ enqueue = async (kind) => {
161
+ const { draftText, pendingFiles, pendingVoice } = this.#state;
162
+ if (!composerHasPayload(draftText, pendingFiles, pendingVoice))
163
+ return;
164
+ this.#set({ issues: [] });
165
+ try {
166
+ const draft = await encodeComposerDraft({ text: draftText, pendingFiles, pendingVoice });
167
+ const message = createComposerPendingMessage({ kind, draft });
168
+ this.#set({
169
+ pendingMessages: orderComposerPendingMessages([...this.#state.pendingMessages, message]),
170
+ ...this.#clearedComposer(),
171
+ });
172
+ if (kind !== 'steer')
173
+ return;
174
+ const turnId = this.#turnId;
175
+ if (!turnId) {
176
+ this.#steerAfterTurnEnded();
177
+ return;
178
+ }
179
+ const inject = userDraftToSteerInject(draft);
180
+ if (inject.length === 0)
181
+ return;
182
+ await this.#options.transport.steer({ turnId, id: message.id, inject });
183
+ }
184
+ catch (err) {
185
+ this.#set({ failure: clientFailure(err, this.#options.iface?.labels) });
186
+ }
187
+ };
188
+ sendNow = async (source) => {
189
+ if (!this.#options.iface)
190
+ return;
191
+ const draft = source ? source.draft : await this.#composerDraft();
192
+ if (!draft)
193
+ return;
194
+ if (this.#busy) {
195
+ this.#abort?.abort();
196
+ await this.#run;
197
+ }
198
+ this.#set({ pendingMessages: convertSteersToFrontQueued(this.#state.pendingMessages) });
199
+ this.#drainArmed = false;
200
+ // why: Sent while the reply waits on gates, the message walks away from them in its own request.
201
+ await this.#sendDraft(draft, {
202
+ walkAway: this.#state.session.gatedTool !== null,
203
+ // why: The sent message leaves the queue, or the composer it came from.
204
+ onPosted: () => this.#set(source
205
+ ? {
206
+ pendingMessages: removeComposerPendingMessage(this.#state.pendingMessages, source.id),
207
+ }
208
+ : this.#clearedComposer()),
209
+ });
210
+ };
211
+ menuAction = (action) => {
212
+ if (action === 'queue' || action === 'steer' || action === 'stash') {
213
+ void this.enqueue(action);
214
+ return;
215
+ }
216
+ if (action === 'send_now')
217
+ void this.sendNow();
218
+ };
219
+ restorePending = async (id) => {
220
+ const message = this.#state.pendingMessages.find((m) => m.id === id);
221
+ if (!message)
222
+ return;
223
+ try {
224
+ await this.#restore(composerFieldsFromDraft(message.draft));
225
+ this.#set({
226
+ pendingMessages: removeComposerPendingMessage(this.#state.pendingMessages, id),
227
+ });
228
+ }
229
+ catch (err) {
230
+ this.#set({ failure: clientFailure(err, this.#options.iface?.labels) });
231
+ }
232
+ };
233
+ queuePending = (id) => {
234
+ const message = this.#state.pendingMessages.find((m) => m.id === id);
235
+ if (message?.kind !== 'stash')
236
+ return;
237
+ if (this.#state.phase === 'idle')
238
+ this.#drainArmed = true;
239
+ this.#set({
240
+ pendingMessages: promoteComposerPendingKind(this.#state.pendingMessages, id, 'queue'),
241
+ });
242
+ };
243
+ decide = (action) => this.#answerGate({ action });
244
+ authenticate = (secret) => this.#answerGate({ action: 'auth', ...(secret === undefined ? {} : { secret }) });
245
+ branch = async (index) => {
246
+ const { iface } = this.#options;
247
+ if (!iface || this.#busy)
248
+ return;
249
+ const ref = this.#conversation;
250
+ if (!keepsConversation(iface) || ref === undefined) {
251
+ const kept = [...this.#state.blocks, ...this.#state.streamBlocks].slice(0, index + 1);
252
+ // why: The kept transcript's last message isn't the one whose delivery was tracked.
253
+ this.#setDelivery(null);
254
+ this.#set({
255
+ blocks: kept,
256
+ streamBlocks: [],
257
+ chatStarted: kept.length > 0,
258
+ session: branchInterfaceTurnSession(this.#state.session, kept),
259
+ });
260
+ return;
261
+ }
262
+ this.#set({ failure: null });
263
+ try {
264
+ // why: The paused reply is not in the host's transcript; a cut inside it keeps the transcript whole.
265
+ const at = Math.min(index, this.#state.blocks.length - 1);
266
+ const rewound = await this.#options.transport.rewind({ conversation: ref, at });
267
+ this.#adopt(rewound.conversation);
268
+ this.#draw(rewound.view, this.#state.session, []);
269
+ this.#setDelivery(null);
270
+ }
271
+ catch (err) {
272
+ this.#showFailure(turnFailure(err, iface.labels));
273
+ }
274
+ };
275
+ setGeneration = (next) => {
276
+ const { iface } = this.#options;
277
+ const effort = next.effort ?? (iface ? defaultInterfaceEffort(iface, next.modelId) : undefined);
278
+ this.#set({
279
+ session: { ...this.#state.session, selectedModel: next.modelId, selectedEffort: effort },
280
+ });
281
+ };
282
+ pickSlot = (name, value) => {
283
+ const before = slotsOf(this.#options.iface, this.#options.slots, this.#picked).values;
284
+ this.#picked = { ...this.#picked, [name]: value };
285
+ this.#set({ slots: slotsOf(this.#options.iface, this.#options.slots, this.#picked).slots });
286
+ this.#options.onSlotsChange?.({ ...before, [name]: value });
287
+ };
288
+ sendText = async (text, files = []) => {
289
+ if (!this.#options.iface || this.#state.phase !== 'idle')
290
+ return null;
291
+ const before = this.#state.blocks.length;
292
+ await this.#sendFields({ text, files: [...files], voice: [] });
293
+ return { blocks: this.#state.blocks.slice(before) };
294
+ };
295
+ #set(patch) {
296
+ const next = { ...this.#state, ...patch };
297
+ next.phase = this.#busy ? 'streaming' : next.session.gatedTool ? 'gated' : 'idle';
298
+ this.#state = next;
299
+ if (this.#flushQueued)
300
+ return;
301
+ this.#flushQueued = true;
302
+ // why: Changes made together reach listeners once, after the code that made them has run.
303
+ queueMicrotask(() => this.#flush());
304
+ }
305
+ #apply(key, value) {
306
+ const next = typeof value === 'function'
307
+ ? value(this.#state[key])
308
+ : value;
309
+ this.#set({ [key]: next });
310
+ }
311
+ #flush() {
312
+ this.#flushQueued = false;
313
+ this.#settle();
314
+ if (this.#flushQueued || this.#notified === this.#state)
315
+ return;
316
+ this.#notified = this.#state;
317
+ for (const listener of [...this.#listeners])
318
+ listener();
319
+ }
320
+ /**
321
+ * What follows from the state as it now stands: the profile's default model, the conversation
322
+ * the chat resumes, a page gate that has no answer, and the queue's next message. Each is done
323
+ * once, so settling again changes nothing.
324
+ */
325
+ #settle() {
326
+ const { iface } = this.#options;
327
+ if (!iface)
328
+ return;
329
+ this.#followDefaults(iface);
330
+ this.#resume(iface);
331
+ this.#answerPageGate();
332
+ this.#drain();
333
+ }
334
+ #clearedComposer() {
335
+ return { draftText: '', pendingFiles: [], pendingVoice: [] };
336
+ }
337
+ #setDelivery(next) {
338
+ this.#delivery = next;
339
+ this.#set({ delivery: next?.status ?? null });
340
+ }
341
+ #adopt(ref) {
342
+ if (this.#conversation === ref)
343
+ return;
344
+ this.#conversation = ref;
345
+ this.#options.onConversationChange?.(ref);
346
+ }
347
+ #showFailure(failure) {
348
+ if (!failure || failure.aborted)
349
+ return;
350
+ const { error, errorKind, errorInternal } = failure;
351
+ this.#set({
352
+ failure: { error, errorKind, ...(errorInternal ? { errorInternal } : {}) },
353
+ ...(failure.issues ? { issues: failure.issues } : {}),
354
+ });
355
+ }
356
+ /** Draws the conversation as the host shows it: its transcript, and the reply that waits on gates. */
357
+ #draw(view, session, events) {
358
+ this.#set({
359
+ blocks: view.transcript,
360
+ streamBlocks: view.paused?.blocks ?? [],
361
+ // why: A reply at rest leaves the run's own events (tokens, an ask); a paused one, the host's.
362
+ session: sessionAfter(session, view.paused?.events ?? events),
363
+ chatStarted: view.transcript.length > 0 || view.paused !== undefined,
364
+ });
365
+ }
366
+ /**
367
+ * Keeps the model and effort valid as the interface changes: a pick still on the old defaults
368
+ * follows the new ones, and a pick the profile no longer has falls back to them.
369
+ */
370
+ #followDefaults(iface) {
371
+ const { session } = this.#state;
372
+ const next = followGenerationDefaults(iface, { model: session.selectedModel, effort: session.selectedEffort }, this.#defaultsFrom);
373
+ this.#defaultsFrom = iface;
374
+ if (next.model === session.selectedModel && next.effort === session.selectedEffort)
375
+ return;
376
+ this.#set({ session: { ...session, selectedModel: next.model, selectedEffort: next.effort } });
377
+ }
378
+ /** Draws the conversation the chat resumes, once. A run that starts first draws it itself. */
379
+ #resume(iface) {
380
+ if (this.#resumed)
381
+ return;
382
+ const ref = this.#conversation;
383
+ const shown = this.#options.conversationView;
384
+ // why: A chat no one follows yet (one React's strict mode builds and drops) reads nothing from the host.
385
+ if (ref !== undefined && !shown && this.#listeners.size === 0)
386
+ return;
387
+ this.#resumed = true;
388
+ if (ref === undefined || !keepsConversation(iface))
389
+ return;
390
+ if (shown) {
391
+ this.#draw(shown, this.#state.session, []);
392
+ return;
393
+ }
394
+ const before = this.#state.blocks;
395
+ void this.#options.transport.conversation({ conversation: ref }).then((view) => {
396
+ // why: A message sent while it loaded posted onto the host's conversation; its run draws it.
397
+ if (this.#state.blocks !== before || this.#busy)
398
+ return;
399
+ this.#draw(view, this.#state.session, []);
400
+ }, (err) => this.#showFailure(turnFailure(err, iface.labels)));
401
+ }
402
+ /**
403
+ * Settles a restored page gate as unanswered.
404
+ * invariant: A restored page gate cannot authorize callback execution. Only active dispatch grants do.
405
+ */
406
+ #answerPageGate() {
407
+ const gated = this.#state.phase === 'gated' ? this.#state.session.gatedTool : null;
408
+ if (gated?.gateKind !== 'page' || !gated.operationId)
409
+ return;
410
+ if (this.#answeredPage === gated.operationId)
411
+ return;
412
+ this.#answeredPage = gated.operationId;
413
+ void this.#answerGate({ action: 'page', page: { unanswered: true } });
414
+ }
415
+ /** Sends the next queued message once the chat is idle, when the run that ended allowed it. */
416
+ #drain() {
417
+ if (this.#state.phase !== 'idle' || !this.#drainArmed)
418
+ return;
419
+ this.#drainArmed = false;
420
+ if (this.#draining)
421
+ return;
422
+ const { message } = consumeNextComposerQueue(this.#state.pendingMessages);
423
+ if (!message)
424
+ return;
425
+ this.#draining = true;
426
+ // why: The message leaves the queue once it posts; one that never starts stays queued.
427
+ void this.#sendDraft(message.draft, {
428
+ onPosted: () => this.#set({
429
+ pendingMessages: removeComposerPendingMessage(this.#state.pendingMessages, message.id),
430
+ }),
431
+ }).finally(() => {
432
+ this.#draining = false;
433
+ });
434
+ }
435
+ /** Says in the console, once for each set of names, where the page's tools and the profile's disagree. */
436
+ #warnPageTools() {
437
+ const { iface, pageTools } = this.#options;
438
+ const declared = iface && 'tools' in iface ? iface.tools.page : undefined;
439
+ if (declared === undefined)
440
+ return;
441
+ const handled = Object.keys(pageTools ?? {}).sort();
442
+ const key = JSON.stringify([declared, handled]);
443
+ if (key === this.#pageToolsWarned)
444
+ return;
445
+ this.#pageToolsWarned = key;
446
+ warnPageToolMismatch(declared, handled);
447
+ }
448
+ /** The composer's draft, when it holds something to send. */
449
+ async #composerDraft() {
450
+ const { draftText, pendingFiles, pendingVoice } = this.#state;
451
+ const draft = await encodeComposerDraft({ text: draftText, pendingFiles, pendingVoice });
452
+ return userDraftHasPayload(draft) ? draft : undefined;
453
+ }
454
+ /** Puts a message back in the composer. What the composer holds now is stashed first, so nothing typed is lost. */
455
+ async #restore(fields) {
456
+ const { draftText, pendingFiles, pendingVoice } = this.#state;
457
+ if (composerHasPayload(draftText, pendingFiles, pendingVoice)) {
458
+ const draft = await encodeComposerDraft({ text: draftText, pendingFiles, pendingVoice });
459
+ const stash = createComposerPendingMessage({ kind: 'stash', draft });
460
+ this.#set({
461
+ pendingMessages: orderComposerPendingMessages([...this.#state.pendingMessages, stash]),
462
+ });
463
+ }
464
+ this.#set({
465
+ draftText: fields.text,
466
+ pendingFiles: fields.files,
467
+ pendingVoice: fields.voice,
468
+ issues: [],
469
+ });
470
+ }
471
+ /** A steer with no running turn: the visitor reads `session.turn_ended`; steers, this one too, move to the front of the queue. */
472
+ #steerAfterTurnEnded() {
473
+ this.#set({
474
+ failure: clientFailure(
475
+ // labels-exempt: internal diagnostic; the user reads session.turn_ended
476
+ new TheoremError('request', 'steer: no active turn', {
477
+ copy: { key: 'session.turn_ended' },
478
+ }), this.#options.iface?.labels),
479
+ // why: The composer already gave the message up, so it waits as the next to send, not lost.
480
+ pendingMessages: orderComposerPendingMessages(convertSteersToFrontQueued(this.#state.pendingMessages)),
481
+ });
482
+ }
483
+ /** The message posted: it joins the transcript, and the reply starts streaming. */
484
+ #posted(blocks, patch) {
485
+ this.#setDelivery({ status: 'sending' });
486
+ this.#set({
487
+ blocks: [...this.#state.blocks, ...blocks],
488
+ chatStarted: true,
489
+ streaming: true,
490
+ ...patch,
491
+ });
492
+ }
493
+ #sendFields(fields) {
494
+ return this.#runTurn(({ iface, view, transport, scope, signal }) => streamInterfaceTurn({
495
+ iface,
496
+ transport,
497
+ session: this.#state.session,
498
+ text: fields.text,
499
+ pendingFiles: fields.files,
500
+ pendingVoice: fields.voice,
501
+ signal,
502
+ scope,
503
+ view,
504
+ transcript: this.#state.blocks,
505
+ onUserBlocks: (blocks) => this.#posted(blocks, this.#clearedComposer()),
506
+ }), { turn: true, message: { restore: () => this.#restore(fields) } });
507
+ }
508
+ #sendDraft(draft, options) {
509
+ return this.#runTurn(({ iface, view, transport, scope, signal }) => streamInterfaceDraftTurn({
510
+ iface,
511
+ transport,
512
+ session: this.#state.session,
513
+ draft,
514
+ signal,
515
+ scope,
516
+ view,
517
+ transcript: this.#state.blocks,
518
+ walkAway: options.walkAway === true,
519
+ onUserBlocks: (blocks) => {
520
+ this.#posted(blocks, { streamBlocks: [] });
521
+ options.onPosted();
522
+ },
523
+ }), { turn: true, message: { restore: () => this.#restore(composerFieldsFromDraft(draft)) } });
524
+ }
525
+ async #answerGate(resolution) {
526
+ // why: Set once the run starts (not while another runs), and cleared once it has ended.
527
+ let started = false;
528
+ try {
529
+ await this.#runTurn(({ iface, view, transport, scope }) => {
530
+ started = true;
531
+ const { session } = this.#state;
532
+ // why: A person's answer shows on its gate while it settles; the page's has no gate to show on.
533
+ if (session.gatedTool && resolution.action !== 'page') {
534
+ this.#set({
535
+ answering: {
536
+ callId: session.gatedTool.callId,
537
+ operationId: issuedOperationId(session.gatedTool),
538
+ action: resolution.action,
539
+ },
540
+ });
541
+ }
542
+ return resumeInterfaceTool({ iface, transport, session, scope, resolution, view });
543
+ });
544
+ }
545
+ finally {
546
+ if (started)
547
+ this.#set({ answering: null });
548
+ }
549
+ }
550
+ /**
551
+ * The transport each run uses: the page's tools answer their calls, each turn carries the
552
+ * page's slots and context as they are when it is sent, and each line lands the steers it
553
+ * reports, moves the message's delivery on, and hands the chat the conversation's new ref
554
+ * (so a page that reloads mid-reply keeps the posted message).
555
+ */
556
+ #runTransport() {
557
+ const { transport, pageTools } = this.#options;
558
+ const base = withPageToolDispatch(transport, pageTools);
559
+ const tap = (onLine) => (line) => {
560
+ this.#took(line);
561
+ onLine(line);
562
+ };
563
+ return {
564
+ ...base,
565
+ turn: (request, onLine, signal) => base.turn(this.#withValues(request), tap(onLine), signal),
566
+ invoke: (request, onLine, signal) => base.invoke(request, tap(onLine), signal),
567
+ };
568
+ }
569
+ #withValues(request) {
570
+ const { iface, slots: fixed, context } = this.#options;
571
+ const slots = slotsOf(iface, fixed, this.#picked).values;
572
+ if (slots === undefined && context === undefined)
573
+ return request;
574
+ return {
575
+ ...request,
576
+ input: {
577
+ ...request.input,
578
+ ...(slots === undefined ? {} : { slots }),
579
+ ...(context === undefined ? {} : { context }),
580
+ },
581
+ };
582
+ }
583
+ /** One line of a run's stream, before the run folds it. */
584
+ #took(line) {
585
+ if (line.type === 'stage' && line.injected?.length) {
586
+ this.#set({ pendingMessages: removeLandedSteers(this.#state.pendingMessages, line) });
587
+ }
588
+ const reached = deliveryOf(line);
589
+ const delivery = this.#delivery;
590
+ if (delivery &&
591
+ reached &&
592
+ DELIVERY_ORDER.indexOf(reached) > DELIVERY_ORDER.indexOf(delivery.status)) {
593
+ this.#setDelivery({ status: reached });
594
+ }
595
+ if (line.type === 'conversation')
596
+ this.#adopt(line.ref);
597
+ else
598
+ this.#options.onTurnEvent?.(line);
599
+ }
600
+ #streamFrame(partial) {
601
+ this.#frameBlocks = partial;
602
+ if (this.#frame)
603
+ return;
604
+ // why: A stream can carry many lines a frame; the chat draws the latest once per frame.
605
+ this.#frame = nextFrame(() => {
606
+ this.#frame = null;
607
+ const next = this.#frameBlocks;
608
+ this.#frameBlocks = null;
609
+ if (next)
610
+ this.#set({ streamBlocks: next });
611
+ });
612
+ }
613
+ /**
614
+ * Runs one stream into the transcript: live partials while it streams, then what the run left
615
+ * (or its failure) and the queue's next step. Does nothing while another run is in flight.
616
+ */
617
+ async #runTurn(run, options = {}) {
618
+ const { iface } = this.#options;
619
+ if (!iface || this.#busy)
620
+ return;
621
+ const { message } = options;
622
+ const priorDelivery = this.#delivery;
623
+ const abort = options.turn ? new AbortController() : null;
624
+ const turnId = options.turn ? newTurnId() : undefined;
625
+ this.#busy = true;
626
+ this.#abort = abort;
627
+ this.#turnId = turnId ?? null;
628
+ this.#drainArmed = false;
629
+ // why: A message streams once it posts, so the previous reply never shows as streaming before it.
630
+ this.#set({ failure: null, ...(message ? {} : { streaming: true }) });
631
+ const start = { blocks: this.#state.blocks, streamBlocks: this.#state.streamBlocks };
632
+ // why: A run that answers a gate continues its reply; any other starts one.
633
+ if (!this.#state.session.gatedTool)
634
+ this.#replyWorkedMs = 0;
635
+ const startedAt = Date.now();
636
+ const done = (async () => {
637
+ try {
638
+ const outcome = await run({
639
+ iface,
640
+ transport: this.#runTransport(),
641
+ scope: { conversation: this.#conversation, turnId },
642
+ signal: abort?.signal,
643
+ view: {
644
+ blocks: (partial) => this.#streamFrame(partial),
645
+ // why: The reply goes on; the composer names what it left out.
646
+ skipped: (error) => this.#set({ failure: clientFailure(error, iface.labels) }),
647
+ },
648
+ });
649
+ const endedAt = Date.now();
650
+ this.#replyWorkedMs += endedAt - startedAt;
651
+ this.#endRun();
652
+ const unposted = message !== undefined && !outcome.posted;
653
+ // why: A message that posts itself (onUserBlocks) gets a fresh delivery.
654
+ if (unposted && this.#delivery !== priorDelivery)
655
+ await this.#takeBack(start, message);
656
+ let waits;
657
+ if (keepsConversation(iface)) {
658
+ waits = await this.#settleHosted(outcome, start);
659
+ }
660
+ else if (unposted) {
661
+ this.#showFailure(outcome.failure);
662
+ waits = this.#state.session.gatedTool !== null;
663
+ }
664
+ else {
665
+ waits = this.#settleLocal(iface, outcome, { workedMs: this.#replyWorkedMs, endedAt });
666
+ }
667
+ this.#runEnded(!outcome.failure && !waits);
668
+ }
669
+ catch (err) {
670
+ // why: The run left no view to draw; the composer says so, and the chat stays as it stood.
671
+ this.#endRun();
672
+ this.#showFailure(turnFailure(err, iface.labels));
673
+ this.#runEnded(false);
674
+ }
675
+ })();
676
+ this.#run = done;
677
+ await done;
678
+ if (this.#run === done)
679
+ this.#run = null;
680
+ }
681
+ /** The run is over: nothing streams, and nothing can stop or steer it. */
682
+ #endRun() {
683
+ this.#frame?.();
684
+ this.#frame = null;
685
+ this.#frameBlocks = null;
686
+ this.#busy = false;
687
+ this.#abort = null;
688
+ this.#turnId = null;
689
+ this.#set({ streaming: false });
690
+ }
691
+ /** Steers the reply never took wait at the front of the queue; the queue sends on when `drain`. */
692
+ #runEnded(drain) {
693
+ this.#drainArmed = drain;
694
+ this.#set({
695
+ pendingMessages: orderComposerPendingMessages(convertSteersToFrontQueued(this.#state.pendingMessages)),
696
+ });
697
+ }
698
+ /** A message that never reached the conversation leaves the transcript and goes back in the composer. */
699
+ async #takeBack(start, message) {
700
+ this.#setDelivery(null);
701
+ this.#set({
702
+ blocks: start.blocks,
703
+ streamBlocks: start.streamBlocks,
704
+ chatStarted: start.blocks.length > 0 || start.streamBlocks.length > 0,
705
+ });
706
+ await message.restore();
707
+ }
708
+ /**
709
+ * A host that keeps the conversation is the truth: the chat draws it again from its ref, even
710
+ * after a message that never posted (a walk can settle calls before it fails). A failure shows
711
+ * in the composer unless the host's transcript closed on it. Returns whether the reply waits
712
+ * on gates.
713
+ */
714
+ async #settleHosted(outcome, start) {
715
+ const { failure } = outcome;
716
+ if (failure)
717
+ this.#setDelivery(null);
718
+ const ref = this.#conversation;
719
+ if (ref === undefined) {
720
+ this.#set({ streamBlocks: start.streamBlocks });
721
+ this.#showFailure(failure);
722
+ return this.#state.session.gatedTool !== null;
723
+ }
724
+ const view = await this.#options.transport.conversation({ conversation: ref });
725
+ this.#draw(view, outcome.session, outcome.events);
726
+ if (!view.transcript.slice(start.blocks.length).some((b) => b.kind === 'error')) {
727
+ this.#showFailure(failure);
728
+ }
729
+ return view.paused !== undefined;
730
+ }
731
+ /**
732
+ * A stateless chat keeps the reply as the run streamed it: in the transcript once it ends
733
+ * (closed by its failure, or by the visitor's stop), or beside it while it waits on a gate.
734
+ * Returns whether it waits.
735
+ */
736
+ #settleLocal(iface, outcome, worked) {
737
+ const session = sessionAfter(outcome.session, outcome.events);
738
+ const { failure } = outcome;
739
+ const reply = stampWorked(foldAssistantTurn(iface, outcome.events), worked);
740
+ if (session.gatedTool !== null) {
741
+ this.#set({ session, streamBlocks: reply });
742
+ this.#showFailure(failure);
743
+ return true;
744
+ }
745
+ if (!failure) {
746
+ this.#set({ session, streamBlocks: [], blocks: [...this.#state.blocks, ...reply] });
747
+ return false;
748
+ }
749
+ this.#setDelivery(null);
750
+ const closing = failure.aborted
751
+ ? [{ id: crypto.randomUUID(), kind: 'turn-done', stop: { kind: 'cancelled' } }]
752
+ : withTurnError(reply, failure.error);
753
+ this.#set({
754
+ session,
755
+ streamBlocks: [],
756
+ blocks: [...this.#state.blocks, ...closing],
757
+ ...(failure.issues ? { issues: failure.issues } : {}),
758
+ });
759
+ return false;
760
+ }
761
+ }
762
+ /** A chat for `options`. Framework-free: see `ChatController`. */
763
+ export function createChatController(options) {
764
+ return new Chat(options);
765
+ }
@@ -0,0 +1,19 @@
1
+ import type { ComposerPendingMessage } from '@theoremjs/agents/interface';
2
+ /** What waits in the composer, by kind; the UI words each kind. */
3
+ export type ComposerDrawerKind = 'steer' | 'queue' | 'stash' | 'attached';
4
+ /** Collapsed-drawer header: the total, then each waiting kind's count, in display order. */
5
+ export type ComposerDrawerSummary = {
6
+ count: number;
7
+ parts: {
8
+ kind: ComposerDrawerKind;
9
+ n: number;
10
+ }[];
11
+ };
12
+ /**
13
+ * What's waiting in the composer, by kind: steering, queued, stashed, attached
14
+ * (files and voice notes). Null when nothing waits.
15
+ */
16
+ export declare function composerDrawerSummary(args: {
17
+ pendingMessages: readonly Pick<ComposerPendingMessage, 'kind'>[];
18
+ attachmentCount: number;
19
+ }): ComposerDrawerSummary | null;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * What's waiting in the composer, by kind: steering, queued, stashed, attached
3
+ * (files and voice notes). Null when nothing waits.
4
+ */
5
+ export function composerDrawerSummary(args) {
6
+ const countOf = (kind) => args.pendingMessages.filter((message) => message.kind === kind).length;
7
+ const parts = [
8
+ { kind: 'steer', n: countOf('steer') },
9
+ { kind: 'queue', n: countOf('queue') },
10
+ { kind: 'stash', n: countOf('stash') },
11
+ { kind: 'attached', n: args.attachmentCount },
12
+ ].filter((part) => part.n > 0);
13
+ const count = parts.reduce((sum, part) => sum + part.n, 0);
14
+ return count === 0 ? null : { count, parts };
15
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Composer hints — ephemeral guidance about the current composer interaction
3
+ * (not pending-message state). Mirrors seance's `stash-selected-draft` hint.
4
+ */
5
+ /** Stash shortcut in Astryx `Kbd` notation (`mod` is ⌘ on macOS, Ctrl elsewhere). */
6
+ export declare const STASH_SHORTCUT = "mod+shift+s";
7
+ /** Which hint applies, and its shortcut; the UI words it. */
8
+ export type ComposerHint = {
9
+ id: 'stash-selected-draft';
10
+ shortcut: string;
11
+ };
12
+ /** Whether a keydown is the stash shortcut (⌘⇧S / Ctrl+Shift+S). */
13
+ export declare function isStashShortcut(event: {
14
+ code: string;
15
+ metaKey: boolean;
16
+ ctrlKey: boolean;
17
+ shiftKey: boolean;
18
+ altKey: boolean;
19
+ }): boolean;
20
+ /** The stash hint — shown while the whole draft is selected in the focused input and it can be stashed. */
21
+ export declare function resolveComposerHint(args: {
22
+ draftText: string;
23
+ selectedText: string;
24
+ canStash: boolean;
25
+ }): ComposerHint | null;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Composer hints — ephemeral guidance about the current composer interaction
3
+ * (not pending-message state). Mirrors seance's `stash-selected-draft` hint.
4
+ */
5
+ /** Stash shortcut in Astryx `Kbd` notation (`mod` is ⌘ on macOS, Ctrl elsewhere). */
6
+ export const STASH_SHORTCUT = 'mod+shift+s';
7
+ /** Whether a keydown is the stash shortcut (⌘⇧S / Ctrl+Shift+S). */
8
+ export function isStashShortcut(event) {
9
+ return (event.code === 'KeyS' && event.shiftKey && !event.altKey && (event.metaKey || event.ctrlKey));
10
+ }
11
+ /** The stash hint — shown while the whole draft is selected in the focused input and it can be stashed. */
12
+ export function resolveComposerHint(args) {
13
+ const draft = args.draftText.trim();
14
+ if (!args.canStash || !draft || args.selectedText.trim() !== draft)
15
+ return null;
16
+ return {
17
+ id: 'stash-selected-draft',
18
+ shortcut: STASH_SHORTCUT,
19
+ };
20
+ }
@@ -0,0 +1,18 @@
1
+ import { type ComposerMenuAction, type ComposerPrimaryAction, type ComposerProfileInterface, type ComposerRunPhase } from '@theoremjs/agents/interface';
2
+ /** Whether the composer holds something to send: text that isn't blank, a file, or a voice note. */
3
+ export declare function composerHasPayload(draftText: string, pendingFiles: readonly File[], pendingVoice: readonly File[]): boolean;
4
+ export type ComposerActionState = {
5
+ primary: ComposerPrimaryAction;
6
+ menuActions: ComposerMenuAction[];
7
+ /** Send is off while recording, with nothing to act on, or with an empty draft to send or queue. */
8
+ primaryDisabled: boolean;
9
+ };
10
+ /** The composer's primary button and send menu for the current draft and run phase. */
11
+ export declare function composerActionState(args: {
12
+ iface: ComposerProfileInterface;
13
+ phase: ComposerRunPhase;
14
+ draftText: string;
15
+ pendingFiles: readonly File[];
16
+ pendingVoice: readonly File[];
17
+ recording: boolean;
18
+ }): ComposerActionState;
@@ -0,0 +1,29 @@
1
+ import { resolveComposerMenuActions, resolveComposerPrimary, userDraftHasPayload, } from '@theoremjs/agents/interface';
2
+ function fileSpecs(files) {
3
+ return files.map((file) => ({
4
+ name: file.name,
5
+ mimeType: file.type || 'application/octet-stream',
6
+ sizeBytes: file.size,
7
+ }));
8
+ }
9
+ /** Whether the composer holds something to send: text that isn't blank, a file, or a voice note. */
10
+ export function composerHasPayload(draftText, pendingFiles, pendingVoice) {
11
+ return userDraftHasPayload({
12
+ ...(draftText.trim() ? { text: draftText } : {}),
13
+ ...(pendingFiles.length ? { attachments: fileSpecs(pendingFiles) } : {}),
14
+ ...(pendingVoice.length ? { voice: fileSpecs(pendingVoice) } : {}),
15
+ });
16
+ }
17
+ /** The composer's primary button and send menu for the current draft and run phase. */
18
+ export function composerActionState(args) {
19
+ const allowSteering = 'allowSteering' in args.iface && Boolean(args.iface.allowSteering);
20
+ const hasPayload = composerHasPayload(args.draftText, args.pendingFiles, args.pendingVoice);
21
+ const context = { phase: args.phase, hasPayload, allowSteering };
22
+ const primary = resolveComposerPrimary(context);
23
+ const needsPayload = primary === 'send' || primary === 'queue';
24
+ return {
25
+ primary,
26
+ menuActions: resolveComposerMenuActions(context),
27
+ primaryDisabled: args.recording || primary === 'none' || (needsPayload && !hasPayload),
28
+ };
29
+ }
package/client/index.d.ts CHANGED
@@ -1,4 +1,8 @@
1
1
  export { float32Rms, float32RmsToLevel, INPUT_LEVEL_GAIN, timeDomainBytesToLevel, } from './audio-level.ts';
2
+ export { type ChatController, type ChatOptions, type ChatSlots, type ChatState, type ChatUpdate, createChatController, type MessageDelivery, type SentTurn, } from './chat.ts';
3
+ export { type ComposerDrawerKind, type ComposerDrawerSummary, composerDrawerSummary, } from './composer-drawer.ts';
4
+ export { type ComposerHint, isStashShortcut, resolveComposerHint, STASH_SHORTCUT, } from './composer-hints.ts';
5
+ export { type ComposerActionState, composerActionState, composerHasPayload, } from './composer-primary.ts';
2
6
  export { createDecisionTransport, type DecisionInterface, type DecisionQuestionView, type DecisionReply, type DecisionTransport, decisionAssets, decisionInterface, failedDecisionTraces, failedTraces, postDecision, readDecisionReply, withFailedTraces, } from './decision-transport.ts';
3
7
  export { type ComposerDraftFields, composerFieldsFromDraft } from './decode-composer-draft.ts';
4
8
  export { addUsage, checkEmbeddingInputs, checkInputs, cosineSimilarity, type EmbeddingFields, type EmbeddingInputCheck, type EmbeddingMatch, type EmbeddingMediaPart, type EmbeddingSplit, embeddingBatches, fieldsInput, inputFields, MAX_BATCH_INPUTS, rankMatches, readEmbeddingInput, readEmbeddingInputs, readsAsText, splitText, } from './embedding-input.ts';
@@ -10,7 +14,7 @@ export { type ClientFailure, clientFailure, type TurnFailure, turnFailure } from
10
14
  export { followGenerationDefaults } from './generation-selection.ts';
11
15
  export { createHostTransport, HOST_TOOL_KINDS, type HostInterface, type HostToolKind, type HostToolSource, type HostToolView, type HostTransport, hostInterface, type TheoremHostCallRequest, } from './host-transport.ts';
12
16
  export type { LiveCallOptions, PageTool, PageToolAnswer, PageTools, } from './live/live-page-tool.ts';
13
- export { pageToolMismatch } from './live/live-page-tool.ts';
17
+ export { pageToolMismatch, warnPageToolMismatch } from './live/live-page-tool.ts';
14
18
  export { type LiveConnectPhase, type LiveSessionStatus, type LiveState, liveState, } from './live/live-state.ts';
15
19
  export type { LiveGateAnswer, LiveToolGatePrompt } from './live/live-tool.ts';
16
20
  export type { LiveConnection } from './live-client.ts';
package/client/index.js CHANGED
@@ -1,4 +1,8 @@
1
1
  export { float32Rms, float32RmsToLevel, INPUT_LEVEL_GAIN, timeDomainBytesToLevel, } from "./audio-level.js";
2
+ export { createChatController, } from "./chat.js";
3
+ export { composerDrawerSummary, } from "./composer-drawer.js";
4
+ export { isStashShortcut, resolveComposerHint, STASH_SHORTCUT, } from "./composer-hints.js";
5
+ export { composerActionState, composerHasPayload, } from "./composer-primary.js";
2
6
  export { createDecisionTransport, decisionAssets, decisionInterface, failedDecisionTraces, failedTraces, postDecision, readDecisionReply, withFailedTraces, } from "./decision-transport.js";
3
7
  export { composerFieldsFromDraft } from "./decode-composer-draft.js";
4
8
  export { addUsage, checkEmbeddingInputs, checkInputs, cosineSimilarity, embeddingBatches, fieldsInput, inputFields, MAX_BATCH_INPUTS, rankMatches, readEmbeddingInput, readEmbeddingInputs, readsAsText, splitText, } from "./embedding-input.js";
@@ -9,7 +13,7 @@ export { encodeFiles, filesToPending, pendingAttachmentsToFiles } from "./encode
9
13
  export { clientFailure, turnFailure } from "./failure.js";
10
14
  export { followGenerationDefaults } from "./generation-selection.js";
11
15
  export { createHostTransport, HOST_TOOL_KINDS, hostInterface, } from "./host-transport.js";
12
- export { pageToolMismatch } from "./live/live-page-tool.js";
16
+ export { pageToolMismatch, warnPageToolMismatch } from "./live/live-page-tool.js";
13
17
  export { liveState, } from "./live/live-state.js";
14
18
  export { LiveSessionClient } from "./live-client.js";
15
19
  export { isOAuthComplete, notifyOAuthComplete } from "./oauth-popup.js";
@@ -32,3 +32,8 @@ export declare function pageToolMismatch(declared: readonly string[], handled: r
32
32
  unanswered: string[];
33
33
  unused: string[];
34
34
  };
35
+ /**
36
+ * Says in the console where the page's tools and the profile's disagree. A wrong name is the
37
+ * builder's to fix, so it is never said to the visitor.
38
+ */
39
+ export declare function warnPageToolMismatch(declared: readonly string[], handled: readonly string[]): void;
@@ -9,3 +9,18 @@ export function pageToolMismatch(declared, handled) {
9
9
  unused: handled.filter((name) => !declared.includes(name)),
10
10
  };
11
11
  }
12
+ /**
13
+ * Says in the console where the page's tools and the profile's disagree. A wrong name is the
14
+ * builder's to fix, so it is never said to the visitor.
15
+ */
16
+ export function warnPageToolMismatch(declared, handled) {
17
+ const { unanswered, unused } = pageToolMismatch(declared, handled);
18
+ for (const name of unanswered) {
19
+ // labels-exempt: builder diagnostic
20
+ console.warn(`Theorem: page tool '${name}' has no handler in pageTools; the agent gets no answer.`);
21
+ }
22
+ for (const name of unused) {
23
+ // labels-exempt: builder diagnostic
24
+ console.warn(`Theorem: pageTools has '${name}', which the profile does not declare answeredBy: 'page'.`);
25
+ }
26
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theoremjs/web",
3
- "version": "0.3.4-next.373.gab46e23",
3
+ "version": "0.3.4-next.377.g4dbec64",
4
4
  "description": "Theorem on the web platform, with no UI framework: Web-standard server handlers that serve a profile, and the browser client that talks to them.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -25,6 +25,6 @@
25
25
  },
26
26
  "peerDependencies": {
27
27
  "zod": "^4.6.5",
28
- "@theoremjs/agents": "^0.3.4-next.373.gab46e23"
28
+ "@theoremjs/agents": "^0.3.4-next.377.g4dbec64"
29
29
  }
30
30
  }