@mlx-bun/module-chat 0.0.0-stage → 1.0.0

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.
@@ -0,0 +1,1076 @@
1
+ // Part of the chat panel (`@mlx-bun/module-chat`), bundled into the app's browser build.
2
+ //
3
+ // The chat controller: WS wiring, thread render, streaming, tool cards,
4
+ // approval card, queue bar. This is the orchestrator for composer.ts and
5
+ // sessions.ts — it owns the WebSocket and the per-turn mutable state
6
+ // (curAssistant, lastUserMsgEl, etc.) that both of those modules act on via
7
+ // explicit function calls rather than shared closures (a deliberate change
8
+ // from the original single-IIFE app.html: the split's whole point is
9
+ // typed, checkable seams between these concerns).
10
+ //
11
+ // Typed WS contract: ClientMessage/ServerMessage
12
+ // and the history/session shapes are imported from ../protocol (data only,
13
+ // shared with the backend) so this module (and the whole bundle) contains
14
+ // zero server code.
15
+
16
+ import type {
17
+ ApiEnvelope, ClientMessage, HistoryItem, ReadyGenDefaults, ServerMessage, SessionListItem,
18
+ } from "../protocol";
19
+ import { $, api, currentRoute, el, toast } from "./dom";
20
+ import type { ChatHost, ToolCardHandle } from "./host";
21
+ import { esc, mdToHtml, highlightIn, linkifyCitations, makeFrameScheduler, renderBlocksIncremental, wireCanvasToggle, captureCanvasViewStates, restoreCanvasViewStates, type BlockState } from "./markdown";
22
+ import {
23
+ ComposerState, addFiles, buildMessageText, clearAttachments, clearOneShotArmed, initMentionPicker,
24
+ initSampling, initSystemPrompt, onSelectAdapter, refreshAdapters as composerRefreshAdapters, refreshSamplingRecs,
25
+ renderAttachments, renderContext, renderQueue, updateAttachHint,
26
+ updateThinkingToggle, type ContextFrame,
27
+ } from "./composer";
28
+ import { initVoiceInput } from "./voice";
29
+ import {
30
+ addMsgActions, newSiblingInfo, newSidebarState, renderSessions, renderSiblingToggle,
31
+ switchSiblingTarget, applySessSearch, type SidebarState, type SiblingInfo,
32
+ } from "./sessions";
33
+ import { AdaptersPanelState, initAdaptersPanel, refreshAdaptersPanel } from "./adapters-panel";
34
+ import type { Citation } from "./rag";
35
+ import { buildAppContext, queryUiElements, resolveSpotlightTarget, showSpotlight, type UiSnapshot } from "./assistant";
36
+ import { isRouteId } from "./ui-catalog";
37
+
38
+ /* ────────────────────────────────────────────────────────────────────
39
+ Chat-with-files RAG v1 — Sources panel (`02d723a:docs/design/web-chat-redesign.md` Appendix A,
40
+ beat-matrix Axis 5). Pure HTML builder so packages/module-chat/tests/panel.test.ts can exercise the
41
+ esc() discipline directly, same reasoning as renderAdapterOptionsHtml
42
+ etc. in composer.ts.
43
+ ──────────────────────────────────────────────────────────────────── */
44
+
45
+ /** Pure: builds the collapsed "Sources · K" summary line + expandable list
46
+ * of [n] filename + snippet for a turn's citations. Snippets are
47
+ * truncated (a citation's chunk can be ~1200 chars — the panel is a
48
+ * pointer back to the source, not a document viewer). */
49
+ export function renderSourcesHtml(citations: Citation[]): string {
50
+ if (!citations.length) return "";
51
+ const rows = citations.map((c) => {
52
+ const snippet = c.text.length > 240 ? c.text.slice(0, 240).trim() + "…" : c.text.trim();
53
+ return '<div class="src-row" data-cite-row="' + c.n + '">' +
54
+ '<span class="src-n">[' + c.n + "]</span>" +
55
+ '<span class="src-meta"><span class="src-file">' + esc(c.fileName) + '</span>' +
56
+ '<span class="src-range">chars ' + c.start + "–" + c.end + "</span></span>" +
57
+ '<div class="src-snippet">' + esc(snippet) + "</div></div>";
58
+ }).join("");
59
+ return '<details class="sources"><summary>Sources · ' + citations.length + "</summary>" +
60
+ '<div class="src-list">' + rows + "</div></details>";
61
+ }
62
+
63
+ /* ────────────────────────────────────────────────────────────────────
64
+ Tool arg formatting helpers (shared by streaming + replayed history)
65
+ ──────────────────────────────────────────────────────────────────── */
66
+
67
+ function argSummary(args: unknown): string {
68
+ if (!args) return "";
69
+ if (typeof args === "string") return args.slice(0, 120);
70
+ const a = args as Record<string, unknown>;
71
+ if (a.command || a.cmd) return String(a.command || a.cmd).slice(0, 120);
72
+ if (a.file_path || a.path) return String(a.file_path || a.path).slice(0, 120);
73
+ if (a.query || a.url || a.location) return String(a.query || a.url || a.location).slice(0, 120);
74
+ const s = JSON.stringify(args); return s.length > 120 ? s.slice(0, 117) + "…" : s;
75
+ }
76
+ function prettyArgs(args: unknown): string {
77
+ if (args == null) return "";
78
+ if (typeof args === "string") return args;
79
+ try { return JSON.stringify(args, null, 2); } catch { return String(args); }
80
+ }
81
+ function diffView(args: unknown): string {
82
+ // Best-effort diff-ish view for edit/write tools.
83
+ if (!args) return "";
84
+ const a = args as Record<string, unknown>;
85
+ if (a.old_string != null || a.new_string != null) {
86
+ const o = String(a.old_string ?? "").split("\n").map((l) => '<span class="diffdel">- ' + esc(l) + "</span>").join("\n");
87
+ const n = String(a.new_string ?? "").split("\n").map((l) => '<span class="diffadd">+ ' + esc(l) + "</span>").join("\n");
88
+ return (a.file_path ? esc(a.file_path) + "\n\n" : "") + o + (o && n ? "\n" : "") + n;
89
+ }
90
+ if (a.content != null) {
91
+ return (a.file_path ? esc(a.file_path) + "\n\n" : "") +
92
+ String(a.content).split("\n").map((l) => '<span class="diffadd">+ ' + esc(l) + "</span>").join("\n");
93
+ }
94
+ return esc(prettyArgs(args));
95
+ }
96
+
97
+ /* ────────────────────────────────────────────────────────────────────
98
+ Per-assistant-message streaming state
99
+ ──────────────────────────────────────────────────────────────────── */
100
+
101
+ // Two distinct card kinds share one map (curAssistant.tools): the generic
102
+ // "wrench" tool card and the memory provenance chip (the
103
+ // single highest-leverage rendering change: a citation, not a shell
104
+ // command). Dispatch on `.kind` at each lifecycle point (toolCard/
105
+ // toolUpdate/toolEnd) rather than keeping two parallel maps, since a card's
106
+ // kind is fixed at tool_start and every later frame (tool_update/tool_end)
107
+ // only carries the callId to look it up by.
108
+ interface WrenchCardState {
109
+ kind: "wrench";
110
+ wrap: HTMLElement;
111
+ res: HTMLElement;
112
+ resBlk: HTMLElement;
113
+ label: HTMLElement;
114
+ chunks: string;
115
+ }
116
+ interface MemChipCardState {
117
+ kind: "memchip";
118
+ wrap: HTMLElement;
119
+ handle: ToolCardHandle;
120
+ chunks: string;
121
+ }
122
+ type ToolCardState = WrenchCardState | MemChipCardState;
123
+
124
+ interface AssistantMsgState {
125
+ m: HTMLElement;
126
+ bubble: HTMLElement;
127
+ thinkBox: HTMLDetailsElement;
128
+ thinkBody: HTMLElement;
129
+ textNode: HTMLElement;
130
+ cursor: HTMLElement;
131
+ meta: HTMLElement;
132
+ tools: Map<string, ToolCardState>;
133
+ text: string;
134
+ thinking: string;
135
+ t0: number;
136
+ tFirst: number;
137
+ tokens: number;
138
+ settled: boolean;
139
+ blockState: BlockState;
140
+ scheduleText: () => void;
141
+ scheduleThinking: () => void;
142
+ // Chat-with-files RAG v1 (`02d723a:docs/design/web-chat-redesign.md` Appendix A,
143
+ // beat-matrix Axis 5): the
144
+ // citation list retrieved for the prompt that started THIS turn, if any
145
+ // (composer.ts's buildMessageText only returns a non-empty list when
146
+ // retrieval mode fired). Empty for the common case — no Sources panel,
147
+ // no [n] linkification, zero visual change from before this feature.
148
+ citations: Citation[];
149
+ }
150
+
151
+ /** What the controller needs from its element and host. */
152
+ export interface ChatPort {
153
+ readonly host: ChatHost | undefined;
154
+ /** The panel element the markup lives in. */
155
+ readonly element: HTMLElement;
156
+ /** The chat page is the one showing (a reconnect waits for it). */
157
+ isActive(): boolean;
158
+ /** Closes the mobile recent-chats drawer. */
159
+ closeDrawer(): void;
160
+ }
161
+
162
+ export function createChatController(port: ChatPort) {
163
+ let ws: WebSocket | null = null, connected = false, reconnectTimer: ReturnType<typeof setTimeout> | undefined, manualClose = false;
164
+ let curAssistant: AssistantMsgState | null = null;
165
+ let turnActive = false;
166
+ // App-aware assistant: the last uiSnapshot pushed to the
167
+ // server, kept client-side too so spotlight_ui's ref resolution
168
+ // (resolveSpotlightTarget) doesn't need a round-trip — the browser
169
+ // already has the freshest snapshot it just sent.
170
+ let lastSnapshot: UiSnapshot | null = null;
171
+ // Citations retrieved for the prompt currently in flight (set in submit(),
172
+ // just before send()) — startAssistant() picks this up when turn_start
173
+ // arrives and attaches it to the new AssistantMsgState, then clears it so
174
+ // a later turn with no attachments doesn't inherit a stale citation set.
175
+ let pendingCitations: Citation[] = [];
176
+ // This connection's active session path (from `sessions` frames). Each
177
+ // connect gets a fresh server session; on a transient reconnect we re-open
178
+ // our own session so a blip doesn't strand us on a blank one.
179
+ let currentSessionPath: string | null = null, pendingResumePath: string | null = null;
180
+ const thread = () => $("chat-thread");
181
+ const composer = new ComposerState();
182
+ const sidebar: SidebarState = newSidebarState();
183
+ const adaptersPanel = new AdaptersPanelState();
184
+
185
+ function wsUrl(): string {
186
+ const proto = location.protocol === "https:" ? "wss:" : "ws:";
187
+ return proto + "//" + location.host + "/ws/chat";
188
+ }
189
+
190
+ function connect(): void {
191
+ manualClose = false;
192
+ // Remember our session across this (re)connect: if we were already in a
193
+ // session, re-open it once the new connection is ready (the server starts
194
+ // each connection on a fresh session by default).
195
+ pendingResumePath = currentSessionPath;
196
+ try { ws = new WebSocket(wsUrl()); } catch { scheduleReconnect(); return; }
197
+ ws.onopen = () => { connected = true; setChatStatus("connected"); };
198
+ ws.onclose = () => { connected = false; setChatStatus("disconnected"); if (!manualClose) scheduleReconnect(); };
199
+ ws.onerror = () => { setChatStatus("error"); };
200
+ ws.onmessage = (ev) => { let m: ServerMessage; try { m = JSON.parse(ev.data); } catch { return; } handle(m); };
201
+ }
202
+ // The served model changed (the hub or picker switched it): a session that is not mid-turn reconnects,
203
+ // so its capabilities (vision, thinking) and model name describe the model that will answer.
204
+ window.addEventListener("mlx-model-changed", () => { if (!turnActive && ws && ws.readyState === 1) ws.close(); });
205
+ function scheduleReconnect(): void {
206
+ clearTimeout(reconnectTimer);
207
+ reconnectTimer = setTimeout(() => { if (port.isActive()) connect(); }, 2200);
208
+ }
209
+ function send(obj: ClientMessage): boolean {
210
+ if (ws && ws.readyState === 1) { ws.send(JSON.stringify(obj)); return true; }
211
+ return false;
212
+ }
213
+
214
+ /* ── App-aware assistant (`02d723a:docs/design/web-chat-redesign.md` Appendix A,
215
+ beat-matrix Axis 12) ──
216
+ "See": push a structured context frame on every route change and every
217
+ wizard-step change. Never on a timer — only these two triggers, so the
218
+ server's ambient one-liner always reflects a real, user-caused state
219
+ change. */
220
+
221
+ /** Which non-route overlay (if any) is currently open, layered on top of
222
+ * whatever route is active underneath — same "where is the user, really"
223
+ * question navigate_app/spotlight_ui need answered. Checked in a fixed
224
+ * order; the first open one wins (only one of these opens at a time in
225
+ * practice — each is closed by shell.ts's closeTopOverlay sweep before
226
+ * another opens). */
227
+ function currentView(): string | null {
228
+ if ($("mem-overlay")?.classList.contains("open")) return "memory-panel";
229
+ if ($("hub-overlay")?.classList.contains("open")) return "hub-panel";
230
+ if ($("adapters-overlay")?.classList.contains("open")) return "adapters-panel";
231
+ return null;
232
+ }
233
+
234
+ function pushAppContext(): void {
235
+ const route = currentRoute();
236
+ if (!isRouteId(route)) return; // "routes" (DAG diagram) isn't a useful assistant destination
237
+ const ctx = buildAppContext(route, currentView());
238
+ lastSnapshot = ctx.snapshot;
239
+ send({ type: "context", context: ctx });
240
+ }
241
+
242
+ /** Rebind after navigation: module panels are created lazily and own their step indicators in shadow DOM. */
243
+ let wizardObserver: MutationObserver | undefined;
244
+ function watchWizardSteps(): void {
245
+ wizardObserver ??= new MutationObserver(() => pushAppContext());
246
+ wizardObserver.disconnect();
247
+ for (const id of ["q-steps", "f-steps", "d-steps"]) {
248
+ const element = queryUiElements(`[id="${id}"]`)[0];
249
+ if (element) wizardObserver.observe(element, { childList: true, subtree: true });
250
+ }
251
+ }
252
+
253
+ function setChatStatus(s: "connected" | "disconnected" | "error"): void {
254
+ const line = $("chat-status-line");
255
+ // The "#" hint lives HERE, not in the composer placeholder — a long
256
+ // placeholder wraps + clips at phone widths (the 2026-07-06 jank class).
257
+ // This status line ellipsizes gracefully instead (.chat-hint CSS).
258
+ if (s === "connected") line.textContent = "connected · type to send · Shift+Enter for newline · # attaches files or recalls memory";
259
+ else if (s === "disconnected") line.textContent = "reconnecting to agent…";
260
+ else if (s === "error") line.textContent = "connection error — retrying";
261
+ }
262
+
263
+ /* — message actions: copy / regenerate / edit-and-resend —
264
+ * Only the LAST assistant message gets "regenerate" and only the LAST
265
+ * user message gets "edit" + the sibling `< i/n >` toggle. Both "last"
266
+ * pointers are tracked module-side and re-derived whenever a `history`
267
+ * frame rebuilds #chat-thread wholesale (see renderHistory/endTurn below).
268
+ * Regenerate/edit-resend re-prompt on a new sibling branch WITHOUT a
269
+ * `history` frame (resendFrom in packages/module-chat/src/pi-backend.ts), so their click handlers below
270
+ * drop the stale lastAssistantMsgEl (and, for edit, update the surviving
271
+ * user bubble's text) themselves before the new reply streams in. */
272
+ let lastUserMsgEl: HTMLElement | null = null; // the <div class="msg user"> for the last user message
273
+ let lastUserEntryId: string | null = null; // its session entryId (from history/siblings frames)
274
+ let siblingInfo: SiblingInfo = newSiblingInfo();
275
+ let lastAssistantMsgEl: HTMLElement | null = null; // the <div class="msg assistant"> eligible for regenerate
276
+ // Citations for the reply currently sitting in lastAssistantMsgEl (kept in
277
+ // lockstep with it — set alongside it in endTurn, cleared alongside it
278
+ // everywhere it's nulled). Regenerate/edit-resend re-prompt the SAME
279
+ // underlying (possibly RAG'd) user turn without going through submit()/
280
+ // buildMessageText, so pendingCitations would otherwise be the stale `[]`
281
+ // left over from consumption at the prior startAssistant() call — see the
282
+ // pendingCitations declaration below. Re-seeding from here means a
283
+ // citation-bearing turn keeps its Sources panel across regenerate/edit.
284
+ let lastAssistantCitations: Citation[] = [];
285
+
286
+ function doRegenerate(): void {
287
+ if (turnActive) return;
288
+ if (!send({ type: "regenerate" })) { toast("Not connected to the agent yet.", "err"); return; }
289
+ // The server re-prompts on a new sibling branch — it does not send a
290
+ // `history` frame for this (see resendFrom in packages/module-chat/src/pi-backend.ts), so the old
291
+ // reply would otherwise linger above the new one as it streams in.
292
+ // Drop it now; startAssistant() appends the fresh reply right after.
293
+ if (lastAssistantMsgEl) { lastAssistantMsgEl.remove(); lastAssistantMsgEl = null; }
294
+ pendingCitations = lastAssistantCitations;
295
+ }
296
+
297
+ function switchSiblingDir(delta: number): void {
298
+ const target = switchSiblingTarget(siblingInfo, delta);
299
+ if (!target) return;
300
+ send({ type: "switch_sibling", entryId: target });
301
+ }
302
+
303
+ /** Turn the last user message bubble into an inline editor. Cancel restores
304
+ * the original text view; Send fires edit_resend, updates the surviving
305
+ * user bubble to the edited text in place, and drops the stale assistant
306
+ * reply — the server re-prompts on a new sibling branch without sending a
307
+ * `history` frame (see resendFrom in packages/module-chat/src/pi-backend.ts), so the old reply/text
308
+ * would otherwise linger next to the new one as it streams in. */
309
+ function startEditLast(m: HTMLElement): void {
310
+ if (turnActive) return;
311
+ const bubble = m.querySelector(".bubble") as HTMLElement;
312
+ const textEl = bubble.querySelector(".msg-text") as HTMLElement | null;
313
+ if (!textEl) return;
314
+ const original = textEl.textContent || "";
315
+ const box = document.createElement("textarea");
316
+ box.className = "msg-edit-box";
317
+ box.value = original;
318
+ const actions = el("div", "msg-edit-actions", null);
319
+ const cancelBtn = el("button", "btn ghost sm", actions);
320
+ cancelBtn.type = "button"; cancelBtn.textContent = "Cancel";
321
+ const sendBtn = el("button", "btn primary sm", actions);
322
+ sendBtn.type = "button"; sendBtn.textContent = "Send";
323
+ textEl.replaceWith(box);
324
+ box.after(actions);
325
+ box.focus(); box.setSelectionRange(box.value.length, box.value.length);
326
+ const restore = () => { box.replaceWith(textEl); actions.remove(); };
327
+ const doSend = () => {
328
+ const text = box.value.trim();
329
+ if (!text) return;
330
+ if (!send({ type: "edit_resend", text })) { toast("Not connected to the agent yet.", "err"); return; }
331
+ textEl.textContent = text;
332
+ box.replaceWith(textEl); actions.remove();
333
+ if (lastAssistantMsgEl) { lastAssistantMsgEl.remove(); lastAssistantMsgEl = null; }
334
+ pendingCitations = lastAssistantCitations;
335
+ };
336
+ cancelBtn.onclick = restore;
337
+ sendBtn.onclick = doSend;
338
+ box.addEventListener("keydown", (e) => {
339
+ if (e.key === "Escape") { e.preventDefault(); restore(); }
340
+ else if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); doSend(); }
341
+ });
342
+ }
343
+
344
+ /* — message + tool DOM construction — */
345
+ function addUserMsg(text: string, atts: { kind: string; data?: string; mimeType?: string; name: string }[] | null, opts: { isLast?: boolean; entryId?: string }): void {
346
+ const m = el("div", "msg user", thread());
347
+ el("div", "who", m).textContent = "you";
348
+ const bubble = el("div", "bubble", m);
349
+ if (atts && atts.length) {
350
+ const wrap = el("div", "msg-atts", bubble);
351
+ for (const a of atts) {
352
+ if (a.kind === "image") {
353
+ const im = el("img", "msg-att-img", wrap);
354
+ im.src = "data:" + a.mimeType + ";base64," + a.data; im.alt = a.name;
355
+ } else {
356
+ el("span", "msg-att-file", wrap).textContent = "📎 " + a.name;
357
+ }
358
+ }
359
+ }
360
+ if (text) el("div", "msg-text", bubble).textContent = text;
361
+ if (opts.isLast) {
362
+ lastUserMsgEl = m;
363
+ lastUserEntryId = opts.entryId || null;
364
+ addMsgActions(m, text, { edit: true }, { onEdit: startEditLast });
365
+ renderSiblingToggle(lastUserMsgEl, lastUserEntryId, siblingInfo, switchSiblingDir);
366
+ }
367
+ stick(true);
368
+ }
369
+ function startAssistant(): void {
370
+ $("chat-hello").style.display = "none";
371
+ const m = el("div", "msg assistant", thread());
372
+ el("div", "who", m).textContent = "local agent";
373
+ const bubble = el("div", "bubble", m);
374
+ const thinkBox = el("details", "thinkbox", bubble);
375
+ thinkBox.open = false; // collapsed by default; the reasoning stays tucked behind "Thinking" — click to expand
376
+ el("summary", "", thinkBox).textContent = "Thinking";
377
+ const thinkBody = el("div", "thinkbody", thinkBox);
378
+ thinkBox.style.display = "none";
379
+ const textNode = el("span", "atext", bubble);
380
+ const cursor = el("span", "cursor", bubble);
381
+ const meta = el("div", "meta", m);
382
+ curAssistant = {
383
+ m, bubble, thinkBox, thinkBody, textNode, cursor, meta, tools: new Map(),
384
+ text: "", thinking: "", t0: performance.now(), tFirst: 0, tokens: 0, settled: false,
385
+ // Block-memoization state (see renderBlocksIncremental) for the main
386
+ // markdown text stream, plus a shared rAF scheduler so a burst of
387
+ // deltas within one frame (e.g. spec-decode emitting several tokens
388
+ // back-to-back) collapses into a single re-render instead of one per
389
+ // token. The thinking channel is plain text (no markdown — see
390
+ // appendThinkingDelta), so it only needs the textContent set
391
+ // throttled, not block splitting; it gets its own scheduler since
392
+ // both channels can stream concurrently (interleaved thinking/text).
393
+ blockState: { blocks: [] },
394
+ scheduleText: () => {},
395
+ scheduleThinking: () => {},
396
+ citations: pendingCitations,
397
+ };
398
+ // Consumed — a later turn with no RAG'd attachments must not inherit
399
+ // this turn's citation set (see the pendingCitations declaration above).
400
+ pendingCitations = [];
401
+ const assistant = curAssistant;
402
+ assistant.scheduleText = makeFrameScheduler(
403
+ () => {
404
+ if (!assistant.settled) renderBlocksIncremental(assistant.textNode, assistant.text, assistant.blockState);
405
+ },
406
+ atBottom, stick);
407
+ assistant.scheduleThinking = makeFrameScheduler(
408
+ () => {
409
+ if (!assistant.settled) assistant.thinkBody.textContent = assistant.thinking;
410
+ },
411
+ atBottom, stick);
412
+ stick(true);
413
+ }
414
+ function ensureAssistant(): void { if (!curAssistant) startAssistant(); }
415
+
416
+ function appendDelta(delta: string): void {
417
+ ensureAssistant();
418
+ const a = curAssistant!;
419
+ if (!a.tFirst) a.tFirst = performance.now();
420
+ a.tokens++;
421
+ a.text += delta;
422
+ // Block-memoized + rAF-throttled render (see renderBlocksIncremental):
423
+ // only the live tail block re-parses per delta; completed blocks are
424
+ // never touched again. A final full mdToHtml pass runs at turn-end
425
+ // (finishStreaming) to guarantee the streamed result is byte-identical
426
+ // to a non-streaming render. Autoscroll is handled INSIDE the scheduler
427
+ // (its shouldStick/doStick args) — not here — because the render itself
428
+ // is rAF-deferred: checking atBottom() synchronously at this point would
429
+ // measure scrollHeight from before the queued render grows it, which
430
+ // desyncs autoscroll once several deltas land within one frame.
431
+ a.scheduleText();
432
+ updateTps();
433
+ }
434
+ function appendThinkingDelta(delta: string): void {
435
+ ensureAssistant();
436
+ const a = curAssistant!;
437
+ if (!a.tFirst) a.tFirst = performance.now();
438
+ a.tokens++;
439
+ a.thinking += delta;
440
+ a.thinkBox.style.display = a.thinking.trim() ? "" : "none";
441
+ // Plain text (no markdown here), but still rAF-throttled: a long
442
+ // thinking trace can otherwise do one textContent-set per token.
443
+ // Autoscroll: see the comment in appendDelta above — handled inside the
444
+ // scheduler, not with a synchronous atBottom() check here.
445
+ a.scheduleThinking();
446
+ updateTps();
447
+ }
448
+
449
+ /* tool card lifecycle — dispatches to the memory provenance chip
450
+ * for memory_ and reference_ tool calls, the generic wrench card
451
+ * for everything else. Both kinds live in the same a.tools map (see
452
+ * ToolCardState above). */
453
+ function toolCard(callId: string, tool: string, args: unknown): ToolCardState {
454
+ ensureAssistant();
455
+ const a = curAssistant!;
456
+ let t = a.tools.get(callId);
457
+ if (t) return t;
458
+ const custom = port.host?.toolCard?.(a.bubble, tool, args);
459
+ if (custom) {
460
+ t = { kind: "memchip", wrap: custom.wrap, handle: custom, chunks: "" };
461
+ a.tools.set(callId, t);
462
+ stick();
463
+ return t;
464
+ }
465
+ const wrap = el("div", "tool running open", a.bubble);
466
+ wrap.innerHTML =
467
+ '<div class="thead"><div class="ticon">⚙</div>' +
468
+ '<span class="tname">' + esc(tool || "tool") + '</span>' +
469
+ '<span class="targs">' + esc(argSummary(args)) + '</span>' +
470
+ '<span class="tstat"><span class="sdot"></span><span class="slabel">running</span></span>' +
471
+ '<span class="caret">›</span></div>' +
472
+ '<div class="tbody">' +
473
+ '<div class="blk"><div class="blbl">arguments</div><pre class="aargs">' + esc(prettyArgs(args)) + '</pre></div>' +
474
+ '<div class="blk tresult" style="display:none"><div class="blbl">result</div><pre class="ares"></pre></div>' +
475
+ '</div>';
476
+ wrap.querySelector(".thead")!.addEventListener("click", () => wrap.classList.toggle("open"));
477
+ t = {
478
+ kind: "wrench", wrap,
479
+ res: wrap.querySelector(".ares") as HTMLElement,
480
+ resBlk: wrap.querySelector(".tresult") as HTMLElement,
481
+ label: wrap.querySelector(".slabel") as HTMLElement,
482
+ chunks: "",
483
+ };
484
+ a.tools.set(callId, t);
485
+ stick();
486
+ return t;
487
+ }
488
+ function toolUpdate(callId: string, chunk: string): void {
489
+ const t = curAssistant && curAssistant.tools.get(callId);
490
+ if (!t) return;
491
+ t.chunks += chunk;
492
+ if (t.kind === "memchip") { t.handle.setResult(t.chunks); if (atBottom()) stick(); return; }
493
+ t.resBlk.style.display = "";
494
+ t.res.textContent = t.chunks.slice(-8000);
495
+ if (atBottom()) stick();
496
+ }
497
+ function toolEnd(callId: string, ok: boolean, result: unknown): void {
498
+ const t = curAssistant && curAssistant.tools.get(callId);
499
+ if (!t) return;
500
+ if (t.kind === "memchip") {
501
+ t.wrap.classList.toggle("fail", !ok);
502
+ t.handle.setResult(result != null ? result : t.chunks);
503
+ return;
504
+ }
505
+ t.wrap.classList.remove("running");
506
+ t.wrap.classList.add(ok ? "ok" : "fail");
507
+ t.label.textContent = ok ? "done" : "failed";
508
+ const body = result != null ? (typeof result === "string" ? result : JSON.stringify(result, null, 2)) : t.chunks;
509
+ if (body) { t.resBlk.style.display = ""; t.res.textContent = String(body).slice(-12000); }
510
+ if (!ok) t.wrap.classList.add("open");
511
+ }
512
+
513
+ /* approval dialog (editable arguments + durable "always
514
+ * allow this tool", on top of the existing running/ok/fail visual
515
+ * language). The diff view (edit/write) stays as a read-only visual aid
516
+ * ABOVE the editable textarea — losing the diff would make large
517
+ * file-content proposals unreadable as raw JSON — but the textarea,
518
+ * pre-filled with the proposed args as JSON, is what actually gets sent:
519
+ * editing it and clicking Allow re-parses it and ships the edited object
520
+ * as `editedArgs`, which packages/module-chat/src/pi-backend.ts mutates onto the SDK's ToolCallEvent.input
521
+ * in place (the SDK's documented tool_call mutation contract). */
522
+ function approvalCard(callId: string, tool: string, args: unknown): void {
523
+ ensureAssistant();
524
+ const a = curAssistant!;
525
+ const wrap = el("div", "approval", a.bubble);
526
+ const isBash = /bash|shell|exec|run/i.test(tool || "");
527
+ const isEdit = /edit|write|patch|create/i.test(tool || "");
528
+ const diffHtml = isEdit ? '<pre class="a-diff">' + diffView(args) + '</pre>' : "";
529
+ const argsJson = prettyArgs(args);
530
+ wrap.innerHTML =
531
+ '<div class="ahead"><div class="ai">⚠</div><div class="at">Approval required</div>' +
532
+ '<div class="as">' + esc(tool || "tool") + '</div></div>' +
533
+ '<div class="abody">' + diffHtml +
534
+ '<div class="blbl" style="margin-bottom:7px">' +
535
+ (isBash ? "command (edit before approving if you like)" : isEdit ? "proposed change — arguments below, editable" : "arguments — editable before approving") +
536
+ '</div>' +
537
+ '<textarea class="a-args" spellcheck="false">' + esc(argsJson) + '</textarea>' +
538
+ '<div class="a-argerr" style="display:none"></div>' +
539
+ '</div>' +
540
+ '<label class="chk a-always"><input type="checkbox" class="a-always-cb">Always allow ' + esc(tool || "this tool") + ' (skip this card next time)</label>' +
541
+ '<div class="actions">' +
542
+ '<button class="btn ghost sm a-deny">Deny</button>' +
543
+ '<button class="btn primary sm a-allow">Allow</button></div>';
544
+ const textarea = wrap.querySelector(".a-args") as HTMLTextAreaElement;
545
+ const errEl = wrap.querySelector(".a-argerr") as HTMLElement;
546
+ const alwaysCb = wrap.querySelector(".a-always-cb") as HTMLInputElement;
547
+ const resolve = (decision: "allow" | "deny") => {
548
+ let editedArgs: Record<string, unknown> | undefined;
549
+ if (decision === "allow") {
550
+ const raw = textarea.value.trim();
551
+ if (raw && raw !== argsJson.trim()) {
552
+ try {
553
+ const parsed = JSON.parse(raw);
554
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
555
+ editedArgs = parsed as Record<string, unknown>;
556
+ } else {
557
+ errEl.textContent = "Arguments must be a JSON object — sending the original instead.";
558
+ errEl.style.display = "";
559
+ }
560
+ } catch {
561
+ errEl.textContent = "Could not parse the edited arguments as JSON — sending the original instead.";
562
+ errEl.style.display = "";
563
+ }
564
+ }
565
+ }
566
+ send({ type: "approval", callId, decision, editedArgs, alwaysAllow: decision === "allow" && alwaysCb.checked });
567
+ wrap.querySelector(".actions")!.remove();
568
+ wrap.querySelector(".a-always")?.remove();
569
+ const r = el("div", "resolved " + (decision === "allow" ? "allow" : "deny"), wrap);
570
+ r.textContent = decision === "allow" ? (editedArgs ? "✓ allowed (edited)" : "✓ allowed") : "✕ denied";
571
+ };
572
+ (wrap.querySelector(".a-allow") as HTMLButtonElement).onclick = () => resolve("allow");
573
+ (wrap.querySelector(".a-deny") as HTMLButtonElement).onclick = () => resolve("deny");
574
+ stick(true);
575
+ }
576
+
577
+ function showAgentError(message: string): void {
578
+ const m = el("div", "msg assistant", thread());
579
+ el("div", "who", m).textContent = "local agent";
580
+ const bubble = el("div", "bubble", m);
581
+ bubble.innerHTML = '<strong style="color:var(--red)">Agent error</strong><br>' + esc(message || "unknown error");
582
+ stick(true);
583
+ }
584
+
585
+ /** Chat-with-files RAG v1 (`02d723a:docs/design/web-chat-redesign.md` Appendix A,
586
+ * beat-matrix Axis 5): append a
587
+ * collapsed "Sources · K" panel under the bubble and linkify any [n]
588
+ * markers the model actually used in its reply. Only ever called with a
589
+ * non-empty citation list (finishStreaming guards this) — an ordinary
590
+ * reply with no attachments gets neither the panel nor any [n]
591
+ * rewriting, so a message that happens to contain literal "[1]" text
592
+ * never gets a false citation link. */
593
+ function attachSourcesPanel(a: AssistantMsgState): void {
594
+ const validNs = new Set(a.citations.map((c) => c.n));
595
+ a.textNode.innerHTML = linkifyCitations(a.textNode.innerHTML, validNs);
596
+ const panel = el("div", "", null);
597
+ panel.innerHTML = renderSourcesHtml(a.citations);
598
+ const detailsEl = panel.firstElementChild as HTMLElement | null;
599
+ if (!detailsEl) return;
600
+ a.bubble.appendChild(detailsEl);
601
+ // Clicking a [n] marker in the rendered text expands the panel (if
602
+ // collapsed) and scrolls/highlights the matching source row.
603
+ a.textNode.querySelectorAll<HTMLElement>(".cite-mark").forEach((btn) => {
604
+ btn.addEventListener("click", () => {
605
+ const n = btn.dataset.cite;
606
+ (detailsEl as HTMLDetailsElement).open = true;
607
+ const row = detailsEl.querySelector<HTMLElement>('.src-row[data-cite-row="' + n + '"]');
608
+ if (row) {
609
+ row.scrollIntoView({ block: "nearest", behavior: "smooth" });
610
+ row.classList.add("pulse");
611
+ setTimeout(() => row.classList.remove("pulse"), 1000);
612
+ }
613
+ });
614
+ });
615
+ }
616
+
617
+ /** Turn-end finalization for the streamed text channel: replace the
618
+ * block-memoized incremental render with ONE full non-memoized
619
+ * mdToHtml(fullText) pass. This is the acceptance test for block
620
+ * memoization — whatever the incremental path did while streaming,
621
+ * the final DOM must match exactly what a plain, non-streaming
622
+ * render of the same text would have produced. Cheap (happens once
623
+ * per turn, not per token) and removes any residual "done" block
624
+ * wrappers so the settled message's markup matches replayed history
625
+ * (renderAssistantStatic / renderHistory), which also call mdToHtml
626
+ * directly on the full string. */
627
+ function finishStreaming(a: AssistantMsgState | null): void {
628
+ if (!a) return;
629
+ a.settled = true;
630
+ // Preserve any Canvas Preview toggle the user made mid-stream (Principle
631
+ // 9: this full non-memoized re-render must not throw away user
632
+ // interaction state) — mdCodeBlock always emits qualifying fences back
633
+ // in their default Source view, so without this a live Preview click
634
+ // silently reverts the instant the turn finalizes.
635
+ const previewIdx = captureCanvasViewStates(a.textNode);
636
+ a.textNode.innerHTML = mdToHtml(a.text);
637
+ restoreCanvasViewStates(a.textNode, previewIdx);
638
+ highlightIn(a.textNode);
639
+ a.thinkBody.textContent = a.thinking;
640
+ if (a.citations.length) attachSourcesPanel(a);
641
+ }
642
+
643
+ /** Demote the previously-"last" assistant message: only ONE message ever
644
+ * shows Regenerate (the last assistant turn). */
645
+ function clearRegenerateAffordance(): void {
646
+ if (!lastAssistantMsgEl) return;
647
+ const row = lastAssistantMsgEl.querySelector(".msg-actions");
648
+ const btn = row && [...row.querySelectorAll(".maction")].find((b) => b.textContent === "Regenerate");
649
+ if (btn) btn.remove();
650
+ }
651
+
652
+ function endTurn(): void {
653
+ turnActive = false;
654
+ if (curAssistant) {
655
+ finishStreaming(curAssistant);
656
+ curAssistant.cursor.remove();
657
+ if (!curAssistant.text && !curAssistant.thinking && curAssistant.tools.size === 0) {
658
+ curAssistant.m.remove();
659
+ } else {
660
+ // A settled assistant reply becomes the new regenerate target.
661
+ // Works after abort/stop too — a partial reply still gets the
662
+ // action row, and the server resolves regenerate from the session's
663
+ // last user message regardless of whether this reply finished.
664
+ clearRegenerateAffordance();
665
+ addMsgActions(curAssistant.m, curAssistant.text, { regenerate: true }, { onRegenerate: doRegenerate });
666
+ lastAssistantMsgEl = curAssistant.m;
667
+ lastAssistantCitations = curAssistant.citations;
668
+ }
669
+ }
670
+ curAssistant = null;
671
+ ($("chat-send") as HTMLButtonElement).disabled = false;
672
+ $("chat-stop").style.display = "none";
673
+ renderQueue({});
674
+ }
675
+
676
+ // Perf strip: tok/s + TTFT update live during streaming;
677
+ // context-fill is driven separately by the `context` frame (renderContext).
678
+ // Computed from data the composer already has.
679
+ function updateTps(): void {
680
+ const a = curAssistant; if (!a) return;
681
+ const secs = (performance.now() - (a.tFirst || a.t0)) / 1000;
682
+ if (a.tokens > 1 && secs > 0) {
683
+ const tps = $("pf-tps"); if (tps) tps.innerHTML = "<b style='color:var(--green)'>" + (a.tokens / secs).toFixed(1) + " tok/s</b>";
684
+ }
685
+ if (a.tFirst) {
686
+ const ttft = $("pf-ttft"); if (ttft) ttft.textContent = "TTFT " + Math.round(a.tFirst - a.t0) + "ms";
687
+ }
688
+ }
689
+ function finalizeMeta(): void {
690
+ const a = curAssistant; if (!a) return;
691
+ const secs = (performance.now() - (a.tFirst || a.t0)) / 1000;
692
+ if (a.tokens > 1 && secs > 0)
693
+ a.meta.innerHTML = "<b>" + (a.tokens / secs).toFixed(1) + " tok/s</b> · " + a.tokens +
694
+ " tokens · first token " + Math.round((a.tFirst - a.t0)) + "ms";
695
+ }
696
+
697
+ function newChat(): void {
698
+ if (!send({ type: "new_session" })) { toast("Not connected to the agent yet.", "err"); return; }
699
+ $("chat-box").focus();
700
+ port.closeDrawer(); // a fresh chat is the natural "I'm done browsing" signal on mobile
701
+ }
702
+
703
+ /** Render a finished assistant turn from replayed history (no streaming).
704
+ * `opts.isLast` attaches the Regenerate action (last turn only). */
705
+ function renderAssistantStatic(item: HistoryItem, opts: { isLast?: boolean }): void {
706
+ const m = el("div", "msg assistant", thread());
707
+ el("div", "who", m).textContent = "local agent";
708
+ const bubble = el("div", "bubble", m);
709
+ if (item.thinking && item.thinking.trim()) {
710
+ const tb = el("details", "thinkbox", bubble); tb.open = false;
711
+ el("summary", "", tb).textContent = "Thinking";
712
+ el("div", "thinkbody", tb).textContent = item.thinking;
713
+ }
714
+ if (item.text) { const tn = el("span", "atext", bubble); tn.innerHTML = mdToHtml(item.text); highlightIn(tn); }
715
+ for (const t of (item.tools || [])) {
716
+ const custom = port.host?.toolCard?.(bubble, t.name, t.args);
717
+ if (custom) {
718
+ if (t.result) custom.setResult(t.result);
719
+ continue;
720
+ }
721
+ const wrap = el("div", "tool ok", bubble); // collapsed; click to expand
722
+ wrap.innerHTML =
723
+ '<div class="thead"><div class="ticon">⚙</div>' +
724
+ '<span class="tname">' + esc(t.name || "tool") + '</span>' +
725
+ '<span class="targs">' + esc(argSummary(t.args)) + '</span>' +
726
+ '<span class="tstat"><span class="sdot"></span><span class="slabel">done</span></span>' +
727
+ '<span class="caret">›</span></div>' +
728
+ '<div class="tbody">' +
729
+ '<div class="blk"><div class="blbl">arguments</div><pre class="aargs">' + esc(prettyArgs(t.args)) + '</pre></div>' +
730
+ (t.result ? '<div class="blk tresult"><div class="blbl">result</div><pre class="ares">' + esc(String(t.result).slice(-12000)) + '</pre></div>' : '') +
731
+ '</div>';
732
+ wrap.querySelector(".thead")!.addEventListener("click", () => wrap.classList.toggle("open"));
733
+ }
734
+ if (item.text) {
735
+ addMsgActions(m, item.text, { regenerate: !!opts.isLast }, { onRegenerate: doRegenerate });
736
+ if (opts.isLast) {
737
+ lastAssistantMsgEl = m;
738
+ // History replay doesn't carry citations (HistoryItem has no
739
+ // citations field — the server doesn't persist them across
740
+ // reload), so there's nothing to restore here; this keeps
741
+ // lastAssistantCitations honest (no stale carry-over from a
742
+ // previous session) rather than claiming a citation set for a
743
+ // reply that regenerate can't actually attribute one to.
744
+ lastAssistantCitations = [];
745
+ }
746
+ }
747
+ }
748
+
749
+ /** Authoritative thread rebuild from a server `history` frame. */
750
+ function renderHistory(items: HistoryItem[]): void {
751
+ turnActive = false;
752
+ curAssistant = null;
753
+ ($("chat-send") as HTMLButtonElement).disabled = false;
754
+ $("chat-stop").style.display = "none";
755
+ renderQueue({});
756
+ thread().innerHTML = "";
757
+ lastUserMsgEl = null; lastAssistantMsgEl = null;
758
+ if (!items || !items.length) { $("chat-hello").style.display = ""; return; }
759
+ $("chat-hello").style.display = "none";
760
+ const lastUserIdx = items.map((it) => it.role).lastIndexOf("user");
761
+ const lastAssistantIdx = items.map((it) => it.role).lastIndexOf("assistant");
762
+ items.forEach((it, i) => {
763
+ if (it.role === "user") addUserMsg(it.text, null, { isLast: i === lastUserIdx, entryId: it.entryId });
764
+ else renderAssistantStatic(it, { isLast: i === lastAssistantIdx });
765
+ });
766
+ stick(true);
767
+ }
768
+
769
+ /* — server -> client dispatch — */
770
+ function handle(m: ServerMessage): void {
771
+ switch (m.type) {
772
+ case "ready":
773
+ $("nav-model").textContent = m.model || ($("nav-model").textContent as string) || "no model";
774
+ composer.visionCapable = !!m.vision; composer.transcriptionCapable = !!m.transcription; updateAttachHint(composer);
775
+ composer.thinkingCapable = !!m.thinking; updateThinkingToggle(composer);
776
+ // Per-model recommended sampling (generation_config.json, server-CLI
777
+ // overrides applied) — replaces the old hardcoded SAMP_REC shape.
778
+ composer.genDefaults = m.genDefaults || ({ temperature: null, topP: null, topK: null } as ReadyGenDefaults);
779
+ refreshSamplingRecs(composer);
780
+ if (composer.thinkingCapable) send({ type: "set_thinking", enabled: composer.thinkingOn });
781
+ composerRefreshAdapters();
782
+ // Restore this browser's codingTools preference.
783
+ // Sent every `ready` (including reconnects): a fresh WS session
784
+ // always starts codingToolsRequested=false server-side, so a
785
+ // previously-on browser must re-assert it, and the coding_tools
786
+ // frame that follows will report whether it's active yet or only
787
+ // pending for the next new/opened chat.
788
+ send({ type: "set_coding_tools", enabled: port.host?.settings?.codingToolsPreference() ?? false });
789
+ // App-aware assistant: a fresh WS session starts with no
790
+ // stored context server-side (currentAppContext is null until the
791
+ // first push) — every `ready` (initial connect AND reconnect) must
792
+ // re-push so get_current_app_context / the ambient line are never
793
+ // blind while a browser tab sits idle on a non-chat route.
794
+ pushAppContext();
795
+ // Reconnect: re-open the session we were in (fresh load has none).
796
+ if (pendingResumePath) { send({ type: "open_session", path: pendingResumePath }); pendingResumePath = null; }
797
+ break;
798
+ case "coding_tools": port.host?.settings?.codingTools(m.active, m.pending); break;
799
+ case "tool_approvals":
800
+ port.host?.settings?.approvals(m.alwaysAllow, (tool) => forgetToolApproval(tool));
801
+ break;
802
+ case "history": renderHistory(m.items); break;
803
+ case "sessions":
804
+ currentSessionPath = m.activePath || null;
805
+ renderSessions(sidebar, m.items, m.activePath, {
806
+ onOpen: (path) => { send({ type: "open_session", path }); port.closeDrawer(); },
807
+ onFork: (path) => send({ type: "fork_session", path }),
808
+ onDelete: (path) => send({ type: "delete_session", path }),
809
+ });
810
+ break;
811
+ case "context": renderContext(m as ContextFrame); break;
812
+ // Sibling group for the last user message (edit-and-resend's `< i/n >`
813
+ // toggle) — see renderSiblingToggle/switchSiblingDir.
814
+ case "siblings":
815
+ siblingInfo = { entryId: m.entryId || null, index: m.index, count: m.count, siblingIds: m.siblingIds || [] };
816
+ lastUserEntryId = siblingInfo.entryId;
817
+ renderSiblingToggle(lastUserMsgEl, lastUserEntryId, siblingInfo, switchSiblingDir);
818
+ break;
819
+ case "turn_start": turnActive = true; startAssistant(); ($("chat-send") as HTMLButtonElement).disabled = true; $("chat-stop").style.display = "block"; break;
820
+ case "text_delta": appendDelta(m.delta || ""); break;
821
+ case "thinking_delta": appendThinkingDelta(m.delta || ""); break;
822
+ case "tool_start": toolCard(m.callId, m.tool, m.args); break;
823
+ case "tool_approval_request": approvalCard(m.callId, m.tool, m.args); break;
824
+ case "tool_update": toolUpdate(m.callId, (m.chunk as string) || ""); break;
825
+ case "tool_end": toolEnd(m.callId, m.ok, m.result); break;
826
+ // clearOneShotArmed: a one-shot next_turn sampling override (if any
827
+ // was armed) has now been consumed by the server for exactly this
828
+ // turn — clear the local indicator in lockstep.
829
+ case "turn_end": finalizeMeta(); clearOneShotArmed(composer); endTurn(); break;
830
+ case "queue_update": renderQueue(m); break;
831
+ // A prompt/regenerate/edit_resend that fails before the server ever
832
+ // reaches turn_start (no model selected, auth failure, etc.) never
833
+ // sends turn_end, so this is also where a one-shot next_turn sampling
834
+ // override must be cleared client-side — the server clears its own
835
+ // copy on this same failure path (clearArmedOneShotOnFailedPrompt in
836
+ // packages/module-chat/src/pi-backend.ts), so the two stay in lockstep instead of the UI's "armed"
837
+ // pill lingering after the override it referred to is already gone.
838
+ case "error": showAgentError(m.message || "agent error"); toast(m.message || "agent error", "err"); clearOneShotArmed(composer); if (turnActive) endTurn(); break;
839
+ // App-aware assistant: navigate_app / spotlight_ui tool
840
+ // calls arrive as these two frames. Navigation always routes through
841
+ // location.hash (the SAME mechanism a nav-tab click uses — shell.ts's
842
+ // hashchange listener does the actual section toggle), so it's
843
+ // indistinguishable from the user clicking the tab themselves.
844
+ case "ui_navigate":
845
+ location.hash = `#/${m.route}`;
846
+ break;
847
+ // The shell mounts non-workspace panels on hashchange. Resolve after
848
+ // that navigation has attached the requested page's controls.
849
+ case "ui_spotlight": {
850
+ const point = () => {
851
+ const resolved = resolveSpotlightTarget(
852
+ { ref: m.ref, label: m.label, selector: m.selector, target: m.target, message: m.message },
853
+ lastSnapshot,
854
+ );
855
+ if (!resolved || !showSpotlight(resolved)) toast("Couldn't find that on screen to point at.", "err");
856
+ };
857
+ if (m.route) {
858
+ const hash = `#/${m.route}`;
859
+ const afterNavigation = () => requestAnimationFrame(point);
860
+ // A preceding ui_navigate may already have set this hash while
861
+ // its navigation event is still queued. The next task follows it;
862
+ // a changed hash instead waits for that navigation event directly.
863
+ if (location.hash === hash) setTimeout(afterNavigation, 0);
864
+ else {
865
+ window.addEventListener("hashchange", afterNavigation, {once:true});
866
+ location.hash = hash;
867
+ }
868
+ } else point();
869
+ break;
870
+ }
871
+ }
872
+ }
873
+
874
+ /* — composer submit — */
875
+ function submit(): void {
876
+ const box = $("chat-box") as HTMLTextAreaElement;
877
+ const text = box.value.trim();
878
+ // Client-side slash commands for session control (not sent to the model).
879
+ const cmd = text.toLowerCase();
880
+ if (cmd === "/reload" || cmd === "/new" || cmd === "/clear") {
881
+ box.value = ""; box.style.height = "auto"; clearAttachments(composer); newChat();
882
+ return;
883
+ }
884
+ if (!text && composer.attachments.length === 0) return;
885
+ if (!connected) { toast("Not connected to the agent yet — reconnecting.", "err"); return; }
886
+ const imgs = composer.attachments.filter((a) => a.kind === "image").map((a) => ({ data: a.data as string, mimeType: a.mimeType as string }));
887
+ const combined = buildMessageText(composer, text);
888
+ // entryId isn't known yet (assigned server-side); the next `siblings`
889
+ // frame (sent on turn_end) backfills it via renderSiblingToggle's
890
+ // entryId-mismatch check, same as a fresh page load's history replay.
891
+ addUserMsg(text, composer.attachments as unknown as { kind: string; data?: string; mimeType?: string; name: string }[], { isLast: true });
892
+ box.value = ""; box.style.height = "auto";
893
+ // buildMessageText returns { text, citations } (RAG v1,
894
+ // `02d723a:docs/design/web-chat-redesign.md` Appendix A, beat-matrix Axis 5):
895
+ // .text is the WS-outgoing prompt string (with the numbered
896
+ // context block injected when retrieval mode fired); .citations stash
897
+ // into pendingCitations so startAssistant() can attach them to the
898
+ // AssistantMsgState this prompt's reply streams into once turn_start
899
+ // arrives (empty array — the common case — is a no-op all the way
900
+ // through: no Sources panel, no [n] linkification).
901
+ pendingCitations = combined.citations;
902
+ const frame: ClientMessage = imgs.length ? { type: "prompt", text: combined.text, images: imgs } : { type: "prompt", text: combined.text };
903
+ // A composer submission is always a new user turn. If the model is still
904
+ // streaming, the server passes it to pi with streamingBehavior:"followUp"
905
+ // so it becomes the next turn instead of steering/mutating the current one.
906
+ send(frame);
907
+ clearAttachments(composer);
908
+ }
909
+ const scroll = () => $("chat-scroll");
910
+ const atBottom = () => scroll().scrollHeight - scroll().scrollTop - scroll().clientHeight < 90;
911
+ function stick(force?: boolean): void { if (force || atBottom()) scroll().scrollTop = scroll().scrollHeight; }
912
+
913
+ /** Cmd/Ctrl+Shift+C: reuses the exact per-message Copy button
914
+ * (addMsgActions) on the last assistant message rather than duplicating
915
+ * its clipboard logic — a synthetic click keeps the "Copied" flash
916
+ * feedback consistent with the mouse path. */
917
+ function copyLastResponse(): void {
918
+ if (!lastAssistantMsgEl) { toast("No response to copy yet.", "err"); return; }
919
+ const btn = lastAssistantMsgEl.querySelector(".msg-actions .maction") as HTMLButtonElement | null;
920
+ if (btn) btn.click();
921
+ }
922
+
923
+ /** Cross-controller call from shell.ts's settings toggle
924
+ * — shell.ts owns the checkbox + localStorage mirror but no WebSocket, so
925
+ * it calls this via controllers.chat.setCodingTools, same pattern as
926
+ * newChat/copyLastResponse below. */
927
+ function setCodingTools(enabled: boolean): void {
928
+ send({ type: "set_coding_tools", enabled });
929
+ }
930
+
931
+ /** "Forget" a durable always-allow entry from the settings panel. REST,
932
+ * not a WS frame — packages/module-chat/src/tool-approvals.ts is a plain server-side file, no
933
+ * live session state to touch, matching the hf-token settings routes'
934
+ * own GET/POST-over-fetch pattern. Re-renders the list from the response
935
+ * rather than waiting on a tool_approvals WS frame (this connection's
936
+ * session may not be the one that granted it, and REST is instant). */
937
+ async function forgetToolApproval(tool: string): Promise<void> {
938
+ const d = await api("/api/settings/tool-approvals", { method: "DELETE", body: { tool } })
939
+ .catch((): ApiEnvelope => ({ ok: false, error: "request failed" }));
940
+ if (!d.ok) { toast(d.error || "could not forget that tool", "err"); return; }
941
+ port.host?.settings?.approvals((d as { alwaysAllow?: string[] }).alwaysAllow || [], (t) => forgetToolApproval(t));
942
+ toast("Forgot “" + tool + "” — it will ask again next time", "ok");
943
+ }
944
+
945
+ return {
946
+ init() {
947
+ ($("chat-send") as HTMLButtonElement).onclick = submit;
948
+ ($("chat-new") as HTMLButtonElement).onclick = newChat;
949
+ ($("chat-stop") as HTMLButtonElement).onclick = () => { send({ type: "abort" }); $("chat-stop").style.display = "none"; };
950
+ const thinkToggle = () => { composer.thinkingOn = !composer.thinkingOn; updateThinkingToggle(composer); send({ type: "set_thinking", enabled: composer.thinkingOn }); };
951
+ ($("chat-think") as HTMLButtonElement).onclick = thinkToggle;
952
+ $("chat-think").addEventListener("keydown", (e) => { if ((e as KeyboardEvent).key === "Enter" || (e as KeyboardEvent).key === " ") { e.preventDefault(); thinkToggle(); } });
953
+ const adapterSel = $("chat-adapter") as HTMLSelectElement | null;
954
+ if (adapterSel) adapterSel.onchange = () => {
955
+ // Keep the routing table's notion of "selected for this chat" in
956
+ // sync with the quick <select> — one source of truth for what's
957
+ // selected, read by both surfaces.
958
+ adaptersPanel.selectedSpec = adapterSel.value || null;
959
+ adaptersPanel.stackPicks.clear();
960
+ onSelectAdapter(adapterSel, send as (obj: unknown) => boolean);
961
+ };
962
+ initAdaptersPanel(adaptersPanel, send as (obj: unknown) => boolean);
963
+ initSampling(composer, send as (obj: unknown) => boolean);
964
+ initSystemPrompt(composer, send as (obj: unknown) => boolean);
965
+ composerRefreshAdapters();
966
+ const sessSearch = $("chat-sess-search");
967
+ if (sessSearch) sessSearch.addEventListener("input", () => applySessSearch(sidebar));
968
+ const box = $("chat-box") as HTMLTextAreaElement;
969
+ // Mention-picker keydown (arrows/Enter/Escape) must see Enter BEFORE
970
+ // the submit-on-Enter handler below — addEventListener preserves
971
+ // registration order, and initMentionPicker's handler only
972
+ // preventDefault()s when its popover is actually open, so a plain
973
+ // Enter with no picker open falls through to submit() untouched.
974
+ initMentionPicker(composer, box, {
975
+ onFileSelected: (att) => {
976
+ const chip = document.querySelector<HTMLElement>('.attach-chip[data-att-id="' + att.id + '"]');
977
+ if (chip) { chip.scrollIntoView({ block: "nearest", behavior: "smooth" }); chip.classList.add("pulse"); setTimeout(() => chip.classList.remove("pulse"), 1000); }
978
+ },
979
+ });
980
+ box.addEventListener("keydown", (e) => {
981
+ if ($("chat-mention-pop").classList.contains("open")) return; // picker owns Enter/arrows/Escape while open
982
+ if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); }
983
+ });
984
+ box.addEventListener("input", () => { box.style.height = "auto"; box.style.height = Math.min(box.scrollHeight, 200) + "px"; });
985
+ // Delegated (not per-chip) so the personalized chips memory-panel.ts
986
+ // inserts on top of these four static ones work with zero
987
+ // extra wiring — #chat-hello-chips is stable even though its contents
988
+ // change (static chips today, +2 personalized ones once the first
989
+ // /api/memory/status + /v1/adapters/available round trip resolves).
990
+ $("chat-hello-chips").addEventListener("click", (e) => {
991
+ const chip = (e.target as HTMLElement).closest<HTMLElement>(".chip");
992
+ if (!chip) return;
993
+ ($("chat-box") as HTMLTextAreaElement).value = chip.dataset.q || "";
994
+ submit();
995
+ });
996
+ port.host?.mounted?.(port.element);
997
+ // Attachments: + button, file picker, clipboard paste, drag-and-drop.
998
+ ($("chat-attach-btn") as HTMLButtonElement).onclick = () => ($("chat-file-input") as HTMLInputElement).click();
999
+ // Hold-to-talk mic → /v1/audio/sessions → text into the composer (voice.ts).
1000
+ initVoiceInput($("chat-mic-btn") as HTMLButtonElement, box as HTMLTextAreaElement);
1001
+ $("chat-file-input").addEventListener("change", (e) => { addFiles(composer, [...(e.target as HTMLInputElement).files || []]); (e.target as HTMLInputElement).value = ""; });
1002
+ box.addEventListener("paste", (e) => {
1003
+ const items = e.clipboardData && e.clipboardData.items;
1004
+ if (!items) return;
1005
+ const files: File[] = [];
1006
+ for (const it of items) if (it.kind === "file") { const f = it.getAsFile(); if (f) files.push(f); }
1007
+ if (files.length) { e.preventDefault(); addFiles(composer, files); }
1008
+ });
1009
+ const composerEl = document.querySelector<HTMLElement>("#s-chat .composer");
1010
+ if (composerEl) {
1011
+ ["dragenter", "dragover"].forEach((ev) => composerEl.addEventListener(ev, (e) => { e.preventDefault(); composerEl.classList.add("dragover"); }));
1012
+ ["dragleave", "drop"].forEach((ev) => composerEl.addEventListener(ev, (e) => {
1013
+ e.preventDefault();
1014
+ const de = e as DragEvent;
1015
+ if (ev === "drop" && de.dataTransfer && de.dataTransfer.files.length) addFiles(composer, [...de.dataTransfer.files]);
1016
+ if (ev === "drop" || !composerEl.contains(de.relatedTarget as Node)) composerEl.classList.remove("dragover");
1017
+ }));
1018
+ }
1019
+ // Delegated copy buttons for rendered code blocks (the thread element
1020
+ // is stable even though its contents re-render on every token).
1021
+ thread().addEventListener("click", (e) => {
1022
+ const target = e.target as HTMLElement;
1023
+ const btn = target.closest && target.closest(".cbcopy");
1024
+ if (!btn) return;
1025
+ const code = btn.closest(".codeblock")?.querySelector("code");
1026
+ if (!code || !navigator.clipboard) return;
1027
+ navigator.clipboard.writeText(code.textContent || "").then(() => {
1028
+ btn.textContent = "Copied"; setTimeout(() => { btn.textContent = "Copy"; }, 1200);
1029
+ }).catch(() => {});
1030
+ });
1031
+ // Canvas v1: Preview|Source toggle for html/svg
1032
+ // fences, delegated the same way as the copy button above.
1033
+ wireCanvasToggle(thread());
1034
+
1035
+ // App-aware assistant: registered at document/window level
1036
+ // (not scoped to the chat route being active) since chat.ts owns the
1037
+ // one WebSocket for the whole app and must push context on every
1038
+ // route, regardless of which route the user navigates TO or FROM.
1039
+ // hashchange covers route changes; the MutationObserver covers wizard
1040
+ // step changes (quantize/finetune/dataset); a MutationObserver on each
1041
+ // overlay's `class` attribute covers view changes (memory/hub/adapters
1042
+ // panel open/close) without touching those three modules.
1043
+ window.addEventListener("hashchange", () => { watchWizardSteps(); pushAppContext(); });
1044
+ watchWizardSteps();
1045
+ const overlayMo = new MutationObserver(() => pushAppContext());
1046
+ for (const id of ["mem-overlay", "hub-overlay", "adapters-overlay"]) {
1047
+ const el = $(id);
1048
+ if (el) overlayMo.observe(el, { attributes: true, attributeFilter: ["class"] });
1049
+ }
1050
+ },
1051
+ enter() {
1052
+ if (!ws || ws.readyState > 1) connect();
1053
+ $("chat-box").focus();
1054
+ // Refresh the adapter list on every chat enter (`02d723a:docs/archive/planning/web-ui-pass-plan.md`
1055
+ // #15's staleness half): a training job that completed while the user
1056
+ // was elsewhere, or a manual drop into the adapters directory, should
1057
+ // show up without needing a full page reload. Cheap (one GET) and the
1058
+ // list is short.
1059
+ composerRefreshAdapters();
1060
+ },
1061
+ leave() { /* keep WS warm; closing on every nav would drop in-flight turns */ },
1062
+ // Exposed so the finetune controller can refresh the chat adapter chip
1063
+ // immediately when a training job finishes, instead of waiting for the
1064
+ // user to navigate back to Chat (which would also refresh it via enter()).
1065
+ refreshAdapters: composerRefreshAdapters,
1066
+ // Exposed for the global keyboard-shortcut sweep: Cmd/Ctrl+Shift+O
1067
+ // and Cmd/Ctrl+Shift+C, bound at document level so they work regardless
1068
+ // of which control has focus while on the chat route.
1069
+ newChat,
1070
+ copyLastResponse,
1071
+ // Exposed for shell.ts's settings modal — it owns the
1072
+ // checkbox/list DOM and localStorage, but has no WebSocket of its own.
1073
+ setCodingTools,
1074
+ forgetToolApproval,
1075
+ };
1076
+ }