| 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 |