clay-server 5.3.0-beta.1 → 5.3.0-beta.3

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.
Files changed (34) hide show
  1. package/lib/project-worker-conversation.js +67 -3
  2. package/lib/public/css/pane.css +0 -36
  3. package/lib/public/css/session-folders.css +1 -1
  4. package/lib/public/css/split-view-layout.css +7 -3
  5. package/lib/public/modules/app-favicon.js +1 -1
  6. package/lib/public/modules/app-message-cards.js +1 -1
  7. package/lib/public/modules/app-messages.js +9 -3
  8. package/lib/public/modules/app-projects.js +2 -1
  9. package/lib/public/modules/app-rendering.js +2 -2
  10. package/lib/public/modules/chat-render-runtime.js +54 -5
  11. package/lib/public/modules/filebrowser.js +6 -2
  12. package/lib/public/modules/markdown-view-mode.js +34 -0
  13. package/lib/public/modules/pane-file-bridge.js +8 -3
  14. package/lib/public/modules/session-folder-collapse.js +2 -1
  15. package/lib/public/modules/session-folder-dnd.js +4 -1
  16. package/lib/public/modules/split-view-presentation-drag.js +24 -0
  17. package/lib/public/modules/split-view-presentations.js +5 -2
  18. package/lib/public/modules/split-view-worker-activity.js +0 -56
  19. package/lib/public/modules/split-view-worker-transcript.js +19 -11
  20. package/lib/public/modules/split-view-workers.js +78 -12
  21. package/lib/public/modules/worker-created-presentation.js +37 -0
  22. package/lib/public/pane-assets/{app-OJEQXBBS.js → app-K4LGSKX5.js} +1 -1
  23. package/lib/public/pane-assets/{chunk-4SMIVAB4.js → chunk-6AJEGTWL.js} +1 -1
  24. package/lib/public/pane-assets/{chunk-I5P3MWQG.js → chunk-AILOLDZA.js} +1 -1
  25. package/lib/public/pane-assets/{chunk-UVGOPFEX.js → chunk-BYSBUZDZ.js} +1 -1
  26. package/lib/public/pane-assets/{chunk-623V5CEO.js → chunk-QJPBAN5H.js} +1 -1
  27. package/lib/public/pane-assets/{chunk-GML2R5JE.js → chunk-W34XSARI.js} +98 -98
  28. package/lib/public/pane-assets/manifest.json +330 -329
  29. package/lib/public/pane-assets/style-JVCOCG3W.css +1 -0
  30. package/lib/public/style.css +0 -1
  31. package/lib/ws-schema.js +1 -0
  32. package/package.json +1 -1
  33. package/lib/public/css/split-view-worker-activity.css +0 -97
  34. package/lib/public/pane-assets/style-GN7R6XKV.css +0 -1
