clay-server 2.36.0 → 2.36.1-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.js CHANGED
@@ -1503,11 +1503,9 @@ async function forkDaemon(mode, keepAwake, extraProjects, addCwd, wantOsUsers) {
1503
1503
  break;
1504
1504
  }
1505
1505
  }
1506
- // Restore access settings from previous config
1506
+ // Restore project-level settings from previous config
1507
1507
  if (prevProjectMap[cwd]) {
1508
- if (prevProjectMap[cwd].visibility) cwdEntry.visibility = prevProjectMap[cwd].visibility;
1509
- if (prevProjectMap[cwd].allowedUsers) cwdEntry.allowedUsers = prevProjectMap[cwd].allowedUsers;
1510
- if (prevProjectMap[cwd].ownerId) cwdEntry.ownerId = prevProjectMap[cwd].ownerId;
1508
+ cwdEntry = Object.assign({}, prevProjectMap[cwd], cwdEntry);
1511
1509
  }
1512
1510
  allProjects.push(cwdEntry);
1513
1511
  usedSlugs.push(slug);
@@ -1522,21 +1520,19 @@ async function forkDaemon(mode, keepAwake, extraProjects, addCwd, wantOsUsers) {
1522
1520
  var rpSlug = generateSlug(rp.path, usedSlugs);
1523
1521
  usedSlugs.push(rpSlug);
1524
1522
  var rpEntry = { path: rp.path, slug: rpSlug, title: rp.title || undefined, icon: rp.icon || undefined, addedAt: rp.addedAt || Date.now() };
1525
- // Restore access settings from previous config
1523
+ // Restore project-level settings from previous config
1526
1524
  if (prevProjectMap[rp.path]) {
1527
- if (prevProjectMap[rp.path].visibility) rpEntry.visibility = prevProjectMap[rp.path].visibility;
1528
- if (prevProjectMap[rp.path].allowedUsers) rpEntry.allowedUsers = prevProjectMap[rp.path].allowedUsers;
1529
- if (prevProjectMap[rp.path].ownerId) rpEntry.ownerId = prevProjectMap[rp.path].ownerId;
1525
+ rpEntry = Object.assign({}, prevProjectMap[rp.path], rpEntry);
1530
1526
  }
1531
1527
  allProjects.push(rpEntry);
1532
1528
  }
1533
1529
  }
1534
1530
 
