// @ts-check
/**
* ChatView — owns the whole conversation surface: the slide-in history panel,
* the ephemeral on-orb bubbles, and all the transcript/tool/streaming
* bookkeeping. main.js wires the realtime client's events straight to the
* `on*` methods here and otherwise doesn't touch chat state.
*
* Two parallel surfaces share one shape (see `_buildMessageEl`):
* - ephemeral bubbles (`.bubble` / `.bubble-*`) fade on a timer
* - persistent history (`.hist-msg` / `.hist-*`) the durable panel log
*
* Keying:
* - user transcripts by the server's `item_id` — a speculative continuation
* REUSES it, so both segments land in one row/bubble; deltas are CUMULATIVE
* (each carries the full sentence so far), so we replace text wholesale.
* - assistant transcripts by `response_id`, so a cancelled speculative reply
* can be marked interrupted without erasing what was already shown.
*/
import { $, escHtml, DEBUG } from "./dom.js";
const WRENCH_PATH = ``;
const CHAT_BUBBLE_SVG = ``;
const EMPTY_STATE_HTML = `
${CHAT_BUBBLE_SVG}No messages yetTap the orb and start talking
`;
export class ChatView {
constructor() {
/** @type {HTMLButtonElement} */
this._chatBtn = $("#chat-btn");
/** @type {HTMLSpanElement} */
this._chatBadge = $("#chat-badge");
/** @type {HTMLDivElement} */
this._chatPanel = $("#chat-panel");
/** @type {HTMLDivElement} */
this._chatPanelBackdrop = $("#chat-panel-backdrop");
/** @type {HTMLButtonElement} */
this._chatPanelClose = $("#chat-panel-close");
/** @type {HTMLDivElement} */
this._chatHistory = $("#chat-history");
/** @type {HTMLDivElement} */
this._bubbleStack = $("#bubble-stack");
this._panelOpen = false;
this._scrollQueued = false;
// ── User transcript state (keyed by item_id) ───────────────────────────
/** @type {Map} */
this._userHistByItem = new Map();
/** @type {HTMLElement | null} */
this._activeUserBubble = null;
this._activeUserItemId = "";
// Monotonic counter for synthesizing unique keys when the server omits an
// item_id / response_id, so id-less messages never collapse onto each other.
this._anonSeq = 0;
// ── Assistant transcript state (keyed by response_id) ──────────────────
/** @type {Map} */
this._asstByResp = new Map();
// ── Ephemeral bubble auto-dismiss ──────────────────────────────────────
// Per-element expiry (epoch ms). A bubble fades once its expiry passes —
// but only in stack order (see _reapBubbles). Refreshing the expiry keeps a
// bubble alive while it updates (e.g. the live user utterance).
/** @type {WeakMap} */
this._bubbleExpiry = new WeakMap();
// Single pending reaper handle: one timer for the whole stack (not one per
// bubble) so dismissal is strictly oldest-first regardless of per-bubble delays.
this._reaperHandle = 0;
this._chatBtn.addEventListener("click", () => (this._panelOpen ? this._closePanel() : this._openPanel()));
this._chatPanelClose.addEventListener("click", () => this._closePanel());
this._chatPanelBackdrop.addEventListener("click", () => this._closePanel());
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && this._panelOpen) this._closePanel();
});
}
// ── Panel ───────────────────────────────────────────────────────────────
_openPanel() {
this._panelOpen = true;
this._chatPanel.classList.add("open");
this._chatBadge.classList.remove("visible");
this._scrollToBottom();
}
_closePanel() {
this._panelOpen = false;
this._chatPanel.classList.remove("open");
}
// Coalesce scroll-to-bottom: a burst of cumulative transcript deltas would
// otherwise queue one rAF per delta, all writing the same scrollTop.
_scrollToBottom() {
if (!this._panelOpen || this._scrollQueued) return;
this._scrollQueued = true;
requestAnimationFrame(() => {
this._scrollQueued = false;
this._chatHistory.scrollTop = this._chatHistory.scrollHeight;
});
}
_markUnread() {
if (this._panelOpen) {
this._scrollToBottom();
return;
}
this._chatBadge.classList.add("visible");
}
// ── Shared rendering ──────────────────────────────────────────────────────
/**
* Build a role-labelled message element. Ephemeral bubbles and persistent
* history rows share the same shape and differ only in their class prefix
* (`bubble`/`bubble-*` vs `hist-msg`/`hist-*`).
* @param {{ container: string, prefix: string, role: "user"|"assistant", text: string, partial?: boolean }} o
* @returns {HTMLElement}
*/
_buildMessageEl({ container, prefix, role, text, partial = false }) {
const el = document.createElement("div");
el.className = `${container} ${role}`;
const label = role === "user" ? "You" : "Assistant";
el.innerHTML = `
${label}
${escHtml(text)}
`;
return el;
}
// ── Ephemeral bubbles ───────────────────────────────────────────────────
/** @param {"user"|"assistant"|"tool"} role @param {string} text @returns {HTMLElement} */
_spawnBubble(role, text) {
let el;
if (role === "tool") {
el = document.createElement("div");
el.className = "bubble tool";
el.innerHTML = `${escHtml(text)}`;
} else {
el = this._buildMessageEl({ container: "bubble", prefix: "bubble", role, text });
}
this._bubbleStack.appendChild(el);
// Cap the stack at 3, but never evict the bubble the caller is still
// actively updating (the live user bubble) — drop the next-oldest instead.
const visible = /** @type {HTMLElement[]} */ ([...this._bubbleStack.querySelectorAll(".bubble:not(.out)")]);
if (visible.length > 3) {
this._dismissBubble(visible.find((b) => b !== this._activeUserBubble) ?? visible[0]);
}
requestAnimationFrame(() => el.classList.add("in"));
return el;
}
/** @param {HTMLElement} el @param {string} text */
_updateBubbleText(el, text) {
const t = el.querySelector(".bubble-body");
if (t) t.textContent = text;
}
/** @param {HTMLElement} el */
_dismissBubble(el) {
if (!el || el.classList.contains("out")) return; // idempotent
this._bubbleExpiry.delete(el);
el.classList.remove("in");
el.classList.add("out");
const remove = () => el.remove();
el.addEventListener("transitionend", remove, { once: true });
// Fallback: transitionend never fires if the bubble's visual state didn't
// change (dismissed pre-paint) or the tab is backgrounded. The transition
// is 0.3s, so force removal a little after.
setTimeout(remove, 400);
}
/**
* Fade bubbles whose expiry has passed — strictly oldest-first. We walk the
* stack top (oldest) to bottom (newest) and stop at the first bubble still
* alive: nothing newer may leave while an older bubble is still on screen. A
* bubble that keeps updating pushes its own expiry forward, so it (and
* everything behind it) stays put until it finally goes quiet.
*/
_reapBubbles() {
this._reaperHandle = 0;
const now = Date.now();
const visible = /** @type {HTMLElement[]} */ ([...this._bubbleStack.querySelectorAll(".bubble:not(.out)")]);
let nextWake = Infinity;
for (const el of visible) {
const exp = this._bubbleExpiry.get(el) ?? now; // no expiry recorded → treat as due
if (exp <= now) {
this._dismissBubble(el);
} else {
// Oldest survivor isn't due yet; stop so nothing newer leaves before it.
nextWake = exp;
break;
}
}
if (nextWake !== Infinity) {
this._reaperHandle = setTimeout(() => this._reapBubbles(), Math.max(50, nextWake - Date.now()));
}
}
/**
* (Re)arm a bubble's auto-dismiss by pushing its expiry out by `delay`.
* Calling it again resets the countdown — so a bubble that keeps updating
* stays on screen and only fades once it goes quiet. Removal is ordered by the
* shared reaper, so the oldest bubble always disappears first.
* @param {HTMLElement} el @param {number} [delay]
*/
_bumpDismiss(el, delay = 4000) {
this._bubbleExpiry.set(el, Date.now() + delay);
if (!this._reaperHandle) this._reaperHandle = setTimeout(() => this._reapBubbles(), delay);
}
// ── History ───────────────────────────────────────────────────────────────
/** Render the empty-state placeholder into the history panel. */
renderEmptyState() {
this._chatHistory.innerHTML = EMPTY_STATE_HTML;
}
/** Reset the panel to the empty state and clear the unread badge. */
clear() {
this.renderEmptyState();
this._chatBadge.classList.remove("visible");
}
/** @param {"user"|"assistant"} role @param {string} text @param {boolean} partial @returns {HTMLElement} */
_appendHistMsg(role, text, partial) {
const empty = this._chatHistory.querySelector(".chat-empty");
if (empty) empty.remove();
const el = this._buildMessageEl({ container: "hist-msg", prefix: "hist", role, text, partial });
this._chatHistory.appendChild(el);
this._scrollToBottom();
return el;
}
/** @param {HTMLElement | null} el @param {string} text @param {boolean} partial */
_updateHistMsg(el, text, partial) {
if (!el) return;
const body = /** @type {HTMLElement | null} */ (el.querySelector(".hist-body"));
if (!body) return;
body.textContent = text;
body.classList.toggle("partial", partial);
this._scrollToBottom();
}
/**
* Append a tool-call row to the conversation. We only add it once the tool
* has run, so the expandable toggle carries BOTH the call input and its result.
* @param {string} name @param {string} argsJson @param {string} output
*/
_appendHistTool(name, argsJson, output) {
const empty = this._chatHistory.querySelector(".chat-empty");
if (empty) empty.remove();
let pretty = argsJson;
try { pretty = JSON.stringify(JSON.parse(argsJson), null, 2); } catch {}
const el = document.createElement("div");
el.className = "hist-msg tool";
el.innerHTML = `
Tool call
Input
${escHtml(pretty)}
Output
${escHtml(output || "(no output)")}
`;
const header = /** @type {HTMLButtonElement} */ (el.querySelector(".hist-tool-header"));
const body = /** @type {HTMLDivElement} */ (el.querySelector(".hist-tool-body"));
header.addEventListener("click", () => {
const expanded = header.getAttribute("aria-expanded") === "true";
header.setAttribute("aria-expanded", String(!expanded));
body.classList.toggle("open", !expanded);
});
this._chatHistory.appendChild(el);
this._scrollToBottom();
}
/** Tag an assistant history row as interrupted (user barged in mid-reply).
* @param {HTMLElement | null} hist */
_markHistInterrupted(hist) {
if (!hist || hist.querySelector(".hist-note")) return;
hist.classList.add("interrupted");
const note = document.createElement("div");
note.className = "hist-note";
note.textContent = "Interrupted";
hist.appendChild(note);
}
/** Render a captured webcam frame in the transcript (the camera tool result).
* @param {string} dataUrl */
_appendHistImage(dataUrl) {
const empty = this._chatHistory.querySelector(".chat-empty");
if (empty) empty.remove();
const el = document.createElement("div");
el.className = "hist-msg tool";
el.innerHTML = `
Snapshot
`;
const img = /** @type {HTMLImageElement} */ (el.querySelector("img"));
img.src = dataUrl;
this._chatHistory.appendChild(el);
this._scrollToBottom();
}
/**
* Reset all streaming bookkeeping for session start / teardown. Pass
* `dismiss` to also fade any bubbles still on screen.
* @param {{ dismiss?: boolean }} [opts]
*/
reset(opts) {
if (opts?.dismiss) {
if (this._activeUserBubble) this._dismissBubble(this._activeUserBubble);
for (const { bubble } of this._asstByResp.values()) this._dismissBubble(bubble);
}
this._userHistByItem.clear();
this._activeUserBubble = null;
this._activeUserItemId = "";
this._asstByResp.clear();
}
// ── Client event handlers ─────────────────────────────────────────────────
/**
* A streamed transcript delta (user or assistant).
* @param {{ role: "user" | "assistant"; text: string; partial: boolean; itemId?: string; responseId?: string }} d
*/
onTranscript(d) {
if (DEBUG) console.debug(`[ui] transcript role=${d.role} partial=${d.partial} item=${d.itemId} resp=${d.responseId} text=${JSON.stringify(d.text)}`);
if (d.role === "user") {
// Group by item_id: a speculative continuation reuses the same id, so it
// updates the same row/bubble. A missing id falls back to the active item
// (same utterance) or a fresh unique key, never a shared sentinel that
// would collapse distinct turns into one row.
const id = d.itemId || this._activeUserItemId || `_u${++this._anonSeq}`;
const text = d.text;
let hist = this._userHistByItem.get(id);
if (!hist) {
hist = this._appendHistMsg("user", text, d.partial);
this._userHistByItem.set(id, hist);
} else {
this._updateHistMsg(hist, text, d.partial);
}
// One ephemeral bubble per active item. Purely timer-based: the timer is
// refreshed on every delta, so it stays while the user keeps talking and
// fades a few seconds after they stop — no dependency on a response ever
// arriving, so it can never get stuck.
if (this._activeUserItemId !== id || !this._activeUserBubble) {
this._activeUserBubble = this._spawnBubble("user", text);
this._activeUserItemId = id;
} else {
this._updateBubbleText(this._activeUserBubble, text);
}
this._bumpDismiss(this._activeUserBubble, 6000);
this._markUnread();
} else if (d.role === "assistant") {
// Assistant transcript arrives once, as the full text, keyed by
// response_id so a cancelled speculative response can be removed later. A
// missing id gets a unique key so two id-less replies never collide.
const rid = d.responseId || `_a${++this._anonSeq}`;
const entry = this._asstByResp.get(rid);
if (!entry) {
const bubble = this._spawnBubble("assistant", d.text);
this._asstByResp.set(rid, { bubble, hist: this._appendHistMsg("assistant", d.text, false) });
this._bumpDismiss(bubble);
} else {
this._updateBubbleText(entry.bubble, d.text);
this._updateHistMsg(entry.hist, d.text, false);
this._bumpDismiss(entry.bubble);
}
this._markUnread();
}
}
/**
* A response closed (completed or cancelled).
* @param {{ responseId: string; status: string; audible?: boolean; transcript?: string }} detail
*/
onResponseFinished(detail) {
const { responseId, status, audible, transcript } = detail;
if (DEBUG) console.debug(`[ui] response-finished resp=${responseId} status=${status} audible=${audible} known=${this._asstByResp.has(responseId)}`);
// Without an id we can't target a specific response; the bubble will
// auto-dismiss on its own timer regardless.
if (!responseId) return;
const entry = this._asstByResp.get(responseId);
if (status === "cancelled") {
// Keep every transcript that was received — mark it interrupted rather
// than erasing it. If the `*.transcript.done` never fired, build the row
// from the text carried in response.done.
let hist = entry?.hist ?? null;
if (!hist && transcript) {
hist = this._appendHistMsg("assistant", transcript, false);
} else if (hist && transcript) {
this._updateHistMsg(hist, transcript, false);
}
if (hist) this._markHistInterrupted(hist);
this._asstByResp.delete(responseId);
return;
}
// Any other terminal close (completed / failed / incomplete / …): just
// release the map entry. The bubble already auto-dismisses on its timer and
// the history row persists as the conversation log. Crucially we do NOT
// touch user state here — that lifecycle is fully independent.
this._asstByResp.delete(responseId);
}
/** The model called a tool — show an ephemeral "running" bubble.
* @param {string} name */
onToolCall(name) {
this._bumpDismiss(this._spawnBubble("tool", name));
this._markUnread();
}
/** The tool finished — append its call+result row (and any captured image).
* @param {string} name @param {string} argsJson @param {string} output @param {string} [image] */
onToolResult(name, argsJson, output, image) {
this._appendHistTool(name, argsJson, output);
if (image) this._appendHistImage(image); // show the captured frame below the call
this._markUnread();
}
}