Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion lib/public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ import { initAppNotifications, handleNotificationsState as _notifHandleState, ha
import { createStore, store } from './modules/store.js';
import { initPanels, updateConfigChip as _panUpdateConfigChip, getModelEffortLevels as _panGetModelEffortLevels, accumulateUsage as _panAccumulateUsage, updateUsagePanel as _panUpdateUsagePanel, resetUsage as _panResetUsage, toggleUsagePanel as _panToggleUsagePanel, formatTokens as _panFormatTokens, updateStatusPanel as _panUpdateStatusPanel, requestProcessStats as _panRequestProcessStats, toggleStatusPanel as _panToggleStatusPanel, accumulateContext as _panAccumulateContext, updateContextPanel as _panUpdateContextPanel, resetContext as _panResetContext, resetContextData as _panResetContextData, minimizeContext as _panMinimizeContext, expandContext as _panExpandContext, toggleContextPanel as _panToggleContextPanel, getContextView as _panGetContextView, renderCtxPopover as _panRenderCtxPopover, hideCtxPopover as _panHideCtxPopover, formatBytes as _panFormatBytes, formatUptime as _panFormatUptime, getModelSupportsEffort as _panGetModelSupportsEffort, getSessionUsage, setSessionUsage, getContextData, setContextData, setContextView as _panSetContextView, applyContextView as _panApplyContextView } from './modules/app-panels.js';
import { initProjects, updateProjectList as _projUpdateProjectList, renderProjectList as _projRenderProjectList, renderTopbarPresence as _projRenderTopbarPresence, switchProject as _projSwitchProject, resetClientState as _projResetClientState, confirmRemoveProject as _projConfirmRemoveProject, handleRemoveProjectCheckResult as _projHandleRemoveProjectCheckResult, handleRemoveProjectResult as _projHandleRemoveProjectResult, openAddProjectModal as _projOpenAddProjectModal, closeAddProjectModal as _projCloseAddProjectModal, handleBrowseDirResult as _projHandleBrowseDirResult, handleAddProjectResult as _projHandleAddProjectResult, handleCloneProgress as _projHandleCloneProgress, showUpdateAvailable as _projShowUpdateAvailable, getCachedProjects, setCachedProjects, getCachedProjectCount, getCachedRemovedProjects, setCachedRemovedProjects } from './modules/app-projects.js';
import { initRendering, addToMessages as _renAddToMessages, scrollToBottom as _renScrollToBottom, forceScrollToBottom as _renForceScrollToBottom, addUserMessage as _renAddUserMessage, getMsgTime as _renGetMsgTime, shouldGroupMessage as _renShouldGroupMessage, ensureAssistantBlock as _renEnsureAssistantBlock, addCopyHandler as _renAddCopyHandler, appendDelta as _renAppendDelta, flushStreamBuffer as _renFlushStreamBuffer, finalizeAssistantBlock as _renFinalizeAssistantBlock, addSystemMessage as _renAddSystemMessage, addConflictMessage as _renAddConflictMessage, addContextOverflowMessage as _renAddContextOverflowMessage, showClaudePreThinking as _renShowClaudePreThinking, showMatePreThinking as _renShowMatePreThinking, removeMatePreThinking as _renRemoveMatePreThinking, showSuggestionChips as _renShowSuggestionChips, hideSuggestionChips as _renHideSuggestionChips, getGhostSuggestion as _renGetGhostSuggestion, getTurnCounter, setTurnCounter, getPrependAnchor, setPrependAnchor, getActivityEl, setActivityEl, getIsUserScrolledUp, setIsUserScrolledUp } from './modules/app-rendering.js';
import { initRendering, addToMessages as _renAddToMessages, scrollToBottom as _renScrollToBottom, forceScrollToBottom as _renForceScrollToBottom, addUserMessage as _renAddUserMessage, getMsgTime as _renGetMsgTime, shouldGroupMessage as _renShouldGroupMessage, ensureAssistantBlock as _renEnsureAssistantBlock, addCopyHandler as _renAddCopyHandler, appendDelta as _renAppendDelta, flushStreamBuffer as _renFlushStreamBuffer, finalizeAssistantBlock as _renFinalizeAssistantBlock, addSystemMessage as _renAddSystemMessage, addConflictMessage as _renAddConflictMessage, addContextOverflowMessage as _renAddContextOverflowMessage, showClaudePreThinking as _renShowClaudePreThinking, showMatePreThinking as _renShowMatePreThinking, removeMatePreThinking as _renRemoveMatePreThinking, showSuggestionChips as _renShowSuggestionChips, hideSuggestionChips as _renHideSuggestionChips, getGhostSuggestion as _renGetGhostSuggestion, getTurnCounter, setTurnCounter, getPrependAnchor, setPrependAnchor, getActivityEl, setActivityEl, getIsUserScrolledUp, setIsUserScrolledUp, getStickyBottom, armStickyBottom, disarmStickyBottom } from './modules/app-rendering.js';
import { initDm, openDm as _dmOpenDm, enterDmMode as _dmEnterDmMode, exitDmMode as _dmExitDmMode, handleMateCreatedInApp as _dmHandleMateCreatedInApp, renderAvailableBuiltins as _dmRenderAvailableBuiltins, buildMateInterviewPrompt as _dmBuildMateInterviewPrompt, updateMateIconStatus as _dmUpdateMateIconStatus, connectMateProject as _dmConnectMateProject, disconnectMateProject as _dmDisconnectMateProject, appendDmMessage as _dmAppendDmMessage, showDmTypingIndicator as _dmShowDmTypingIndicator, handleDmSend as _dmHandleDmSend } from './modules/app-dm.js';
import { initMention, handleMentionStart, handleMentionStream, handleMentionDone, handleMentionError, handleMentionActivity, renderMentionUser, renderMentionResponse } from './modules/mention.js';
import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateResumed, handleDebateTurn, handleDebateActivity, handleDebateStream, handleDebateTurnDone, handleDebateCommentQueued, handleDebateCommentInjected, handleDebateEnded, handleDebateError, renderDebateStarted, renderDebateTurnDone, renderDebateEnded, renderDebateCommentInjected, renderDebateUserResume, openDebateModal, closeDebateModal, handleDebateBriefReady, renderDebateBriefReady, isDebateActive, resetDebateState, exportDebateAsPdf, renderMcpDebateProposal } from './modules/debate.js';
Expand Down Expand Up @@ -518,6 +518,12 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
var newMsgBtnActivity = "\u2193 New activity";