1535
- var config = {
1531
+ var config = Object.assign({}, prevConfig || {}, {
1536
1532
  pid: null,
1537
1533
  port: port,
1538
1534
  host: host,
1539
- pinHash: mode === "multi" && cliPin ? generateAuthToken(cliPin) : null,
1535
+ pinHash: mode === "multi" && cliPin ? generateAuthToken(cliPin) : (prevConfig && prevConfig.pinHash) || null,
1540
1536
  tls: hasTls,
1541
1537
  builtinCert: hasBuiltinCert,
1542
1538
  mkcertDetected: mkcertDetected,
@@ -1548,7 +1544,7 @@ async function forkDaemon(mode, keepAwake, extraProjects, addCwd, wantOsUsers) {
1548
1544
  mode: mode || "single",
1549
1545
  setupCompleted: true,
1550
1546
  projects: allProjects,
1551
- };
1547
+ });
1552
1548
 
1553
1549
  ensureConfigDir();
1554
1550
  saveConfig(config);
@@ -1687,8 +1683,7 @@ async function devMode(mode, keepAwake, existingPinHash, wantOsUsers) {
1687
1683
  }
1688
1684
  // Restore access settings for cwd from previous config
1689
1685
  if (prevDevProjectMap[cwd]) {
1690
- if (prevDevProjectMap[cwd].visibility) cwdDevEntry.visibility = prevDevProjectMap[cwd].visibility;
1691
- if (prevDevProjectMap[cwd].allowedUsers) cwdDevEntry.allowedUsers = prevDevProjectMap[cwd].allowedUsers;
1686
+ cwdDevEntry = Object.assign({}, prevDevProjectMap[cwd], cwdDevEntry);
1692
1687
  }
1693
1688
  var allProjects = [cwdDevEntry];
1694
1689
  var usedSlugs = [slug];
@@ -1697,15 +1692,14 @@ async function devMode(mode, keepAwake, existingPinHash, wantOsUsers) {
1697
1692
  var rpSlug = generateSlug(rp.path, usedSlugs);
1698
1693
  usedSlugs.push(rpSlug);
1699
1694
  var rpDevEntry = { path: rp.path, slug: rpSlug, title: rp.title || undefined, icon: rp.icon || undefined, addedAt: rp.addedAt || Date.now() };
1700
- // Restore access settings from previous config
1695
+ // Restore project-level settings from previous config
1701
1696
  if (prevDevProjectMap[rp.path]) {
1702
- if (prevDevProjectMap[rp.path].visibility) rpDevEntry.visibility = prevDevProjectMap[rp.path].visibility;
1703
- if (prevDevProjectMap[rp.path].allowedUsers) rpDevEntry.allowedUsers = prevDevProjectMap[rp.path].allowedUsers;
1697
+ rpDevEntry = Object.assign({}, prevDevProjectMap[rp.path], rpDevEntry);
1704
1698
  }
1705
1699
  allProjects.push(rpDevEntry);
1706
1700
  }
1707
1701
 
1708
- var config = {
1702
+ var config = Object.assign({}, prevDevConfig || {}, {
1709
1703
  pid: null,
1710
1704
  port: port,
1711
1705
  host: host,
@@ -1720,7 +1714,7 @@ async function devMode(mode, keepAwake, existingPinHash, wantOsUsers) {
1720
1714
  setupCompleted: true,
1721
1715
  projects: allProjects,
1722
1716
  osUsers: wantOsUsers || (prevDevConfig ? (prevDevConfig.osUsers || false) : false),
1723
- };
1717
+ });
1724
1718
 
1725
1719
  ensureConfigDir();
1726
1720
  saveConfig(config);
package/lib/project.js CHANGED
@@ -911,7 +911,19 @@ function createProjectContext(opts) {
911
911
  var firstModel = vendorModels[0] || "";
912
912
  // model value can be string or {value, displayName} object
913
913
  var defaultModel = typeof firstModel === "string" ? firstModel : (firstModel.value || "");
914
- sendTo(ws, { type: "model_info", model: defaultModel, models: vendorModels, vendor: msg.vendor, availableVendors: sm.availableVendors || [], installedVendors: sm.installedVendors || [] });
914
+ // Preserve the user's current model selection if it belongs to this
915
+ // vendor, rather than always snapping back to the vendor's default.
916
+ var modelToSend = defaultModel;
917
+ if (sm.currentModel) {
918
+ for (var mi = 0; mi < vendorModels.length; mi++) {
919
+ var mv = typeof vendorModels[mi] === "string" ? vendorModels[mi] : (vendorModels[mi].value || "");
920
+ if (mv === sm.currentModel) {
921
+ modelToSend = sm.currentModel;
922
+ break;
923
+ }
924
+ }
925
+ }
926
+ sendTo(ws, { type: "model_info", model: modelToSend, models: vendorModels, vendor: msg.vendor, availableVendors: sm.availableVendors || [], installedVendors: sm.installedVendors || [] });
915
927
  })();
916
928
  return;
917
929
  }
