pretheme
This commit is contained in:
+127
-30
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user