messagesEl.addEventListener("scroll", function () {
// While sticky-bottom is armed (e.g. just after history_done or a "New
// activity" click), suppress "user scrolled up" detection. Growth-induced
// scroll events from deferred layout are not the user — the ResizeObserver
// is busy re-pinning to bottom. Real user input (wheel/touch/PageUp)
// disarms the flag separately, so this gate doesn't block genuine intent.
if (getStickyBottom()) return;
var distFromBottom = messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight;
var scrolledUp = distFromBottom > 150;
setIsUserScrolledUp(scrolledUp);
Expand Down
27 changes: 23 additions & 4 deletions lib/public/modules/app-messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import { handleMcpServersState } from './mcp-ui.js';
import { handleLoopRegistryUpdated, handleScheduleRunStarted, handleScheduleRunFinished, handleLoopScheduled, isSchedulerOpen, enterCraftingMode, exitCraftingMode, handleLoopRegistryFiles } from './scheduler.js';

// --- App module imports ---
import { scrollToBottom, addToMessages, addUserMessage, addSystemMessage, removeMatePreThinking, appendDelta, finalizeAssistantBlock, addConflictMessage, addContextOverflowMessage, showSuggestionChips } from './app-rendering.js';
import { scrollToBottom, addToMessages, addUserMessage, addSystemMessage, removeMatePreThinking, appendDelta, finalizeAssistantBlock, addConflictMessage, addContextOverflowMessage, showSuggestionChips, armStickyBottom } from './app-rendering.js';
import { setActivity, startUrgentBlink, stopUrgentBlink, blinkSessionDot, updateCrossProjectBlink } from './app-favicon.js';
import { setStatus } from './app-connection.js';
import { getModelEffortLevels, accumulateUsage, updateUsagePanel, accumulateContext, updateContextPanel, renderCtxPopover, updateStatusPanel } from './app-panels.js';
Expand Down Expand Up @@ -190,10 +190,29 @@ export function processMessage(msg) {
var dbBanner = document.getElementById("debate-floor-banner");
if (dbBanner) dbBanner.remove();
}
scrollToBottom();
// Scroll to tool element if navigating from file edit history
// Resume landing position: arm sticky-bottom for ~1.5s so deferred
// layout (tool widgets via tools.js, markdown/syntax highlighting,
// image loads, IntersectionObserver-driven todo sticky reflows)
// can't strand the user mid-conversation. The ResizeObserver
// re-pins on every height change while armed. Disarms early on
// any real user scroll input.
// Skip arming when we have a pending in-conversation navigate
// target (file-edit deeplink) — the navigate block below scrolls
// that element into view, and sticky-bottom would fight it.
var nav = getPendingNavigate();
if (nav && (nav.toolId || nav.assistantUuid)) {
var hasNavTarget = nav && (nav.toolId || nav.assistantUuid);
if (hasNavTarget) {
// Navigate block below will scrollIntoView on the target — don't
// arm sticky-bottom or it would fight that scroll.
scrollToBottom();
} else {
// Quiet window: ResizeObserver extends this for as long as
// layout keeps shifting (long sessions, late-rendering tool
// widgets, image loads), bounded by an internal hard ceiling.
armStickyBottom(750);
}
// Scroll to tool element if navigating from file edit history
if (hasNavTarget) {
requestAnimationFrame(function() {
// Prefer scrolling to the exact tool element
var target = nav.toolId ? messagesEl.querySelector('[data-tool-id="' + nav.toolId + '"]') : null;
Expand Down
116 changes: 108 additions & 8 deletions lib/public/modules/app-rendering.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,109 @@ var streamDrainTimer = null;
var isUserScrolledUp = false;
var scrollThreshold = 150;

// --- Sticky-bottom mode ---
// While armed, a ResizeObserver re-pins #messages to scrollHeight on every
// height change so deferred content (tools, syntax highlighting, images,
// IntersectionObserver-driven reflows) doesn't strand the user mid-page.
// The scroll listener in app.js consults getStickyBottom() and ignores
// growth-induced scroll events while armed.
//
// Disarm rules:
// - Real user input (wheel / touchmove / PageUp / Home / ArrowUp): immediate.
// - Quiet detector: armStickyBottom(durationMs) treats durationMs as the
// QUIET WINDOW, not a hard timer. Each ResizeObserver callback resets
// a debounce timer; sticky-bottom only disarms after no resize for
// durationMs. Long-settling sessions (large todo widgets, slow code
// highlighting) keep extending the window naturally.
// - Hard ceiling: a separate cap prevents pathological lock-in.
var stickyBottom = false;
var stickyBottomQuietTimer = null;
var stickyBottomCeilingTimer = null;
var stickyBottomQuietMs = 750;
var stickyBottomCeilingMs = 8000;
var stickyBottomResizeObs = null;
var stickyBottomInputBound = false;

export function getStickyBottom() { return stickyBottom; }

function pinToBottomNow() {
var messagesEl = getMessagesEl();
if (!messagesEl) return;
messagesEl.scrollTop = messagesEl.scrollHeight;
}

function ensureStickyInfrastructure() {
var messagesEl = getMessagesEl();
if (!messagesEl) return;
if (!stickyBottomResizeObs && typeof ResizeObserver !== "undefined") {
stickyBottomResizeObs = new ResizeObserver(function () {
if (!stickyBottom) return;
// Re-pin on every layout change while armed.
pinToBottomNow();
// Reset the quiet timer — settling has not finished yet.
if (stickyBottomQuietTimer) clearTimeout(stickyBottomQuietTimer);
stickyBottomQuietTimer = setTimeout(disarmStickyBottom, stickyBottomQuietMs);
});
stickyBottomResizeObs.observe(messagesEl);
// Also observe direct children so child-size changes (image loads, code
// block highlighting, expanding tool groups) trigger a re-pin even when
// they don't change the scroller's own size.
var kids = messagesEl.children;
for (var i = 0; i < kids.length; i++) stickyBottomResizeObs.observe(kids[i]);
}
if (!stickyBottomInputBound) {
stickyBottomInputBound = true;
var disarmOnUserScroll = function () { disarmStickyBottom(); };
messagesEl.addEventListener("wheel", disarmOnUserScroll, { passive: true });
messagesEl.addEventListener("touchmove", disarmOnUserScroll, { passive: true });
document.addEventListener("keydown", function (e) {
if (!stickyBottom) return;
if (e.key === "PageUp" || e.key === "Home" || e.key === "ArrowUp") {
disarmStickyBottom();
}
});
}
}

export function armStickyBottom(durationMs) {
if (prependAnchor) return; // never fight pagination
ensureStickyInfrastructure();
stickyBottom = true;
isUserScrolledUp = false;
var newMsgBtn = document.getElementById("new-msg-btn");
if (newMsgBtn) {
newMsgBtn.classList.add("hidden");
newMsgBtn.textContent = NEW_MSG_BTN_DEFAULT;
}
pinToBottomNow();
// After children may have been replaced since last arm, re-observe.
if (stickyBottomResizeObs) {
var messagesEl = getMessagesEl();
if (messagesEl) {
var kids = messagesEl.children;
for (var i = 0; i < kids.length; i++) {
try { stickyBottomResizeObs.observe(kids[i]); } catch (e) {}
}
}
}
// Quiet window: callers pass intended quiet duration; ResizeObserver
// resets this each time layout changes, so the actual armed duration
// stretches to "no resize for durationMs".
stickyBottomQuietMs = durationMs || 750;
if (stickyBottomQuietTimer) clearTimeout(stickyBottomQuietTimer);
stickyBottomQuietTimer = setTimeout(disarmStickyBottom, stickyBottomQuietMs);
// Hard ceiling so we never lock the scroller indefinitely if some
// animation/observer keeps firing forever.
if (stickyBottomCeilingTimer) clearTimeout(stickyBottomCeilingTimer);
stickyBottomCeilingTimer = setTimeout(disarmStickyBottom, stickyBottomCeilingMs);
}

export function disarmStickyBottom() {
stickyBottom = false;
if (stickyBottomQuietTimer) { clearTimeout(stickyBottomQuietTimer); stickyBottomQuietTimer = null; }
if (stickyBottomCeilingTimer) { clearTimeout(stickyBottomCeilingTimer); stickyBottomCeilingTimer = null; }
}

export function initRendering() {
// Update input placeholder when vendor changes
store.subscribe(function (state, prev) {
Expand Down Expand Up @@ -86,14 +189,11 @@ export function scrollToBottom() {

export function forceScrollToBottom() {
if (prependAnchor) return;
isUserScrolledUp = false;
var newMsgBtn = document.getElementById("new-msg-btn");
newMsgBtn.classList.add("hidden");
newMsgBtn.textContent = NEW_MSG_BTN_DEFAULT;
var messagesEl = getMessagesEl();
requestAnimationFrame(function () {
messagesEl.scrollTop = messagesEl.scrollHeight;
});
// Arm sticky-bottom mode so deferred layout (tool widgets, code highlighting,
// image loads) can't strand the user partway down — single-rAF pin captures
// a stale scrollHeight, then growth below pushes the bottom further away.
// The quiet detector extends the window automatically while layout shifts.
armStickyBottom(750);
}

export function getMsgTime() {
Expand Down
Loading
Loading