返回 CodeWhale
app.mjs
根目录 / crates / tui / src / runtime_web / app.mjs
1 export const STREAM_EVENT_NAMES = [
2 "thread.started",
3 "thread.updated",
4 "thread.forked",
5 "turn.started",
6 "turn.lifecycle",
7 "turn.usage",
8 "turn.steered",
9 "turn.steer_dropped",
10 "turn.interrupt_requested",
11 "turn.artifacts",
12 "turn.workspace_snapshot",
13 "turn.completed",
14 "item.started",
15 "item.delta",
16 "item.completed",
17 "item.canceled",
18 "item.failed",
19 "item.interrupted",
20 "approval.required",
21 "approval.decided",
22 "approval.timeout",
23 "approval.grant_added",
24 "approval.grant_revoked",
25 "user_input.required",
26 "user_input.answered",
27 "user_input.canceled",
28 "sandbox.denied",
29 "agent.spawned",
30 "agent.progress",
31 "agent.completed",
32 "agent.list",
33 "tool_call.requested",
34 "tool_call.resolved",
35 "tool_call.canceled",
36 "tool_call.timeout",
37 ];
38
39 export function createThreadState(threadId = "") {
40 return {
41 threadId,
42 thread: null,
43 turns: new Map(),
44 turnOrder: [],
45 items: new Map(),
46 itemOrder: [],
47 latestSeq: 0,
48 approvals: new Map(),
49 userInputs: new Map(),
50 dynamicToolCalls: new Map(),
51 };
52 }
53
54 export function applySnapshot(state, detail, expectedThreadId = state.threadId) {
55 if (!detail || !detail.thread || detail.thread.id !== expectedThreadId) {
56 return false;
57 }
58 state.threadId = expectedThreadId;
59 state.thread = detail.thread;
60 state.turns = new Map();
61 state.turnOrder = [];
62 for (const turn of Array.isArray(detail.turns) ? detail.turns : []) {
63 if (!turn || !turn.id) continue;
64 state.turns.set(turn.id, turn);
65 state.turnOrder.push(turn.id);
66 }
67 state.items = new Map();
68 state.itemOrder = [];
69 for (const item of Array.isArray(detail.items) ? detail.items : []) {
70 if (!item || !item.id) continue;
71 state.items.set(item.id, item);
72 state.itemOrder.push(item.id);
73 }
74 state.latestSeq = normalizedSequence(detail.latest_seq);
75 state.approvals = new Map();
76 for (const approval of Array.isArray(detail.pending_approvals) ? detail.pending_approvals : []) {
77 const approvalId = approval?.approval_id || approval?.id;
78 if (approvalId) state.approvals.set(approvalId, approval);
79 }
80 state.userInputs = new Map();
81 for (const input of Array.isArray(detail.pending_user_inputs) ? detail.pending_user_inputs : []) {
82 const inputId = input?.input_id || input?.id;
83 if (inputId) state.userInputs.set(inputId, input);
84 }
85 state.dynamicToolCalls = new Map();
86 for (const call of Array.isArray(detail.pending_dynamic_tool_calls) ? detail.pending_dynamic_tool_calls : []) {
87 if (call?.call_id) state.dynamicToolCalls.set(call.call_id, call);
88 }
89 return true;
90 }
91
92 export function applyRuntimeEvent(state, envelope) {
93 if (runtimeEventContinuity(state, envelope) !== "next") {
94 return false;
95 }
96 const sequence = normalizedSequence(envelope.seq);
97 state.latestSeq = sequence;
98
99 const eventName = envelope.event || envelope.kind || "";
100 const payload = envelope.payload && typeof envelope.payload === "object"
101 ? envelope.payload
102 : {};
103
104 if (
105 (eventName === "thread.started" || eventName === "thread.updated" || eventName === "thread.forked")
106 && payload.thread
107 ) {
108 state.thread = payload.thread;
109 } else if (eventName === "turn.started" || eventName === "turn.completed") {
110 if (payload.turn) upsertTurn(state, payload.turn);
111 if (eventName === "turn.completed") {
112 clearTurnAttention(state, envelope.turn_id || payload.turn?.id || "");
113 }
114 } else if (eventName === "turn.lifecycle") {
115 const turnId = envelope.turn_id;
116 const turn = turnId ? state.turns.get(turnId) : null;
117 if (turn && payload.status) {
118 state.turns.set(turnId, { ...turn, status: payload.status });
119 }
120 } else if (eventName === "turn.interrupt_requested") {
121 const turnId = envelope.turn_id;
122 const turn = turnId ? state.turns.get(turnId) : null;
123 if (turn) state.turns.set(turnId, { ...turn, status: "in_progress" });
124 } else if (eventName === "turn.workspace_snapshot") {
125 // The payload is one restore-point receipt; the turn record keeps them
126 // in the order the engine took them (`workspace_snapshots`).
127 const turnId = envelope.turn_id;
128 const turn = turnId ? state.turns.get(turnId) : null;
129 if (turn && payload.kind && payload.tree_id) {
130 const receipts = Array.isArray(turn.workspace_snapshots) ? turn.workspace_snapshots : [];
131 state.turns.set(turnId, { ...turn, workspace_snapshots: [...receipts, payload] });
132 }
133 } else if (
134 eventName === "item.started"
135 || eventName === "item.completed"
136 || eventName === "item.canceled"
137 || eventName === "item.failed"
138 || eventName === "item.interrupted"
139 || eventName === "agent.spawned"
140 || eventName === "agent.progress"
141 || eventName === "agent.completed"
142 || eventName === "agent.list"
143 ) {
144 if (payload.item) upsertItem(state, payload.item);
145 } else if (eventName === "item.delta") {
146 appendItemDelta(state, envelope.item_id, payload);
147 } else if (eventName === "approval.required") {
148 const approvalId = payload.approval_id || payload.id;
149 if (approvalId) {
150 state.approvals.set(approvalId, {
151 ...payload,
152 turn_id: payload.turn_id || envelope.turn_id || "",
153 });
154 }
155 } else if (eventName === "approval.decided" || eventName === "approval.timeout") {
156 const approvalId = payload.approval_id || payload.id;
157 if (approvalId) state.approvals.delete(approvalId);
158 } else if (eventName === "user_input.required") {
159 const inputId = payload.id;
160 if (inputId) {
161 state.userInputs.set(inputId, {
162 ...payload,
163 turn_id: payload.turn_id || envelope.turn_id || "",
164 });
165 }
166 } else if (eventName === "user_input.answered" || eventName === "user_input.canceled") {
167 const inputId = payload.input_id || payload.id;
168 if (inputId) state.userInputs.delete(inputId);
169 } else if (eventName === "tool_call.requested") {
170 if (payload.call_id) {
171 state.dynamicToolCalls.set(payload.call_id, {
172 ...payload,
173 turn_id: payload.turn_id || envelope.turn_id || "",
174 });
175 }
176 } else if (
177 eventName === "tool_call.resolved"
178 || eventName === "tool_call.canceled"
179 || eventName === "tool_call.timeout"
180 ) {
181 if (payload.call_id) state.dynamicToolCalls.delete(payload.call_id);
182 }
183 return true;
184 }
185
186 function clearTurnAttention(state, turnId) {
187 for (const [id, approval] of state.approvals) {
188 if (!approval?.turn_id || approval.turn_id === turnId) state.approvals.delete(id);
189 }
190 for (const [id, input] of state.userInputs) {
191 if (!input?.turn_id || input.turn_id === turnId) state.userInputs.delete(id);
192 }
193 for (const [id, call] of state.dynamicToolCalls) {
194 if (!call?.turn_id || call.turn_id === turnId) state.dynamicToolCalls.delete(id);
195 }
196 }
197
198 export function runtimeEventContinuity(state, envelope) {
199 if (!envelope || envelope.thread_id !== state.threadId) {
200 return "ignore";
201 }
202 const sequence = normalizedSequence(envelope.seq);
203 if (sequence <= state.latestSeq) {
204 return "ignore";
205 }
206 if (Object.hasOwn(envelope, "previous_seq")) {
207 const previousSequence = normalizedSequence(envelope.previous_seq);
208 if (previousSequence !== state.latestSeq) {
209 return "gap";
210 }
211 }
212 return "next";
213 }
214
215 export async function snapshotThenSubscribe({
216 state,
217 threadId,
218 loadSnapshot,
219 subscribe,
220 isCurrent = () => true,
221 }) {
222 const detail = await loadSnapshot(threadId);
223 if (!isCurrent() || !applySnapshot(state, detail, threadId)) {
224 return false;
225 }
226 if (!isCurrent()) return false;
227 // A recovery caller may return a stream-open handshake. Await it so a
228 // replacement snapshot is not called continuous until the replacement SSE
229 // stream has actually opened. Synchronous subscribers remain supported.
230 await subscribe(threadId, state.latestSeq);
231 return true;
232 }
233
234 export async function recoverSnapshotAndSubscribe(options, onRecovered) {
235 const subscribed = await snapshotThenSubscribe(options);
236 if (!subscribed) return false;
237 onRecovered();
238 return true;
239 }
240
241 // ---------------------------------------------------------------------------
242 // Typed selection identity (#4397)
243 //
244 // The dashboard can have two very different things selected: a *saved session*
245 // (a recording on disk) or a *live thread* (a running runtime object). Almost
246 // every safety rule in this slice reduces to "which one is it?", so the answer
247 // is a typed value rather than a pair of loosely-related string fields that can
248 // both be set, both be empty, or disagree.
249 // ---------------------------------------------------------------------------
250
251 // Nothing selected.
252 export const NO_TARGET = Object.freeze({ kind: "none" });
253
254 // A saved session: read-only. Peek only, never reply, never approve.
255 export function sessionTarget(sessionId) {
256 return Object.freeze({ kind: "session", sessionId: String(sessionId || "") });
257 }
258
259 // A live thread: the only thing that can receive a reply or an approval.
260 export function threadTarget(threadId) {
261 return Object.freeze({ kind: "thread", threadId: String(threadId || "") });
262 }
263
264 // May the composer send to this target?
265 //
266 // Only a live thread. A saved session has no runtime to receive a message;
267 // offering a composer against one would be an affordance with nothing behind
268 // it, and "resume it silently on send" would attach the user's message to a
269 // thread they never asked to create.
270 export function canReply(target) {
271 return target?.kind === "thread" && Boolean(target.threadId);
272 }
273
274 // Resolve the id a reply must be POSTed to, or an explicit refusal.
275 //
276 // Fails closed on every ambiguity: no target, a session target, or a target
277 // whose thread is not the one the live stream is following (a stale target —
278 // the user changed rows while a request was in flight).
279 export function resolveReplyTarget(target, streamState) {
280 if (!target || target.kind === "none") {
281 return { ok: false, reason: "no-target" };
282 }
283 if (target.kind === "session") {
284 return { ok: false, reason: "session-not-live" };
285 }
286 if (!target.threadId) {
287 return { ok: false, reason: "no-target" };
288 }
289 if (streamState && streamState.threadId && streamState.threadId !== target.threadId) {
290 return { ok: false, reason: "stale-target" };
291 }
292 return { ok: true, threadId: target.threadId };
293 }
294
295 // Resolve an approval decision to the thread that owns it, or refuse.
296 //
297 // An approval is authority: answering the wrong one, or answering one that
298 // has already been decided elsewhere, is worse than not answering. So the
299 // approval must be present in the *current* stream state, and that state must
300 // belong to the selected live thread.
301 export function resolveApprovalTarget(approvalId, target, streamState) {
302 const reply = resolveReplyTarget(target, streamState);
303 if (!reply.ok) return reply;
304 if (!approvalId) return { ok: false, reason: "no-approval" };
305 if (!streamState || streamState.threadId !== reply.threadId) {
306 return { ok: false, reason: "stale-target" };
307 }
308 if (!streamState.approvals || !streamState.approvals.has(approvalId)) {
309 // Decided, timed out, or belonging to a thread we are no longer watching.
310 return { ok: false, reason: "stale-approval" };
311 }
312 return { ok: true, threadId: reply.threadId, approvalId };
313 }
314
315 // Resolve a user-input submission to the live thread and pending request that
316 // own it. User-input answers can resume a paused turn, so a stale card must not
317 // be able to answer a request from another thread or one already settled by a
318 // different client.
319 export function resolveUserInputTarget(inputId, target, streamState) {
320 const reply = resolveReplyTarget(target, streamState);
321 if (!reply.ok) return reply;
322 if (!inputId) return { ok: false, reason: "no-user-input" };
323 if (!streamState || streamState.threadId !== reply.threadId) {
324 return { ok: false, reason: "stale-target" };
325 }
326 if (!streamState.userInputs || !streamState.userInputs.has(inputId)) {
327 return { ok: false, reason: "stale-user-input" };
328 }
329 return { ok: true, threadId: reply.threadId, inputId };
330 }
331
332 function ownAnswerValue(collection, id) {
333 if (collection instanceof Map) return collection.get(id);
334 if (collection && typeof collection === "object" && Object.hasOwn(collection, id)) {
335 return collection[id];
336 }
337 return undefined;
338 }
339
340 // Build the exact Runtime answer payload from selected options and custom
341 // text. This keeps single-select questions single, rejects stale/forged option
342 // values, and preserves the TUI rule that a custom answer is always reachable.
343 export function answersForUserInput(request, selections = {}, freeText = {}) {
344 const questions = Array.isArray(request?.questions) ? request.questions : [];
345 if (questions.length === 0) {
346 return { ok: false, reason: "invalid-request", question: "the question" };
347 }
348
349 const answers = [];
350 const questionIds = new Set();
351 for (const question of questions) {
352 const id = String(question?.id || "");
353 const questionLabel = String(question?.header || question?.question || id || "the question");
354 if (!id || questionIds.has(id)) {
355 return { ok: false, reason: "invalid-request", question: questionLabel };
356 }
357 questionIds.add(id);
358
359 const optionLabels = new Set(
360 (Array.isArray(question.options) ? question.options : [])
361 .map((option) => String(option?.label || ""))
362 .filter((value) => Boolean(value.trim())),
363 );
364 const selectedValue = ownAnswerValue(selections, id);
365 const selected = Array.isArray(selectedValue)
366 ? [...new Set(selectedValue.map((value) => String(value)).filter((value) => value.trim()))]
367 : [];
368 if (selected.some((value) => !optionLabels.has(value))) {
369 return { ok: false, reason: "invalid-option", question: questionLabel };
370 }
371
372 const otherValue = ownAnswerValue(freeText, id);
373 const other = String(otherValue || "").trim();
374 const count = selected.length + (other ? 1 : 0);
375 if (count === 0) {
376 return { ok: false, reason: "missing-answer", question: questionLabel };
377 }
378 if (!question.multi_select && count !== 1) {
379 return { ok: false, reason: "multiple-answers", question: questionLabel };
380 }
381
382 for (const value of selected) answers.push({ id, label: value, value });
383 if (other) answers.push({ id, label: "Other", value: other });
384 }
385 return { ok: true, answers };
386 }
387
388 // Human-readable reason for a refusal, for the status banner.
389 export function refusalMessage(reason) {
390 switch (reason) {
391 case "session-not-live":
392 return "This is a saved session, not a live thread — nothing was sent. Resume it first to reply.";
393 case "stale-target":
394 return "That thread is no longer the selected one — nothing was sent.";
395 case "stale-approval":
396 return "That request was already answered or has expired — nothing was sent.";
397 case "no-approval":
398 return "No approval was identified — nothing was sent.";
399 case "stale-user-input":
400 return "That question was already answered or has expired — nothing was sent.";
401 case "no-user-input":
402 return "No user-input request was identified — nothing was sent.";
403 default:
404 return "Select a live thread first — nothing was sent.";
405 }
406 }
407
408 // The SSE resume cursor and whether the stream is known to have a gap.
409 //
410 // Surfaced rather than kept internal: after a reconnect the user needs to
411 // know whether what they are reading is continuous or whether events were
412 // missed and a re-snapshot is pending.
413 export function streamCursor(state, { gap = false, connected = true } = {}) {
414 const seq = normalizedSequence(state?.latestSeq);
415 return {
416 latestSeq: seq,
417 gap: Boolean(gap),
418 connected: Boolean(connected),
419 label: !connected
420 ? `Reconnecting — resuming from #${seq}`
421 : gap
422 ? `Gap detected — re-syncing from #${seq}`
423 : `Live — event #${seq}`,
424 };
425 }
426
427 // Keep machine diagnostics available without making them the conversation's
428 // loudest content. Known transport failures get one calm product sentence;
429 // the byte-for-byte receipt remains behind the disclosure.
430 export function receiptPresentation(item = {}) {
431 const detail = String(item.detail || item.summary || "");
432 const raw = String(item.summary || detail || humanize(item.kind));
433 const fullRaw = detail && detail !== raw ? `${raw}\n\n${detail}` : raw;
434 const workflow = workflowReceiptPresentation(item, detail, fullRaw);
435 if (workflow) return workflow;
436 const mcpFailure = raw.match(/Failed to connect MCP server ['"]?([^'":\s]+)['"]?/i);
437 if (mcpFailure) {
438 const server = mcpFailure[1] || "server";
439 return {
440 label: "MCP · Unavailable",
441 summary: `${server} could not connect`,
442 raw,
443 failed: true,
444 };
445 }
446 const failed = item.status === "failed" || /^(?:error|failed|failure)\b/i.test(raw);
447 return {
448 label: `${humanize(item.kind)} · ${humanize(item.status)}`,
449 summary: raw,
450 raw: fullRaw,
451 failed,
452 };
453 }
454
455 export function workflowReceiptPresentation(item, detail, raw) {
456 const metadata = item?.metadata && typeof item.metadata === "object"
457 ? item.metadata
458 : {};
459 let payload = null;
460 try {
461 const parsed = JSON.parse(detail);
462 if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) payload = parsed;
463 } catch (_error) {
464 // Non-JSON tool receipts continue through the ordinary presentation path.
465 }
466 const looksLikeWorkflow = /^workflow(?:\s|:)/i.test(String(item?.summary || ""))
467 || Object.hasOwn(metadata, "dispatch_failure_count")
468 || Boolean(payload && Object.hasOwn(payload, "dispatch_failure_count"));
469 if (!looksLikeWorkflow) return null;
470
471 const status = String(metadata.status || payload?.status || "").toLowerCase();
472 const countValue = metadata.dispatch_failure_count ?? payload?.dispatch_failure_count;
473 const count = Number(countValue);
474 const rejected = Number.isSafeInteger(count) && count > 0 ? count : 0;
475 if (rejected === 0 && status !== "degraded" && status !== "failed") return null;
476
477 const summary = rejected === 1
478 ? "1 task dispatch was rejected"
479 : rejected > 1
480 ? `${rejected} task dispatches were rejected`
481 : status === "failed"
482 ? "The workflow did not complete"
483 : "The workflow completed with degraded results";
484 return {
485 label: status === "failed" ? "Workflow · Failed" : "Workflow · Needs attention",
486 summary,
487 raw,
488 failed: true,
489 };
490 }
491
492 export function eventStreamUrl(threadId, latestSeq, ticket = "") {
493 const url = `/v1/threads/${encodeURIComponent(threadId)}/events?since_seq=${normalizedSequence(latestSeq)}`;
494 return ticket ? `${url}&web_stream_ticket=${encodeURIComponent(ticket)}` : url;
495 }
496
497 export function createWebSessionFetch({ location, history, storage, fetch, pageProof = "" }) {
498 const key = "codewhale_web_request_proof";
499 const fragmentProof = new URLSearchParams(location.hash.slice(1)).get("p");
500 if (fragmentProof) history.replaceState(null, "", location.pathname + location.search);
501 let proof = pageProof || fragmentProof || "";
502 if (proof) {
503 try { storage().setItem(key, proof); } catch (_) { /* Memory-only session. */ }
504 } else {
505 try { proof = storage().getItem(key) || ""; } catch (_) { /* Storage unavailable. */ }
506 }
507 return (path, options = {}) => {
508 const headers = new Headers(options.headers || {});
509 headers.set("x-codewhale-web-request", proof);
510 if (options.body != null && !headers.has("content-type")) {
511 headers.set("content-type", "application/json");
512 }
513 return fetch(path, { ...options, headers, credentials: "same-origin", cache: "no-store" });
514 };
515 }
516
517 // One owner for pending ticket requests, EventSources, and reconnect timers.
518 // Ticket HTTP 401/403 ends retries; EventSource hides HTTP status, so its
519 // errors retry through a fresh ticket request. Snapshot recovery owns its
520 // own retry when it requests an open handshake.
521 export function createStreamConnector({
522 app, api, EventSource, receive, setConnection, showStatus,
523 setTimeout = globalThis.setTimeout, clearTimeout = globalThis.clearTimeout,
524 }) {
525 let attempt = 0;
526 let retryDelay = 900;
527 function stopStream() {
528 attempt += 1;
529 if (app.streamOpenCancel) app.streamOpenCancel();
530 app.streamOpenCancel = null;
531 if (app.stream) app.stream.close();
532 app.stream = null;
533 if (app.reconnectTimer) clearTimeout(app.reconnectTimer);
534 app.reconnectTimer = null;
535 }
536
537 async function connectStream(threadId, sequence, generation, waitForOpen = false) {
538 if (generation !== app.generation || threadId !== app.selectedThreadId) return;
539 stopStream();
540 const currentAttempt = attempt;
541 const isCurrent = () => currentAttempt === attempt
542 && generation === app.generation && threadId === app.selectedThreadId;
543 const reconnect = () => {
544 if (!isCurrent() || app.stream !== null) return;
545 setConnection("", "Reconnecting to local runtime…");
546 app.reconnectTimer = setTimeout(() => {
547 app.reconnectTimer = null;
548 if (!isCurrent() || app.stream !== null) return;
549 void connectStream(threadId, app.threadState.latestSeq, generation).catch(() => {});
550 }, retryDelay);
551 retryDelay = Math.min(retryDelay * 2, 30_000);
552 };
553 let ticket;
554 try {
555 ({ stream_ticket: ticket } = await api("/__codewhale/web/stream-ticket", { method: "POST" }));
556 } catch (error) {
557 if (!isCurrent()) return;
558 showStatus(error.message);
559 setConnection("error", "Runtime connection failed");
560 if (!waitForOpen && error.status !== 401 && error.status !== 403) reconnect();
561 throw error;
562 }
563 if (!isCurrent() || app.stream !== null) return;
564 const stream = new EventSource(eventStreamUrl(threadId, sequence, ticket), { withCredentials: true });
565 app.stream = stream;
566 let opened = false;
567 let resolveOpen;
568 let rejectOpen;
569 const openHandshake = waitForOpen
570 ? new Promise((resolve, reject) => {
571 resolveOpen = resolve;
572 rejectOpen = reject;
573 })
574 : undefined;
575 const cancelOpen = () => {
576 if (!rejectOpen) return;
577 const reject = rejectOpen;
578 resolveOpen = null;
579 rejectOpen = null;
580 reject(new Error("Runtime event stream open was cancelled"));
581 };
582 if (waitForOpen) app.streamOpenCancel = cancelOpen;
583 const clearOpenHandshake = () => {
584 if (app.streamOpenCancel === cancelOpen) app.streamOpenCancel = null;
585 };
586 stream.onopen = () => {
587 if (!isCurrent() || app.stream !== stream) {
588 stream.close();
589 return;
590 }
591 retryDelay = 900;
592 opened = true;
593 setConnection("ready", "Local runtime connected");
594 clearOpenHandshake();
595 if (resolveOpen) resolveOpen();
596 resolveOpen = null;
597 rejectOpen = null;
598 };
599 for (const name of STREAM_EVENT_NAMES) {
600 stream.addEventListener(name, (message) => {
601 if (isCurrent() && app.stream === stream) receive(stream, message, threadId, generation);
602 });
603 }
604 stream.onerror = () => {
605 if (app.stream !== stream) {
606 stream.close();
607 return;
608 }
609 stream.close();
610 app.stream = null;
611 if (generation !== app.generation || threadId !== app.selectedThreadId) return;
612 if (waitForOpen && !opened) {
613 clearOpenHandshake();
614 const reject = rejectOpen;
615 resolveOpen = null;
616 rejectOpen = null;
617 reject?.(new Error("Runtime event stream did not reopen"));
618 return;
619 }
620 reconnect();
621 };
622 return openHandshake;
623 }
624
625 return { connectStream, stopStream };
626 }
627
628 export function saveDraft(drafts, threadId, value) {
629 if (!threadId) return;
630 if (value) drafts.set(threadId, value);
631 else drafts.delete(threadId);
632 }
633
634 export function restoreDraft(drafts, threadId) {
635 return drafts.get(threadId) || "";
636 }
637
638 export function pendingAttentionCount(summary) {
639 const count = Number(summary?.pending_attention_count);
640 return Number.isSafeInteger(count) && count > 0 ? count : 0;
641 }
642
643 // Preserve the Runtime's newest-first order within each group. The server's
644 // typed pending-request count is the only attention authority; status prose
645 // and turn lifecycle strings deliberately do not participate.
646 export function groupThreadSummaries(summaries) {
647 const groups = { needsYou: [], recent: [] };
648 for (const summary of Array.isArray(summaries) ? summaries : []) {
649 const group = pendingAttentionCount(summary) > 0 ? groups.needsYou : groups.recent;
650 group.push(summary);
651 }
652 return groups;
653 }
654
655 export function pendingAttentionLabel(summary) {
656 const count = pendingAttentionCount(summary);
657 return count === 1
658 ? "1 item needs your attention"
659 : `${count} items need your attention`;
660 }
661
662 // Match the CWC composer grammar while keeping the embedded client free of a
663 // framework dependency: Enter sends, Shift+Enter inserts a newline, and an
664 // active IME composition is never interrupted.
665 export function isComposerSubmitKey({ key, shiftKey = false, isComposing = false } = {}) {
666 return key === "Enter" && !shiftKey && !isComposing;
667 }
668
669 export function newThreadDefaults(catalog) {
670 const providers = Array.isArray(catalog?.providers)
671 ? catalog.providers.filter((provider) => String(provider?.id || "").trim())
672 : [];
673 const current = String(catalog?.current || "").trim();
674 const provider = providers.find((entry) => entry.id === current) || providers[0] || null;
675 return {
676 providerId: String(provider?.id || "").trim(),
677 modelProviderId: String(provider?.model_provider_id || "").trim(),
678 model: String(provider?.default_model || "").trim(),
679 };
680 }
681
682 export function imageInputPresentation(value) {
683 if (value === "supported") {
684 return {
685 state: "supported",
686 label: "Vision",
687 description: "This exact provider route supports image input. Browser attachments are not enabled yet.",
688 };
689 }
690 if (value === "unsupported") {
691 return {
692 state: "unsupported",
693 label: "Text only",
694 description: "This exact provider route does not support image input.",
695 };
696 }
697 return {
698 state: "unknown",
699 label: "Image support unverified",
700 description: "Image-input support is not verified for this exact provider route.",
701 };
702 }
703
704 export function modelOptionLabel(model) {
705 const id = String(model?.id || "").trim();
706 return model?.image_input === "supported" ? `${id} · Vision` : id;
707 }
708
709 export function providerOptionLabel(provider) {
710 const id = String(provider?.id || "").trim();
711 const displayName = String(provider?.display_name || id).trim();
712 const exactId = String(provider?.model_provider_id || "").trim();
713 return exactId && exactId !== id ? `${displayName} · ${exactId}` : displayName;
714 }
715
716 export function buildCreateThreadRequest(providerId, model, modelProviderId = "") {
717 const modelProvider = String(providerId || "").trim();
718 const exactProviderId = String(modelProviderId || "").trim();
719 const selectedModel = String(model || "").trim();
720 if (!modelProvider || !selectedModel) {
721 throw new Error("Choose both a provider and a model.");
722 }
723 const request = { model_provider: modelProvider, model: selectedModel };
724 if (exactProviderId) request.model_provider_id = exactProviderId;
725 return request;
726 }
727
728 export function threadProviderLabel(thread) {
729 const exact = String(thread?.model_provider_id || "").trim();
730 const generic = String(thread?.model_provider || "").trim();
731 return exact || generic;
732 }
733
734 export function claimInFlight(inFlight, key) {
735 const action = String(key || "").trim();
736 if (!(inFlight instanceof Set) || !action || inFlight.has(action)) return false;
737 inFlight.add(action);
738 return true;
739 }
740
741 export function setSafeText(element, value) {
742 element.textContent = value == null ? "" : String(value);
743 return element;
744 }
745
746 function normalizedSequence(value) {
747 const sequence = Number(value);
748 return Number.isSafeInteger(sequence) && sequence > 0 ? sequence : 0;
749 }
750
751 function upsertTurn(state, turn) {
752 if (!turn || !turn.id) return;
753 if (!state.turns.has(turn.id)) state.turnOrder.push(turn.id);
754 state.turns.set(turn.id, turn);
755 }
756
757 function upsertItem(state, item) {
758 if (!item || !item.id) return;
759 if (!state.items.has(item.id)) state.itemOrder.push(item.id);
760 state.items.set(item.id, item);
761 }
762
763 function appendItemDelta(state, itemId, payload) {
764 if (!itemId) return;
765 const delta = typeof payload.delta === "string" ? payload.delta : "";
766 const existing = state.items.get(itemId) || {
767 id: itemId,
768 turn_id: "",
769 kind: payload.kind || "agent_message",
770 status: "in_progress",
771 summary: "",
772 detail: "",
773 };
774 if (!state.items.has(itemId)) state.itemOrder.push(itemId);
775 state.items.set(itemId, {
776 ...existing,
777 status: "in_progress",
778 detail: `${existing.detail || ""}${delta}`,
779 });
780 }
781
782 const PROVIDER_MODELS_PAGE_SIZE = 250;
783 const MAX_PROVIDER_MODELS = 10_000;
784 const MAX_PROVIDER_MODEL_PAGES = Math.ceil(
785 MAX_PROVIDER_MODELS / PROVIDER_MODELS_PAGE_SIZE,
786 );
787
788 /**
789 * Load every bounded page of one provider catalog.
790 *
791 * `fetchPage` is injected so the browser client can retain its authenticated
792 * Runtime API boundary and tests can prove catalogs larger than one page are
793 * not silently truncated. Cursors are opaque and may never repeat.
794 */
795 export async function collectProviderModelPages(providerId, fetchPage) {
796 const provider = String(providerId || "").trim();
797 if (!provider || typeof fetchPage !== "function") {
798 throw new Error("A provider and page loader are required.");
799 }
800
801 const entries = [];
802 const seenCursors = new Set();
803 let expectedTotal;
804 let cursor = "";
805 for (let page = 0; page < MAX_PROVIDER_MODEL_PAGES; page += 1) {
806 const query = new URLSearchParams({ limit: String(PROVIDER_MODELS_PAGE_SIZE) });
807 if (cursor) query.set("cursor", cursor);
808 const response = await fetchPage(
809 `/v1/providers/${encodeURIComponent(provider)}/models?${query.toString()}`,
810 );
811 if (String(response?.provider || "") !== provider) {
812 throw new Error("The Runtime returned a model page for a different provider.");
813 }
814 if (!Array.isArray(response?.models)
815 || response.models.length > PROVIDER_MODELS_PAGE_SIZE
816 || !Number.isSafeInteger(response.total)
817 || response.total < 0
818 || response.total > MAX_PROVIDER_MODELS) {
819 throw new Error("The Runtime returned an invalid provider model page.");
820 }
821 if (expectedTotal !== undefined && expectedTotal !== response.total) {
822 throw new Error("The provider catalog changed; restart loading its models.");
823 }
824 expectedTotal = response.total;
825 const pageEntries = response.models;
826 if (entries.length + pageEntries.length > MAX_PROVIDER_MODELS) {
827 throw new Error(`The provider catalog exceeds ${MAX_PROVIDER_MODELS} models.`);
828 }
829 entries.push(...pageEntries);
830
831 const nextCursor = typeof response?.nextCursor === "string"
832 ? response.nextCursor.trim()
833 : "";
834 if (!nextCursor) {
835 if (entries.length !== expectedTotal) {
836 throw new Error("The Runtime returned an incomplete provider model catalog.");
837 }
838 return entries;
839 }
840 if (pageEntries.length === 0 || seenCursors.has(nextCursor)) {
841 throw new Error("The Runtime returned a non-progressing model cursor.");
842 }
843 if (entries.length >= expectedTotal) {
844 throw new Error("The Runtime returned a cursor beyond its provider model catalog.");
845 }
846 seenCursors.add(nextCursor);
847 cursor = nextCursor;
848 }
849 throw new Error(`The provider catalog exceeds ${MAX_PROVIDER_MODELS} models.`);
850 }
851
852 function startBrowserClient() {
853 const dom = {
854 shell: document.querySelector("#app-shell"),
855 rail: document.querySelector("#thread-rail"),
856 railOpen: document.querySelector("#rail-open"),
857 railClose: document.querySelector("#rail-close"),
858 railScrim: document.querySelector("#rail-scrim"),
859 search: document.querySelector("#thread-search"),
860 threadList: document.querySelector("#thread-list"),
861 newThread: document.querySelector("#new-thread"),
862 newThreadDialog: document.querySelector("#new-thread-dialog"),
863 newThreadForm: document.querySelector("#new-thread-form"),
864 newThreadProvider: document.querySelector("#new-thread-provider"),
865 newThreadModel: document.querySelector("#new-thread-model"),
866 newThreadModelSelectField: document.querySelector("#new-thread-model-select-field"),
867 newThreadModelInput: document.querySelector("#new-thread-model-input"),
868 newThreadModelInputField: document.querySelector("#new-thread-model-input-field"),
869 newThreadCapability: document.querySelector("#new-thread-capability"),
870 newThreadStatus: document.querySelector("#new-thread-status"),
871 newThreadCancel: document.querySelector("#new-thread-cancel"),
872 newThreadCreate: document.querySelector("#new-thread-create"),
873 connectionDot: document.querySelector("#connection-dot"),
874 connectionLabel: document.querySelector("#connection-label"),
875 runtimeProvenance: document.querySelector("#runtime-provenance"),
876 kicker: document.querySelector("#session-kicker"),
877 title: document.querySelector("#session-title"),
878 facts: document.querySelector("#session-facts"),
879 rename: document.querySelector("#rename-thread"),
880 archive: document.querySelector("#archive-thread"),
881 status: document.querySelector("#status-banner"),
882 transcript: document.querySelector("#transcript"),
883 attention: document.querySelector("#attention"),
884 composer: document.querySelector("#composer"),
885 composerInput: document.querySelector("#composer-input"),
886 send: document.querySelector("#send-message"),
887 interrupt: document.querySelector("#interrupt-turn"),
888 renameDialog: document.querySelector("#rename-dialog"),
889 renameForm: document.querySelector("#rename-form"),
890 renameInput: document.querySelector("#rename-input"),
891 peek: document.querySelector("#session-peek"),
892 savedSessions: document.querySelector("#saved-sessions"),
893 sessionList: document.querySelector("#session-list"),
894 session: document.querySelector(".session"),
895 };
896
897 const app = {
898 summaries: [],
899 sessionSummaries: [],
900 // Typed selection: `none`, a read-only `session`, or a live `thread`.
901 // Every reply/approval authority check reads this, not a loose id.
902 target: NO_TARGET,
903 // Bounded, redacted peek for the selected saved session, or null.
904 peek: null,
905 // Set when the SSE stream reported a sequence gap and a re-snapshot is
906 // pending. Surfaced in the connection label rather than hidden.
907 streamGap: false,
908 selectedThreadId: "",
909 threadState: createThreadState(),
910 workspace: null,
911 runtimeInfo: null,
912 drafts: new Map(),
913 stream: null,
914 streamOpenCancel: null,
915 reconnectTimer: null,
916 generation: 0,
917 searchTimer: null,
918 railReturnFocus: null,
919 inFlightActions: new Set(),
920 providerCatalog: null,
921 newThreadModels: [],
922 newThreadGeneration: 0,
923 newThreadLoading: false,
924 creatingThread: false,
925 };
926
927 const narrowRail = globalThis.matchMedia("(max-width: 800px)");
928 const composerSendAction = "composer-send";
929
930 function element(tag, className, text) {
931 const created = document.createElement(tag);
932 if (className) created.className = className;
933 if (text != null) setSafeText(created, text);
934 return created;
935 }
936
937 function setInert(element, inert) {
938 element.inert = inert;
939 if (inert) element.setAttribute("inert", "");
940 else element.removeAttribute("inert");
941 }
942
943 function applyDesktopRailAccessibility() {
944 dom.shell.classList.remove("rail-visible");
945 dom.rail.removeAttribute("aria-hidden");
946 dom.rail.removeAttribute("aria-modal");
947 dom.rail.removeAttribute("role");
948 dom.session.removeAttribute("aria-hidden");
949 setInert(dom.rail, false);
950 setInert(dom.session, false);
951 dom.railScrim.hidden = true;
952 dom.railOpen.setAttribute("aria-expanded", "false");
953 app.railReturnFocus = null;
954 }
955
956 function applyClosedMobileRailAccessibility() {
957 dom.shell.classList.remove("rail-visible");
958 dom.session.removeAttribute("aria-hidden");
959 setInert(dom.session, false);
960 dom.rail.setAttribute("role", "dialog");
961 dom.rail.setAttribute("aria-modal", "true");
962 dom.rail.setAttribute("aria-hidden", "true");
963 setInert(dom.rail, true);
964 dom.railScrim.hidden = true;
965 dom.railOpen.setAttribute("aria-expanded", "false");
966 }
967
968 function openRail() {
969 if (!narrowRail.matches) return;
970 app.railReturnFocus = document.activeElement;
971 dom.rail.setAttribute("role", "dialog");
972 dom.rail.setAttribute("aria-modal", "true");
973 dom.rail.setAttribute("aria-hidden", "false");
974 setInert(dom.rail, false);
975 dom.railScrim.hidden = false;
976 dom.shell.classList.add("rail-visible");
977 dom.railOpen.setAttribute("aria-expanded", "true");
978 dom.railClose.focus({ preventScroll: true });
979 dom.session.setAttribute("aria-hidden", "true");
980 setInert(dom.session, true);
981 }
982
983 function closeRail({ restoreFocus = true } = {}) {
984 if (!narrowRail.matches) {
985 applyDesktopRailAccessibility();
986 return;
987 }
988 dom.session.removeAttribute("aria-hidden");
989 setInert(dom.session, false);
990 const returnTarget = app.railReturnFocus?.isConnected
991 ? app.railReturnFocus
992 : dom.railOpen;
993 if (restoreFocus) returnTarget.focus({ preventScroll: true });
994 applyClosedMobileRailAccessibility();
995 app.railReturnFocus = null;
996 }
997
998 function syncRailAccessibility() {
999 if (!narrowRail.matches) {
1000 applyDesktopRailAccessibility();
1001 return;
1002 }
1003 if (dom.shell.classList.contains("rail-visible")) {
1004 dom.rail.setAttribute("role", "dialog");
1005 dom.rail.setAttribute("aria-modal", "true");
1006 dom.rail.setAttribute("aria-hidden", "false");
1007 setInert(dom.rail, false);
1008 dom.session.setAttribute("aria-hidden", "true");
1009 setInert(dom.session, true);
1010 dom.railScrim.hidden = false;
1011 dom.railOpen.setAttribute("aria-expanded", "true");
1012 return;
1013 }
1014 if (dom.rail.contains(document.activeElement)) {
1015 dom.railOpen.focus({ preventScroll: true });
1016 }
1017 applyClosedMobileRailAccessibility();
1018 }
1019
1020 function syncVisualViewport() {
1021 const viewport = globalThis.visualViewport;
1022 const height = viewport?.height || globalThis.innerHeight;
1023 const offsetTop = viewport?.offsetTop || 0;
1024 if (Number.isFinite(height) && height > 0) {
1025 dom.shell.style.setProperty("--visual-viewport-height", `${Math.round(height)}px`);
1026 }
1027 dom.shell.style.setProperty("--visual-viewport-offset-top", `${Math.max(0, Math.round(offsetTop))}px`);
1028 }
1029
1030 function focusableWithin(container) {
1031 return [...container.querySelectorAll(
1032 'button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])',
1033 )].filter((node) => !node.closest("[hidden]") && node.getAttribute("aria-hidden") !== "true");
1034 }
1035
1036 function trapFocusWithin(event, container) {
1037 if (event.key !== "Tab") return false;
1038 const focusable = focusableWithin(container);
1039 if (focusable.length === 0) {
1040 event.preventDefault();
1041 container.focus({ preventScroll: true });
1042 return true;
1043 }
1044 const first = focusable[0];
1045 const last = focusable.at(-1);
1046 const active = document.activeElement;
1047 if (event.shiftKey && (active === first || !container.contains(active))) {
1048 event.preventDefault();
1049 last.focus({ preventScroll: true });
1050 return true;
1051 }
1052 if (!event.shiftKey && (active === last || !container.contains(active))) {
1053 event.preventDefault();
1054 first.focus({ preventScroll: true });
1055 return true;
1056 }
1057 return false;
1058 }
1059
1060 function trapRailFocus(event) {
1061 if (
1062 event.key !== "Tab"
1063 || !narrowRail.matches
1064 || !dom.shell.classList.contains("rail-visible")
1065 ) return false;
1066 return trapFocusWithin(event, dom.rail);
1067 }
1068
1069 function setConnection(kind, message) {
1070 dom.connectionDot.className = `connection-dot ${kind || ""}`.trim();
1071 setSafeText(dom.connectionLabel, message);
1072 }
1073
1074 function showStatus(message) {
1075 setSafeText(dom.status, message || "");
1076 dom.status.hidden = !message;
1077 }
1078
1079 const sessionFetch = createWebSessionFetch({
1080 location: window.location,
1081 history: window.history,
1082 storage: () => window.sessionStorage,
1083 pageProof: document.querySelector('meta[name="codewhale-web-request"]')?.content,
1084 fetch: (...args) => fetch(...args),
1085 });
1086
1087 async function api(path, options = {}) {
1088 const response = await sessionFetch(path, options);
1089 if (!response.ok) {
1090 let message = `${response.status} ${response.statusText}`.trim();
1091 try {
1092 const body = await response.json();
1093 message = body?.error?.message || body?.message || message;
1094 } catch (_error) {
1095 // The status line is enough when the response is not JSON.
1096 }
1097 if (response.status === 401) {
1098 message = "This browser session is not authenticated. Restart `codewhale web` to open a fresh one-time session.";
1099 }
1100 throw Object.assign(new Error(message), { status: response.status });
1101 }
1102 if (response.status === 204) return null;
1103 const contentType = response.headers.get("content-type") || "";
1104 return contentType.includes("application/json") ? response.json() : response.text();
1105 }
1106
1107 function renderThreadList() {
1108 dom.threadList.replaceChildren();
1109 if (app.summaries.length === 0) {
1110 const empty = element("p", "thread-preview", "No matching threads");
1111 empty.style.padding = "8px 10px";
1112 dom.threadList.append(empty);
1113 return;
1114 }
1115
1116 const groups = groupThreadSummaries(app.summaries);
1117 if (groups.needsYou.length > 0) {
1118 appendThreadGroup("Needs you", "needs-you", groups.needsYou);
1119 }
1120 if (groups.recent.length > 0) {
1121 appendThreadGroup("Recent", "recent", groups.recent);
1122 }
1123 }
1124
1125 function appendThreadGroup(label, idSuffix, summaries) {
1126 const group = element("section", `thread-group thread-group-${idSuffix}`);
1127 const headingId = `thread-group-${idSuffix}-title`;
1128 const heading = element("h2", "rail-section-title thread-group-title", label);
1129 heading.id = headingId;
1130 group.setAttribute("aria-labelledby", headingId);
1131 group.append(heading);
1132
1133 for (const summary of summaries) {
1134 const row = element("button", "thread-row");
1135 row.type = "button";
1136 row.dataset.threadId = summary.id;
1137 row.setAttribute("aria-current", summary.id === app.selectedThreadId ? "true" : "false");
1138 const titleRow = element("span", "thread-title-row");
1139 titleRow.append(element("span", "thread-title", summary.title || "New thread"));
1140 const indicators = element("span", "thread-row-indicators");
1141 const attentionCount = pendingAttentionCount(summary);
1142 if (attentionCount > 0) {
1143 const attention = element("span", "thread-attention-count", String(attentionCount));
1144 attention.setAttribute("aria-label", pendingAttentionLabel(summary));
1145 indicators.append(attention);
1146 }
1147 const status = element("span", `status-pip ${summary.latest_turn_status === "inprogress" || summary.latest_turn_status === "in_progress" ? "running" : summary.latest_turn_status === "failed" ? "failed" : ""}`);
1148 status.setAttribute("aria-label", summary.latest_turn_status || "idle");
1149 indicators.append(status);
1150 titleRow.append(indicators);
1151 row.append(titleRow);
1152 row.append(element("span", "thread-preview", summary.preview || "No messages yet"));
1153 const branch = summary.branch || basename(summary.workspace) || "local";
1154 row.append(element("span", "thread-meta", `${branch} · ${relativeTime(summary.updated_at)}`));
1155 row.addEventListener("click", () => selectThread(summary.id));
1156 group.append(row);
1157 }
1158 dom.threadList.append(group);
1159 }
1160
1161 async function loadThreads(search = dom.search.value.trim()) {
1162 const query = new URLSearchParams({ limit: "100" });
1163 if (search) query.set("search", search);
1164 app.summaries = await api(`/v1/threads/summary?${query.toString()}`);
1165 renderThreadList();
1166 return app.summaries;
1167 }
1168
1169 // Saved sessions are the durable session store the terminal browses. They
1170 // are rendered with the same row shape as threads because
1171 // /v1/sessions/summary and /v1/threads/summary are field-compatible
1172 // projections — one vocabulary, not two.
1173 function renderSessionList() {
1174 dom.sessionList.replaceChildren();
1175 // The section only exists when the backend actually returned sessions;
1176 // an affordance for an empty store would imply a capability that has
1177 // nothing behind it.
1178 dom.savedSessions.hidden = app.sessionSummaries.length === 0;
1179 if (app.sessionSummaries.length === 0) return;
1180
1181 for (const summary of app.sessionSummaries) {
1182 const row = element("button", "thread-row");
1183 row.type = "button";
1184 row.dataset.sessionId = summary.id;
1185 const titleRow = element("span", "thread-title-row");
1186 titleRow.append(element("span", "thread-title", summary.title || "Untitled session"));
1187 row.append(titleRow);
1188 row.append(element("span", "thread-preview", summary.preview || summary.title));
1189 const scope = basename(summary.workspace) || "local";
1190 row.append(
1191 element(
1192 "span",
1193 "thread-meta",
1194 `${scope} · ${summary.message_count} msg · ${relativeTime(summary.updated_at)}`,
1195 ),
1196 );
1197 row.setAttribute(
1198 "aria-current",
1199 app.target.kind === "session" && app.target.sessionId === summary.id ? "true" : "false",
1200 );
1201 // Click peeks; resuming is a separate, explicit button inside the peek.
1202 row.addEventListener("click", () => peekSession(summary.id));
1203 dom.sessionList.append(row);
1204 }
1205 }
1206
1207 async function loadSessions(search = dom.search.value.trim()) {
1208 const query = new URLSearchParams({ limit: "50" });
1209 if (search) query.set("search", search);
1210 try {
1211 app.sessionSummaries = await api(`/v1/sessions/summary?${query.toString()}`);
1212 } catch (_error) {
1213 // A runtime without a readable session store is not a broken dashboard;
1214 // hide the section rather than blocking the thread view behind an error.
1215 app.sessionSummaries = [];
1216 }
1217 renderSessionList();
1218 return app.sessionSummaries;
1219 }
1220
1221 // Resume goes through the existing endpoint, which seeds a real thread from
1222 // the saved messages. The dashboard does not reconstruct history itself.
1223 // Selecting a saved session shows a read-only peek. It does NOT resume:
1224 // resuming spawns a real thread and an engine, which must be a deliberate
1225 // act, not a side effect of clicking a row to see what it was about.
1226 async function peekSession(sessionId) {
1227 stopStream();
1228 app.selectedThreadId = "";
1229 app.threadState = createThreadState();
1230 app.target = sessionTarget(sessionId);
1231 showStatus("");
1232 renderThreadList();
1233 renderSessionList();
1234 try {
1235 // `?peek=true` returns a bounded, redacted projection — twelve entries,
1236 // tool payloads summarised — so the browser never receives the full
1237 // transcript in order to display a preview of it.
1238 app.peek = await api(
1239 `/v1/sessions/${encodeURIComponent(sessionId)}?peek=true&entries=12`,
1240 );
1241 } catch (error) {
1242 app.peek = null;
1243 showStatus(error.message);
1244 }
1245 renderAll();
1246 }
1247
1248 async function resumeSession(sessionId) {
1249 showStatus("");
1250 try {
1251 const resumed = await api(`/v1/sessions/${encodeURIComponent(sessionId)}/resume-thread`, {
1252 method: "POST",
1253 body: "{}",
1254 });
1255 app.peek = null;
1256 await loadThreads("");
1257 // `selectThread` sets the live thread target; only after this can the
1258 // composer or an approval act.
1259 await selectThread(resumed.thread_id);
1260 showStatus(resumed.summary || "");
1261 } catch (error) {
1262 showStatus(error.message);
1263 }
1264 }
1265
1266 // Render the read-only peek pane for a selected saved session.
1267 function renderPeek() {
1268 if (!dom.peek) return;
1269 const showing = app.target.kind === "session" && app.peek;
1270 dom.peek.hidden = !showing;
1271 if (!showing) {
1272 dom.peek.replaceChildren();
1273 return;
1274 }
1275 const peek = app.peek;
1276 dom.peek.replaceChildren();
1277
1278 const header = element("div", "peek-header");
1279 header.append(element("p", "eyebrow", "Saved session — read only"));
1280 header.append(element("h2", "", peek.title || "Untitled session"));
1281 header.append(
1282 element(
1283 "p",
1284 "thread-meta",
1285 `${basename(peek.workspace) || "local"} · ${peek.message_count} messages · ${relativeTime(peek.updated_at)}${peek.archived ? " · archived" : ""}`,
1286 ),
1287 );
1288 dom.peek.append(header);
1289
1290 if (peek.omitted_before > 0) {
1291 dom.peek.append(
1292 element("p", "peek-omitted", `${peek.omitted_before} earlier messages not shown`),
1293 );
1294 }
1295
1296 for (const entry of peek.entries || []) {
1297 const row = element("div", `peek-entry peek-${entry.kind}`);
1298 row.append(element("span", "peek-kind", entry.kind));
1299 // `element()` assigns via textContent. Peek text is recorded user/model
1300 // content and must never reach an HTML sink; this is the XSS boundary.
1301 row.append(element("p", "peek-text", entry.text));
1302 if (entry.redacted) row.append(element("span", "peek-flag", "redacted"));
1303 if (entry.truncated) row.append(element("span", "peek-flag", "truncated"));
1304 dom.peek.append(row);
1305 }
1306
1307 const resume = element("button", "primary-button", "Resume into a live thread");
1308 resume.type = "button";
1309 resume.addEventListener("click", () => resumeSession(peek.session_id));
1310 dom.peek.append(resume);
1311 }
1312
1313 const { connectStream, stopStream } = createStreamConnector({
1314 app, api, EventSource, setConnection, showStatus,
1315 receive: (stream, message, threadId, generation) => {
1316 try {
1317 const envelope = JSON.parse(message.data);
1318 if (runtimeEventContinuity(app.threadState, envelope) === "gap") {
1319 app.streamGap = true;
1320 renderStreamCursor();
1321 showStatus("Runtime event continuity changed; refreshing the thread snapshot…");
1322 void recoverProjection(threadId, generation, stream);
1323 return;
1324 }
1325 if (!applyRuntimeEvent(app.threadState, envelope)) return;
1326 renderAll(true);
1327 if (
1328 envelope.event === "turn.completed"
1329 || envelope.event === "thread.updated"
1330 || envelope.event === "approval.required"
1331 || envelope.event === "approval.decided"
1332 || envelope.event === "approval.timeout"
1333 || envelope.event === "user_input.required"
1334 || envelope.event === "user_input.answered"
1335 || envelope.event === "user_input.canceled"
1336 ) {
1337 loadThreads().catch((error) => showStatus(error.message));
1338 }
1339 } catch (error) {
1340 showStatus(`Could not read a Runtime event: ${error.message}`);
1341 }
1342 },
1343 });
1344
1345 async function selectThread(threadId) {
1346 if (!threadId) return;
1347 saveDraft(app.drafts, app.selectedThreadId, dom.composerInput.value);
1348 stopStream();
1349 app.selectedThreadId = threadId;
1350 // A live thread is now the target: from here the composer and approvals
1351 // may act. Clear any saved-session peek so the two surfaces are exclusive.
1352 app.target = threadTarget(threadId);
1353 app.peek = null;
1354 app.streamGap = false;
1355 app.threadState = createThreadState(threadId);
1356 app.generation += 1;
1357 const generation = app.generation;
1358 dom.composerInput.value = restoreDraft(app.drafts, threadId);
1359 resizeComposer();
1360 renderThreadList();
1361 renderSessionList();
1362 renderAll();
1363 closeRailIfNarrow();
1364 setConnection("", "Loading thread snapshot…");
1365 showStatus("");
1366
1367 try {
1368 const subscribed = await snapshotThenSubscribe({
1369 state: app.threadState,
1370 threadId,
1371 loadSnapshot: (id) => api(`/v1/threads/${encodeURIComponent(id)}`),
1372 subscribe: (id, sequence) => {
1373 return connectStream(id, sequence, generation);
1374 },
1375 isCurrent: () => generation === app.generation && threadId === app.selectedThreadId,
1376 });
1377 if (!subscribed) return;
1378 renderAll();
1379 setConnection("ready", "Local runtime connected");
1380 } catch (error) {
1381 if (generation !== app.generation) return;
1382 showStatus(error.message);
1383 setConnection("error", "Runtime connection failed");
1384 }
1385 }
1386
1387 async function recoverProjection(threadId, generation, sourceStream = null) {
1388 if (
1389 generation !== app.generation
1390 || threadId !== app.selectedThreadId
1391 || (sourceStream && app.stream !== sourceStream)
1392 ) return;
1393
1394 stopStream();
1395 setConnection("", "Refreshing thread snapshot…");
1396
1397 try {
1398 const subscribed = await recoverSnapshotAndSubscribe({
1399 state: app.threadState,
1400 threadId,
1401 loadSnapshot: (id) => api(`/v1/threads/${encodeURIComponent(id)}`),
1402 subscribe: (id, sequence) => connectStream(id, sequence, generation, true),
1403 isCurrent: () => generation === app.generation && threadId === app.selectedThreadId,
1404 }, () => {
1405 // A gap is continuous again only after both the replacement snapshot
1406 // and the replacement EventSource open handshake have succeeded.
1407 app.streamGap = false;
1408 });
1409 if (!subscribed) return;
1410 renderAll();
1411 showStatus("");
1412 setConnection("ready", "Local runtime connected");
1413 } catch (error) {
1414 if (generation !== app.generation || threadId !== app.selectedThreadId) return;
1415 showStatus(`Could not refresh the thread snapshot: ${error.message}`);
1416 setConnection("error", "Runtime recovery failed");
1417 if (error.status !== 401 && error.status !== 403) {
1418 app.reconnectTimer = setTimeout(
1419 () => recoverProjection(threadId, generation),
1420 900,
1421 );
1422 }
1423 }
1424 }
1425
1426 function renderAll(preserveScroll = false) {
1427 renderHeader();
1428 renderPeek();
1429 renderTranscript(preserveScroll);
1430 renderAttention();
1431 renderComposer();
1432 renderStreamCursor();
1433 }
1434
1435 // Show the SSE resume cursor so "am I reading everything?" is answerable.
1436 function renderStreamCursor() {
1437 if (app.target.kind !== "thread") return;
1438 const cursor = streamCursor(app.threadState, {
1439 gap: app.streamGap,
1440 connected: Boolean(app.stream),
1441 });
1442 setConnection(cursor.gap ? "error" : cursor.connected ? "ready" : "", cursor.label);
1443 }
1444
1445 function renderHeader() {
1446 const thread = app.threadState.thread;
1447 const summary = app.summaries.find((item) => item.id === app.selectedThreadId);
1448 const title = thread?.title || summary?.title || (thread ? "New thread" : "Choose a thread");
1449 setSafeText(dom.title, title);
1450 setSafeText(dom.kicker, thread ? "Local Runtime thread" : "Local Runtime");
1451 dom.rename.disabled = !thread;
1452 dom.archive.disabled = !thread;
1453 dom.facts.replaceChildren();
1454 if (!thread) return;
1455
1456 const workspace = summary?.workspace || thread.workspace || app.workspace?.workspace;
1457 const branch = summary?.branch || app.workspace?.branch;
1458 dom.facts.append(factChip("Workspace", basename(workspace) || "local"));
1459 if (branch) dom.facts.append(factChip("Branch", branch));
1460 const provider = threadProviderLabel(thread);
1461 if (provider) dom.facts.append(factChip("Provider", provider));
1462 dom.facts.append(factChip("Model", thread.model || "Runtime default"));
1463 dom.facts.append(factChip("Mode", modeLabel(thread.mode)));
1464 dom.facts.append(factChip("Permission", permissionLabel(thread)));
1465 }
1466
1467 function factChip(label, value) {
1468 const chip = element("span", "fact-chip");
1469 chip.dataset.fact = String(label || "").toLowerCase();
1470 chip.append(element("span", "", label));
1471 chip.append(element("strong", "", value));
1472 return chip;
1473 }
1474
1475 function renderTranscript(preserveScroll) {
1476 const wasNearBottom = dom.transcript.scrollHeight - dom.transcript.scrollTop - dom.transcript.clientHeight < 120;
1477 if (!app.threadState.thread) {
1478 renderTranscriptEmpty(
1479 "choose-thread",
1480 "Your local agent, in the browser.",
1481 "Create a thread or choose one from the rail. This client uses the same Runtime as the terminal.",
1482 );
1483 return;
1484 }
1485 if (app.threadState.itemOrder.length === 0) {
1486 renderTranscriptEmpty(
1487 "ready",
1488 "Ready for a task.",
1489 "Send a message below. Model, mode, and permission posture come from the Runtime and are shown read-only above.",
1490 );
1491 return;
1492 }
1493
1494 const selection = captureTranscriptSelection();
1495 const existing = new Map(
1496 [...dom.transcript.children]
1497 .filter((node) => node.dataset.itemId)
1498 .map((node) => [node.dataset.itemId, node]),
1499 );
1500 const desired = [];
1501 for (const itemId of app.threadState.itemOrder) {
1502 const item = app.threadState.items.get(itemId);
1503 if (!item) continue;
1504 let node = existing.get(itemId);
1505 if (!node || !updateItemNode(node, item)) node = renderItem(item);
1506 desired.push(node);
1507 }
1508 reconcileChildren(dom.transcript, desired);
1509 restoreTranscriptSelection(selection);
1510 if (!preserveScroll || wasNearBottom) {
1511 requestAnimationFrame(() => {
1512 dom.transcript.scrollTop = dom.transcript.scrollHeight;
1513 });
1514 }
1515 }
1516
1517 function renderTranscriptEmpty(kind, title, description) {
1518 const current = dom.transcript.children.length === 1
1519 ? dom.transcript.firstElementChild
1520 : null;
1521 if (current?.dataset.emptyState === kind) return;
1522 const empty = emptyState(title, description);
1523 empty.dataset.emptyState = kind;
1524 reconcileChildren(dom.transcript, [empty]);
1525 }
1526
1527 function reconcileChildren(container, desired) {
1528 const keep = new Set(desired);
1529 let cursor = container.firstElementChild;
1530 for (const node of desired) {
1531 if (node === cursor) {
1532 cursor = cursor.nextElementSibling;
1533 } else {
1534 container.insertBefore(node, cursor);
1535 }
1536 }
1537 for (const child of [...container.children]) {
1538 if (!keep.has(child)) child.remove();
1539 }
1540 }
1541
1542 function emptyState(title, description) {
1543 const empty = element("div", "empty-state");
1544 const mark = document.createElement("img");
1545 mark.className = "empty-mark";
1546 mark.src = "/assets/codewhale-192.png";
1547 mark.alt = "";
1548 empty.append(mark);
1549 empty.append(element("h2", "", title));
1550 empty.append(element("p", "", description));
1551 return empty;
1552 }
1553
1554 function renderItem(item) {
1555 let card;
1556 if (item.kind === "user_message" || item.kind === "agent_message") {
1557 const role = item.kind === "user_message" ? "user" : "agent";
1558 card = element("article", `message ${role}`);
1559 const label = element("div", "message-label");
1560 label.dataset.itemPart = "label";
1561 const body = element("div", "message-body");
1562 body.dataset.itemPart = "body";
1563 card.append(label, body);
1564 } else if (item.kind === "agent_reasoning") {
1565 card = element("article", "reasoning");
1566 const disclosure = element("details");
1567 const summary = element("summary");
1568 summary.dataset.itemPart = "summary";
1569 const detail = element("pre");
1570 detail.dataset.itemPart = "detail";
1571 disclosure.append(summary, detail);
1572 card.append(disclosure);
1573 } else {
1574 card = element("article", "receipt");
1575 card.append(element("span", "receipt-dot"));
1576 const copy = element("span", "receipt-copy");
1577 const label = element("strong");
1578 label.dataset.itemPart = "label";
1579 const summary = element("span", "receipt-summary");
1580 summary.dataset.itemPart = "summary";
1581 copy.append(label, summary);
1582 card.append(copy);
1583 }
1584 card.dataset.itemId = item.id;
1585 card.dataset.itemKind = item.kind;
1586 updateItemNode(card, item);
1587 return card;
1588 }
1589
1590 function updateItemNode(card, item) {
1591 if (card.dataset.itemKind !== item.kind) return false;
1592 const detail = item.detail || item.summary || "";
1593 if (item.kind === "user_message" || item.kind === "agent_message") {
1594 const role = item.kind === "user_message" ? "user" : "agent";
1595 card.className = `message ${role} ${item.status === "in_progress" ? "in-progress" : ""}`.trim();
1596 setTextIfChanged(card.querySelector('[data-item-part="label"]'), role === "user" ? "You" : "Codewhale");
1597 setTextIfChanged(card.querySelector('[data-item-part="body"]'), detail);
1598 return true;
1599 }
1600 if (item.kind === "agent_reasoning") {
1601 setTextIfChanged(
1602 card.querySelector('[data-item-part="summary"]'),
1603 item.status === "in_progress" ? "Reasoning…" : "Reasoning",
1604 );
1605 setTextIfChanged(card.querySelector('[data-item-part="detail"]'), detail);
1606 return true;
1607 }
1608
1609 const presentation = receiptPresentation(item);
1610 card.className = `receipt ${presentation.failed ? "failed" : ""}`.trim();
1611 setTextIfChanged(card.querySelector('[data-item-part="label"]'), presentation.label);
1612 setTextIfChanged(card.querySelector('[data-item-part="summary"]'), presentation.summary);
1613 const copy = card.querySelector(".receipt-copy");
1614 let disclosure = copy.querySelector("details");
1615 if (presentation.raw && presentation.raw !== presentation.summary) {
1616 if (!disclosure) {
1617 disclosure = element("details");
1618 const summary = element("summary", "", "Show receipt");
1619 const raw = element("pre");
1620 raw.dataset.itemPart = "raw";
1621 disclosure.append(summary, raw);
1622 copy.append(disclosure);
1623 }
1624 setTextIfChanged(disclosure.querySelector('[data-item-part="raw"]'), presentation.raw);
1625 } else if (disclosure) {
1626 disclosure.remove();
1627 }
1628 return true;
1629 }
1630
1631 function setTextIfChanged(target, value) {
1632 const next = value == null ? "" : String(value);
1633 if (target.textContent !== next) setSafeText(target, next);
1634 }
1635
1636 function captureTranscriptSelection() {
1637 const selection = globalThis.getSelection?.();
1638 if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return null;
1639 const range = selection.getRangeAt(0);
1640 const start = transcriptSelectionEndpoint(range.startContainer, range.startOffset);
1641 const end = transcriptSelectionEndpoint(range.endContainer, range.endOffset);
1642 return start && end ? { start, end } : null;
1643 }
1644
1645 function transcriptSelectionEndpoint(node, offset) {
1646 const elementNode = node.nodeType === 1 ? node : node.parentElement;
1647 const item = elementNode?.closest?.("[data-item-id]");
1648 if (!item || !dom.transcript.contains(item)) return null;
1649 const prefix = document.createRange();
1650 prefix.selectNodeContents(item);
1651 try {
1652 prefix.setEnd(node, offset);
1653 } catch (_error) {
1654 return null;
1655 }
1656 return { itemId: item.dataset.itemId, offset: prefix.toString().length };
1657 }
1658
1659 function restoreTranscriptSelection(captured) {
1660 if (!captured) return;
1661 const startRoot = [...dom.transcript.children]
1662 .find((node) => node.dataset.itemId === captured.start.itemId);
1663 const endRoot = [...dom.transcript.children]
1664 .find((node) => node.dataset.itemId === captured.end.itemId);
1665 if (!startRoot || !endRoot) return;
1666 const start = textPointAt(startRoot, captured.start.offset);
1667 const end = textPointAt(endRoot, captured.end.offset);
1668 if (!start || !end) return;
1669 const range = document.createRange();
1670 try {
1671 range.setStart(start.node, start.offset);
1672 range.setEnd(end.node, end.offset);
1673 } catch (_error) {
1674 return;
1675 }
1676 const selection = globalThis.getSelection?.();
1677 if (!selection) return;
1678 selection.removeAllRanges();
1679 selection.addRange(range);
1680 }
1681
1682 function textPointAt(root, requestedOffset) {
1683 const walker = document.createTreeWalker(
1684 root,
1685 globalThis.NodeFilter?.SHOW_TEXT || 4,
1686 );
1687 let remaining = Math.max(0, requestedOffset);
1688 let last = null;
1689 for (let node = walker.nextNode(); node; node = walker.nextNode()) {
1690 last = node;
1691 const length = node.data.length;
1692 if (remaining <= length) return { node, offset: remaining };
1693 remaining -= length;
1694 }
1695 return last ? { node: last, offset: last.data.length } : null;
1696 }
1697
1698 function renderAttention() {
1699 const existing = new Map(
1700 [...dom.attention.children]
1701 .filter((node) => node.dataset.attentionKey)
1702 .map((node) => [node.dataset.attentionKey, node]),
1703 );
1704 const desired = [];
1705 for (const [approvalId, approval] of app.threadState.approvals) {
1706 const key = `approval:${approvalId}`;
1707 const card = existing.get(key) || renderApproval(approvalId, approval);
1708 card.dataset.attentionKey = key;
1709 setAttentionCardBusyNode(card, app.inFlightActions.has(key));
1710 desired.push(card);
1711 }
1712 for (const [inputId, input] of app.threadState.userInputs) {
1713 const key = `input:${inputId}`;
1714 const card = existing.get(key) || renderUserInput(inputId, input);
1715 card.dataset.attentionKey = key;
1716 setAttentionCardBusyNode(card, app.inFlightActions.has(key));
1717 desired.push(card);
1718 }
1719 reconcileChildren(dom.attention, desired);
1720 dom.attention.hidden = desired.length === 0;
1721 }
1722
1723 function setAttentionCardBusy(key, busy) {
1724 const card = [...dom.attention.children]
1725 .find((node) => node.dataset.attentionKey === key);
1726 if (card) setAttentionCardBusyNode(card, busy);
1727 }
1728
1729 function setAttentionCardBusyNode(card, busy) {
1730 card.setAttribute("aria-busy", busy ? "true" : "false");
1731 for (const control of card.querySelectorAll("button, input, textarea, select")) {
1732 control.disabled = busy;
1733 }
1734 }
1735
1736 function renderApproval(approvalId, approval) {
1737 const card = element("article", "attention-card");
1738 const titleId = `attention-approval-${safeDomId(approvalId)}`;
1739 card.setAttribute("role", "group");
1740 card.setAttribute("aria-labelledby", titleId);
1741 card.append(element("p", "eyebrow", "Approval required"));
1742 const title = element("h2", "", approval.tool_name || "Tool request");
1743 title.id = titleId;
1744 card.append(title);
1745 card.append(element("p", "", approval.intent_summary || approval.description || "Codewhale is waiting for permission."));
1746 const actions = element("div", "attention-actions");
1747 const rememberLabel = element("label", "remember-field");
1748 const remember = document.createElement("input");
1749 remember.type = "checkbox";
1750 rememberLabel.append(remember, document.createTextNode("Remember for this thread"));
1751 const deny = element("button", "quiet-button danger", "Deny");
1752 deny.type = "button";
1753 deny.addEventListener("click", () => resolveApproval(approvalId, "deny", remember.checked));
1754 const allow = element("button", "primary-button", "Allow");
1755 allow.type = "button";
1756 allow.addEventListener("click", () => resolveApproval(approvalId, "allow", remember.checked));
1757 actions.append(rememberLabel, deny, allow);
1758 card.append(actions);
1759 return card;
1760 }
1761
1762 async function resolveApproval(approvalId, decision, remember) {
1763 // Authority check before authority action. The approval must belong to the
1764 // thread we are actually watching, and that thread must be the selected
1765 // live target — never a saved-session peek, never a row the user has since
1766 // moved off. Refusals are loud and send nothing.
1767 const resolved = resolveApprovalTarget(approvalId, app.target, app.threadState);
1768 if (!resolved.ok) {
1769 showStatus(refusalMessage(resolved.reason));
1770 renderAttention();
1771 return;
1772 }
1773 const action = `approval:${approvalId}`;
1774 if (!claimInFlight(app.inFlightActions, action)) return;
1775 setAttentionCardBusy(action, true);
1776 try {
1777 await api(`/v1/approvals/${encodeURIComponent(resolved.approvalId)}`, {
1778 method: "POST",
1779 body: JSON.stringify({ decision, remember }),
1780 });
1781 app.threadState.approvals.delete(approvalId);
1782 showStatus("");
1783 renderAttention();
1784 } catch (error) {
1785 showStatus(error.message);
1786 } finally {
1787 app.inFlightActions.delete(action);
1788 setAttentionCardBusy(action, false);
1789 }
1790 }
1791
1792 function renderUserInput(inputId, envelope) {
1793 const card = element("form", "attention-card");
1794 const titleId = `attention-input-${safeDomId(inputId)}`;
1795 card.setAttribute("role", "group");
1796 card.setAttribute("aria-labelledby", titleId);
1797 card.append(element("p", "eyebrow", "Input required"));
1798 const title = element("h2", "", "Codewhale has a question");
1799 title.id = titleId;
1800 card.append(title);
1801 const questions = Array.isArray(envelope.request?.questions) ? envelope.request.questions : [];
1802 const groups = [];
1803 for (const question of questions) {
1804 const fieldset = element("fieldset", "question-fieldset");
1805 fieldset.append(element("legend", "", question.question || question.header || "Choose an option"));
1806 const controls = [];
1807 for (const option of Array.isArray(question.options) ? question.options : []) {
1808 const label = element("label", "answer-option");
1809 const input = document.createElement("input");
1810 input.type = question.multi_select ? "checkbox" : "radio";
1811 input.name = `question-${inputId}-${question.id}`;
1812 input.value = option.label || "";
1813 label.append(input);
1814 const copy = element("span", "", option.label || "Option");
1815 if (option.description) copy.append(element("small", "", option.description));
1816 label.append(copy);
1817 fieldset.append(label);
1818 controls.push({ input, label: option.label || "", value: option.label || "" });
1819 }
1820 // Match the terminal surface: a custom answer stays available even when
1821 // an older request omitted or disabled the legacy allow_free_text hint.
1822 const other = document.createElement("input");
1823 other.className = "other-answer";
1824 other.type = "text";
1825 other.placeholder = "Other response";
1826 other.setAttribute("aria-label", `${question.header || "Question"} other response`);
1827 if (!question.multi_select) {
1828 other.addEventListener("input", () => {
1829 if (other.value.trim()) {
1830 for (const control of controls) control.input.checked = false;
1831 }
1832 });
1833 for (const control of controls) {
1834 control.input.addEventListener("change", () => {
1835 if (control.input.checked) other.value = "";
1836 });
1837 }
1838 }
1839 fieldset.append(other);
1840 card.append(fieldset);
1841 groups.push({ question, controls, other });
1842 }
1843 const actions = element("div", "attention-actions");
1844 const submit = element("button", "primary-button", "Submit answers");
1845 submit.type = "submit";
1846 actions.append(submit);
1847 card.append(actions);
1848 card.addEventListener("submit", async (event) => {
1849 event.preventDefault();
1850 const selections = new Map();
1851 const freeText = new Map();
1852 for (const group of groups) {
1853 const selected = [];
1854 for (const control of group.controls) {
1855 if (control.input.checked) selected.push(control.value);
1856 }
1857 selections.set(group.question.id, selected);
1858 freeText.set(group.question.id, group.other.value);
1859 }
1860 const resolved = resolveUserInputTarget(inputId, app.target, app.threadState);
1861 if (!resolved.ok) {
1862 showStatus(refusalMessage(resolved.reason));
1863 renderAttention();
1864 return;
1865 }
1866 const built = answersForUserInput(envelope.request, selections, freeText);
1867 if (!built.ok) {
1868 const message = built.reason === "missing-answer"
1869 ? `Choose an answer for ${built.question}.`
1870 : built.reason === "multiple-answers"
1871 ? `Choose one answer for ${built.question}.`
1872 : `That question changed before it could be submitted — nothing was sent.`;
1873 showStatus(message);
1874 return;
1875 }
1876 const action = `input:${inputId}`;
1877 if (!claimInFlight(app.inFlightActions, action)) return;
1878 setAttentionCardBusy(action, true);
1879 try {
1880 await api(`/v1/user-input/${encodeURIComponent(resolved.threadId)}/${encodeURIComponent(resolved.inputId)}`, {
1881 method: "POST",
1882 body: JSON.stringify({ answers: built.answers }),
1883 });
1884 app.threadState.userInputs.delete(inputId);
1885 showStatus("");
1886 renderAttention();
1887 } catch (error) {
1888 showStatus(error.message);
1889 } finally {
1890 app.inFlightActions.delete(action);
1891 setAttentionCardBusy(action, false);
1892 }
1893 });
1894 return card;
1895 }
1896
1897 function safeDomId(value) {
1898 return String(value || "item").replace(/[^a-zA-Z0-9_-]/g, "-");
1899 }
1900
1901 function latestTurn() {
1902 const id = app.threadState.turnOrder.at(-1);
1903 return id ? app.threadState.turns.get(id) : null;
1904 }
1905
1906 function activeTurn() {
1907 const turn = latestTurn();
1908 return turn && (turn.status === "in_progress" || turn.status === "queued") ? turn : null;
1909 }
1910
1911 function renderComposer() {
1912 const ready = Boolean(app.threadState.thread);
1913 const active = activeTurn();
1914 const sending = app.inFlightActions.has(composerSendAction);
1915 dom.composerInput.disabled = sending || !ready;
1916 dom.send.disabled = sending || !ready || !dom.composerInput.value.trim();
1917 dom.composer.setAttribute("aria-busy", sending ? "true" : "false");
1918 dom.interrupt.hidden = !active;
1919 setSafeText(dom.send, sending ? (active ? "Steering…" : "Sending…") : active ? "Steer" : "Send");
1920 }
1921
1922 function selectedNewThreadProvider() {
1923 const providerId = dom.newThreadProvider.value;
1924 return app.providerCatalog?.providers?.find((provider) => provider.id === providerId) || null;
1925 }
1926
1927 function selectedNewThreadModel() {
1928 const provider = selectedNewThreadProvider();
1929 return provider?.has_model_catalog
1930 ? dom.newThreadModel.value
1931 : dom.newThreadModelInput.value.trim();
1932 }
1933
1934 function setNewThreadStatus(message, state = "") {
1935 setSafeText(dom.newThreadStatus, message || "");
1936 dom.newThreadStatus.dataset.state = state;
1937 }
1938
1939 function renderNewThreadCapability() {
1940 const provider = selectedNewThreadProvider();
1941 const modelId = selectedNewThreadModel();
1942 if (!provider || !modelId) {
1943 setSafeText(dom.newThreadCapability, "");
1944 dom.newThreadCapability.dataset.state = "unknown";
1945 return;
1946 }
1947 const model = provider.has_model_catalog
1948 ? app.newThreadModels.find((entry) => entry.id === modelId)
1949 : null;
1950 const presentation = imageInputPresentation(model?.image_input);
1951 dom.newThreadCapability.dataset.state = presentation.state;
1952 setSafeText(
1953 dom.newThreadCapability,
1954 `${presentation.label} — ${presentation.description}`,
1955 );
1956 }
1957
1958 function syncNewThreadControls() {
1959 const provider = selectedNewThreadProvider();
1960 const hasCatalog = Boolean(provider?.has_model_catalog);
1961 const busy = app.newThreadLoading || app.creatingThread;
1962 dom.newThreadProvider.disabled = app.creatingThread || !app.providerCatalog?.providers?.length;
1963 dom.newThreadModel.disabled = busy || !hasCatalog || app.newThreadModels.length === 0;
1964 dom.newThreadModelInput.disabled = busy || !provider || hasCatalog;
1965 dom.newThreadCancel.disabled = app.creatingThread;
1966 dom.newThreadCreate.disabled = busy || !provider || !selectedNewThreadModel();
1967 renderNewThreadCapability();
1968 }
1969
1970 function setNewThreadModelSurface(provider) {
1971 const hasCatalog = Boolean(provider?.has_model_catalog);
1972 dom.newThreadModelSelectField.hidden = !hasCatalog;
1973 dom.newThreadModelInputField.hidden = hasCatalog;
1974 }
1975
1976 async function loadNewThreadModels(providerId, preferredModel, generation) {
1977 if (generation !== app.newThreadGeneration || !dom.newThreadDialog.open) return;
1978 const provider = app.providerCatalog?.providers?.find((entry) => entry.id === providerId);
1979 app.newThreadModels = [];
1980 dom.newThreadModel.replaceChildren();
1981 dom.newThreadModelInput.value = "";
1982 setNewThreadModelSurface(provider);
1983 if (!provider) {
1984 app.newThreadLoading = false;
1985 setNewThreadStatus("Choose a provider.", "error");
1986 syncNewThreadControls();
1987 return;
1988 }
1989
1990 const modelDefault = String(preferredModel || provider.default_model || "").trim();
1991 if (!provider.has_model_catalog) {
1992 dom.newThreadModelInput.value = modelDefault;
1993 app.newThreadLoading = false;
1994 setNewThreadStatus("");
1995 syncNewThreadControls();
1996 return;
1997 }
1998
1999 app.newThreadLoading = true;
2000 setNewThreadStatus("Loading models…");
2001 syncNewThreadControls();
2002 try {
2003 const modelEntries = await collectProviderModelPages(provider.id, async (path) => {
2004 const page = await api(path);
2005 if (generation !== app.newThreadGeneration || !dom.newThreadDialog.open) {
2006 throw new Error("The model request was superseded.");
2007 }
2008 return page;
2009 });
2010 if (generation !== app.newThreadGeneration || !dom.newThreadDialog.open) return;
2011 const seen = new Set();
2012 const models = [];
2013 for (const entry of modelEntries) {
2014 const id = String(entry?.id || "").trim();
2015 const key = id.toLowerCase();
2016 if (!id || seen.has(key)) continue;
2017 seen.add(key);
2018 models.push({
2019 id,
2020 image_input: ["supported", "unsupported", "unknown"].includes(entry?.image_input)
2021 ? entry.image_input
2022 : "unknown",
2023 });
2024 }
2025 if (modelDefault && !seen.has(modelDefault.toLowerCase())) {
2026 models.unshift({ id: modelDefault, image_input: "unknown" });
2027 }
2028 app.newThreadModels = models;
2029 for (const model of models) {
2030 const option = document.createElement("option");
2031 option.value = model.id;
2032 setSafeText(option, modelOptionLabel(model));
2033 dom.newThreadModel.append(option);
2034 }
2035 const selectedDefault = models.find(
2036 (model) => model.id.toLowerCase() === modelDefault.toLowerCase(),
2037 );
2038 if (selectedDefault) dom.newThreadModel.value = selectedDefault.id;
2039 app.newThreadLoading = false;
2040 setNewThreadStatus(
2041 models.length ? "" : "No models are available for this provider.",
2042 models.length ? "" : "error",
2043 );
2044 syncNewThreadControls();
2045 } catch (error) {
2046 if (generation !== app.newThreadGeneration || !dom.newThreadDialog.open) return;
2047 app.newThreadLoading = false;
2048 setNewThreadStatus(`Could not load models: ${error.message}`, "error");
2049 syncNewThreadControls();
2050 }
2051 }
2052
2053 async function openNewThreadDialog() {
2054 if (dom.newThreadDialog.open || app.creatingThread) return;
2055 dom.newThreadDialog.showModal();
2056 dom.newThreadCancel.focus({ preventScroll: true });
2057 const generation = ++app.newThreadGeneration;
2058 app.providerCatalog = null;
2059 app.newThreadModels = [];
2060 app.newThreadLoading = true;
2061 dom.newThreadProvider.replaceChildren();
2062 dom.newThreadModel.replaceChildren();
2063 dom.newThreadModelInput.value = "";
2064 setNewThreadStatus("Loading providers…");
2065 renderNewThreadCapability();
2066 syncNewThreadControls();
2067 try {
2068 const catalog = await api("/v1/providers");
2069 if (generation !== app.newThreadGeneration || !dom.newThreadDialog.open) return;
2070 const providers = Array.isArray(catalog?.providers)
2071 ? catalog.providers.filter((provider) => String(provider?.id || "").trim())
2072 : [];
2073 if (providers.length === 0) throw new Error("The Runtime returned no providers.");
2074 app.providerCatalog = { ...catalog, providers };
2075 for (const provider of providers) {
2076 const option = document.createElement("option");
2077 option.value = provider.id;
2078 setSafeText(option, providerOptionLabel(provider));
2079 dom.newThreadProvider.append(option);
2080 }
2081 const defaults = newThreadDefaults(app.providerCatalog);
2082 dom.newThreadProvider.value = defaults.providerId;
2083 dom.newThreadProvider.disabled = false;
2084 dom.newThreadProvider.focus({ preventScroll: true });
2085 await loadNewThreadModels(defaults.providerId, defaults.model, generation);
2086 } catch (error) {
2087 if (generation !== app.newThreadGeneration || !dom.newThreadDialog.open) return;
2088 app.newThreadLoading = false;
2089 setNewThreadStatus(`Could not load providers: ${error.message}`, "error");
2090 syncNewThreadControls();
2091 }
2092 }
2093
2094 async function submitNewThread(event) {
2095 event.preventDefault();
2096 const provider = selectedNewThreadProvider();
2097 let request;
2098 try {
2099 request = buildCreateThreadRequest(
2100 dom.newThreadProvider.value,
2101 selectedNewThreadModel(),
2102 provider?.model_provider_id,
2103 );
2104 } catch (error) {
2105 setNewThreadStatus(error.message, "error");
2106 return;
2107 }
2108 app.creatingThread = true;
2109 setNewThreadStatus("Creating thread…");
2110 dom.newThreadDialog.focus({ preventScroll: true });
2111 syncNewThreadControls();
2112 const thread = await createThread(
2113 request,
2114 (message) => setNewThreadStatus(message, message ? "error" : ""),
2115 );
2116 app.creatingThread = false;
2117 if (thread) {
2118 dom.newThreadDialog.close();
2119 dom.composerInput.focus();
2120 return;
2121 }
2122 syncNewThreadControls();
2123 dom.newThreadProvider.focus({ preventScroll: true });
2124 }
2125
2126 async function createThread(request = {}, reportError = showStatus) {
2127 showStatus("");
2128 reportError("");
2129 try {
2130 const thread = await api("/v1/threads", {
2131 method: "POST",
2132 body: JSON.stringify(request),
2133 });
2134 await loadThreads("");
2135 await selectThread(thread.id);
2136 dom.composerInput.focus();
2137 return thread;
2138 } catch (error) {
2139 reportError(error.message);
2140 return null;
2141 }
2142 }
2143
2144 async function sendMessage() {
2145 const prompt = dom.composerInput.value.trim();
2146 if (!prompt) return;
2147 // A reply goes to a live thread or nowhere. A saved-session peek must not
2148 // silently resume-and-send: that would attach the user's message to a
2149 // thread they never asked to create.
2150 if (app.target.kind === "session") {
2151 showStatus(refusalMessage("session-not-live"));
2152 return;
2153 }
2154 if (!claimInFlight(app.inFlightActions, composerSendAction)) return;
2155 renderComposer();
2156 showStatus("");
2157 try {
2158 let threadId = app.selectedThreadId;
2159 if (!threadId) {
2160 const thread = await createThread();
2161 if (!thread) return;
2162 threadId = thread.id;
2163 }
2164 const resolved = resolveReplyTarget(threadTarget(threadId), app.threadState);
2165 if (!resolved.ok) {
2166 showStatus(refusalMessage(resolved.reason));
2167 return;
2168 }
2169 threadId = resolved.threadId;
2170 const turn = activeTurn();
2171 if (turn) {
2172 await api(`/v1/threads/${encodeURIComponent(threadId)}/turns/${encodeURIComponent(turn.id)}/steer`, {
2173 method: "POST",
2174 body: JSON.stringify({ prompt }),
2175 });
2176 } else {
2177 await api(`/v1/threads/${encodeURIComponent(threadId)}/turns`, {
2178 method: "POST",
2179 body: JSON.stringify({ prompt }),
2180 });
2181 }
2182 saveDraft(app.drafts, threadId, "");
2183 dom.composerInput.value = "";
2184 resizeComposer();
2185 renderComposer();
2186 loadThreads().catch((error) => showStatus(error.message));
2187 } catch (error) {
2188 showStatus(error.message);
2189 } finally {
2190 app.inFlightActions.delete(composerSendAction);
2191 renderComposer();
2192 }
2193 }
2194
2195 async function interruptTurn() {
2196 const turn = activeTurn();
2197 if (!turn || !app.selectedThreadId) return;
2198 dom.interrupt.disabled = true;
2199 try {
2200 await api(`/v1/threads/${encodeURIComponent(app.selectedThreadId)}/turns/${encodeURIComponent(turn.id)}/interrupt`, { method: "POST" });
2201 } catch (error) {
2202 showStatus(error.message);
2203 } finally {
2204 dom.interrupt.disabled = false;
2205 }
2206 }
2207
2208 async function archiveThread() {
2209 if (!app.selectedThreadId) return;
2210 if (!globalThis.confirm("Archive this thread? You can still access it through the Runtime API.")) return;
2211 try {
2212 await api(`/v1/threads/${encodeURIComponent(app.selectedThreadId)}`, {
2213 method: "PATCH",
2214 body: JSON.stringify({ archived: true }),
2215 });
2216 saveDraft(app.drafts, app.selectedThreadId, "");
2217 stopStream();
2218 app.selectedThreadId = "";
2219 app.threadState = createThreadState();
2220 await loadThreads();
2221 if (app.summaries[0]) await selectThread(app.summaries[0].id);
2222 else renderAll();
2223 } catch (error) {
2224 showStatus(error.message);
2225 }
2226 }
2227
2228 function openRenameDialog() {
2229 if (!app.threadState.thread) return;
2230 dom.renameInput.value = app.threadState.thread.title || "";
2231 dom.renameDialog.showModal();
2232 dom.renameInput.focus();
2233 dom.renameInput.select();
2234 }
2235
2236 async function submitRename(event) {
2237 event.preventDefault();
2238 const action = event.submitter?.value;
2239 if (action !== "save") {
2240 dom.renameDialog.close();
2241 return;
2242 }
2243 const title = dom.renameInput.value.trim();
2244 if (!title || !app.selectedThreadId) return;
2245 try {
2246 const thread = await api(`/v1/threads/${encodeURIComponent(app.selectedThreadId)}`, {
2247 method: "PATCH",
2248 body: JSON.stringify({ title }),
2249 });
2250 app.threadState.thread = thread;
2251 dom.renameDialog.close();
2252 await loadThreads();
2253 renderHeader();
2254 } catch (error) {
2255 showStatus(error.message);
2256 }
2257 }
2258
2259 function resizeComposer() {
2260 dom.composerInput.style.height = "auto";
2261 dom.composerInput.style.height = `${Math.min(dom.composerInput.scrollHeight, 220)}px`;
2262 }
2263
2264 function closeRailIfNarrow() {
2265 if (globalThis.matchMedia("(max-width: 800px)").matches) closeRail();
2266 }
2267
2268 dom.railOpen.addEventListener("click", openRail);
2269 dom.railClose.addEventListener("click", closeRail);
2270 dom.railScrim.addEventListener("click", closeRail);
2271 dom.newThread.addEventListener("click", () => void openNewThreadDialog());
2272 dom.newThreadForm.addEventListener("submit", submitNewThread);
2273 dom.newThreadProvider.addEventListener("change", () => {
2274 const provider = selectedNewThreadProvider();
2275 const generation = ++app.newThreadGeneration;
2276 void loadNewThreadModels(
2277 provider?.id || "",
2278 provider?.default_model || "",
2279 generation,
2280 );
2281 });
2282 dom.newThreadModel.addEventListener("change", syncNewThreadControls);
2283 dom.newThreadModelInput.addEventListener("input", syncNewThreadControls);
2284 dom.newThreadCancel.addEventListener("click", () => {
2285 if (!app.creatingThread) dom.newThreadDialog.close();
2286 });
2287 dom.newThreadDialog.addEventListener("cancel", (event) => {
2288 if (app.creatingThread) event.preventDefault();
2289 });
2290 dom.newThreadDialog.addEventListener("keydown", (event) => {
2291 trapFocusWithin(event, dom.newThreadDialog);
2292 });
2293 dom.newThreadDialog.addEventListener("close", () => {
2294 app.newThreadGeneration += 1;
2295 app.newThreadLoading = false;
2296 setNewThreadStatus("");
2297 });
2298 dom.rename.addEventListener("click", openRenameDialog);
2299 dom.archive.addEventListener("click", archiveThread);
2300 dom.renameForm.addEventListener("submit", submitRename);
2301 dom.interrupt.addEventListener("click", interruptTurn);
2302 dom.composer.addEventListener("submit", (event) => {
2303 event.preventDefault();
2304 sendMessage();
2305 });
2306 dom.composerInput.addEventListener("input", () => {
2307 saveDraft(app.drafts, app.selectedThreadId, dom.composerInput.value);
2308 resizeComposer();
2309 renderComposer();
2310 });
2311 dom.composerInput.addEventListener("keydown", (event) => {
2312 if (!isComposerSubmitKey(event)) return;
2313 event.preventDefault();
2314 sendMessage();
2315 });
2316 dom.search.addEventListener("input", () => {
2317 if (app.searchTimer) clearTimeout(app.searchTimer);
2318 app.searchTimer = setTimeout(() => {
2319 loadThreads().catch((error) => showStatus(error.message));
2320 loadSessions().catch(() => {});
2321 }, 180);
2322 });
2323 document.addEventListener("keydown", (event) => {
2324 if (dom.newThreadDialog.open) return;
2325 if (trapRailFocus(event)) return;
2326 if (event.key === "Escape" && narrowRail.matches && dom.shell.classList.contains("rail-visible")) {
2327 event.preventDefault();
2328 closeRail();
2329 }
2330 });
2331 narrowRail.addEventListener("change", syncRailAccessibility);
2332 globalThis.visualViewport?.addEventListener("resize", syncVisualViewport);
2333 globalThis.visualViewport?.addEventListener("scroll", syncVisualViewport);
2334 globalThis.addEventListener("resize", syncVisualViewport);
2335 globalThis.addEventListener("beforeunload", stopStream);
2336
2337 async function initialize() {
2338 syncVisualViewport();
2339 syncRailAccessibility();
2340 try {
2341 [app.runtimeInfo, app.workspace] = await Promise.all([
2342 api("/v1/runtime/info"),
2343 api("/v1/workspace/status"),
2344 ]);
2345 renderRuntimeProvenance(dom.runtimeProvenance, app.runtimeInfo);
2346 setConnection("ready", "Local runtime connected");
2347 await loadThreads();
2348 await loadSessions();
2349 if (app.summaries[0]) await selectThread(app.summaries[0].id);
2350 else renderAll();
2351 } catch (error) {
2352 setConnection("error", "Runtime connection failed");
2353 showStatus(error.message);
2354 renderAll();
2355 }
2356 }
2357
2358 initialize();
2359 }
2360
2361 function basename(path) {
2362 if (!path) return "";
2363 const normalized = String(path).replaceAll("\\", "/").replace(/\/$/, "");
2364 return normalized.split("/").at(-1) || normalized;
2365 }
2366
2367 function humanize(value) {
2368 if (!value) return "Status";
2369 return String(value)
2370 .replaceAll("_", " ")
2371 .replace(/\b\w/g, (letter) => letter.toUpperCase());
2372 }
2373
2374 export function modeLabel(mode) {
2375 if (mode === "agent") return "Work";
2376 if (mode === "plan") return "Plan";
2377 if (mode === "operate") return "Operate";
2378 return humanize(mode || "Runtime default");
2379 }
2380
2381 export function formatRuntimeProvenance(runtimeInfo) {
2382 const version = String(
2383 runtimeInfo?.codewhale_version || runtimeInfo?.version || "",
2384 ).trim() || "version unknown";
2385 const commit = String(runtimeInfo?.codewhale_commit || "").trim();
2386 const source = /^[0-9a-f]{40}$/i.test(commit)
2387 ? commit.slice(0, 12)
2388 : "source unknown";
2389 return `${version} · ${source}`;
2390 }
2391
2392 export function renderRuntimeProvenance(element, runtimeInfo) {
2393 return setSafeText(element, formatRuntimeProvenance(runtimeInfo));
2394 }
2395
2396 function permissionLabel(thread) {
2397 if (thread.trust_mode) return "Full Access";
2398 if (thread.auto_approve) return "Auto-Review";
2399 return "Ask";
2400 }
2401
2402 function relativeTime(value) {
2403 const timestamp = Date.parse(value);
2404 if (!Number.isFinite(timestamp)) return "recent";
2405 const seconds = Math.max(0, Math.round((Date.now() - timestamp) / 1000));
2406 if (seconds < 60) return "now";
2407 if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
2408 if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
2409 return `${Math.floor(seconds / 86400)}d`;
2410 }
2411
2412 if (typeof document !== "undefined") {
2413 startBrowserClient();
2414 }
2415
2415 lines Plain Text