package/lib/public/app.js CHANGED
@@ -66,7 +66,7 @@ import { initAppNotifications, handleNotificationsState as _notifHandleState, ha
66
66
  import { createStore, store } from './modules/store.js';
67
67
  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';
68
68
  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';
69
- 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';
69
+ 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';
70
70
  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';
71
71
  import { initMention, handleMentionStart, handleMentionStream, handleMentionDone, handleMentionError, handleMentionActivity, renderMentionUser, renderMentionResponse } from './modules/mention.js';
72
72
  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';
@@ -518,6 +518,12 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
518
518
  var newMsgBtnActivity = "\u2193 New activity";
519
519
 
520
520
  messagesEl.addEventListener("scroll", function () {
521
+ // While sticky-bottom is armed (e.g. just after history_done or a "New
522
+ // activity" click), suppress "user scrolled up" detection. Growth-induced
523
+ // scroll events from deferred layout are not the user — the ResizeObserver
524
+ // is busy re-pinning to bottom. Real user input (wheel/touch/PageUp)
525
+ // disarms the flag separately, so this gate doesn't block genuine intent.
526
+ if (getStickyBottom()) return;
521
527
  var distFromBottom = messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight;
522
528
  var scrolledUp = distFromBottom > 150;
523
529
  setIsUserScrolledUp(scrolledUp);
@@ -38,7 +38,7 @@ import { handleMcpServersState } from './mcp-ui.js';
38
38
  import { handleLoopRegistryUpdated, handleScheduleRunStarted, handleScheduleRunFinished, handleLoopScheduled, isSchedulerOpen, enterCraftingMode, exitCraftingMode, handleLoopRegistryFiles } from './scheduler.js';
39
39
 
40
40
  // --- App module imports ---
41
- import { scrollToBottom, addToMessages, addUserMessage, addSystemMessage, removeMatePreThinking, appendDelta, finalizeAssistantBlock, addConflictMessage, addContextOverflowMessage, showSuggestionChips } from './app-rendering.js';
41
+ import { scrollToBottom, addToMessages, addUserMessage, addSystemMessage, removeMatePreThinking, appendDelta, finalizeAssistantBlock, addConflictMessage, addContextOverflowMessage, showSuggestionChips, armStickyBottom } from './app-rendering.js';
42
42
  import { setActivity, startUrgentBlink, stopUrgentBlink, blinkSessionDot, updateCrossProjectBlink } from './app-favicon.js';
43
43
  import { setStatus } from './app-connection.js';
44
44
  import { getModelEffortLevels, accumulateUsage, updateUsagePanel, accumulateContext, updateContextPanel, renderCtxPopover, updateStatusPanel } from './app-panels.js';
@@ -190,10 +190,29 @@ export function processMessage(msg) {
190
190
  var dbBanner = document.getElementById("debate-floor-banner");
191
191
  if (dbBanner) dbBanner.remove();
192
192
  }
193
- scrollToBottom();
194
- // Scroll to tool element if navigating from file edit history
193
+ // Resume landing position: arm sticky-bottom for ~1.5s so deferred
194
+ // layout (tool widgets via tools.js, markdown/syntax highlighting,
195
+ // image loads, IntersectionObserver-driven todo sticky reflows)
196
+ // can't strand the user mid-conversation. The ResizeObserver
197
+ // re-pins on every height change while armed. Disarms early on
198
+ // any real user scroll input.
199
+ // Skip arming when we have a pending in-conversation navigate
200
+ // target (file-edit deeplink) — the navigate block below scrolls
201
+ // that element into view, and sticky-bottom would fight it.
195
202
  var nav = getPendingNavigate();
196
- if (nav && (nav.toolId || nav.assistantUuid)) {
203
+ var hasNavTarget = nav && (nav.toolId || nav.assistantUuid);
204
+ if (hasNavTarget) {
205
+ // Navigate block below will scrollIntoView on the target — don't
206
+ // arm sticky-bottom or it would fight that scroll.
207
+ scrollToBottom();
208
+ } else {
209
+ // Quiet window: ResizeObserver extends this for as long as
210
+ // layout keeps shifting (long sessions, late-rendering tool
211
+ // widgets, image loads), bounded by an internal hard ceiling.
212
+ armStickyBottom(750);
213
+ }
214
+ // Scroll to tool element if navigating from file edit history
215
+ if (hasNavTarget) {
197
216
  requestAnimationFrame(function() {
198
217
  // Prefer scrolling to the exact tool element
199
218
  var target = nav.toolId ? messagesEl.querySelector('[data-tool-id="' + nav.toolId + '"]') : null;
@@ -370,7 +389,11 @@ export function processMessage(msg) {
370
389
  if (_modelVal && typeof _modelVal === "object") _modelVal = _modelVal.value || _modelVal.displayName || "";
371
390
  var _miUpdate = { currentModels: msg.models || [] };
372
391
  if (Object.prototype.hasOwnProperty.call(msg, "model")) {
373
- _miUpdate.currentModel = _modelVal || "";
392
+ if (store.get('vendorSelectionLocked') && store.get('currentModel')) {
393
+ // Keep the user's existing selection; only update models list
394
+ } else {
395
+ _miUpdate.currentModel = _modelVal || "";
396
+ }
374
397
  } else {
375
398
  _miUpdate.currentModel = store.get('currentModel');
376
399
  }
@@ -36,6 +36,109 @@ var streamDrainTimer = null;
36
36
  var isUserScrolledUp = false;
37
37
  var scrollThreshold = 150;
38
38
 
39
+ // --- Sticky-bottom mode ---
40
+ // While armed, a ResizeObserver re-pins #messages to scrollHeight on every
41
+ // height change so deferred content (tools, syntax highlighting, images,
42
+ // IntersectionObserver-driven reflows) doesn't strand the user mid-page.
43
+ // The scroll listener in app.js consults getStickyBottom() and ignores
44
+ // growth-induced scroll events while armed.
45
+ //
46
+ // Disarm rules:
47
+ // - Real user input (wheel / touchmove / PageUp / Home / ArrowUp): immediate.
48
+ // - Quiet detector: armStickyBottom(durationMs) treats durationMs as the
49
+ // QUIET WINDOW, not a hard timer. Each ResizeObserver callback resets
50
+ // a debounce timer; sticky-bottom only disarms after no resize for
51
+ // durationMs. Long-settling sessions (large todo widgets, slow code
52
+ // highlighting) keep extending the window naturally.
53
+ // - Hard ceiling: a separate cap prevents pathological lock-in.
54
+ var stickyBottom = false;
55
+ var stickyBottomQuietTimer = null;
56
+ var stickyBottomCeilingTimer = null;
57
+ var stickyBottomQuietMs = 750;
58
+ var stickyBottomCeilingMs = 8000;
59
+ var stickyBottomResizeObs = null;
60
+ var stickyBottomInputBound = false;
61
+
62
+ export function getStickyBottom() { return stickyBottom; }
63
+
64
+ function pinToBottomNow() {
65
+ var messagesEl = getMessagesEl();
66
+ if (!messagesEl) return;
67
+ messagesEl.scrollTop = messagesEl.scrollHeight;
68
+ }
69
+
70
+ function ensureStickyInfrastructure() {
71
+ var messagesEl = getMessagesEl();
72
+ if (!messagesEl) return;
73
+ if (!stickyBottomResizeObs && typeof ResizeObserver !== "undefined") {
74
+ stickyBottomResizeObs = new ResizeObserver(function () {
75
+ if (!stickyBottom) return;
76
+ // Re-pin on every layout change while armed.
77
+ pinToBottomNow();
78
+ // Reset the quiet timer — settling has not finished yet.
79
+ if (stickyBottomQuietTimer) clearTimeout(stickyBottomQuietTimer);
80
+ stickyBottomQuietTimer = setTimeout(disarmStickyBottom, stickyBottomQuietMs);
81
+ });
82
+ stickyBottomResizeObs.observe(messagesEl);
83
+ // Also observe direct children so child-size changes (image loads, code
84
+ // block highlighting, expanding tool groups) trigger a re-pin even when
85
+ // they don't change the scroller's own size.
86
+ var kids = messagesEl.children;
87
+ for (var i = 0; i < kids.length; i++) stickyBottomResizeObs.observe(kids[i]);
88
+ }
89
+ if (!stickyBottomInputBound) {
90
+ stickyBottomInputBound = true;
91
+ var disarmOnUserScroll = function () { disarmStickyBottom(); };
92
+ messagesEl.addEventListener("wheel", disarmOnUserScroll, { passive: true });
93
+ messagesEl.addEventListener("touchmove", disarmOnUserScroll, { passive: true });
94
+ document.addEventListener("keydown", function (e) {
95
+ if (!stickyBottom) return;
96
+ if (e.key === "PageUp" || e.key === "Home" || e.key === "ArrowUp") {
97
+ disarmStickyBottom();
98
+ }
99
+ });
100
+ }
101
+ }
102
+
103
+ export function armStickyBottom(durationMs) {
104
+ if (prependAnchor) return; // never fight pagination
105
+ ensureStickyInfrastructure();
106
+ stickyBottom = true;
107
+ isUserScrolledUp = false;
108
+ var newMsgBtn = document.getElementById("new-msg-btn");
109
+ if (newMsgBtn) {
110
+ newMsgBtn.classList.add("hidden");
111
+ newMsgBtn.textContent = NEW_MSG_BTN_DEFAULT;
112
+ }
113
+ pinToBottomNow();
114
+ // After children may have been replaced since last arm, re-observe.
115
+ if (stickyBottomResizeObs) {
116
+ var messagesEl = getMessagesEl();
117
+ if (messagesEl) {
118
+ var kids = messagesEl.children;
119
+ for (var i = 0; i < kids.length; i++) {
120
+ try { stickyBottomResizeObs.observe(kids[i]); } catch (e) {}
121
+ }
122
+ }
123
+ }
124
+ // Quiet window: callers pass intended quiet duration; ResizeObserver
125
+ // resets this each time layout changes, so the actual armed duration
126
+ // stretches to "no resize for durationMs".
127
+ stickyBottomQuietMs = durationMs || 750;
128
+ if (stickyBottomQuietTimer) clearTimeout(stickyBottomQuietTimer);
129
+ stickyBottomQuietTimer = setTimeout(disarmStickyBottom, stickyBottomQuietMs);
130
+ // Hard ceiling so we never lock the scroller indefinitely if some
131
+ // animation/observer keeps firing forever.
132
+ if (stickyBottomCeilingTimer) clearTimeout(stickyBottomCeilingTimer);
133
+ stickyBottomCeilingTimer = setTimeout(disarmStickyBottom, stickyBottomCeilingMs);
134
+ }
135
+
136
+ export function disarmStickyBottom() {
137
+ stickyBottom = false;
138
+ if (stickyBottomQuietTimer) { clearTimeout(stickyBottomQuietTimer); stickyBottomQuietTimer = null; }
139
+ if (stickyBottomCeilingTimer) { clearTimeout(stickyBottomCeilingTimer); stickyBottomCeilingTimer = null; }
140
+ }
141
+
39
142
  export function initRendering() {
40
143
  // Update input placeholder when vendor changes
41
144
  store.subscribe(function (state, prev) {
@@ -86,14 +189,11 @@ export function scrollToBottom() {
86
189
 
87
190
  export function forceScrollToBottom() {
88
191
  if (prependAnchor) return;
89
- isUserScrolledUp = false;
90
- var newMsgBtn = document.getElementById("new-msg-btn");
91
- newMsgBtn.classList.add("hidden");
92
- newMsgBtn.textContent = NEW_MSG_BTN_DEFAULT;
93
- var messagesEl = getMessagesEl();
94
- requestAnimationFrame(function () {
95
- messagesEl.scrollTop = messagesEl.scrollHeight;
96
- });
192
+ // Arm sticky-bottom mode so deferred layout (tool widgets, code highlighting,
193
+ // image loads) can't strand the user partway down — single-rAF pin captures
194
+ // a stale scrollHeight, then growth below pushes the bottom further away.
195
+ // The quiet detector extends the window automatically while layout shifts.
196
+ armStickyBottom(750);
97
197
  }
98
198
 
99
199
  export function getMsgTime() {
@@ -10,6 +10,16 @@ import { VENDOR_NAMES } from './app-rendering.js';
10
10
 
11
11
  var ctx;
12
12
 
13
+ // During history replay, individual tool renders (todos, file edits, command
14
+ // outputs) must not auto-scroll. The history_done handler arms sticky-bottom
15
+ // which pins the viewport to the true bottom after the whole replay settles.
16
+ // Per-tool scroll calls during replay fight that and re-anchor the user to
17
+ // whichever tool widget grew last (commonly the todo widget).
18
+ function maybeScrollToBottom() {
19
+ if (store.get('replayingHistory')) return;
20
+ if (ctx && ctx.scrollToBottom) ctx.scrollToBottom();
21
+ }
22
+
13
23
  // --- Plan mode state ---
14
24
  var inPlanMode = false;
15
25
  var planContent = null;
@@ -365,7 +375,7 @@ export function renderAskUserQuestion(toolId, input) {
365
375
  ctx.addToMessages(container);
366
376
  disableMainInput();
367
377
  ctx.setActivity(null);
368
- ctx.scrollToBottom();
378
+ maybeScrollToBottom();
369
379
  }
370
380
 
371
381
  export function disableMainInput() {
@@ -602,7 +612,7 @@ function renderFormalPermission(requestId, toolName, toolInput, decisionReason)
602
612
  pendingPermissions[requestId] = container;
603
613
  refreshIcons();
604
614
  ctx.setActivity(null);
605
- ctx.scrollToBottom();
615
+ maybeScrollToBottom();
606
616
  }
607
617
 
608
618
  function renderPlanPermission(requestId) {
@@ -709,7 +719,7 @@ function renderPlanPermission(requestId) {
709
719
  pendingPermissions[requestId] = container;
710
720
  refreshIcons();
711
721
  ctx.setActivity(null);
712
- ctx.scrollToBottom();
722
+ maybeScrollToBottom();
713
723
  // Focus the feedback input after render
714
724
  setTimeout(function () { feedbackInput.focus(); }, 50);
715
725
  }
@@ -888,7 +898,7 @@ function renderConversationalPermission(requestId, toolName, toolInput, mateId,
888
898
  pendingPermissions[requestId] = container;
889
899
  refreshIcons();
890
900
  ctx.setActivity(null);
891
- ctx.scrollToBottom();
901
+ maybeScrollToBottom();
892
902
  }
893
903
 
894
904
  function sendPermissionResponse(container, requestId, decision) {
@@ -1115,7 +1125,7 @@ export function renderElicitationRequest(msg) {
1115
1125
  pendingElicitations[msg.requestId] = container;
1116
1126
  refreshIcons();
1117
1127
  ctx.setActivity(null);
1118
- ctx.scrollToBottom();
1128
+ maybeScrollToBottom();
1119
1129
  }
1120
1130
 
1121
1131
  function sendElicitationResponse(container, requestId, action, content) {
@@ -1194,7 +1204,7 @@ export function renderPlanBanner(type) {
1194
1204
 
1195
1205
  ctx.addToMessages(el);
1196
1206
  refreshIcons();
1197
- ctx.scrollToBottom();
1207
+ maybeScrollToBottom();
1198
1208
  return el;
1199
1209
  }
1200
1210
 
@@ -1255,7 +1265,7 @@ export function renderPlanCard(content) {
1255
1265
  }
1256
1266
 
1257
1267
  refreshIcons();
1258
- if (isNew) ctx.scrollToBottom();
1268
+ if (isNew) maybeScrollToBottom();
1259
1269
  return el;
1260
1270
  }
1261
1271
 
@@ -1388,7 +1398,7 @@ function renderTodoWidget() {
1388
1398
  }
1389
1399
  updateTodoSticky();
1390
1400
  refreshIcons();
1391
- ctx.scrollToBottom();
1401
+ maybeScrollToBottom();
1392
1402
  }
1393
1403
 
1394
1404
  function setupTodoObserver() {
@@ -1517,7 +1527,7 @@ export function startThinking() {
1517
1527
  }
1518
1528
  currentThinking = { el: el, fullText: "", startTime: Date.now() };
1519
1529
  refreshIcons();
1520
- ctx.scrollToBottom();
1530
+ maybeScrollToBottom();
1521
1531
  if (!el.classList.contains("mate-thinking")) {
1522
1532
  ctx.setActivity("thinking");
1523
1533
  }
@@ -1561,7 +1571,7 @@ export function startThinking() {
1561
1571
 
1562
1572
  ctx.addToMessages(el);
1563
1573
  refreshIcons();
1564
- ctx.scrollToBottom();
1574
+ maybeScrollToBottom();
1565
1575
  thinkingGroup = { el: el, count: 0, totalDuration: 0 };
1566
1576
  currentThinking = { el: el, fullText: "", startTime: Date.now() };
1567
1577
  if (!ctx.isMateDm()) {
@@ -1573,7 +1583,7 @@ export function appendThinking(text) {
1573
1583
  if (!currentThinking) return;
1574
1584
  currentThinking.fullText += text;
1575
1585
  currentThinking.el.querySelector(".thinking-content").textContent = currentThinking.fullText;
1576
- ctx.scrollToBottom();
1586
+ maybeScrollToBottom();
1577
1587
  }
1578
1588
 
1579
1589
  export function stopThinking(duration) {
@@ -1699,7 +1709,7 @@ export function createToolItem(id, name) {
1699
1709
  updateToolGroupHeader(currentToolGroup);
1700
1710
 
1701
1711
  refreshIcons();
1702
- ctx.scrollToBottom();
1712
+ maybeScrollToBottom();
1703
1713
 
1704
1714
  tools[id] = { el: el, name: name, input: null, done: false, groupId: currentToolGroup.id };
1705
1715
  ctx.setActivity("Running " + name + "...");
@@ -1737,7 +1747,7 @@ export function updateToolExecuting(id, name, input) {
1737
1747
  var subtitleText = tool.el.querySelector(".tool-subtitle-text");
1738
1748
  if (subtitleText) subtitleText.textContent = toolActivityText(name, input);
1739
1749
 
1740
- ctx.scrollToBottom();
1750
+ maybeScrollToBottom();
1741
1751
  }
1742
1752
 
1743
1753
  // Shared chrome (filename header + unified/split toggle) for diff renderings.
@@ -2029,7 +2039,7 @@ export function updateToolResult(id, content, isError, images) {
2029
2039
  });
2030
2040
 
2031
2041
  markToolDone(id, isError);
2032
- ctx.scrollToBottom();
2042
+ maybeScrollToBottom();
2033
2043
  }
2034
2044
 
2035
2045
  export function markToolDone(id, isError) {
@@ -2087,7 +2097,7 @@ export function updateSubagentActivity(parentToolId, text) {
2087
2097
  }
2088
2098
 
2089
2099
  ctx.setActivity(text);
2090
- ctx.scrollToBottom();
2100
+ maybeScrollToBottom();
2091
2101
  }
2092
2102
 
2093
2103
  export function addSubagentToolEntry(parentToolId, toolName, toolId, text) {
@@ -2121,7 +2131,7 @@ export function addSubagentToolEntry(parentToolId, toolName, toolId, text) {
2121
2131
  log.scrollTop = log.scrollHeight;
2122
2132
 
2123
2133
  ctx.setActivity(text);
2124
- ctx.scrollToBottom();
2134
+ maybeScrollToBottom();
2125
2135
  }
2126
2136
 
2127
2137
  function fmtTokens(n) {
@@ -2254,7 +2264,7 @@ export function addTurnMeta(cost, duration) {
2254
2264
  if (parts.length) {
2255
2265
  div.textContent = parts.join(" \u00b7 ");
2256
2266
  ctx.addToMessages(div);
2257
- ctx.scrollToBottom();
2267
+ maybeScrollToBottom();
2258
2268
  }
2259
2269
  }
2260
2270
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "2.36.0",
3
+ "version": "2.36.1-beta.2",
4
4
  "description": "Self-hosted team workspace for Claude Code and Codex. Multi-user, browser-based, with persistent AI mates.",
5
5
  "bin": {
6
6
  "clay-server": "./bin/cli.js",