@@ -5,6 +5,7 @@ var provenance = require("./session-provenance");
5
5
  function attachProjectWorkerConversation(ctx) {
6
6
  var sm = ctx.sm;
7
7
  var users = ctx.usersModule;
8
+ var watches = new WeakMap();
8
9
  var MAX_MESSAGES = 120;
9
10
  var MAX_TEXT = 8000;
10
11
  var MAX_PAGE_TEXT = 64000;
@@ -136,19 +137,82 @@ function attachProjectWorkerConversation(ctx) {
136
137
  if (!nextBefore && index >= 0) nextBefore = { index: index, offset: offset };
137
138
  return { messages: rows, nextBefore: nextBefore, hasMore: nextBefore !== null };
138
139
  }
140
+ // Follow recorded events forward without dropping an intervening busy burst.
141
+ function snapshotAfter(history, after) {
142
+ var rows = [], index = after.index, offset = after.offset, budget = MAX_PAGE_TEXT;
143
+ while (index < history.length && rows.length < MAX_MESSAGES && budget > 0) {
144
+ var item = record(history, index);
145
+ if (!item || offset >= item.text.length) { index++; offset = 0; continue; }
146
+ var metadataSize = (item.toolName || '').length + (item.toolId || '').length;
147
+ if (budget <= metadataSize) break;
148
+ var end = Math.min(item.text.length, offset + Math.min(MAX_TEXT, budget - metadataSize));
149
+ rows.push({ role: item.role, text: item.text.slice(offset, end), recordIndex: index, recordType: history[index].type,
150
+ start: offset, end: end, toolName: item.toolName || null, toolId: item.toolId || null, isError: item.isError === true });
151
+ budget -= end - offset + metadataSize;
152
+ offset = end;
153
+ }
154
+ while (index < history.length) {
155
+ var remaining = record(history, index);
156
+ if (remaining && offset < remaining.text.length) break;
157
+ index++; offset = 0;
158
+ }
159
+ return { messages: rows, hasMore: false, nextBefore: null, moreRecent: index < history.length };
160
+ }
161
+ function stopWatching(ws) {
162
+ var watch = watches.get(ws);
163
+ if (!watch) return;
164
+ watches.delete(ws);
165
+ clearTimeout(watch.timer);
166
+ if (watch.release) watch.release();
167
+ if (ws.removeListener) ws.removeListener('close', watch.close);
168
+ }
169
+ function watchWorker(ws, msg, pair) {
170
+ stopWatching(ws);
171
+ msg = { requestId: msg.requestId.slice(0, 160), parentSessionOriginId: pair.driver.sessionOriginId,
172
+ workerSessionOriginId: pair.worker.sessionOriginId, generation: msg.generation };
173
+ var watch = { worker: pair.worker, requestId: msg.requestId, timer: null };
174
+ watch.close = function () { stopWatching(ws); };
175
+ watch.notify = function () {
176
+ if (watch.timer) return;
177
+ watch.timer = setTimeout(function () {
178
+ watch.timer = null;
179
+ var user = actor(ws), latest = user && exactPair(ws, msg, user);
180
+ if (ws.readyState !== 1 || !latest || latest.worker !== pair.worker || latest.driver !== pair.driver) { stopWatching(ws); return; }
181
+ send(ws, { type: 'split_worker_conversation_changed', subscriptionId: msg.requestId,
182
+ parentSessionOriginId: msg.parentSessionOriginId, workerSessionOriginId: msg.workerSessionOriginId,
183
+ generation: msg.generation, isProcessing: pair.worker.isProcessing === true });
184
+ }, 32);
185
+ };
186
+ watch.release = sm.subscribeSession(pair.worker.localId, watch.notify);
187
+ watches.set(ws, watch);
188
+ if (ws.once) ws.once('close', watch.close);
189
+ }
139
190
  function handleMessage(ws, msg) {
140
191
  if (!msg || msg.type !== "split_worker_conversation_request") return false;
141
192
  var requestId = typeof msg.requestId === "string" ? msg.requestId.slice(0, 160) : "";
142
193
  var response = { type: "split_worker_conversation", requestId: requestId, pageBefore: msg.before == null ? null : msg.before,
143
194
  parentSessionOriginId: msg.parentSessionOriginId || null, workerSessionOriginId: msg.workerSessionOriginId || null,
144
195
  generation: msg.generation || null, messages: [], hasMore: false, nextBefore: null };
196
+ if (msg.watch === false) {
197
+ var watch = watches.get(ws);
198
+ if (watch && watch.requestId === msg.requestId && watch.worker.sessionOriginId === msg.workerSessionOriginId) stopWatching(ws);
199
+ return true;
200
+ }
145
201
  var current = actor(ws), pair = current && exactPair(ws, msg, current);
146
202
  if (!requestId || !pair) response.error = "This Worker conversation is no longer available.";
147
203
  else {
148
- var before = msg.before == null ? pair.worker.history.length : msg.before;
149
- if (!(Number.isInteger(before) && before >= 0) && !(before && typeof before === "object" && Number.isInteger(before.index) && Number.isInteger(before.offset) && before.index >= 0 && before.offset >= 0)) response.error = "Worker conversation page is invalid.";
150
- else Object.assign(response, snapshot(pair.worker.history || [], before));
204
+ if (msg.after != null) {
205
+ var after = msg.after;
206
+ if (msg.before != null || !after || !Number.isInteger(after.index) || after.index < 0 || after.index >= pair.worker.history.length ||
207
+ !Number.isInteger(after.offset) || after.offset < 0) response.error = 'Worker activity cursor is invalid.';
208
+ else Object.assign(response, snapshotAfter(pair.worker.history || [], after));
209
+ } else {
210
+ var before = msg.before == null ? pair.worker.history.length : msg.before;
211
+ if (!(Number.isInteger(before) && before >= 0) && !(before && typeof before === "object" && Number.isInteger(before.index) && Number.isInteger(before.offset) && before.index >= 0 && before.offset >= 0)) response.error = "Worker conversation page is invalid.";
212
+ else Object.assign(response, snapshot(pair.worker.history || [], before));
213
+ }
151
214
  }
215
+ if (!response.error && msg.watch === true) watchWorker(ws, msg, pair);
152
216
  send(ws, response);
153
217
  return true;
154
218
  }
@@ -229,31 +229,6 @@ body.pane-mode #input-area {
229
229
  }
230
230
  .split-pair-live { color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, transparent); animation: pair-status-pulse 1.5s ease-in-out infinite; }
231
231
 
232
- #split-host.split-delegating::after {
233
- content: "→";
234
- position: absolute;
235
- z-index: 4;
236
- top: calc(50% - 10px);
237
- left: calc(50% - 17px);
238
- width: 34px;
239
- height: 20px;
240
- display: grid;
241
- place-items: center;
242
- border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border));
243
- border-radius: 999px;
244
- pointer-events: none;
245
- background: var(--sidebar-bg);
246
- color: var(--accent);
247
- font-size: 14px;
248
- font-weight: 800;
249
- animation: pair-divider-flow 1.1s ease-in-out infinite;
250
- filter: drop-shadow(0 0 4px color-mix(in srgb, var(--accent) 65%, transparent));
251
- }
252
-
253
- #split-host.split-direction-left::after { content: "←"; animation-name: pair-divider-flow-left; }
254
-
255
- @keyframes pair-divider-flow { 0%, 100% { transform: translateX(-3px); opacity: 0.65; } 50% { transform: translateX(3px); opacity: 1; } }
256
- @keyframes pair-divider-flow-left { 0%, 100% { transform: translateX(3px); opacity: 0.65; } 50% { transform: translateX(-3px); opacity: 1; } }
257
232
  @keyframes pair-status-pulse { 50% { opacity: 0.55; } }
258
233
 
259
234
  /* Worker activity notice shown while this pane processes a delegated task. */
@@ -596,18 +571,7 @@ body.pane-mode #input-area {
596
571
  flex: 1 1 auto;
597
572
  }
598
573
 
599
- #split-host:has(.split-pane-role-worker).split-delegating::after {
600
- content: "↑";
601
- /* Center on worker bottom + half the gap, accounting for the shell
602
- inset and the 30% basis being relative to the inset content box. */
603
- top: calc(var(--split-shell-inset) + var(--split-worker-height) + var(--split-pane-gap-half) - 10px);
604
- animation-name: pair-divider-flow-up;
605
- }
606
574
 
607
- @keyframes pair-divider-flow-up {
608
- 0%, 100% { transform: translateY(3px); opacity: 0.65; }
609
- 50% { transform: translateY(-3px); opacity: 1; }
610
- }
611
575
  }
612
576
 
613
577
  /* Prevent an iframe from taking the pointer midway through a note gesture. */
