返回 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.steered",
8 "turn.interrupt_requested",
9 "turn.completed",
10 "item.started",
11 "item.delta",
12 "item.completed",
13 "item.failed",
14 "item.interrupted",
15 "approval.required",
16 "approval.decided",
17 "approval.timeout",
18 "user_input.required",
19 "user_input.answered",
20 "user_input.canceled",
21 "sandbox.denied",
22 "agent.spawned",
23 "agent.progress",
24 "agent.completed",
25 "agent.list",
26 "tool_call.requested",
27 "tool_call.resolved",
28 "tool_call.canceled",
29 "tool_call.timeout",
30 ];
31
32 export function createThreadState(threadId = "") {
33 return {
34 threadId,
35 thread: null,
36 turns: new Map(),
37 turnOrder: [],
38 items: new Map(),
39 itemOrder: [],
40 latestSeq: 0,
41 approvals: new Map(),
42 userInputs: new Map(),
43 dynamicToolCalls: new Map(),
44 };
45 }
46
47 export function applySnapshot(state, detail, expectedThreadId = state.threadId) {
48 if (!detail || !detail.thread || detail.thread.id !== expectedThreadId) {
49 return false;
50 }
51 state.threadId = expectedThreadId;
52 state.thread = detail.thread;
53 state.turns = new Map();
54 state.turnOrder = [];
55 for (const turn of Array.isArray(detail.turns) ? detail.turns : []) {
56 if (!turn || !turn.id) continue;
57 state.turns.set(turn.id, turn);
58 state.turnOrder.push(turn.id);
59 }
60 state.items = new Map();
61 state.itemOrder = [];
62 for (const item of Array.isArray(detail.items) ? detail.items : []) {
63 if (!item || !item.id) continue;
64 state.items.set(item.id, item);
65 state.itemOrder.push(item.id);
66 }
67 state.latestSeq = normalizedSequence(detail.latest_seq);
68 state.approvals = new Map();
69 for (const approval of Array.isArray(detail.pending_approvals) ? detail.pending_approvals : []) {
70 const approvalId = approval?.approval_id || approval?.id;
71 if (approvalId) state.approvals.set(approvalId, approval);
72 }
73 state.userInputs = new Map();
74 for (const input of Array.isArray(detail.pending_user_inputs) ? detail.pending_user_inputs : []) {
75 const inputId = input?.input_id || input?.id;
76 if (inputId) state.userInputs.set(inputId, input);
77 }
78 state.dynamicToolCalls = new Map();
79 for (const call of Array.isArray(detail.pending_dynamic_tool_calls) ? detail.pending_dynamic_tool_calls : []) {
80 if (call?.call_id) state.dynamicToolCalls.set(call.call_id, call);
81 }
82 return true;
83 }
84
85 export function applyRuntimeEvent(state, envelope) {
86 if (runtimeEventContinuity(state, envelope) !== "next") {
87 return false;
88 }
89 const sequence = normalizedSequence(envelope.seq);
90 state.latestSeq = sequence;
91
92 const eventName = envelope.event || envelope.kind || "";
93 const payload = envelope.payload && typeof envelope.payload === "object"
94 ? envelope.payload
95 : {};
96
97 if (
98 (eventName === "thread.started" || eventName === "thread.updated" || eventName === "thread.forked")
99 && payload.thread
100 ) {
101 state.thread = payload.thread;
102 } else if (eventName === "turn.started" || eventName === "turn.completed") {
103 if (payload.turn) upsertTurn(state, payload.turn);
104 if (eventName === "turn.completed") {
105 clearTurnAttention(state, envelope.turn_id || payload.turn?.id || "");
106 }
107 } else if (eventName === "turn.lifecycle") {
108 const turnId = envelope.turn_id;
109 const turn = turnId ? state.turns.get(turnId) : null;
110 if (turn && payload.status) {
111 state.turns.set(turnId, { ...turn, status: payload.status });
112 }
113 } else if (eventName === "turn.interrupt_requested") {
114 const turnId = envelope.turn_id;
115 const turn = turnId ? state.turns.get(turnId) : null;
116 if (turn) state.turns.set(turnId, { ...turn, status: "in_progress" });
117 } else if (
118 eventName === "item.started"
119 || eventName === "item.completed"
120 || eventName === "item.failed"
121 || eventName === "item.interrupted"
122 || eventName === "agent.spawned"
123 || eventName === "agent.progress"
124 || eventName === "agent.completed"
125 || eventName === "agent.list"
126 ) {
127 if (payload.item) upsertItem(state, payload.item);
128 } else if (eventName === "item.delta") {
129 appendItemDelta(state, envelope.item_id, payload);
130 } else if (eventName === "approval.required") {
131 const approvalId = payload.approval_id || payload.id;
132 if (approvalId) {
133 state.approvals.set(approvalId, {
134 ...payload,
135 turn_id: payload.turn_id || envelope.turn_id || "",
136 });
137 }
138 } else if (eventName === "approval.decided" || eventName === "approval.timeout") {
139 const approvalId = payload.approval_id || payload.id;
140 if (approvalId) state.approvals.delete(approvalId);
141 } else if (eventName === "user_input.required") {
142 const inputId = payload.id;
143 if (inputId) {
144 state.userInputs.set(inputId, {
145 ...payload,
146 turn_id: payload.turn_id || envelope.turn_id || "",
147 });
148 }
149 } else if (eventName === "user_input.answered" || eventName === "user_input.canceled") {
150 const inputId = payload.input_id || payload.id;
151 if (inputId) state.userInputs.delete(inputId);
152 } else if (eventName === "tool_call.requested") {
153 if (payload.call_id) {
154 state.dynamicToolCalls.set(payload.call_id, {
155 ...payload,
156 turn_id: payload.turn_id || envelope.turn_id || "",
157 });
158 }
159 } else if (
160 eventName === "tool_call.resolved"
161 || eventName === "tool_call.canceled"
162 || eventName === "tool_call.timeout"
163 ) {
164 if (payload.call_id) state.dynamicToolCalls.delete(payload.call_id);
165 }
166 return true;
167 }
168
169 function clearTurnAttention(state, turnId) {
170 for (const [id, approval] of state.approvals) {
171 if (!approval?.turn_id || approval.turn_id === turnId) state.approvals.delete(id);
172 }
173 for (const [id, input] of state.userInputs) {
174 if (!input?.turn_id || input.turn_id === turnId) state.userInputs.delete(id);
175 }
176 for (const [id, call] of state.dynamicToolCalls) {
177 if (!call?.turn_id || call.turn_id === turnId) state.dynamicToolCalls.delete(id);
178 }
179 }
180
181 export function runtimeEventContinuity(state, envelope) {
182 if (!envelope || envelope.thread_id !== state.threadId) {
183 return "ignore";
184 }
185 const sequence = normalizedSequence(envelope.seq);
186 if (sequence <= state.latestSeq) {
187 return "ignore";
188 }
189 if (Object.hasOwn(envelope, "previous_seq")) {
190 const previousSequence = normalizedSequence(envelope.previous_seq);
191 if (previousSequence !== state.latestSeq) {
192 return "gap";
193 }
194 }
195 return "next";
196 }
197
198 export async function snapshotThenSubscribe({
199 state,
200 threadId,
201 loadSnapshot,
202 subscribe,
203 isCurrent = () => true,
204 }) {
205 const detail = await loadSnapshot(threadId);
206 if (!isCurrent() || !applySnapshot(state, detail, threadId)) {
207 return false;
208 }
209 if (!isCurrent()) return false;
210 subscribe(threadId, state.latestSeq);
211 return true;
212 }
213
214 // ---------------------------------------------------------------------------
215 // Typed selection identity (#4397)
216 //
217 // The dashboard can have two very different things selected: a *saved session*
218 // (a recording on disk) or a *live thread* (a running runtime object). Almost
219 // every safety rule in this slice reduces to "which one is it?", so the answer
220 // is a typed value rather than a pair of loosely-related string fields that can
221 // both be set, both be empty, or disagree.
222 // ---------------------------------------------------------------------------
223
224 // Nothing selected.
225 export const NO_TARGET = Object.freeze({ kind: "none" });
226
227 // A saved session: read-only. Peek only, never reply, never approve.
228 export function sessionTarget(sessionId) {
229 return Object.freeze({ kind: "session", sessionId: String(sessionId || "") });
230 }
231
232 // A live thread: the only thing that can receive a reply or an approval.
233 export function threadTarget(threadId) {
234 return Object.freeze({ kind: "thread", threadId: String(threadId || "") });
235 }
236
237 // May the composer send to this target?
238 //
239 // Only a live thread. A saved session has no runtime to receive a message;
240 // offering a composer against one would be an affordance with nothing behind
241 // it, and "resume it silently on send" would attach the user's message to a
242 // thread they never asked to create.
243 export function canReply(target) {
244 return target?.kind === "thread" && Boolean(target.threadId);
245 }
246
247 // Resolve the id a reply must be POSTed to, or an explicit refusal.
248 //
249 // Fails closed on every ambiguity: no target, a session target, or a target
250 // whose thread is not the one the live stream is following (a stale target —
251 // the user changed rows while a request was in flight).
252 export function resolveReplyTarget(target, streamState) {
253 if (!target || target.kind === "none") {
254 return { ok: false, reason: "no-target" };
255 }
256 if (target.kind === "session") {
257 return { ok: false, reason: "session-not-live" };
258 }
259 if (!target.threadId) {
260 return { ok: false, reason: "no-target" };
261 }
262 if (streamState && streamState.threadId && streamState.threadId !== target.threadId) {
263 return { ok: false, reason: "stale-target" };
264 }
265 return { ok: true, threadId: target.threadId };
266 }
267
268 // Resolve an approval decision to the thread that owns it, or refuse.
269 //
270 // An approval is authority: answering the wrong one, or answering one that
271 // has already been decided elsewhere, is worse than not answering. So the
272 // approval must be present in the *current* stream state, and that state must
273 // belong to the selected live thread.
274 export function resolveApprovalTarget(approvalId, target, streamState) {
275 const reply = resolveReplyTarget(target, streamState);
276 if (!reply.ok) return reply;
277 if (!approvalId) return { ok: false, reason: "no-approval" };
278 if (!streamState || streamState.threadId !== reply.threadId) {
279 return { ok: false, reason: "stale-target" };
280 }
281 if (!streamState.approvals || !streamState.approvals.has(approvalId)) {
282 // Decided, timed out, or belonging to a thread we are no longer watching.
283 return { ok: false, reason: "stale-approval" };
284 }
285 return { ok: true, threadId: reply.threadId, approvalId };
286 }
287
288 // Human-readable reason for a refusal, for the status banner.
289 export function refusalMessage(reason) {
290 switch (reason) {
291 case "session-not-live":
292 return "This is a saved session, not a live thread — nothing was sent. Resume it first to reply.";
293 case "stale-target":
294 return "That thread is no longer the selected one — nothing was sent.";
295 case "stale-approval":
296 return "That request was already answered or has expired — nothing was sent.";
297 case "no-approval":
298 return "No approval was identified — nothing was sent.";
299 default:
300 return "Select a live thread first — nothing was sent.";
301 }
302 }
303
304 // The SSE resume cursor and whether the stream is known to have a gap.
305 //
306 // Surfaced rather than kept internal: after a reconnect the user needs to
307 // know whether what they are reading is continuous or whether events were
308 // missed and a re-snapshot is pending.
309 export function streamCursor(state, { gap = false, connected = true } = {}) {
310 const seq = normalizedSequence(state?.latestSeq);
311 return {
312 latestSeq: seq,
313 gap: Boolean(gap),
314 connected: Boolean(connected),
315 label: !connected
316 ? `Reconnecting — resuming from #${seq}`
317 : gap
318 ? `Gap detected — re-syncing from #${seq}`
319 : `Live — event #${seq}`,
320 };
321 }
322
323 export function eventStreamUrl(threadId, latestSeq) {
324 return `/v1/threads/${encodeURIComponent(threadId)}/events?since_seq=${normalizedSequence(latestSeq)}`;
325 }
326
327 export function saveDraft(drafts, threadId, value) {
328 if (!threadId) return;
329 if (value) drafts.set(threadId, value);
330 else drafts.delete(threadId);
331 }
332
333 export function restoreDraft(drafts, threadId) {
334 return drafts.get(threadId) || "";
335 }
336
337 export function setSafeText(element, value) {
338 element.textContent = value == null ? "" : String(value);
339 return element;
340 }
341
342 function normalizedSequence(value) {
343 const sequence = Number(value);
344 return Number.isSafeInteger(sequence) && sequence > 0 ? sequence : 0;
345 }
346
347 function upsertTurn(state, turn) {
348 if (!turn || !turn.id) return;
349 if (!state.turns.has(turn.id)) state.turnOrder.push(turn.id);
350 state.turns.set(turn.id, turn);
351 }
352
353 function upsertItem(state, item) {
354 if (!item || !item.id) return;
355 if (!state.items.has(item.id)) state.itemOrder.push(item.id);
356 state.items.set(item.id, item);
357 }
358
359 function appendItemDelta(state, itemId, payload) {
360 if (!itemId) return;
361 const delta = typeof payload.delta === "string" ? payload.delta : "";
362 const existing = state.items.get(itemId) || {
363 id: itemId,
364 turn_id: "",
365 kind: payload.kind || "agent_message",
366 status: "in_progress",
367 summary: "",
368 detail: "",
369 };
370 if (!state.items.has(itemId)) state.itemOrder.push(itemId);
371 state.items.set(itemId, {
372 ...existing,
373 status: "in_progress",
374 detail: `${existing.detail || ""}${delta}`,
375 });
376 }
377
378 function startBrowserClient() {
379 const dom = {
380 shell: document.querySelector("#app-shell"),
381 railOpen: document.querySelector("#rail-open"),
382 railClose: document.querySelector("#rail-close"),
383 railScrim: document.querySelector("#rail-scrim"),
384 search: document.querySelector("#thread-search"),
385 threadList: document.querySelector("#thread-list"),
386 newThread: document.querySelector("#new-thread"),
387 connectionDot: document.querySelector("#connection-dot"),
388 connectionLabel: document.querySelector("#connection-label"),
389 kicker: document.querySelector("#session-kicker"),
390 title: document.querySelector("#session-title"),
391 facts: document.querySelector("#session-facts"),
392 rename: document.querySelector("#rename-thread"),
393 archive: document.querySelector("#archive-thread"),
394 status: document.querySelector("#status-banner"),
395 transcript: document.querySelector("#transcript"),
396 attention: document.querySelector("#attention"),
397 composer: document.querySelector("#composer"),
398 composerInput: document.querySelector("#composer-input"),
399 send: document.querySelector("#send-message"),
400 interrupt: document.querySelector("#interrupt-turn"),
401 renameDialog: document.querySelector("#rename-dialog"),
402 renameForm: document.querySelector("#rename-form"),
403 renameInput: document.querySelector("#rename-input"),
404 peek: document.querySelector("#session-peek"),
405 savedSessions: document.querySelector("#saved-sessions"),
406 sessionList: document.querySelector("#session-list"),
407 };
408
409 const app = {
410 summaries: [],
411 sessionSummaries: [],
412 // Typed selection: `none`, a read-only `session`, or a live `thread`.
413 // Every reply/approval authority check reads this, not a loose id.
414 target: NO_TARGET,
415 // Bounded, redacted peek for the selected saved session, or null.
416 peek: null,
417 // Set when the SSE stream reported a sequence gap and a re-snapshot is
418 // pending. Surfaced in the connection label rather than hidden.
419 streamGap: false,
420 selectedThreadId: "",
421 threadState: createThreadState(),
422 workspace: null,
423 runtimeInfo: null,
424 drafts: new Map(),
425 stream: null,
426 reconnectTimer: null,
427 generation: 0,
428 searchTimer: null,
429 };
430
431 function element(tag, className, text) {
432 const created = document.createElement(tag);
433 if (className) created.className = className;
434 if (text != null) setSafeText(created, text);
435 return created;
436 }
437
438 function closeRail() {
439 dom.shell.classList.remove("rail-visible");
440 dom.railOpen.focus({ preventScroll: true });
441 }
442
443 function setConnection(kind, message) {
444 dom.connectionDot.className = `connection-dot ${kind || ""}`.trim();
445 setSafeText(dom.connectionLabel, message);
446 }
447
448 function showStatus(message) {
449 setSafeText(dom.status, message || "");
450 dom.status.hidden = !message;
451 }
452
453 async function api(path, options = {}) {
454 const headers = new Headers(options.headers || {});
455 if (options.body != null && !headers.has("content-type")) {
456 headers.set("content-type", "application/json");
457 }
458 const response = await fetch(path, {
459 ...options,
460 headers,
461 credentials: "same-origin",
462 cache: "no-store",
463 });
464 if (!response.ok) {
465 let message = `${response.status} ${response.statusText}`.trim();
466 try {
467 const body = await response.json();
468 message = body?.error?.message || body?.message || message;
469 } catch (_error) {
470 // The status line is enough when the response is not JSON.
471 }
472 if (response.status === 401) {
473 message = "This browser session is not authenticated. Restart `codewhale web` to open a fresh one-time session.";
474 }
475 throw new Error(message);
476 }
477 if (response.status === 204) return null;
478 const contentType = response.headers.get("content-type") || "";
479 return contentType.includes("application/json") ? response.json() : response.text();
480 }
481
482 function renderThreadList() {
483 dom.threadList.replaceChildren();
484 if (app.summaries.length === 0) {
485 const empty = element("p", "thread-preview", "No matching threads");
486 empty.style.padding = "8px 10px";
487 dom.threadList.append(empty);
488 return;
489 }
490 for (const summary of app.summaries) {
491 const row = element("button", "thread-row");
492 row.type = "button";
493 row.dataset.threadId = summary.id;
494 row.setAttribute("aria-current", summary.id === app.selectedThreadId ? "true" : "false");
495 const titleRow = element("span", "thread-title-row");
496 titleRow.append(element("span", "thread-title", summary.title || "New thread"));
497 const status = element("span", `status-pip ${summary.latest_turn_status === "inprogress" || summary.latest_turn_status === "in_progress" ? "running" : summary.latest_turn_status === "failed" ? "failed" : ""}`);
498 status.setAttribute("aria-label", summary.latest_turn_status || "idle");
499 titleRow.append(status);
500 row.append(titleRow);
501 row.append(element("span", "thread-preview", summary.preview || "No messages yet"));
502 const branch = summary.branch || basename(summary.workspace) || "local";
503 row.append(element("span", "thread-meta", `${branch} · ${relativeTime(summary.updated_at)}`));
504 row.addEventListener("click", () => selectThread(summary.id));
505 dom.threadList.append(row);
506 }
507 }
508
509 async function loadThreads(search = dom.search.value.trim()) {
510 const query = new URLSearchParams({ limit: "100" });
511 if (search) query.set("search", search);
512 app.summaries = await api(`/v1/threads/summary?${query.toString()}`);
513 renderThreadList();
514 return app.summaries;
515 }
516
517 // Saved sessions are the durable session store the terminal browses. They
518 // are rendered with the same row shape as threads because
519 // /v1/sessions/summary and /v1/threads/summary are field-compatible
520 // projections — one vocabulary, not two.
521 function renderSessionList() {
522 dom.sessionList.replaceChildren();
523 // The section only exists when the backend actually returned sessions;
524 // an affordance for an empty store would imply a capability that has
525 // nothing behind it.
526 dom.savedSessions.hidden = app.sessionSummaries.length === 0;
527 if (app.sessionSummaries.length === 0) return;
528
529 for (const summary of app.sessionSummaries) {
530 const row = element("button", "thread-row");
531 row.type = "button";
532 row.dataset.sessionId = summary.id;
533 const titleRow = element("span", "thread-title-row");
534 titleRow.append(element("span", "thread-title", summary.title || "Untitled session"));
535 row.append(titleRow);
536 row.append(element("span", "thread-preview", summary.preview || summary.title));
537 const scope = basename(summary.workspace) || "local";
538 row.append(
539 element(
540 "span",
541 "thread-meta",
542 `${scope} · ${summary.message_count} msg · ${relativeTime(summary.updated_at)}`,
543 ),
544 );
545 row.setAttribute(
546 "aria-current",
547 app.target.kind === "session" && app.target.sessionId === summary.id ? "true" : "false",
548 );
549 // Click peeks; resuming is a separate, explicit button inside the peek.
550 row.addEventListener("click", () => peekSession(summary.id));
551 dom.sessionList.append(row);
552 }
553 }
554
555 async function loadSessions(search = dom.search.value.trim()) {
556 const query = new URLSearchParams({ limit: "50" });
557 if (search) query.set("search", search);
558 try {
559 app.sessionSummaries = await api(`/v1/sessions/summary?${query.toString()}`);
560 } catch (_error) {
561 // A runtime without a readable session store is not a broken dashboard;
562 // hide the section rather than blocking the thread view behind an error.
563 app.sessionSummaries = [];
564 }
565 renderSessionList();
566 return app.sessionSummaries;
567 }
568
569 // Resume goes through the existing endpoint, which seeds a real thread from
570 // the saved messages. The dashboard does not reconstruct history itself.
571 // Selecting a saved session shows a read-only peek. It does NOT resume:
572 // resuming spawns a real thread and an engine, which must be a deliberate
573 // act, not a side effect of clicking a row to see what it was about.
574 async function peekSession(sessionId) {
575 stopStream();
576 app.selectedThreadId = "";
577 app.threadState = createThreadState();
578 app.target = sessionTarget(sessionId);
579 showStatus("");
580 renderThreadList();
581 renderSessionList();
582 try {
583 // `?peek=true` returns a bounded, redacted projection — twelve entries,
584 // tool payloads summarised — so the browser never receives the full
585 // transcript in order to display a preview of it.
586 app.peek = await api(
587 `/v1/sessions/${encodeURIComponent(sessionId)}?peek=true&entries=12`,
588 );
589 } catch (error) {
590 app.peek = null;
591 showStatus(error.message);
592 }
593 renderAll();
594 }
595
596 async function resumeSession(sessionId) {
597 showStatus("");
598 try {
599 const resumed = await api(`/v1/sessions/${encodeURIComponent(sessionId)}/resume-thread`, {
600 method: "POST",
601 body: "{}",
602 });
603 app.peek = null;
604 await loadThreads("");
605 // `selectThread` sets the live thread target; only after this can the
606 // composer or an approval act.
607 await selectThread(resumed.thread_id);
608 showStatus(resumed.summary || "");
609 } catch (error) {
610 showStatus(error.message);
611 }
612 }
613
614 // Render the read-only peek pane for a selected saved session.
615 function renderPeek() {
616 if (!dom.peek) return;
617 const showing = app.target.kind === "session" && app.peek;
618 dom.peek.hidden = !showing;
619 if (!showing) {
620 dom.peek.replaceChildren();
621 return;
622 }
623 const peek = app.peek;
624 dom.peek.replaceChildren();
625
626 const header = element("div", "peek-header");
627 header.append(element("p", "eyebrow", "Saved session — read only"));
628 header.append(element("h2", "", peek.title || "Untitled session"));
629 header.append(
630 element(
631 "p",
632 "thread-meta",
633 `${basename(peek.workspace) || "local"} · ${peek.message_count} messages · ${relativeTime(peek.updated_at)}${peek.archived ? " · archived" : ""}`,
634 ),
635 );
636 dom.peek.append(header);
637
638 if (peek.omitted_before > 0) {
639 dom.peek.append(
640 element("p", "peek-omitted", `${peek.omitted_before} earlier messages not shown`),
641 );
642 }
643
644 for (const entry of peek.entries || []) {
645 const row = element("div", `peek-entry peek-${entry.kind}`);
646 row.append(element("span", "peek-kind", entry.kind));
647 // `element()` assigns via textContent. Peek text is recorded user/model
648 // content and must never reach an HTML sink; this is the XSS boundary.
649 row.append(element("p", "peek-text", entry.text));
650 if (entry.redacted) row.append(element("span", "peek-flag", "redacted"));
651 if (entry.truncated) row.append(element("span", "peek-flag", "truncated"));
652 dom.peek.append(row);
653 }
654
655 const resume = element("button", "primary-button", "Resume into a live thread");
656 resume.type = "button";
657 resume.addEventListener("click", () => resumeSession(peek.session_id));
658 dom.peek.append(resume);
659 }
660
661 function stopStream() {
662 if (app.stream) app.stream.close();
663 app.stream = null;
664 if (app.reconnectTimer) clearTimeout(app.reconnectTimer);
665 app.reconnectTimer = null;
666 }
667
668 async function selectThread(threadId) {
669 if (!threadId) return;
670 saveDraft(app.drafts, app.selectedThreadId, dom.composerInput.value);
671 stopStream();
672 app.selectedThreadId = threadId;
673 // A live thread is now the target: from here the composer and approvals
674 // may act. Clear any saved-session peek so the two surfaces are exclusive.
675 app.target = threadTarget(threadId);
676 app.peek = null;
677 app.streamGap = false;
678 app.threadState = createThreadState(threadId);
679 app.generation += 1;
680 const generation = app.generation;
681 dom.composerInput.value = restoreDraft(app.drafts, threadId);
682 resizeComposer();
683 renderThreadList();
684 renderAll();
685 closeRailIfNarrow();
686 setConnection("", "Loading thread snapshot…");
687 showStatus("");
688
689 try {
690 const subscribed = await snapshotThenSubscribe({
691 state: app.threadState,
692 threadId,
693 loadSnapshot: (id) => api(`/v1/threads/${encodeURIComponent(id)}`),
694 subscribe: (id, sequence) => connectStream(id, sequence, generation),
695 isCurrent: () => generation === app.generation && threadId === app.selectedThreadId,
696 });
697 if (!subscribed) return;
698 renderAll();
699 setConnection("ready", "Local runtime connected");
700 } catch (error) {
701 if (generation !== app.generation) return;
702 showStatus(error.message);
703 setConnection("error", "Runtime connection failed");
704 }
705 }
706
707 function connectStream(threadId, sequence, generation) {
708 if (generation !== app.generation || threadId !== app.selectedThreadId) return;
709 if (app.stream) app.stream.close();
710 const stream = new EventSource(eventStreamUrl(threadId, sequence), { withCredentials: true });
711 app.stream = stream;
712 stream.onopen = () => setConnection("ready", "Local runtime connected");
713 const receive = (message) => {
714 if (
715 app.stream !== stream
716 || generation !== app.generation
717 || threadId !== app.selectedThreadId
718 ) return;
719 try {
720 const envelope = JSON.parse(message.data);
721 if (runtimeEventContinuity(app.threadState, envelope) === "gap") {
722 app.streamGap = true;
723 renderStreamCursor();
724 showStatus("Runtime event continuity changed; refreshing the thread snapshot…");
725 void recoverProjection(threadId, generation, stream);
726 return;
727 }
728 if (!applyRuntimeEvent(app.threadState, envelope)) return;
729 renderAll(true);
730 if (envelope.event === "turn.completed" || envelope.event === "thread.updated") {
731 loadThreads().catch((error) => showStatus(error.message));
732 }
733 } catch (error) {
734 showStatus(`Could not read a Runtime event: ${error.message}`);
735 }
736 };
737 for (const name of STREAM_EVENT_NAMES) stream.addEventListener(name, receive);
738 stream.onerror = () => {
739 if (app.stream !== stream) {
740 stream.close();
741 return;
742 }
743 stream.close();
744 app.stream = null;
745 if (generation !== app.generation || threadId !== app.selectedThreadId) return;
746 setConnection("", "Reconnecting to local runtime…");
747 app.reconnectTimer = setTimeout(
748 () => connectStream(threadId, app.threadState.latestSeq, generation),
749 900,
750 );
751 };
752 }
753
754 async function recoverProjection(threadId, generation, sourceStream = null) {
755 if (
756 generation !== app.generation
757 || threadId !== app.selectedThreadId
758 || (sourceStream && app.stream !== sourceStream)
759 ) return;
760
761 if (app.stream) app.stream.close();
762 app.stream = null;
763 if (app.reconnectTimer) clearTimeout(app.reconnectTimer);
764 app.reconnectTimer = null;
765 setConnection("", "Refreshing thread snapshot…");
766
767 try {
768 const subscribed = await snapshotThenSubscribe({
769 state: app.threadState,
770 threadId,
771 loadSnapshot: (id) => api(`/v1/threads/${encodeURIComponent(id)}`),
772 subscribe: (id, sequence) => connectStream(id, sequence, generation),
773 isCurrent: () => generation === app.generation && threadId === app.selectedThreadId,
774 });
775 if (!subscribed) return;
776 renderAll();
777 showStatus("");
778 setConnection("ready", "Local runtime connected");
779 } catch (error) {
780 if (generation !== app.generation || threadId !== app.selectedThreadId) return;
781 showStatus(`Could not refresh the thread snapshot: ${error.message}`);
782 setConnection("error", "Runtime recovery failed");
783 app.reconnectTimer = setTimeout(
784 () => recoverProjection(threadId, generation),
785 900,
786 );
787 }
788 }
789
790 function renderAll(preserveScroll = false) {
791 renderHeader();
792 renderPeek();
793 renderTranscript(preserveScroll);
794 renderAttention();
795 renderComposer();
796 renderThreadList();
797 renderSessionList();
798 renderStreamCursor();
799 }
800
801 // Show the SSE resume cursor so "am I reading everything?" is answerable.
802 function renderStreamCursor() {
803 if (app.target.kind !== "thread") return;
804 const cursor = streamCursor(app.threadState, {
805 gap: app.streamGap,
806 connected: Boolean(app.stream),
807 });
808 setConnection(cursor.gap ? "error" : cursor.connected ? "ready" : "", cursor.label);
809 }
810
811 function renderHeader() {
812 const thread = app.threadState.thread;
813 const summary = app.summaries.find((item) => item.id === app.selectedThreadId);
814 const title = thread?.title || summary?.title || (thread ? "New thread" : "Choose a thread");
815 setSafeText(dom.title, title);
816 setSafeText(dom.kicker, thread ? "Local Runtime thread" : "Local Runtime");
817 dom.rename.disabled = !thread;
818 dom.archive.disabled = !thread;
819 dom.facts.replaceChildren();
820 if (!thread) return;
821
822 const workspace = summary?.workspace || thread.workspace || app.workspace?.workspace;
823 const branch = summary?.branch || app.workspace?.branch;
824 dom.facts.append(factChip("Workspace", basename(workspace) || "local"));
825 if (branch) dom.facts.append(factChip("Branch", branch));
826 dom.facts.append(factChip("Model", thread.model || "Runtime default"));
827 dom.facts.append(factChip("Mode", modeLabel(thread.mode)));
828 dom.facts.append(factChip("Permission", permissionLabel(thread)));
829 }
830
831 function factChip(label, value) {
832 const chip = element("span", "fact-chip");
833 chip.append(element("span", "", label));
834 chip.append(element("strong", "", value));
835 return chip;
836 }
837
838 function renderTranscript(preserveScroll) {
839 const wasNearBottom = dom.transcript.scrollHeight - dom.transcript.scrollTop - dom.transcript.clientHeight < 120;
840 dom.transcript.replaceChildren();
841 if (!app.threadState.thread) {
842 dom.transcript.append(emptyState("Your local agent, in the browser.", "Create a thread or choose one from the rail. This client uses the same Runtime as the terminal."));
843 return;
844 }
845 if (app.threadState.itemOrder.length === 0) {
846 dom.transcript.append(emptyState("Ready for a task.", "Send a message below. Model, mode, and permission posture come from the Runtime and are shown read-only above."));
847 return;
848 }
849 for (const itemId of app.threadState.itemOrder) {
850 const item = app.threadState.items.get(itemId);
851 if (!item) continue;
852 dom.transcript.append(renderItem(item));
853 }
854 if (!preserveScroll || wasNearBottom) {
855 requestAnimationFrame(() => {
856 dom.transcript.scrollTop = dom.transcript.scrollHeight;
857 });
858 }
859 }
860
861 function emptyState(title, description) {
862 const empty = element("div", "empty-state");
863 empty.append(element("div", "empty-orbit", "◌"));
864 empty.append(element("h2", "", title));
865 empty.append(element("p", "", description));
866 return empty;
867 }
868
869 function renderItem(item) {
870 const detail = item.detail || item.summary || "";
871 if (item.kind === "user_message" || item.kind === "agent_message") {
872 const role = item.kind === "user_message" ? "user" : "agent";
873 const card = element("article", `message ${role} ${item.status === "in_progress" ? "in-progress" : ""}`.trim());
874 card.append(element("div", "message-label", role === "user" ? "You" : "Codewhale"));
875 card.append(element("div", "message-body", detail));
876 return card;
877 }
878 if (item.kind === "agent_reasoning") {
879 const reasoning = element("article", "reasoning");
880 const disclosure = element("details");
881 disclosure.append(element("summary", "", item.status === "in_progress" ? "Reasoning…" : "Reasoning"));
882 disclosure.append(element("pre", "", detail));
883 reasoning.append(disclosure);
884 return reasoning;
885 }
886
887 const receipt = element("article", `receipt ${item.status === "failed" ? "failed" : ""}`.trim());
888 receipt.append(element("div", "receipt-label", `${humanize(item.kind)} · ${humanize(item.status)}`));
889 receipt.append(element("div", "receipt-summary", item.summary || detail || humanize(item.kind)));
890 if (detail && detail !== item.summary) {
891 const disclosure = element("details");
892 disclosure.append(element("summary", "", "Show receipt"));
893 disclosure.append(element("pre", "", detail));
894 receipt.append(disclosure);
895 }
896 return receipt;
897 }
898
899 function renderAttention() {
900 dom.attention.replaceChildren();
901 for (const [approvalId, approval] of app.threadState.approvals) {
902 dom.attention.append(renderApproval(approvalId, approval));
903 }
904 for (const [inputId, input] of app.threadState.userInputs) {
905 dom.attention.append(renderUserInput(inputId, input));
906 }
907 dom.attention.hidden = dom.attention.childElementCount === 0;
908 }
909
910 function renderApproval(approvalId, approval) {
911 const card = element("article", "attention-card");
912 card.append(element("p", "eyebrow", "Approval required"));
913 card.append(element("h2", "", approval.tool_name || "Tool request"));
914 card.append(element("p", "", approval.intent_summary || approval.description || "Codewhale is waiting for permission."));
915 const actions = element("div", "attention-actions");
916 const rememberLabel = element("label", "remember-field");
917 const remember = document.createElement("input");
918 remember.type = "checkbox";
919 rememberLabel.append(remember, document.createTextNode("Remember for this thread"));
920 const deny = element("button", "quiet-button danger", "Deny");
921 deny.type = "button";
922 deny.addEventListener("click", () => resolveApproval(approvalId, "deny", remember.checked));
923 const allow = element("button", "primary-button", "Allow");
924 allow.type = "button";
925 allow.addEventListener("click", () => resolveApproval(approvalId, "allow", remember.checked));
926 actions.append(rememberLabel, deny, allow);
927 card.append(actions);
928 return card;
929 }
930
931 async function resolveApproval(approvalId, decision, remember) {
932 // Authority check before authority action. The approval must belong to the
933 // thread we are actually watching, and that thread must be the selected
934 // live target — never a saved-session peek, never a row the user has since
935 // moved off. Refusals are loud and send nothing.
936 const resolved = resolveApprovalTarget(approvalId, app.target, app.threadState);
937 if (!resolved.ok) {
938 showStatus(refusalMessage(resolved.reason));
939 renderAttention();
940 return;
941 }
942 try {
943 await api(`/v1/approvals/${encodeURIComponent(resolved.approvalId)}`, {
944 method: "POST",
945 body: JSON.stringify({ decision, remember }),
946 });
947 app.threadState.approvals.delete(approvalId);
948 renderAttention();
949 } catch (error) {
950 showStatus(error.message);
951 }
952 }
953
954 function renderUserInput(inputId, envelope) {
955 const card = element("form", "attention-card");
956 card.append(element("p", "eyebrow", "Input required"));
957 card.append(element("h2", "", "Codewhale has a question"));
958 const questions = Array.isArray(envelope.request?.questions) ? envelope.request.questions : [];
959 const groups = [];
960 for (const question of questions) {
961 const fieldset = element("fieldset", "question-fieldset");
962 fieldset.append(element("legend", "", question.question || question.header || "Choose an option"));
963 const controls = [];
964 for (const option of Array.isArray(question.options) ? question.options : []) {
965 const label = element("label", "answer-option");
966 const input = document.createElement("input");
967 input.type = question.multi_select ? "checkbox" : "radio";
968 input.name = `question-${inputId}-${question.id}`;
969 input.value = option.label || "";
970 label.append(input);
971 const copy = element("span", "", option.label || "Option");
972 if (option.description) copy.append(element("small", "", option.description));
973 label.append(copy);
974 fieldset.append(label);
975 controls.push({ input, label: option.label || "", value: option.label || "" });
976 }
977 let other = null;
978 if (question.allow_free_text) {
979 other = document.createElement("input");
980 other.className = "other-answer";
981 other.type = "text";
982 other.placeholder = "Other response";
983 other.setAttribute("aria-label", `${question.header || "Question"} other response`);
984 fieldset.append(other);
985 }
986 card.append(fieldset);
987 groups.push({ question, controls, other });
988 }
989 const actions = element("div", "attention-actions");
990 const submit = element("button", "primary-button", "Submit answers");
991 submit.type = "submit";
992 actions.append(submit);
993 card.append(actions);
994 card.addEventListener("submit", async (event) => {
995 event.preventDefault();
996 const answers = [];
997 for (const group of groups) {
998 for (const control of group.controls) {
999 if (control.input.checked) {
1000 answers.push({ id: group.question.id, label: control.label, value: control.value });
1001 }
1002 }
1003 const otherValue = group.other?.value.trim();
1004 if (otherValue) answers.push({ id: group.question.id, label: "Other", value: otherValue });
1005 if (!answers.some((answer) => answer.id === group.question.id)) {
1006 showStatus(`Choose an answer for ${group.question.header || group.question.question || "each question"}.`);
1007 return;
1008 }
1009 }
1010 try {
1011 await api(`/v1/user-input/${encodeURIComponent(app.selectedThreadId)}/${encodeURIComponent(inputId)}`, {
1012 method: "POST",
1013 body: JSON.stringify({ answers }),
1014 });
1015 app.threadState.userInputs.delete(inputId);
1016 showStatus("");
1017 renderAttention();
1018 } catch (error) {
1019 showStatus(error.message);
1020 }
1021 });
1022 return card;
1023 }
1024
1025 function latestTurn() {
1026 const id = app.threadState.turnOrder.at(-1);
1027 return id ? app.threadState.turns.get(id) : null;
1028 }
1029
1030 function activeTurn() {
1031 const turn = latestTurn();
1032 return turn && (turn.status === "in_progress" || turn.status === "queued") ? turn : null;
1033 }
1034
1035 function renderComposer() {
1036 const ready = Boolean(app.threadState.thread);
1037 const active = activeTurn();
1038 dom.composerInput.disabled = !ready;
1039 dom.send.disabled = !ready || !dom.composerInput.value.trim();
1040 dom.interrupt.hidden = !active;
1041 setSafeText(dom.send, active ? "Steer" : "Send");
1042 }
1043
1044 async function createThread() {
1045 showStatus("");
1046 try {
1047 const thread = await api("/v1/threads", { method: "POST", body: "{}" });
1048 await loadThreads("");
1049 await selectThread(thread.id);
1050 dom.composerInput.focus();
1051 return thread;
1052 } catch (error) {
1053 showStatus(error.message);
1054 return null;
1055 }
1056 }
1057
1058 async function sendMessage() {
1059 const prompt = dom.composerInput.value.trim();
1060 if (!prompt) return;
1061 // A reply goes to a live thread or nowhere. A saved-session peek must not
1062 // silently resume-and-send: that would attach the user's message to a
1063 // thread they never asked to create.
1064 if (app.target.kind === "session") {
1065 showStatus(refusalMessage("session-not-live"));
1066 return;
1067 }
1068 let threadId = app.selectedThreadId;
1069 if (!threadId) {
1070 const thread = await createThread();
1071 if (!thread) return;
1072 threadId = thread.id;
1073 }
1074 const resolved = resolveReplyTarget(threadTarget(threadId), app.threadState);
1075 if (!resolved.ok) {
1076 showStatus(refusalMessage(resolved.reason));
1077 return;
1078 }
1079 threadId = resolved.threadId;
1080 const turn = activeTurn();
1081 dom.send.disabled = true;
1082 showStatus("");
1083 try {
1084 if (turn) {
1085 await api(`/v1/threads/${encodeURIComponent(threadId)}/turns/${encodeURIComponent(turn.id)}/steer`, {
1086 method: "POST",
1087 body: JSON.stringify({ prompt }),
1088 });
1089 } else {
1090 await api(`/v1/threads/${encodeURIComponent(threadId)}/turns`, {
1091 method: "POST",
1092 body: JSON.stringify({ prompt }),
1093 });
1094 }
1095 saveDraft(app.drafts, threadId, "");
1096 dom.composerInput.value = "";
1097 resizeComposer();
1098 renderComposer();
1099 loadThreads().catch((error) => showStatus(error.message));
1100 } catch (error) {
1101 showStatus(error.message);
1102 renderComposer();
1103 }
1104 }
1105
1106 async function interruptTurn() {
1107 const turn = activeTurn();
1108 if (!turn || !app.selectedThreadId) return;
1109 dom.interrupt.disabled = true;
1110 try {
1111 await api(`/v1/threads/${encodeURIComponent(app.selectedThreadId)}/turns/${encodeURIComponent(turn.id)}/interrupt`, { method: "POST" });
1112 } catch (error) {
1113 showStatus(error.message);
1114 } finally {
1115 dom.interrupt.disabled = false;
1116 }
1117 }
1118
1119 async function archiveThread() {
1120 if (!app.selectedThreadId) return;
1121 if (!globalThis.confirm("Archive this thread? You can still access it through the Runtime API.")) return;
1122 try {
1123 await api(`/v1/threads/${encodeURIComponent(app.selectedThreadId)}`, {
1124 method: "PATCH",
1125 body: JSON.stringify({ archived: true }),
1126 });
1127 saveDraft(app.drafts, app.selectedThreadId, "");
1128 stopStream();
1129 app.selectedThreadId = "";
1130 app.threadState = createThreadState();
1131 await loadThreads();
1132 if (app.summaries[0]) await selectThread(app.summaries[0].id);
1133 else renderAll();
1134 } catch (error) {
1135 showStatus(error.message);
1136 }
1137 }
1138
1139 function openRenameDialog() {
1140 if (!app.threadState.thread) return;
1141 dom.renameInput.value = app.threadState.thread.title || "";
1142 dom.renameDialog.showModal();
1143 dom.renameInput.focus();
1144 dom.renameInput.select();
1145 }
1146
1147 async function submitRename(event) {
1148 event.preventDefault();
1149 const action = event.submitter?.value;
1150 if (action !== "save") {
1151 dom.renameDialog.close();
1152 return;
1153 }
1154 const title = dom.renameInput.value.trim();
1155 if (!title || !app.selectedThreadId) return;
1156 try {
1157 const thread = await api(`/v1/threads/${encodeURIComponent(app.selectedThreadId)}`, {
1158 method: "PATCH",
1159 body: JSON.stringify({ title }),
1160 });
1161 app.threadState.thread = thread;
1162 dom.renameDialog.close();
1163 await loadThreads();
1164 renderHeader();
1165 } catch (error) {
1166 showStatus(error.message);
1167 }
1168 }
1169
1170 function resizeComposer() {
1171 dom.composerInput.style.height = "auto";
1172 dom.composerInput.style.height = `${Math.min(dom.composerInput.scrollHeight, 180)}px`;
1173 }
1174
1175 function closeRailIfNarrow() {
1176 if (globalThis.matchMedia("(max-width: 800px)").matches) closeRail();
1177 }
1178
1179 dom.railOpen.addEventListener("click", () => dom.shell.classList.add("rail-visible"));
1180 dom.railClose.addEventListener("click", closeRail);
1181 dom.railScrim.addEventListener("click", closeRail);
1182 dom.newThread.addEventListener("click", createThread);
1183 dom.rename.addEventListener("click", openRenameDialog);
1184 dom.archive.addEventListener("click", archiveThread);
1185 dom.renameForm.addEventListener("submit", submitRename);
1186 dom.interrupt.addEventListener("click", interruptTurn);
1187 dom.composer.addEventListener("submit", (event) => {
1188 event.preventDefault();
1189 sendMessage();
1190 });
1191 dom.composerInput.addEventListener("input", () => {
1192 saveDraft(app.drafts, app.selectedThreadId, dom.composerInput.value);
1193 resizeComposer();
1194 renderComposer();
1195 });
1196 dom.composerInput.addEventListener("keydown", (event) => {
1197 if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
1198 event.preventDefault();
1199 sendMessage();
1200 }
1201 });
1202 dom.search.addEventListener("input", () => {
1203 if (app.searchTimer) clearTimeout(app.searchTimer);
1204 app.searchTimer = setTimeout(() => {
1205 loadThreads().catch((error) => showStatus(error.message));
1206 loadSessions().catch(() => {});
1207 }, 180);
1208 });
1209 globalThis.addEventListener("beforeunload", stopStream);
1210
1211 async function initialize() {
1212 try {
1213 [app.runtimeInfo, app.workspace] = await Promise.all([
1214 api("/v1/runtime/info"),
1215 api("/v1/workspace/status"),
1216 ]);
1217 setConnection("ready", "Local runtime connected");
1218 await loadThreads();
1219 await loadSessions();
1220 if (app.summaries[0]) await selectThread(app.summaries[0].id);
1221 else renderAll();
1222 } catch (error) {
1223 setConnection("error", "Runtime connection failed");
1224 showStatus(error.message);
1225 renderAll();
1226 }
1227 }
1228
1229 initialize();
1230 }
1231
1232 function basename(path) {
1233 if (!path) return "";
1234 const normalized = String(path).replaceAll("\\", "/").replace(/\/$/, "");
1235 return normalized.split("/").at(-1) || normalized;
1236 }
1237
1238 function humanize(value) {
1239 if (!value) return "Status";
1240 return String(value)
1241 .replaceAll("_", " ")
1242 .replace(/\b\w/g, (letter) => letter.toUpperCase());
1243 }
1244
1245 function modeLabel(mode) {
1246 if (mode === "agent") return "Act";
1247 if (mode === "plan") return "Plan";
1248 if (mode === "operate") return "Operate";
1249 return humanize(mode || "Runtime default");
1250 }
1251
1252 function permissionLabel(thread) {
1253 if (thread.trust_mode) return "Full Access";
1254 if (thread.auto_approve) return "Auto-Review";
1255 return "Ask";
1256 }
1257
1258 function relativeTime(value) {
1259 const timestamp = Date.parse(value);
1260 if (!Number.isFinite(timestamp)) return "recent";
1261 const seconds = Math.max(0, Math.round((Date.now() - timestamp) / 1000));
1262 if (seconds < 60) return "now";
1263 if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
1264 if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
1265 return `${Math.floor(seconds / 86400)}d`;
1266 }
1267
1268 if (typeof document !== "undefined") {
1269 startBrowserClient();
1270 }
1271
1271 lines Plain Text