This commit is contained in:
2026-09-10 15:57:48 -04:00
parent d58be9613f
commit 413ba08c21
13 changed files with 4649 additions and 334 deletions
+127 -30
View File
@@ -38,23 +38,35 @@ const EMPTY_STATE_HTML = `<div id="chat-empty" class="chat-empty">${CHAT_BUBBLE_
export class ChatView {
constructor() {
/** @type {HTMLButtonElement} */
this._chatBtn = $("#chat-btn");
/** @type {HTMLSpanElement} */
this._chatBadge = $("#chat-badge");
/** @type {HTMLDivElement} */
// In the V2 layout the chat panel lives inline in #app, shown permanently
// below the orb — there is NO toggle button, close button, backdrop, or
// unread badge any more. The strict $() helper THROWS when the selector is
// missing, so for the optional overlay-era refs we MUST use plain
// document.querySelector (returns null when absent). Only the
// always-present inline refs go through $().
/** @type {HTMLButtonElement | null} */
this._chatBtn = /** @type {HTMLButtonElement | null} */ (document.querySelector("#chat-btn"));
/** @type {HTMLSpanElement | null} */
this._chatBadge = /** @type {HTMLSpanElement | null} */ (document.querySelector("#chat-badge"));
/** @type {HTMLElement} */
this._chatPanel = $("#chat-panel");
/** @type {HTMLDivElement} */
this._chatPanelBackdrop = $("#chat-panel-backdrop");
/** @type {HTMLButtonElement} */
this._chatPanelClose = $("#chat-panel-close");
/** @type {HTMLDivElement} */
/** @type {HTMLElement | null} */
this._chatPanelBackdrop = /** @type {HTMLElement | null} */ (document.querySelector("#chat-panel-backdrop"));
/** @type {HTMLButtonElement | null} */
this._chatPanelClose = /** @type {HTMLButtonElement | null} */ (document.querySelector("#chat-panel-close"));
/** @type {HTMLElement} */
this._chatHistory = $("#chat-history");
/** @type {HTMLDivElement} */
/** @type {HTMLElement} */
this._bubbleStack = $("#bubble-stack");
this._panelOpen = false;
this._scrollQueued = false;
// Inline chat is always "open" (permanently visible on screen).
this._panelOpen = true;
// ── Always-bottom scroll bookkeeping ─────────────────────────────────
// Active timer for 4-step retry chain (0/30/100/250ms snap-to-bottom).
/** @type {number | null} */
this._scrollTimerId = null;
// Current step index inside a retry chain (0..3).
this._scrollStep = 0;
// ── User transcript state (keyed by item_id) ───────────────────────────
/** @type {Map<string, HTMLElement>} */
@@ -80,11 +92,14 @@ export class ChatView {
// 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());
// ── New inline-chat handlers: only wire when old overlay buttons exist.
if (this._chatBtn) this._chatBtn.addEventListener("click", () => (this._panelOpen ? this._closePanel() : this._openPanel()));
if (this._chatPanelClose) this._chatPanelClose.addEventListener("click", () => this._closePanel());
if (this._chatPanelBackdrop) this._chatPanelBackdrop.addEventListener("click", () => this._closePanel());
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && this._panelOpen) this._closePanel();
// Old behaviour (ESC closes overlay) is a no-op on the inline permanent
// panel; only execute it when a close button actually exists.
if (e.key === "Escape" && this._chatPanelClose && this._panelOpen) this._closePanel();
});
}
@@ -92,33 +107,104 @@ export class ChatView {
_openPanel() {
this._panelOpen = true;
this._chatPanel.classList.add("open");
this._chatBadge.classList.remove("visible");
if (this._chatPanel) this._chatPanel.classList.add("open");
if (this._chatBadge) this._chatBadge.classList.remove("visible");
this._scrollToBottom();
}
_closePanel() {
// Closing disabled on the new inline-permanent layout; guard so if the
// overlay UI is still in use by another variant it continues to work.
if (!this._chatPanelClose) return;
this._panelOpen = false;
this._chatPanel.classList.remove("open");
if (this._chatPanel) 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.
// Scroll the chat history to place the LAST message's FINAL LINE fully above
// the sticky footer bar.
//
// DESIGN NOTES (2026-09-04, after user report "scroll non scrolla
// sufficientemente"):
// 1. NO early-return on "already queued". The previous coalescing flag was
// the root cause of tail-clipping: the 1st incoming delta would queue a
// rAF, every subsequent delta (up to ~30/frame) was SKIPPED, then the
// rAF ran against the scrollHeight of the 1st delta only — so the tail
// words appended later were never accounted for.
// 2. Instead we CANCEL any pending timer chain and RESCHEDULE from scratch
// every single time `_scrollToBottom()` is called. This means the very
// last append/update call always "wins" the chain — so we scroll
// against the final grown content, not an intermediate snapshot.
// 3. We run 4 scroll attempts staggered at [0ms, 30ms, 100ms, 250ms].
// Layout is async: line-wrap, font metrics, the scroll anchoring reset,
// and tool-row height expansions all resolve in later micro/task frames.
// Attempt 0 = immediate post-append; 30 = after one layout-pass; 100 =
// after any Chrome scroll-anchor reflow; 250 = after React-like deferred
// re-renders (if any). At 250ms we are guaranteed stable scrollHeight.
// 4. Instead of scrollTop = scrollHeight we use scrollTo({top: 9999999}).
// The browser clamps automatically; using a giant literal avoids any
// race where scrollHeight was read before a layout commit propagated.
_scrollToBottom() {
if (!this._panelOpen || this._scrollQueued) return;
this._scrollQueued = true;
requestAnimationFrame(() => {
this._scrollQueued = false;
this._chatHistory.scrollTop = this._chatHistory.scrollHeight;
});
if (!this._chatHistory) return;
const h = this._chatHistory;
// Cancel any in-flight attempt chain and start fresh — guarantees the LAST
// caller wins (so if 100 tokens arrive in 1 frame we don't run 100 chains,
// we only run ONE chain: the one scheduled from the 100th token append).
if (this._scrollTimerId != null) {
clearTimeout(this._scrollTimerId);
this._scrollTimerId = null;
}
// Also reset any per-chain tracking so we re-run all 4 steps from the new
// point-in-time content height.
this._scrollStep = 0;
const snap = () => {
// Use Number.MAX_SAFE_INTEGER: browsers clamp to the real max
// (scrollHeight - clientHeight) automatically. This avoids ANY race
// where we read scrollHeight at an intermediate layout commit, then
// text wraps to more lines later and the scroll delta is short by a
// few pixels (the exact bug reported by the user where the last line
// was ½ visible behind the floating footer bar).
const BIG = Number.MAX_SAFE_INTEGER;
try { h.scrollTo({ top: BIG, behavior: "instant" }); }
catch (_) {
// Fallback for browsers without instant behavior option support
h.scrollTop = BIG;
}
};
const step = () => {
snap();
this._scrollStep++;
// 5 attempts, staggered: next delay in ms per step index
// step 0 (already run above): immediate
// step 1: +30ms after one layout pass
// step 2: +70ms after line-wrap reflow (most-common "one word left on
// line 7" tail-clipping gets fixed here)
// step 3: +150ms after Chrome scroll-anchor reflow
// step 4: +250ms final after deferred tool-row / image layout
// stop: step 5 reached → exit.
const nextDelay = [30, 70, 150, 250][this._scrollStep];
if (nextDelay != null) {
this._scrollTimerId = setTimeout(step, nextDelay);
} else {
// Step 4 done → release guard. The id is kept so a new incoming call
// can cancel a mid-flight chain cleanly.
this._scrollTimerId = null;
}
};
// Attempt 0 — IMMEDIATELY (same microtask as append — catches 90% of cases).
step();
}
_markUnread() {
// New inline layout: the chat is always visible so no "unread badge" UX.
// If we're running the old overlay variant we still show it.
if (this._panelOpen) {
this._scrollToBottom();
return;
}
this._chatBadge.classList.add("visible");
if (this._chatBadge) this._chatBadge.classList.add("visible");
}
// ── Shared rendering ──────────────────────────────────────────────────────
@@ -230,7 +316,7 @@ export class ChatView {
/** Reset the panel to the empty state and clear the unread badge. */
clear() {
this.renderEmptyState();
this._chatBadge.classList.remove("visible");
if (this._chatBadge) this._chatBadge.classList.remove("visible");
}
/** @param {"user"|"assistant"} role @param {string} text @param {boolean} partial @returns {HTMLElement} */
@@ -419,6 +505,10 @@ export class ChatView {
// 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);
// ALWAYS re-anchor to the last line when a response ends, since the final
// punctuation line + any trailing tool-result row pushes the tail further
// down than the last streamed delta did.
this._scrollToBottom();
}
/** The model called a tool — show an ephemeral "running" bubble.
@@ -426,6 +516,7 @@ export class ChatView {
onToolCall(name) {
this._bumpDismiss(this._spawnBubble("tool", name));
this._markUnread();
this._scrollToBottom();
}
/** The tool finished — append its call+result row (and any captured image).
@@ -433,6 +524,12 @@ export class ChatView {
onToolResult(name, argsJson, output, image) {
const startedAt = performance.now();
this._appendHistTool(name, argsJson, output);
if (image) this._appendHistImage(image);
// Tool rows can be tall (big JSON payloads, tables). Force a scroll after
// insert so the start of the result isn't hidden off-screen above the
// sticky footer; we want the LAST line of the answer always in view.
this._scrollToBottom();
this._markUnread();
// #region debug-point C:chat-tool-render
reportChatDebug("C", "ui/chat.js:onToolResult", "rendered tool result row", {
name,