@@ -21,7 +21,7 @@
21
21
 
22
22
  .session-folder-chevron, .session-folder-icon { display: inline-flex; flex: none; }
23
23
  .session-folder-chevron svg, .session-folder-icon svg { width: 12px; height: 12px; display: block; }
24
- .session-folder-chevron { transition: transform 0.15s ease; }
24
+ .session-folder-chevron { align-items: center; justify-content: center; min-height: 20px; min-width: 16px; margin: -4px -2px; transition: transform 0.15s ease; }
25
25
  .session-folder-toggle[aria-expanded="true"] .session-folder-chevron { transform: rotate(90deg); }
26
26
  .session-folder-favorites .session-folder-icon { color: var(--accent); }
27
27
  .session-folder-label { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -274,7 +274,7 @@ body.split-view-resizing .split-pane-frame { pointer-events: none; }
274
274
  .split-view-worker-controls:hover { background: var(--sidebar-hover); color: var(--text); }
275
275
  .split-view-worker-controls:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
276
276
  .split-view-worker-dot { width: 5px; height: 5px; flex: 0 0 5px; border-radius: 50%; background: var(--text-dimmer); }
277
- .split-view-worker-dot[data-state="working"] { background: var(--brand-green); animation: split-view-working 1.6s ease-in-out infinite; }
277
+ .split-view-worker-dot[data-state="working"] { background: var(--brand-green); }
278
278
  .split-view-worker-dot[data-state="waiting"] { background: var(--brand-gold); }
279
279
  @keyframes split-view-working { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
280
280
  .split-view-group { margin: 2px 0 8px; }
@@ -404,11 +404,12 @@ body.split-view-resizing .split-pane-frame { pointer-events: none; }
404
404
  .split-view-worker-popover-header strong { flex: 1; min-width: 0; font-size: 12px; }
405
405
  .split-view-worker-popover-source { padding: 0 13px 10px; color: var(--text-dimmer); font-size: 10px; }
406
406
  .split-view-worker-popover-body { flex: 1; min-height: 0; overflow: auto; padding: 14px; }
407
- .split-view-worker-popover-body .message { display: block; margin: 0 0 18px; padding: 0; max-width: none; }
407
+ .split-view-worker-popover-body .message { display: flex; flex-direction: column; align-items: stretch; margin: 0 0 18px; padding: 0; max-width: none; }
408
408
  .split-view-worker-popover-body .author { display: block; margin-bottom: 4px; font-size: 11px; color: var(--text-muted); }
409
409
  .split-view-worker-popover-body .message-body { white-space: normal; overflow-wrap: anywhere; font-size: 12px; line-height: 1.65; }
410
410
  .split-view-worker-popover-body .message-body pre { white-space: pre-wrap; }
411
- .split-view-worker-popover-body .worker-tool-message summary { cursor: pointer; font-size: 11px; }
411
+ .split-view-worker-popover-body .worker-tool-message pre { max-height: 280px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
412
+ .split-view-worker-popover-body .worker-tool-message summary { cursor: pointer; font-size: 13px; }
412
413
  .split-view-worker-popover-body .worker-tool-message { padding: 6px 8px; border-radius: 5px; background: var(--bg-alt); }
413
414
  .split-view-worker-popover-footer { padding: 8px 13px; border-top: 1px solid var(--border-subtle); color: var(--text-dimmer); font-size: 10px; }
414
415
  .split-view-worker-page-status { display: block; margin-top: 3px; line-height: 1.4; }
@@ -458,3 +459,6 @@ body.split-view-resizing .split-pane-frame { pointer-events: none; }
458
459
  #split-view-arrange-context-list button { display: block; width: 100%; min-height: 28px; padding: 5px 7px; text-align: left; font-size: 11px; }
459
460
  #split-view-arrange-context-list [aria-selected="true"] { background: var(--accent-bg); color: var(--accent); }
460
461
  .split-view-arrange [data-focus-layout] { width: 100%; min-height: 29px; margin-top: 5px; border-top: 1px solid var(--border-subtle); font-size: 11px; }
462
+
463
+ /* Light reflects received WebSocket output, independent of processing state. */
464
+ .split-view-worker-dot.io { background: var(--brand-green); box-shadow: 0 0 5px var(--brand-green); }
@@ -139,7 +139,7 @@ export function blinkIO() {
139
139
 
140
140
  export function blinkSessionDot(sessionId) {
141
141
  // A favorite is on screen twice; blink every copy.
142
- var els = document.querySelectorAll('.session-item[data-session-id="' + sessionId + '"] .session-processing');
142
+ var els = document.querySelectorAll('.session-item[data-session-id="' + sessionId + '"] .session-processing, .split-view-worker-chip[data-worker-session-id="' + sessionId + '"] .split-view-worker-dot');
143
143
  if (!els.length) return;
144
144
  for (var i = 0; i < els.length; i++) els[i].classList.add("io");
145
145
  clearTimeout(sessionIoTimers[sessionId]);
@@ -166,7 +166,7 @@ export function addUserMessage(text, images, pastes, fromUserId, fromUserName, d
166
166
 
167
167
  addToMessages(div);
168
168
  refreshIcons();
169
- forceScrollToBottom();
169
+ forceScrollToBottom(div);
170
170
  }
171
171
 
172
172
  export function addSystemMessage(text, isError) {
@@ -1,3 +1,4 @@
1
+ import { showCreatedWorkerPresentation } from './worker-created-presentation.js';
1
2
  import { handleWorkerRemovalResult } from './split-view-worker-removal.js';
2
3
  import { handleStudioDocumentMessage } from './clay-studio-document.js';
3
4
  import { saveSessionRichDraft, restoreSessionRichDraft } from './session-rich-drafts.js';
@@ -55,7 +56,7 @@ import { handlePaletteSessionSwitch, handlePaletteHistoryDone, handlePaletteHist
55
56
  import { handleFindInSessionResults } from './session-search.js';
56
57
  import { syncPaneTitles, maybeRestoreSplitGroup, openGroup } from './split-view.js';
57
58
  import { handleProjectSplitViews, handleProjectSplitViewResult, requestProjectSplitViews } from './split-view-presentations.js';
58
- import { handleSplitWorkerConversation } from './split-view-workers.js';
59
+ import { handleSplitWorkerConversation, handleWorkerSessionActivity, handleWorkerConversationChanged } from './split-view-workers.js';
59
60
  import { showPairDialog, handlePairCreated, handleSplitDelegation, showWorkerDelegationNotice, hideWorkerDelegationNotice } from './split-pair-ui.js';
60
61
  import { handleSessionActionMessage } from './session-actions.js';
61
62
  import { renderWorkerProposal, updateWorkerProposal } from './worker-proposal.js';
@@ -95,7 +96,7 @@ import { renderGeneratedImage, renderImageGenerationProgress, clearImageGenerati
95
96
  import { clearPairResultStatus, handlePairResultStatus } from './pair-result-status.js';
96
97
 
97
98
  // --- App module imports ---
98
- import { scrollToBottom, addToMessages, addUserMessage, addSystemMessage, removeMatePreThinking, resetStreamBuffer, appendDelta, flushStreamBuffer, finalizeAssistantBlock, addConflictMessage, addContextOverflowMessage, showSuggestionChips, armStickyBottom, VENDOR_AVATARS, VENDOR_NAMES } from './app-rendering.js';
99
+ import { scrollToBottom, addToMessages, addUserMessage, addSystemMessage, removeMatePreThinking, resetStreamBuffer, appendDelta, flushStreamBuffer, finalizeAssistantBlock, addConflictMessage, addContextOverflowMessage, showSuggestionChips, armStickyBottom, releaseTurnAnchor, VENDOR_AVATARS, VENDOR_NAMES } from './app-rendering.js';
99
100
  import { setActivity, startUrgentBlink, stopUrgentBlink, blinkSessionDot, updateCrossProjectBlink } from './app-favicon.js';
100
101
  import { setStatus } from './app-connection.js';
101
102
  import { resolveSwitchedVendor } from './pane-session.js';
@@ -808,6 +809,9 @@ export function processMessage(msg) {
808
809
  case "project_split_view_result":
809
810
  handleProjectSplitViewResult(msg);
810
811
  break;
812
+ case "split_worker_conversation_changed":
813
+ handleWorkerConversationChanged(msg);
814
+ break;
811
815
  case "split_worker_conversation":
812
816
  handleSplitWorkerConversation(msg);
813
817
  break;
@@ -840,7 +844,7 @@ export function processMessage(msg) {
840
844
 
841
845
  case "pair_session_created":
842
846
  var createdPairGroup = handlePairCreated(msg);
843
- if (createdPairGroup) openGroup(createdPairGroup);
847
+ if (createdPairGroup) showCreatedWorkerPresentation(createdPairGroup);
844
848
  break;
845
849
 
846
850
  case "session_handoff_result":
@@ -915,6 +919,7 @@ export function processMessage(msg) {
915
919
 
916
920
  case "session_io":
917
921
  blinkSessionDot(msg.id);
922
+ handleWorkerSessionActivity(msg);
918
923
  break;
919
924
 
920
925
  case "session_unread":
@@ -1493,6 +1498,7 @@ export function processMessage(msg) {
1493
1498
 
1494
1499
  case "done":
1495
1500
  store.set({ turnActivity: null });
1501
+ releaseTurnAnchor();
1496
1502
  removeMatePreThinking();
1497
1503
  setActivity(null);
1498
1504
  finishThinkingTurn();
@@ -24,7 +24,7 @@ import { hideMemory } from './mate-memory.js';
24
24
  import { isSchedulerOpen, closeScheduler, resetScheduler } from './scheduler.js';
25
25
  import { closeScheduledTasks } from './scheduled-tasks.js';
26
26
  import { connect, cancelReconnect, setStatus } from './app-connection.js';
27
- import { setTurnCounter, setPrependAnchor, setActivityEl, setIsUserScrolledUp, hideSuggestionChips } from './app-rendering.js';
27
+ import { setTurnCounter, setPrependAnchor, setActivityEl, setIsUserScrolledUp, releaseTurnAnchor, hideSuggestionChips } from './app-rendering.js';
28
28
  import { resetToolState, enableMainInput, resetTurnMetaCost } from './tools.js';
29
29
  import { resetShellCommand } from './shell-command.js';
30
30
  import { clearPendingImages } from './input.js';
@@ -210,6 +210,7 @@ export function resetClientState() {
210
210
  setPrependAnchor(null);
211
211
  store.set({ loadingMore: false });
212
212
  setIsUserScrolledUp(false);
213
+ releaseTurnAnchor();
213
214
  document.getElementById("new-msg-btn").classList.add("hidden");
214
215
  setRewindMode(false);
215
216
  setActivity(null);
@@ -8,10 +8,10 @@ import { refreshIcons } from './icons.js';
8
8
  import { closeToolGroup } from './tools.js';
9
9
  import { sendMessage, hasSendableContent } from './input.js';
10
10
  import { getChatLayout } from './theme.js';
11
- import { addToMessages, addHistoryIndex, scrollToBottom, forceScrollToBottom, getMsgTime, shouldGroupMessage, getTurnCounter, getActivityEl } from './chat-render-runtime.js';
11
+ import { addToMessages, addHistoryIndex, scrollToBottom, forceScrollToBottom, releaseTurnAnchor, getMsgTime, shouldGroupMessage, getTurnCounter, getActivityEl } from './chat-render-runtime.js';
12
12
  import { resolveProjectAssistantIdentity, applyProjectAssistantIdentity } from './project-chat-identity.js';
13
13
  export { VENDOR_ORDER, EXPERIMENTAL_VENDORS, isExperimentalVendor } from './vendor-priority.js';
14
- export { initRendering, addToMessages, addHistoryIndex, scrollToBottom, forceScrollToBottom, getMsgTime, shouldGroupMessage, getTurnCounter, setTurnCounter, getPrependAnchor, setPrependAnchor, getActivityEl, setActivityEl, getIsUserScrolledUp, setIsUserScrolledUp, getStickyBottom, armStickyBottom, disarmStickyBottom, VENDOR_AVATARS, VENDOR_NAMES, VENDOR_HOMEPAGES } from './chat-render-runtime.js';
14
+ export { initRendering, addToMessages, addHistoryIndex, scrollToBottom, forceScrollToBottom, releaseTurnAnchor, getMsgTime, shouldGroupMessage, getTurnCounter, setTurnCounter, getPrependAnchor, setPrependAnchor, getActivityEl, setActivityEl, getIsUserScrolledUp, setIsUserScrolledUp, getStickyBottom, armStickyBottom, disarmStickyBottom, VENDOR_AVATARS, VENDOR_NAMES, VENDOR_HOMEPAGES } from './chat-render-runtime.js';
15
15
  export { addUserMessage, addSystemMessage, addConflictMessage, addContextOverflowMessage } from './app-message-cards.js';
16
16
 
17
17
  // --- Module-owned state (not in store) ---
@@ -56,15 +56,25 @@ var stickyBottom = false;
56
56
  var stickyBottomQuietTimer = null;
57
57
  var stickyBottomCeilingTimer = null;
58
58
  var stickyBottomQuietMs = 750;
59
+ var TURN_ANCHOR_MARGIN = 8;
59
60
  var stickyBottomCeilingMs = 8000;
60
61
  var stickyBottomResizeObs = null;
61
62
  var stickyBottomInputBound = false;
63
+ // The sent user turn that the transcript aligns to while its reply arrives.
64
+ var turnAnchor = null;
65
+ // Completion keeps the clamp until the follow settles, so a final render or a
66
+ // 'done' inside the follow window cannot jump past the sent turn.
67
+ var turnAnchorEnded = false;
62
68
 
63
69
  function layoutScrollLock() { return store.get('paneLayoutScrollLock'); }
64
70
 
65
71
  export function beginPaneLayoutScrollLock(token, scrollTop) {
66
72
  var messagesEl = getMessagesEl();
67
73
  if (!messagesEl || !token) return false;
74
+ // A layout-only change must not undo a send that is still being followed:
75
+ // the stored reading position predates that explicit new turn, and the
76
+ // matching end receipt may never arrive. The follow keeps the turn visible.
77
+ if (stickyBottom || activeTurnAnchor(messagesEl)) return false;
68
78
  disarmStickyBottom();
69
79
  var userPosition = store.get('paneLastUserScrollTop');
70
80
  var position = Number.isFinite(userPosition) ? userPosition : Number.isFinite(scrollTop) ? Math.max(0, scrollTop) : messagesEl.scrollTop;
@@ -88,10 +98,29 @@ export function endPaneLayoutScrollLock(token) {
88
98
 
89
99
  export function getStickyBottom() { return stickyBottom; }
90
100
 
101
+ function activeTurnAnchor(messagesEl) {
102
+ if (turnAnchor && (!messagesEl || turnAnchor.parentNode !== messagesEl)) { turnAnchor = null; turnAnchorEnded = false; }
103
+ return turnAnchor;
104
+ }
105
+
106
+ // Follow the tail, but never past the top of the sent turn: a long immediate
107
+ // reply grows below the user's message instead of pushing it out of view.
108
+ function followTarget(messagesEl) {
109
+ var anchor = activeTurnAnchor(messagesEl);
110
+ if (!anchor) return messagesEl.scrollHeight;
111
+ var top = anchor.getBoundingClientRect().top - messagesEl.getBoundingClientRect().top + messagesEl.scrollTop - TURN_ANCHOR_MARGIN;
112
+ return Math.min(messagesEl.scrollHeight, Math.max(0, top));
113
+ }
114
+
115
+ export function releaseTurnAnchor() {
116
+ if (stickyBottom && turnAnchor) turnAnchorEnded = true;
117
+ else { turnAnchor = null; turnAnchorEnded = false; }
118
+ }
119
+
91
120
  function pinToBottomNow() {
92
121
  var messagesEl = getMessagesEl();
93
122
  if (!messagesEl) return;
94
- messagesEl.scrollTop = messagesEl.scrollHeight;
123
+ messagesEl.scrollTop = followTarget(messagesEl);
95
124
  }
96
125
 
97
126
  function ensureStickyInfrastructure() {
@@ -110,12 +139,13 @@ function ensureStickyInfrastructure() {
110
139
  }
111
140
  if (!stickyBottomInputBound) {
112
141
  stickyBottomInputBound = true;
113
- var disarmOnUserScroll = function () { disarmStickyBottom(); };
142
+ var disarmOnUserScroll = function () { disarmStickyBottom(); turnAnchor = null; turnAnchorEnded = false; };
114
143
  messagesEl.addEventListener("wheel", disarmOnUserScroll, { passive: true });
115
144
  messagesEl.addEventListener("touchmove", disarmOnUserScroll, { passive: true });
145
+ messagesEl.addEventListener("pointerdown", disarmOnUserScroll, { passive: true });
116
146
  document.addEventListener("keydown", function (event) {
117
- if (!stickyBottom) return;
118
- if (event.key === "PageUp" || event.key === "Home" || event.key === "ArrowUp") disarmStickyBottom();
147
+ if (!stickyBottom && !turnAnchor) return;
148
+ if (event.key === "PageUp" || event.key === "Home" || event.key === "ArrowUp") { disarmStickyBottom(); turnAnchor = null; turnAnchorEnded = false; }
119
149
  });
120
150
  }
121
151
  }
@@ -150,6 +180,7 @@ export function armStickyBottom(durationMs) {
150
180
 
151
181
  export function disarmStickyBottom() {
152
182
  stickyBottom = false;
183
+ if (turnAnchorEnded) { turnAnchor = null; turnAnchorEnded = false; }
153
184
  if (stickyBottomQuietTimer) { clearTimeout(stickyBottomQuietTimer); stickyBottomQuietTimer = null; }
154
185
  if (stickyBottomCeilingTimer) { clearTimeout(stickyBottomCeilingTimer); stickyBottomCeilingTimer = null; }
155
186
  }
@@ -205,6 +236,7 @@ export function addToMessages(element) {
205
236
  if (prependAnchor) messagesEl.insertBefore(element, prependAnchor);
206
237
  else messagesEl.appendChild(element);
207
238
  keepActiveThinkingAtTail();
239
+ if (stickyBottomResizeObs && stickyBottom) stickyBottomResizeObs.observe(element);
208
240
  }
209
241
 
210
242
  export function addHistoryIndex(element, historyIndex) {
@@ -247,6 +279,16 @@ export function scrollToBottom() {
247
279
  if (prependAnchor || store.get('replayingHistory')) return;
248
280
  var messagesEl = getMessagesEl();
249
281
  if (!messagesEl) return;
282
+ if (stickyBottom) {
283
+ pinToBottomNow();
284
+ requestAnimationFrame(function () { if (stickyBottom && !layoutScrollLock()) pinToBottomNow(); });
285
+ return;
286
+ }
287
+ if (activeTurnAnchor(messagesEl) && !isUserScrolledUp) {
288
+ messagesEl.scrollTop = followTarget(messagesEl);
289
+ requestAnimationFrame(function () { if (!layoutScrollLock() && activeTurnAnchor(messagesEl)) messagesEl.scrollTop = followTarget(messagesEl); });
290
+ return;
291
+ }
250
292
  var distFromBottom = messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight;
251
293
  if (distFromBottom > 150 || isUserScrolledUp) {
252
294
  if (distFromBottom > 150) isUserScrolledUp = true;
@@ -268,9 +310,16 @@ export function scrollToBottom() {
268
310
  });
269
311
  }
270
312
 
271
- export function forceScrollToBottom() {
313
+ export function forceScrollToBottom(sentTurnEl) {
272
314
  if (prependAnchor || store.get('replayingHistory')) return;
315
+ // An explicit new turn supersedes a layout-only scroll receipt and any
316
+ // earlier reading position.
317
+ if (layoutScrollLock()) store.set({ paneLayoutScrollLock: null });
318
+ store.set({ paneLastUserScrollTop: null });
319
+ turnAnchor = sentTurnEl || null;
320
+ turnAnchorEnded = false;
273
321
  armStickyBottom(750);
322
+ requestAnimationFrame(function () { if (stickyBottom && !layoutScrollLock()) pinToBottomNow(); });
274
323
  }
275
324
 
276
325
  export function getMsgTime() {
@@ -16,6 +16,7 @@ import { initFileBrowserContextMenu, downloadProjectFile } from './filebrowser-c
16
16
  import { registerClayFileLinkOpener } from './clay-file-links.js';
17
17
  import { isPlantUmlFile, renderPlantUmlPreview, cancelPlantUmlPreview } from './plantuml-preview.js';
18
18
  import { isHtmlFile, renderHtmlPreview } from './html-preview.js';
19
+ import { initialMarkdownRendered, rememberMarkdownViewMode, resetMarkdownViewModes } from './markdown-view-mode.js';
19
20
 
20
21
  var ctx;
21
22
  var showDropHint = function () {};
@@ -48,7 +49,7 @@ export function initFileBrowser(_ctx) {
48
49
  ctx.fileViewerEl.classList.remove("hidden");
49
50
  if (path !== currentFilePath || (store.get("fileViewerIsHtml") && isRendered && !document.querySelector(".file-viewer-html-preview"))) requestFileContent(path);
50
51
  },
51
- onEmpty: function() { teardownFileViewer(); },
52
+ onEmpty: function() { resetMarkdownViewModes(); teardownFileViewer(); },
52
53
  });
53
54
  registerRightWorkbench("file-viewer", teardownFileViewer);
54
55
 
@@ -204,6 +205,7 @@ export function initFileBrowser(_ctx) {
204
205
  document.getElementById("file-viewer-render").addEventListener("click", function () {
205
206
  if (currentContent == null || (!currentIsMarkdown && !currentIsSvg && !store.get("fileViewerIsPlantUml") && !store.get("fileViewerIsHtml"))) return;
206
207
  isRendered = !isRendered;
208
+ if (currentIsMarkdown) rememberMarkdownViewMode((store.get('clayControlFileView') || {}).projectSlug, currentFilePath, isRendered);
207
209
  if (store.get("fileViewerIsHtml")) {
208
210
  renderHtmlBody();
209
211
  return;
@@ -534,6 +536,7 @@ export function setFileViewerFullscreen(enabled) {
534
536
  export function resetFileBrowser() {
535
537
  releaseRightWorkbench("file-viewer");
536
538
  clearFileViewerTabs();
539
+ resetMarkdownViewModes();
537
540
  teardownFileViewer();
538
541
  // Clear all cached state
539
542
  treeData = {};
@@ -1217,6 +1220,7 @@ function showFileContent(msg, expectedRevision) {
1217
1220
  store.set({ fileViewerIsHtml: !lightweightPreview && isHtmlFile(msg.path) });
1218
1221
  store.set({ fileViewerIsPlantUml: !lightweightPreview && isPlantUmlFile(msg.path) });
1219
1222
  if (lightweightPreview) isRendered = false;
1223
+ if (currentIsMarkdown && !keepRenderState) isRendered = initialMarkdownRendered((store.get('clayControlFileView') || {}).projectSlug, msg.path, store.get('pendingFileNavigation'), pendingOpenMode);
1220
1224
  if (pendingRenderedOpen && currentIsMarkdown) isRendered = true;
1221
1225
 
1222
1226
  if (currentIsMarkdown || currentIsSvg || store.get("fileViewerIsPlantUml") || store.get("fileViewerIsHtml")) {
@@ -1227,7 +1231,7 @@ function showFileContent(msg, expectedRevision) {
1227
1231
  copyBtn.title = "Copy contents";
1228
1232
  }
1229
1233
 
1230
- // Markdown starts as source; HTML, SVG and diagrams start as previews.
1234
+ // Markdown, HTML, SVG and diagrams start as previews; a line target or diff starts as source.
1231
1235
  if (lightweightPreview) {
1232
1236
  renderLargeTextFile(bodyEl, msg.content, msg.size);
1233
1237
  } else if (currentIsMarkdown) {
@@ -0,0 +1,34 @@
1
+ // Initial Markdown view for the file viewer. Rendered is the default; an
2
+ // explicit line target or diff needs source rows, and a toggle made by the
3
+ // user for a path in this tab session is respected on later focus/reopen.
4
+ // Choices are scoped to the source project that owns the file.
5
+
6
+ import { store } from './store.js';
7
+
8
+ function modeKey(projectSlug, path) {
9
+ return JSON.stringify([projectSlug || null, path]);
10
+ }
11
+
12
+ function chosenMode(projectSlug, path) {
13
+ var modes = store.get('fileViewerMarkdownModes') || {};
14
+ var key = modeKey(projectSlug, path);
15
+ return Object.prototype.hasOwnProperty.call(modes, key) ? modes[key] : null;
16
+ }
17
+
18
+ export function initialMarkdownRendered(projectSlug, path, pendingNavigation, pendingMode) {
19
+ if (pendingNavigation && pendingNavigation.line && pendingNavigation.path === path) return false;
20
+ if (pendingMode && pendingMode.type === 'diff') return false;
21
+ var chosen = chosenMode(projectSlug, path);
22
+ return chosen ? chosen === 'rendered' : true;
23
+ }
24
+
25
+ export function rememberMarkdownViewMode(projectSlug, path, rendered) {
26
+ if (!path) return;
27
+ var next = Object.assign({}, store.get('fileViewerMarkdownModes') || {});
28
+ next[modeKey(projectSlug, path)] = rendered ? 'rendered' : 'source';
29
+ store.set({ fileViewerMarkdownModes: next });
30
+ }
31
+
32
+ export function resetMarkdownViewModes() {
33
+ store.set({ fileViewerMarkdownModes: {} });
34
+ }
@@ -59,9 +59,14 @@ export function handlePaneFileMessage(event, host, openFile, openFiles) {
59
59
  if (!msg || msg.type !== "clay-pane-open-file") return false;
60
60
  var frame = paneFrame(host, event.source);
61
61
  var state = store.snap();
62
- var pane = matchingPane(frame, state.splitPanes);
63
- if (frame && frame.clayPane && !livePaneBinding(frame, msg)) return true;
64
- if (!pane || !hasLivePaneSource(frame, pane) || String(pane.slug) !== String(state.currentSlug) || msg.projectSlug !== frame.dataset.projectSlug || String(msg.sessionId) !== String(frame.dataset.sessionId) || !validLocation(msg.line) || !validLocation(msg.column)) return true;
62
+ // A live reusable pane is authorized by its document binding, which covers
63
+ // legacy splits, saved views and single-session views. Other frames keep the
64
+ // legacy split membership and URL checks.
65
+ var live = frame && frame.clayPane;
66
+ var pane = live ? { slug: frame.dataset.projectSlug, sessionId: frame.dataset.sessionId } : matchingPane(frame, state.splitPanes);
67
+ if (live && !livePaneBinding(frame, msg)) return true;
68
+ if (live && Number(msg.sessionId) !== live.identity().sessionId) return true;
69
+ if (!pane || (!live && !hasLivePaneSource(frame, pane)) || String(pane.slug) !== String(state.currentSlug) || msg.projectSlug !== frame.dataset.projectSlug || String(msg.sessionId) !== String(frame.dataset.sessionId) || !validLocation(msg.line) || !validLocation(msg.column)) return true;
65
70
  if (typeof msg.path !== "string" || !msg.path) return true;
66
71
  if (!state.connected || typeof openFile !== "function") return true;
67
72
  // The frame/session association was validated above. The host browser owns
@@ -89,7 +89,8 @@ function setAccessibleState(root, body, toggle, open) {
89
89
  }
90
90
 
91
91
  function finish(key, token, root, body, toggle, open) {
92
- if (currentRecord(key, token)) putRecord(key, null);
92
+ if (!currentRecord(key, token) || !root.isConnected) return;
93
+ putRecord(key, null);
93
94
  cleanBody(body);
94
95
  setAccessibleState(root, body, toggle, open);
95
96
  body.hidden = !open;
@@ -186,8 +186,11 @@ function ghostFor(label) {
186
186
  }
187
187
 
188
188
  export function wireFolderDrag(header, folderId, label) {
189
- header.setAttribute("draggable", "true");
189
+ // Keep disclosure clicks separate from the native folder-reorder gesture.
190
+ var handle = header.querySelector('.session-folder-label') || header;
191
+ handle.setAttribute("draggable", "true");
190
192
  header.addEventListener("dragstart", function (event) {
193
+ if (event.target.closest && event.target.closest('.session-folder-chevron, .session-folder-new-btn')) { event.preventDefault(); return; }
191
194
  var root = header.closest(".session-folder-folder");
192
195
  var parent = root && root.parentElement;
193
196
  if (!parent) return;
@@ -3,6 +3,8 @@
3
3
  import { store } from './store.js';
4
4
  import { getCachedSessions } from './sidebar-sessions.js';
5
5
  import { showToast } from './utils.js';
6
+ import { currentFolderState, sendFolderOp } from './session-folders.js';
7
+ import { organizePresentationView } from './split-view-presentations.js';
6
8
 
7
9
  var host = null;
8
10
  var commitDrop = null;
@@ -48,6 +50,13 @@ function onPointerDown(event) {
48
50
  function targetAt(x, y) {
49
51
  var element = document.elementFromPoint(x, y);
50
52
  if (!element || !element.closest || element.closest(".workbench, .split-view-menu, .split-view-arrange, [role='dialog'], .session-folder-view-menu, .session-context-menu, .session-folder-menu")) return null;
53
+ var folder = element.closest(".session-folder");
54
+ if (folder && !element.closest(".session-folder-unit, .session-item, .split-view-group")) {
55
+ var folderId = folder.dataset.folderId;
56
+ if (folderId === "favorites" || folderId === "unfiled" || currentFolderState().folders.some(function (item) { return item.id === folderId; })) {
57
+ return { folderId: folderId, element: folder, sidebar: true };
58
+ }
59
+ }
51
60
  var sidebar = sidebarTargetAt(element);
52
61
  if (sidebar) return sidebar;
53
62
  var pane = element.closest('.split-pane');
@@ -143,6 +152,11 @@ function renderPreview(drag, x, y) {
143
152
  if (!preview || !target) { if (preview) preview.hidden = true; return; }
144
153
  if (target.sidebar) {
145
154
  preview.hidden = true;
155
+ if (target.folderId) {
156
+ target.element.classList.add("session-folder-drop");
157
+ ghost.textContent += target.folderId === "favorites" ? " · Add to Favorites" : " · Move to folder";
158
+ return;
159
+ }
146
160
  var sameView = target.viewId && target.viewId === drag.sourceViewId;
147
161
  var view = viewForOrigin(target.origin), blocked = !sameView && view && view.sessions.length >= 8;
148
162
  target.element.dataset.splitViewDrop = blocked ? 'blocked' : 'ready';
@@ -181,6 +195,7 @@ function renderPreview(drag, x, y) {
181
195
  }
182
196
 
183
197
  function clearSidebarPreview() {
198
+ document.querySelectorAll('.session-folder-drop').forEach(function (element) { element.classList.remove('session-folder-drop'); });
184
199
  document.querySelectorAll('[data-split-view-drop]').forEach(function (element) { delete element.dataset.splitViewDrop; });
185
200
  }
186
201
 
@@ -225,6 +240,15 @@ function onPointerUp(event) {
225
240
  cleanup(true, true); showToast("Eight sessions are already open. Close a pane to add another.", "warn"); return;
226
241
  }
227
242
  cleanup(true, true);
243
+ if (target && target.folderId) {
244
+ var session = sessionById(drag.sessionId);
245
+ if (!validSession(session) || session.sessionOriginId !== drag.origin) return;
246
+ var view = viewForOrigin(drag.origin), favorite = target.folderId === 'favorites';
247
+ var folderId = target.folderId === 'unfiled' ? null : target.folderId;
248
+ if (view) organizePresentationView(view.id, view.revision, favorite ? view.folderId || null : folderId, favorite ? true : view.favorite === true);
249
+ else sendFolderOp(favorite ? { op: 'set_favorite', sessionId: session.id, favorite: true } : { op: 'place_session', sessionId: session.id, folderId: folderId });
250
+ return;
251
+ }
228
252
  if (target && drag.origin !== target.origin && commitDrop) commitDrop(drag, target);
229
253
  }
230
254
 
@@ -108,6 +108,9 @@ export function handleProjectSplitViews(msg) {
108
108
  renderActive();
109
109
  return true;
110
110
  }
111
+ // A repeated read must not cancel a captured gesture in an unchanged view.
112
+ if (state.projectSplitViewHydrated && !state.projectSplitViewPending && !msg.error && !state.projectSplitViewError &&
113
+ msg.revision === state.projectSplitViewRevision && JSON.stringify(nextViews) === JSON.stringify(state.projectSplitViews)) return true;
111
114
  var pending = state.projectSplitViewPending;
112
115
  if (state.projectSplitViewDrag || state.projectSplitViewDividerDrag) cleanupTransientInteractions(state);
113
116
  var activeId = state.projectSplitViewActiveId;
@@ -195,7 +198,7 @@ function mutation(type, body, meta) {
195
198
  }
196
199
  return true;
197
200
  }
198
- function setActiveView(id, preferredOrigin) {
201
+ export function setActiveView(id, preferredOrigin) {
199
202
  var state = store.snap(), view = viewExists(state.projectSplitViews, id);
200
203
  if (!view) return false;
201
204
  try {
@@ -217,7 +220,7 @@ function checkMountedPaneDrafts() {
217
220
  if (!host) return;
218
221
  Array.from(host.querySelectorAll('.split-pane-frame')).forEach(function (frame) { if (frame.clayPane) frame.clayPane.checkDraft(); });
219
222
  }
220
- function switchToStandalone(sessionId) {
223
+ export function switchToStandalone(sessionId) {
221
224
  var state = store.snap();
222
225
  try {
223
226
  if (state.projectSplitViewActiveId || state.splitPanes) checkMountedPaneDrafts();