tabminal 3.0.13 → 3.0.15

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/public/app.js CHANGED
@@ -104,13 +104,26 @@ const editorPane = document.getElementById('editor-pane');
104
104
  const HEARTBEAT_INTERVAL_MS = 1000;
105
105
  const RECONNECT_RETRY_MS = 5000;
106
106
  const FILE_TREE_REFRESH_INTERVAL_MS = 3000;
107
+ const FILE_VERSION_CHECK_INTERVAL_MS = 3000;
108
+ const AGENT_TRANSCRIPT_INITIAL_VISIBLE_BLOCKS = 100;
109
+ const AGENT_TRANSCRIPT_WINDOW_STEP = 50;
110
+ const AGENT_TRANSCRIPT_FOLLOW_LATEST_TOLERANCE = 5;
111
+ const WORKSPACE_TAB_TITLE_MAX_LENGTH = 20;
107
112
  const MAIN_SERVER_ID = 'main';
108
113
  const RUNTIME_BOOT_ID_STORAGE_KEY = 'tabminal_runtime_boot_id';
109
114
  const WORKSPACE_DEVICE_ID_STORAGE_KEY = 'tabminal_workspace_device_id';
110
115
  const RECENT_AGENT_USAGE_STORAGE_KEY = 'tabminal_recent_agent_usage';
111
116
  const FILE_WORKSPACE_TAB_PREFIX = 'file:';
117
+ const MARKDOWN_PREVIEW_WORKSPACE_TAB_PREFIX = 'markdown-preview:';
112
118
  const AGENT_WORKSPACE_TAB_PREFIX = 'agent:';
113
119
  const TERMINAL_WORKSPACE_TAB_KEY = 'terminal:main';
120
+ const SUPPORTED_MARKDOWN_EXTENSIONS = new Set([
121
+ 'md',
122
+ 'markdown',
123
+ 'mkd',
124
+ 'mkdn',
125
+ 'mdown'
126
+ ]);
114
127
  const SUPPORTED_IMAGE_EXTENSIONS = new Set([
115
128
  'png',
116
129
  'jpg',
@@ -119,6 +132,20 @@ const SUPPORTED_IMAGE_EXTENSIONS = new Set([
119
132
  'svg',
120
133
  'webp'
121
134
  ]);
135
+ const SUPPORTED_PDF_EXTENSIONS = new Set([
136
+ 'pdf'
137
+ ]);
138
+ const PDFJS_VERSION = '5.6.205';
139
+ const PDFJS_MODULE_URL = `https://cdn.jsdelivr.net/npm/pdfjs-dist@${PDFJS_VERSION}/build/pdf.min.mjs`;
140
+ const PDFJS_WORKER_URL = `https://cdn.jsdelivr.net/npm/pdfjs-dist@${PDFJS_VERSION}/build/pdf.worker.min.mjs`;
141
+ const MARKDOWN_IT_MODULE_URL = 'https://cdn.jsdelivr.net/npm/markdown-it@14.1.1/+esm';
142
+ const MARKDOWN_TASK_LISTS_MODULE_URL = 'https://cdn.jsdelivr.net/npm/markdown-it-task-lists@2.1.1/+esm';
143
+ const MARKDOWN_KATEX_MODULE_URL = 'https://cdn.jsdelivr.net/npm/@traptitech/markdown-it-katex@3.6.0/+esm';
144
+ const KATEX_MODULE_URL = 'https://cdn.jsdelivr.net/npm/katex@0.16.25/+esm';
145
+ const HIGHLIGHT_JS_MODULE_URL = 'https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/+esm';
146
+ const MARKDOWN_PREVIEW_GITHUB_CSS_URL = 'https://cdn.jsdelivr.net/npm/github-markdown-css@5.8.1/github-markdown-dark.min.css';
147
+ const MARKDOWN_PREVIEW_HIGHLIGHT_CSS_URL = 'https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/styles/github-dark.css';
148
+ const MARKDOWN_PREVIEW_KATEX_CSS_URL = 'https://cdn.jsdelivr.net/npm/katex@0.16.25/dist/katex.min.css';
122
149
  const CLOSE_ICON_SVG = '<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>';
123
150
  const AGENT_ICON_SVG = '<svg viewBox="0 0 24 24" width="17" height="17" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="7" width="10" height="10" rx="2"></rect><path d="M9 7V5"></path><path d="M15 7V5"></path><path d="M12 17v2"></path><path d="M5 12H3"></path><path d="M21 12h-2"></path><path d="M9 11h.01"></path><path d="M15 11h.01"></path><path d="M9.5 14c.7.67 1.53 1 2.5 1s1.8-.33 2.5-1"></path></svg>';
124
151
  const TERMINAL_TAB_ICON_SVG = '<svg viewBox="0 0 24 24" width="15" height="15" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"></rect><path d="m8 10 3 2-3 2"></path><path d="M13 15h4"></path></svg>';
@@ -137,6 +164,9 @@ const RENAME_ICON_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" stroke=
137
164
  const DELETE_ICON_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" stroke-width="1.9" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16"></path><path d="M10 11v6"></path><path d="M14 11v6"></path><path d="M6 7l1 12h10l1-12"></path><path d="M9 7V4h6v3"></path></svg>';
138
165
  const NEW_FOLDER_ICON_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 7.5A2.5 2.5 0 0 1 6 5h4l2 2h6a2.5 2.5 0 0 1 2.5 2.5V17A2.5 2.5 0 0 1 18 19.5H6A2.5 2.5 0 0 1 3.5 17Z"></path><path d="M12 10.5v5"></path><path d="M9.5 13h5"></path></svg>';
139
166
  const NEW_FILE_ICON_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M7 3.5h7l4 4V20.5H7A2.5 2.5 0 0 1 4.5 18V6A2.5 2.5 0 0 1 7 3.5Z"></path><path d="M14 3.5V8h4"></path><path d="M12 11v6"></path><path d="M9 14h6"></path></svg>';
167
+ const MARKDOWN_PREVIEW_ICON_SVG = '<svg viewBox="0 0 24 24" width="15" height="15" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M3 5.5h18"></path><path d="M3 9.5h18"></path><path d="M5 5.5V18a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V5.5"></path><path d="M9 13h6"></path><path d="M9 16h4"></path></svg>';
168
+ const MARKDOWN_SPLIT_ENABLE_ICON_SVG = '<svg viewBox="0 0 24 24" width="15" height="15" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="14" rx="2"></rect><path d="M12 5v14"></path></svg>';
169
+ const MARKDOWN_SPLIT_DISABLE_ICON_SVG = '<svg viewBox="0 0 24 24" width="15" height="15" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="5" width="16" height="14" rx="2"></rect><path d="M12 5v14"></path><path d="m9.25 8.5 5.5 7"></path></svg>';
140
170
  const TERMINAL_FONT_FAMILY = '\'Monaspace Neon\', "SF Mono Terminal", '
141
171
  + '"SFMono-Regular", "SF Mono", "JetBrains Mono", Menlo, Consolas, '
142
172
  + 'monospace';
@@ -161,12 +191,18 @@ const agentSetupState = {
161
191
  };
162
192
  let primaryServerBootId = '';
163
193
  let runtimeReloadScheduled = false;
194
+ let pdfJsLibPromise = null;
195
+ let markdownPreviewBundlePromise = null;
164
196
  // #endregion
165
197
 
166
198
  function makeFileWorkspaceTabKey(filePath) {
167
199
  return `${FILE_WORKSPACE_TAB_PREFIX}${filePath}`;
168
200
  }
169
201
 
202
+ function makeMarkdownPreviewWorkspaceTabKey(filePath) {
203
+ return `${MARKDOWN_PREVIEW_WORKSPACE_TAB_PREFIX}${filePath}`;
204
+ }
205
+
170
206
  function makeAgentTabKey(serverId, tabId) {
171
207
  return `${AGENT_WORKSPACE_TAB_PREFIX}${serverId}:${tabId}`;
172
208
  }
@@ -182,7 +218,15 @@ function isTerminalWorkspaceTabKey(key) {
182
218
 
183
219
  function isFileWorkspaceTabKey(key) {
184
220
  return typeof key === 'string'
185
- && key.startsWith(FILE_WORKSPACE_TAB_PREFIX);
221
+ && (
222
+ key.startsWith(FILE_WORKSPACE_TAB_PREFIX)
223
+ || key.startsWith(MARKDOWN_PREVIEW_WORKSPACE_TAB_PREFIX)
224
+ );
225
+ }
226
+
227
+ function isMarkdownPreviewWorkspaceTabKey(key) {
228
+ return typeof key === 'string'
229
+ && key.startsWith(MARKDOWN_PREVIEW_WORKSPACE_TAB_PREFIX);
186
230
  }
187
231
 
188
232
  function isCompactWorkspaceMode() {
@@ -201,10 +245,127 @@ function isSupportedImagePath(filePath) {
201
245
  return SUPPORTED_IMAGE_EXTENSIONS.has(ext);
202
246
  }
203
247
 
248
+ function isSupportedPdfPath(filePath) {
249
+ if (typeof filePath !== 'string') {
250
+ return false;
251
+ }
252
+ const dotIndex = filePath.lastIndexOf('.');
253
+ if (dotIndex === -1) {
254
+ return false;
255
+ }
256
+ const ext = filePath.slice(dotIndex + 1).toLowerCase();
257
+ return SUPPORTED_PDF_EXTENSIONS.has(ext);
258
+ }
259
+
260
+ function isSupportedMarkdownPath(filePath) {
261
+ if (typeof filePath !== 'string') {
262
+ return false;
263
+ }
264
+ const dotIndex = filePath.lastIndexOf('.');
265
+ if (dotIndex === -1) {
266
+ return false;
267
+ }
268
+ const ext = filePath.slice(dotIndex + 1).toLowerCase();
269
+ return SUPPORTED_MARKDOWN_EXTENSIONS.has(ext);
270
+ }
271
+
272
+ function ensureExternalStylesheet(id, href) {
273
+ if (!href || document.getElementById(id)) {
274
+ return;
275
+ }
276
+ const link = document.createElement('link');
277
+ link.id = id;
278
+ link.rel = 'stylesheet';
279
+ link.href = href;
280
+ document.head.appendChild(link);
281
+ }
282
+
283
+ async function loadMarkdownPreviewBundle() {
284
+ if (!markdownPreviewBundlePromise) {
285
+ markdownPreviewBundlePromise = (async () => {
286
+ ensureExternalStylesheet(
287
+ 'markdown-preview-github-css',
288
+ MARKDOWN_PREVIEW_GITHUB_CSS_URL
289
+ );
290
+ ensureExternalStylesheet(
291
+ 'markdown-preview-highlight-css',
292
+ MARKDOWN_PREVIEW_HIGHLIGHT_CSS_URL
293
+ );
294
+ ensureExternalStylesheet(
295
+ 'markdown-preview-katex-css',
296
+ MARKDOWN_PREVIEW_KATEX_CSS_URL
297
+ );
298
+ const [
299
+ { default: MarkdownIt },
300
+ { default: markdownItTaskLists },
301
+ { default: markdownItKatex },
302
+ { default: katex },
303
+ { default: hljs }
304
+ ] = await Promise.all([
305
+ import(MARKDOWN_IT_MODULE_URL),
306
+ import(MARKDOWN_TASK_LISTS_MODULE_URL),
307
+ import(MARKDOWN_KATEX_MODULE_URL),
308
+ import(KATEX_MODULE_URL),
309
+ import(HIGHLIGHT_JS_MODULE_URL)
310
+ ]);
311
+ const renderer = new MarkdownIt({
312
+ html: true,
313
+ linkify: true,
314
+ breaks: false,
315
+ highlight(source, language) {
316
+ const code = String(source || '');
317
+ const nextLanguage = String(language || '').trim();
318
+ let html = '';
319
+ if (nextLanguage && hljs.getLanguage(nextLanguage)) {
320
+ html = hljs.highlight(code, {
321
+ language: nextLanguage,
322
+ ignoreIllegals: true
323
+ }).value;
324
+ } else {
325
+ html = hljs.highlightAuto(code).value;
326
+ }
327
+ const languageClass = nextLanguage
328
+ ? ` language-${escapeHtml(nextLanguage)}`
329
+ : '';
330
+ return `<pre class="hljs"><code class="hljs${languageClass}">${html}</code></pre>`;
331
+ }
332
+ });
333
+ renderer.use(markdownItTaskLists, {
334
+ enabled: false,
335
+ label: true,
336
+ labelAfter: true
337
+ });
338
+ renderer.use(markdownItKatex, { katex });
339
+ return {
340
+ renderer
341
+ };
342
+ })().catch((error) => {
343
+ markdownPreviewBundlePromise = null;
344
+ throw error;
345
+ });
346
+ }
347
+ return await markdownPreviewBundlePromise;
348
+ }
349
+
350
+ async function loadPdfJs() {
351
+ if (!pdfJsLibPromise) {
352
+ pdfJsLibPromise = import(PDFJS_MODULE_URL)
353
+ .then((pdfjsLib) => {
354
+ pdfjsLib.GlobalWorkerOptions.workerSrc = PDFJS_WORKER_URL;
355
+ return pdfjsLib;
356
+ });
357
+ }
358
+ return await pdfJsLibPromise;
359
+ }
360
+
204
361
  function isCompactTerminalTabsMode() {
205
362
  return !!window.__tabminalCompactTerminalTabsMode;
206
363
  }
207
364
 
365
+ function canUseMarkdownSplitTabsMode() {
366
+ return !isForcedTerminalWorkspaceMode();
367
+ }
368
+
208
369
  function isForcedTerminalWorkspaceMode() {
209
370
  return isCompactWorkspaceMode() || isCompactTerminalTabsMode();
210
371
  }
@@ -220,8 +381,66 @@ function buildMainTerminalTheme() {
220
381
  }
221
382
 
222
383
  function workspaceKeyToFilePath(key) {
223
- if (!isFileWorkspaceTabKey(key)) return '';
224
- return key.slice(FILE_WORKSPACE_TAB_PREFIX.length);
384
+ if (typeof key !== 'string' || key.length === 0) return '';
385
+ if (key.startsWith(MARKDOWN_PREVIEW_WORKSPACE_TAB_PREFIX)) {
386
+ return key.slice(MARKDOWN_PREVIEW_WORKSPACE_TAB_PREFIX.length);
387
+ }
388
+ if (key.startsWith(FILE_WORKSPACE_TAB_PREFIX)) {
389
+ return key.slice(FILE_WORKSPACE_TAB_PREFIX.length);
390
+ }
391
+ return '';
392
+ }
393
+
394
+ function isExternalHref(href) {
395
+ return /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(String(href || '').trim());
396
+ }
397
+
398
+ function resolveMarkdownLocalTarget(baseFilePath, href) {
399
+ const value = String(href || '').trim();
400
+ const basePath = String(baseFilePath || '').trim();
401
+ if (!value || !basePath || value.startsWith('#') || isExternalHref(value)) {
402
+ return null;
403
+ }
404
+ const baseDir = basePath.includes('/')
405
+ ? basePath.slice(0, basePath.lastIndexOf('/') + 1)
406
+ : '/';
407
+ try {
408
+ const resolved = new URL(
409
+ value,
410
+ `https://tabminal.local${encodeURI(baseDir)}`
411
+ );
412
+ if (resolved.origin !== 'https://tabminal.local') {
413
+ return null;
414
+ }
415
+ return {
416
+ path: decodeURIComponent(resolved.pathname),
417
+ hash: resolved.hash || ''
418
+ };
419
+ } catch {
420
+ return null;
421
+ }
422
+ }
423
+
424
+ function buildMarkdownContextBasePath(filePath = '', baseDirectory = '') {
425
+ const nextFilePath = String(filePath || '').trim();
426
+ if (nextFilePath) {
427
+ return nextFilePath;
428
+ }
429
+ const nextBaseDirectory = String(baseDirectory || '')
430
+ .trim()
431
+ .replace(/\/+$/, '');
432
+ if (!nextBaseDirectory) {
433
+ return '';
434
+ }
435
+ return `${nextBaseDirectory}/__tabminal__.md`;
436
+ }
437
+
438
+ function slugifyMarkdownHeading(text) {
439
+ return String(text || '')
440
+ .trim()
441
+ .toLowerCase()
442
+ .replace(/[^\p{L}\p{N}\s-]/gu, '')
443
+ .replace(/\s+/g, '-');
225
444
  }
226
445
 
227
446
  function getWorkspaceDeviceId() {
@@ -263,15 +482,44 @@ function normalizeWorkspaceSnapshot(input = {}, fallback = {}) {
263
482
  ? base.updatedBy
264
483
  : ''
265
484
  );
485
+ const openFiles = uniqueStringList(source.openFiles);
486
+ const fallbackOpenFiles = uniqueStringList(base.openFiles);
487
+ const markdownSplitPathSource =
488
+ typeof source.markdownSplitPath === 'string'
489
+ ? source.markdownSplitPath
490
+ : (
491
+ typeof base.markdownSplitPath === 'string'
492
+ ? base.markdownSplitPath
493
+ : ''
494
+ );
495
+ const markdownSplitPath = (
496
+ markdownSplitPathSource
497
+ && isSupportedMarkdownPath(markdownSplitPathSource)
498
+ && (
499
+ openFiles.includes(markdownSplitPathSource)
500
+ || fallbackOpenFiles.includes(markdownSplitPathSource)
501
+ )
502
+ )
503
+ ? markdownSplitPathSource
504
+ : '';
505
+ const activeWorkspaceTabKey = typeof source.activeWorkspaceTabKey === 'string'
506
+ ? source.activeWorkspaceTabKey
507
+ : (
508
+ typeof base.activeWorkspaceTabKey === 'string'
509
+ ? base.activeWorkspaceTabKey
510
+ : ''
511
+ );
266
512
  return {
267
513
  updatedAt,
268
514
  updatedBy,
269
515
  isVisible: !!source.isVisible,
270
- openFiles: uniqueStringList(source.openFiles),
516
+ openFiles,
271
517
  terminalDisplayMode: source.terminalDisplayMode === 'tab'
272
518
  ? 'tab'
273
519
  : 'auto',
274
- expandedPaths: uniqueStringList(source.expandedPaths)
520
+ expandedPaths: uniqueStringList(source.expandedPaths),
521
+ markdownSplitPath,
522
+ activeWorkspaceTabKey
275
523
  };
276
524
  }
277
525
 
@@ -299,6 +547,8 @@ function buildWorkspaceSnapshotForSession(session, overrides = {}) {
299
547
  openFiles: session.editorState.openFiles,
300
548
  terminalDisplayMode: session.sharedWorkspaceState.terminalDisplayMode,
301
549
  expandedPaths: session.sharedWorkspaceState.expandedPaths,
550
+ markdownSplitPath: session.workspaceState.markdownSplitPath,
551
+ activeWorkspaceTabKey: session.workspaceState.activeTabKey,
302
552
  ...overrides
303
553
  });
304
554
  }
@@ -709,6 +959,26 @@ class EditorManager {
709
959
  this.monacoContainer = document.getElementById('monaco-container');
710
960
  this.imagePreviewContainer = document.getElementById('image-preview-container');
711
961
  this.imagePreview = document.getElementById('image-preview');
962
+ this.pdfPreviewContainer = document.getElementById(
963
+ 'pdf-preview-container'
964
+ );
965
+ this.pdfPreviewStatus = document.getElementById('pdf-preview-status');
966
+ this.pdfPreviewStatusPrimary = document.getElementById(
967
+ 'pdf-preview-status-primary'
968
+ );
969
+ this.pdfPreviewStatusSecondary = document.getElementById(
970
+ 'pdf-preview-status-secondary'
971
+ );
972
+ this.pdfPreviewPages = document.getElementById('pdf-preview-pages');
973
+ this.markdownPreviewContainer = document.getElementById(
974
+ 'markdown-preview-container'
975
+ );
976
+ this.markdownPreviewScroll = document.getElementById(
977
+ 'markdown-preview-scroll'
978
+ );
979
+ this.markdownPreviewContent = document.getElementById(
980
+ 'markdown-preview-content'
981
+ );
712
982
  this.emptyState = document.getElementById('empty-editor-state');
713
983
  this.terminalWrapper = terminalWrapper;
714
984
  this.terminalOriginalParent = terminalWrapper?.parentElement || null;
@@ -758,17 +1028,47 @@ class EditorManager {
758
1028
  this.agentEmbeddedEditors = [];
759
1029
  this.agentEmbeddedTerminals = new Map();
760
1030
  this.agentTranscriptLayout = null;
1031
+ this.pdfPreviewState = {
1032
+ path: '',
1033
+ sessionKey: '',
1034
+ renderToken: 0,
1035
+ document: null,
1036
+ loadingTask: null,
1037
+ metadata: '',
1038
+ renderedWidth: 0,
1039
+ relayoutTimer: 0
1040
+ };
1041
+ this.markdownPreviewState = {
1042
+ path: '',
1043
+ sessionKey: '',
1044
+ renderToken: 0,
1045
+ renderTimer: 0,
1046
+ pendingHash: ''
1047
+ };
1048
+ this.fileVersionCheckTimer = null;
1049
+ this.fileVersionCheckPromise = null;
1050
+ this.fileConflictDialogKey = '';
1051
+ this.suppressFileWriteCapture = false;
761
1052
  this.agentTranscriptResizeObserver = null;
1053
+ this.treeDirectoryFetches = new Map();
1054
+ this.treeRefreshInFlight = false;
1055
+ this.treeRefreshRerunRequested = false;
1056
+ this.treeRefreshBatchQueued = false;
1057
+ this.pendingForcedTreeRefreshSessions = new Set();
762
1058
 
763
1059
  this.initTerminalControls();
764
1060
  this.initResizer();
765
1061
  this.initAgentPanel();
1062
+ this.initMarkdownPreview();
766
1063
  this.initMonaco();
767
1064
  this.loadIconMap();
768
1065
  this.agentTimestampTimer = window.setInterval(() => {
769
1066
  this.refreshAgentTimelineTimestamps();
770
1067
  this.refreshAgentUsageHud();
771
1068
  }, 1000);
1069
+ this.fileVersionCheckTimer = window.setInterval(() => {
1070
+ void this.checkActiveFileVersion();
1071
+ }, FILE_VERSION_CHECK_INTERVAL_MS);
772
1072
  }
773
1073
 
774
1074
  isTerminalTabPinned(session = this.currentSession) {
@@ -811,6 +1111,155 @@ class EditorManager {
811
1111
  this.terminalWrapper.appendChild(this.terminalLayoutButton);
812
1112
  }
813
1113
 
1114
+ initMarkdownPreview() {
1115
+ if (!this.markdownPreviewContainer || !this.markdownPreviewContent) {
1116
+ return;
1117
+ }
1118
+ this.markdownPreviewContainer.addEventListener('click', (event) => {
1119
+ const link = event.target.closest('a[data-markdown-local-path]');
1120
+ if (!link) {
1121
+ return;
1122
+ }
1123
+ const filePath = String(
1124
+ link.dataset.markdownLocalPath || ''
1125
+ ).trim();
1126
+ if (!filePath) {
1127
+ return;
1128
+ }
1129
+ event.preventDefault();
1130
+ event.stopPropagation();
1131
+ void this.openLocalMarkdownLink(
1132
+ filePath,
1133
+ String(link.dataset.markdownLocalHash || '')
1134
+ );
1135
+ });
1136
+ }
1137
+
1138
+ getMarkdownSplitPath(session = this.currentSession) {
1139
+ if (!session?.workspaceState) {
1140
+ return '';
1141
+ }
1142
+ return typeof session.workspaceState.markdownSplitPath === 'string'
1143
+ ? session.workspaceState.markdownSplitPath
1144
+ : '';
1145
+ }
1146
+
1147
+ isMarkdownSplitViewEnabled(
1148
+ session = this.currentSession,
1149
+ filePath = session?.editorState?.activeFilePath || ''
1150
+ ) {
1151
+ return !!(
1152
+ session
1153
+ && canUseMarkdownSplitTabsMode()
1154
+ && filePath
1155
+ && this.getMarkdownSplitPath(session) === filePath
1156
+ && isSupportedMarkdownPath(filePath)
1157
+ );
1158
+ }
1159
+
1160
+ setMarkdownSplitView(
1161
+ filePath,
1162
+ enabled,
1163
+ session = this.currentSession
1164
+ ) {
1165
+ if (!session?.workspaceState || !isSupportedMarkdownPath(filePath)) {
1166
+ return;
1167
+ }
1168
+ const nextMarkdownSplitPath = (
1169
+ enabled
1170
+ && canUseMarkdownSplitTabsMode()
1171
+ )
1172
+ ? filePath
1173
+ : '';
1174
+ if (session.workspaceState.markdownSplitPath === nextMarkdownSplitPath) {
1175
+ return;
1176
+ }
1177
+ session.workspaceState.markdownSplitPath = nextMarkdownSplitPath;
1178
+ session.saveState({ touchWorkspace: true });
1179
+ if (session.key !== this.currentSession?.key) {
1180
+ return;
1181
+ }
1182
+ this.renderEditorTabs();
1183
+ const activeKey = this.getActiveWorkspaceTabKey(session);
1184
+ if (
1185
+ activeKey === makeFileWorkspaceTabKey(filePath)
1186
+ || activeKey === makeMarkdownPreviewWorkspaceTabKey(filePath)
1187
+ ) {
1188
+ this.activateWorkspaceTab(activeKey, true);
1189
+ }
1190
+ this.layout();
1191
+ }
1192
+
1193
+ syncMarkdownSplitSupport(session = this.currentSession) {
1194
+ if (!session?.workspaceState) {
1195
+ return;
1196
+ }
1197
+ const markdownSplitPath = this.getMarkdownSplitPath(session);
1198
+ if (
1199
+ markdownSplitPath
1200
+ && (
1201
+ !isSupportedMarkdownPath(markdownSplitPath)
1202
+ || !session.editorState.openFiles.includes(markdownSplitPath)
1203
+ )
1204
+ ) {
1205
+ session.workspaceState.markdownSplitPath = '';
1206
+ }
1207
+ }
1208
+
1209
+ showMarkdownSplitView(filePath, options = {}) {
1210
+ const session = options.session || this.currentSession;
1211
+ const focusEditor = options.focusEditor !== false;
1212
+ if (!session || !filePath) {
1213
+ return false;
1214
+ }
1215
+ const file = this.getModel(filePath, session);
1216
+ if (!file || file.type !== 'text') {
1217
+ return false;
1218
+ }
1219
+ if (!this.editor || !this.monacoContainer || !this.contentContainer) {
1220
+ return false;
1221
+ }
1222
+
1223
+ this.contentContainer.classList.add('markdown-split-active');
1224
+ this.agentContainer.style.display = 'none';
1225
+ this.imagePreviewContainer.style.display = 'none';
1226
+ this.hidePdfPreview();
1227
+ this.monacoContainer.style.display = 'block';
1228
+ this.markdownPreviewContainer.style.display = 'flex';
1229
+ this.emptyState.style.display = 'none';
1230
+
1231
+ if (!file.model && file.content !== null && this.monacoInstance) {
1232
+ file.model = this.monacoInstance.editor.createModel(
1233
+ file.content,
1234
+ undefined,
1235
+ this.monacoInstance.Uri.file(filePath)
1236
+ );
1237
+ }
1238
+
1239
+ if (file.model) {
1240
+ this.editor.setModel(file.model);
1241
+ this.editor.updateOptions({ readOnly: !!file.readonly });
1242
+ const savedViewState = session.editorState.viewStates.get(filePath);
1243
+ if (savedViewState) {
1244
+ this.editor.restoreViewState(savedViewState);
1245
+ }
1246
+ }
1247
+
1248
+ void this.renderMarkdownPreview(filePath, {
1249
+ session,
1250
+ show: true
1251
+ });
1252
+
1253
+ requestAnimationFrame(() => {
1254
+ this.layout();
1255
+ if (focusEditor && this.editor) {
1256
+ this.editor.focus();
1257
+ }
1258
+ });
1259
+
1260
+ return true;
1261
+ }
1262
+
814
1263
  updateTerminalLayoutButton() {
815
1264
  if (!this.terminalLayoutButton) return;
816
1265
 
@@ -900,7 +1349,13 @@ class EditorManager {
900
1349
  }
901
1350
  } else if (isFileWorkspaceTabKey(lastNonTerminal)) {
902
1351
  const filePath = workspaceKeyToFilePath(lastNonTerminal);
903
- if (session.editorState.openFiles.includes(filePath)) {
1352
+ if (
1353
+ session.editorState.openFiles.includes(filePath)
1354
+ && (
1355
+ !isMarkdownPreviewWorkspaceTabKey(lastNonTerminal)
1356
+ || isSupportedMarkdownPath(filePath)
1357
+ )
1358
+ ) {
904
1359
  return lastNonTerminal;
905
1360
  }
906
1361
  }
@@ -1098,6 +1553,24 @@ class EditorManager {
1098
1553
  this.agentTranscript = document.createElement('div');
1099
1554
  this.agentTranscript.className = 'agent-panel-transcript';
1100
1555
  this.agentTranscript.addEventListener('click', (event) => {
1556
+ const markdownLink = event.target.closest(
1557
+ 'a[data-markdown-local-path]'
1558
+ );
1559
+ if (markdownLink) {
1560
+ const filePath = String(
1561
+ markdownLink.dataset.markdownLocalPath || ''
1562
+ ).trim();
1563
+ if (!filePath) {
1564
+ return;
1565
+ }
1566
+ event.preventDefault();
1567
+ event.stopPropagation();
1568
+ void this.openLocalMarkdownLink(
1569
+ filePath,
1570
+ String(markdownLink.dataset.markdownLocalHash || '')
1571
+ );
1572
+ return;
1573
+ }
1101
1574
  const anchor = event.target.closest('a');
1102
1575
  if (!anchor) return;
1103
1576
  const href = anchor.getAttribute('href') || '';
@@ -1108,6 +1581,20 @@ class EditorManager {
1108
1581
  void this.openFile(href);
1109
1582
  });
1110
1583
  this.agentTranscript.addEventListener('scroll', () => {
1584
+ const activeAgentTab = getActiveAgentTab();
1585
+ if (
1586
+ activeAgentTab
1587
+ && this.agentTranscript.scrollTop <= 24
1588
+ ) {
1589
+ activeAgentTab.scrollToBottomOnNextRender = false;
1590
+ void this.loadOlderAgentTimeline(activeAgentTab);
1591
+ } else if (
1592
+ activeAgentTab
1593
+ && this.isAgentTranscriptNearBottom(24)
1594
+ ) {
1595
+ activeAgentTab.scrollToBottomOnNextRender = false;
1596
+ void this.loadNewerAgentTimeline(activeAgentTab);
1597
+ }
1111
1598
  this.updateAgentScrollBottomButton();
1112
1599
  this.rememberAgentTranscriptLayout();
1113
1600
  });
@@ -1445,6 +1932,12 @@ class EditorManager {
1445
1932
  && session.editorState.openFiles.includes(
1446
1933
  workspaceKeyToFilePath(explicitKey)
1447
1934
  )
1935
+ && (
1936
+ !isMarkdownPreviewWorkspaceTabKey(explicitKey)
1937
+ || isSupportedMarkdownPath(
1938
+ workspaceKeyToFilePath(explicitKey)
1939
+ )
1940
+ )
1448
1941
  ) {
1449
1942
  return explicitKey;
1450
1943
  }
@@ -1485,53 +1978,526 @@ class EditorManager {
1485
1978
  store.set(filePath, value);
1486
1979
  }
1487
1980
 
1488
- remapTreePath(pathValue, oldPath, newPath, isDirectory) {
1489
- if (typeof pathValue !== 'string' || pathValue.length === 0) {
1490
- return pathValue;
1491
- }
1492
- if (pathValue === oldPath) {
1493
- return newPath;
1494
- }
1495
- if (
1496
- isDirectory
1497
- && pathValue.startsWith(`${oldPath}/`)
1498
- ) {
1499
- return `${newPath}${pathValue.slice(oldPath.length)}`;
1981
+ normalizePendingFileWrite(write, entry = null) {
1982
+ if (write && typeof write === 'object' && !Array.isArray(write)) {
1983
+ return {
1984
+ content: typeof write.content === 'string' ? write.content : '',
1985
+ expectedVersion: typeof write.expectedVersion === 'string'
1986
+ ? write.expectedVersion
1987
+ : (
1988
+ typeof entry?.version === 'string'
1989
+ ? entry.version
1990
+ : ''
1991
+ ),
1992
+ blocked: write.blocked === true,
1993
+ force: write.force === true
1994
+ };
1500
1995
  }
1501
- return pathValue;
1996
+ return {
1997
+ content: typeof write === 'string' ? write : '',
1998
+ expectedVersion: typeof entry?.version === 'string'
1999
+ ? entry.version
2000
+ : '',
2001
+ blocked: false,
2002
+ force: false
2003
+ };
1502
2004
  }
1503
2005
 
1504
- remapWorkspaceTabKey(key, oldPath, newPath, isDirectory) {
1505
- if (!isFileWorkspaceTabKey(key)) return key;
1506
- const filePath = workspaceKeyToFilePath(key);
1507
- const nextPath = this.remapTreePath(
1508
- filePath,
1509
- oldPath,
1510
- newPath,
1511
- isDirectory
2006
+ queuePendingFileWrite(session, filePath, content, overrides = {}) {
2007
+ if (!session || !filePath) return;
2008
+ const pending = getPendingSession(session.key);
2009
+ const entry = this.getModel(filePath, session);
2010
+ const previous = this.normalizePendingFileWrite(
2011
+ pending.fileWrites.get(filePath),
2012
+ entry
2013
+ );
2014
+ pending.fileWrites.set(filePath, {
2015
+ ...previous,
2016
+ content,
2017
+ expectedVersion: typeof overrides.expectedVersion === 'string'
2018
+ ? overrides.expectedVersion
2019
+ : previous.expectedVersion,
2020
+ blocked: overrides.blocked ?? false,
2021
+ force: overrides.force ?? false
2022
+ });
2023
+ }
2024
+
2025
+ getPendingFileWrite(session, filePath) {
2026
+ if (!session || !filePath) return null;
2027
+ const pending = getPendingSession(session.key);
2028
+ if (!pending?.fileWrites?.has(filePath)) {
2029
+ return null;
2030
+ }
2031
+ return this.normalizePendingFileWrite(
2032
+ pending.fileWrites.get(filePath),
2033
+ this.getModel(filePath, session)
1512
2034
  );
1513
- return nextPath ? makeFileWorkspaceTabKey(nextPath) : key;
1514
2035
  }
1515
2036
 
1516
- cloneRenamedModelEntry(entry, nextPath) {
1517
- if (!entry || typeof entry !== 'object') return entry;
1518
- const nextEntry = {
1519
- ...entry
1520
- };
1521
- if (nextEntry.model) {
1522
- let nextContent = nextEntry.content;
1523
- try {
1524
- if (typeof nextEntry.model.getValue === 'function') {
1525
- nextContent = nextEntry.model.getValue();
1526
- }
1527
- } catch {
1528
- // Ignore content extraction failure and keep cached content.
2037
+ getTextFileEntry(filePath, session = this.currentSession) {
2038
+ const entry = this.getModel(filePath, session);
2039
+ if (!entry || entry.type !== 'text') {
2040
+ return null;
2041
+ }
2042
+ if (typeof entry.contentVersion !== 'string') {
2043
+ entry.contentVersion = typeof entry.version === 'string'
2044
+ ? entry.version
2045
+ : '';
2046
+ }
2047
+ return entry;
2048
+ }
2049
+
2050
+ getCurrentTextFileContent(filePath, session = this.currentSession) {
2051
+ const entry = this.getTextFileEntry(filePath, session);
2052
+ if (!entry) return '';
2053
+ try {
2054
+ if (typeof entry.model?.getValue === 'function') {
2055
+ return entry.model.getValue();
1529
2056
  }
1530
- nextEntry.content = nextContent;
2057
+ } catch {
2058
+ // Ignore model access failures and fall back to cached content.
2059
+ }
2060
+ return typeof entry.content === 'string' ? entry.content : '';
2061
+ }
1531
2062
 
1532
- if (
1533
- this.monacoInstance
1534
- && typeof nextEntry.model.getLanguageId === 'function'
2063
+ isActiveTextFile(session, filePath) {
2064
+ if (!session || !filePath) return false;
2065
+ if (this.currentSession?.key !== session.key) return false;
2066
+ if (state.activeSessionKey !== session.key) return false;
2067
+ if (session.editorState.activeFilePath !== filePath) return false;
2068
+ return this.getActiveWorkspaceTabKey(session)
2069
+ === makeFileWorkspaceTabKey(filePath);
2070
+ }
2071
+
2072
+ updateTextFileEntry(filePath, updates, session = this.currentSession) {
2073
+ const entry = this.getTextFileEntry(filePath, session);
2074
+ if (!entry || !updates || typeof updates !== 'object') {
2075
+ return null;
2076
+ }
2077
+ Object.assign(entry, updates);
2078
+ return entry;
2079
+ }
2080
+
2081
+ updateActiveEditorReadOnlyState(session, filePath, readonly) {
2082
+ if (!this.isActiveTextFile(session, filePath) || !this.editor) {
2083
+ return;
2084
+ }
2085
+ this.editor.updateOptions({ readOnly: !!readonly });
2086
+ this.renderEditorTabs();
2087
+ }
2088
+
2089
+ applyProgrammaticTextContent(entry, nextContent) {
2090
+ if (
2091
+ !entry?.model
2092
+ || typeof entry.model.getValue !== 'function'
2093
+ || typeof entry.model.setValue !== 'function'
2094
+ ) {
2095
+ entry.content = nextContent;
2096
+ return;
2097
+ }
2098
+ const currentValue = entry.model.getValue();
2099
+ if (currentValue === nextContent) {
2100
+ entry.content = nextContent;
2101
+ return;
2102
+ }
2103
+ this.suppressFileWriteCapture = true;
2104
+ try {
2105
+ entry.model.setValue(nextContent);
2106
+ } finally {
2107
+ this.suppressFileWriteCapture = false;
2108
+ }
2109
+ entry.content = nextContent;
2110
+ }
2111
+
2112
+ async readTextFileSnapshot(session, filePath) {
2113
+ if (!session || !filePath) {
2114
+ throw new Error('File path required');
2115
+ }
2116
+ const response = await session.server.fetch(
2117
+ `/api/fs/read?path=${encodeURIComponent(filePath)}`
2118
+ );
2119
+ if (!response.ok) {
2120
+ await throwResponseError(response, 'Failed to read file');
2121
+ }
2122
+ return await response.json();
2123
+ }
2124
+
2125
+ async readTextFileInfo(session, filePath) {
2126
+ if (!session || !filePath) {
2127
+ throw new Error('File path required');
2128
+ }
2129
+ const response = await session.server.fetch(
2130
+ `/api/fs/info?path=${encodeURIComponent(filePath)}`
2131
+ );
2132
+ if (!response.ok) {
2133
+ await throwResponseError(response, 'Failed to inspect file');
2134
+ }
2135
+ return await response.json();
2136
+ }
2137
+
2138
+ applyTextFileSnapshot(session, filePath, snapshot, options = {}) {
2139
+ const entry = this.getTextFileEntry(filePath, session);
2140
+ if (!entry || !snapshot || typeof snapshot !== 'object') {
2141
+ return null;
2142
+ }
2143
+ const useLocalContent = options.useLocalContent === true;
2144
+ const nextReadonly = !!snapshot.readonly;
2145
+ const nextVersion = typeof snapshot.version === 'string'
2146
+ ? snapshot.version
2147
+ : entry.version || '';
2148
+ const nextContent = typeof snapshot.content === 'string'
2149
+ ? snapshot.content
2150
+ : entry.content || '';
2151
+
2152
+ if (!entry.model && this.monacoInstance) {
2153
+ const uri = this.monacoInstance.Uri.file(filePath);
2154
+ const existing = this.monacoInstance.editor.getModel(uri);
2155
+ entry.model = existing || this.monacoInstance.editor.createModel(
2156
+ typeof entry.content === 'string' ? entry.content : '',
2157
+ undefined,
2158
+ uri
2159
+ );
2160
+ }
2161
+
2162
+ if (!useLocalContent) {
2163
+ const restoreViewState = (
2164
+ this.isActiveTextFile(session, filePath)
2165
+ && this.editor
2166
+ && this.editor.getModel?.() === entry.model
2167
+ )
2168
+ ? this.editor.saveViewState()
2169
+ : null;
2170
+ this.applyProgrammaticTextContent(entry, nextContent);
2171
+ if (restoreViewState && this.editor) {
2172
+ this.editor.restoreViewState(restoreViewState);
2173
+ }
2174
+ entry.contentVersion = nextVersion;
2175
+ } else if (typeof snapshot.content === 'string') {
2176
+ entry.content = snapshot.content;
2177
+ entry.contentVersion = nextVersion;
2178
+ }
2179
+
2180
+ entry.version = nextVersion;
2181
+ entry.readonly = nextReadonly;
2182
+ entry.size = Number.isFinite(snapshot.size) ? snapshot.size : entry.size;
2183
+ entry.mtimeMs = Number.isFinite(snapshot.mtimeMs)
2184
+ ? snapshot.mtimeMs
2185
+ : entry.mtimeMs;
2186
+ entry.lastDismissedRemoteVersion = '';
2187
+ this.updateActiveEditorReadOnlyState(session, filePath, nextReadonly);
2188
+ if (
2189
+ this.currentSession?.key === session.key
2190
+ && isSupportedMarkdownPath(filePath)
2191
+ && this.currentSession.editorState.activeFilePath === filePath
2192
+ ) {
2193
+ this.scheduleMarkdownPreviewRender(filePath, session);
2194
+ }
2195
+ return entry;
2196
+ }
2197
+
2198
+ getFileConflictDialogKey(session, filePath, version, source) {
2199
+ return [
2200
+ session?.key || '',
2201
+ filePath || '',
2202
+ version || '',
2203
+ source || ''
2204
+ ].join(':');
2205
+ }
2206
+
2207
+ async promptTextFileConflict(session, filePath, snapshot, source) {
2208
+ if (!session || !filePath || !snapshot) {
2209
+ return 'dismiss';
2210
+ }
2211
+ const version = typeof snapshot.version === 'string'
2212
+ ? snapshot.version
2213
+ : '';
2214
+ const dialogKey = this.getFileConflictDialogKey(
2215
+ session,
2216
+ filePath,
2217
+ version,
2218
+ source
2219
+ );
2220
+ if (this.fileConflictDialogKey === dialogKey) {
2221
+ return 'dismiss';
2222
+ }
2223
+ this.fileConflictDialogKey = dialogKey;
2224
+ const fileName = filePath.split('/').pop() || filePath;
2225
+ const keepLocal = await showConfirmModal({
2226
+ title: source === 'save-conflict'
2227
+ ? 'Save Conflict'
2228
+ : 'File Changed on Disk',
2229
+ message: source === 'save-conflict'
2230
+ ? `“${fileName}” changed on disk before Tabminal could save it.`
2231
+ : `“${fileName}” was modified outside Tabminal.`,
2232
+ note: 'Use Remote reloads the disk version. Use Local keeps your '
2233
+ + 'current editor contents and overwrites the remote change '
2234
+ + 'on the next save.',
2235
+ confirmLabel: 'Use Local',
2236
+ cancelLabel: 'Use Remote',
2237
+ preferredFocus: 'cancel',
2238
+ allowDismiss: false,
2239
+ returnFocus: this.isActiveTextFile(session, filePath)
2240
+ ? this.monacoContainer
2241
+ : document.activeElement
2242
+ });
2243
+ this.fileConflictDialogKey = '';
2244
+ return keepLocal ? 'local' : 'remote';
2245
+ }
2246
+
2247
+ async resolveTextFileConflict(session, filePath, snapshot, source) {
2248
+ const entry = this.getTextFileEntry(filePath, session);
2249
+ if (!entry || !snapshot) {
2250
+ return;
2251
+ }
2252
+ const decision = await this.promptTextFileConflict(
2253
+ session,
2254
+ filePath,
2255
+ snapshot,
2256
+ source
2257
+ );
2258
+ if (decision === 'remote') {
2259
+ const remoteSnapshot = typeof snapshot.content === 'string'
2260
+ ? snapshot
2261
+ : await this.readTextFileSnapshot(session, filePath);
2262
+ this.applyTextFileSnapshot(session, filePath, remoteSnapshot);
2263
+ this.clearPendingFileWrite(session.key, filePath);
2264
+ if (this.isActiveTextFile(session, filePath)) {
2265
+ this.renderEditorTabs();
2266
+ }
2267
+ return;
2268
+ }
2269
+
2270
+ if (decision === 'local') {
2271
+ const currentContent = this.getCurrentTextFileContent(
2272
+ filePath,
2273
+ session
2274
+ );
2275
+ this.applyTextFileSnapshot(session, filePath, snapshot, {
2276
+ useLocalContent: true
2277
+ });
2278
+ this.queuePendingFileWrite(session, filePath, currentContent, {
2279
+ expectedVersion: typeof snapshot.version === 'string'
2280
+ ? snapshot.version
2281
+ : entry.version || '',
2282
+ blocked: false,
2283
+ force: false
2284
+ });
2285
+ requestImmediateServerSync(session.server, 0);
2286
+ }
2287
+ }
2288
+
2289
+ async applyFileWriteResults(server, sessionResults, sentFileWrites) {
2290
+ if (!server || !Array.isArray(sessionResults)) {
2291
+ return;
2292
+ }
2293
+ for (const update of sessionResults) {
2294
+ const session = state.sessions.get(
2295
+ makeSessionKey(server.id, update?.id)
2296
+ );
2297
+ if (!session || !Array.isArray(update?.fileWrites)) {
2298
+ continue;
2299
+ }
2300
+ for (const result of update.fileWrites) {
2301
+ const filePath = typeof result?.path === 'string'
2302
+ ? result.path
2303
+ : '';
2304
+ if (!filePath) continue;
2305
+ const entry = this.getTextFileEntry(filePath, session);
2306
+ const sentWrite = sentFileWrites?.get(update.id)?.get(filePath)
2307
+ || null;
2308
+ if (!entry) {
2309
+ this.clearPendingFileWrite(session.key, filePath);
2310
+ continue;
2311
+ }
2312
+ if (result.status === 'ok') {
2313
+ const currentWrite = this.getPendingFileWrite(
2314
+ session,
2315
+ filePath
2316
+ );
2317
+ const sentContent = sentWrite?.content
2318
+ ?? this.getCurrentTextFileContent(filePath, session);
2319
+ entry.content = sentContent;
2320
+ entry.version = typeof result.version === 'string'
2321
+ ? result.version
2322
+ : entry.version || '';
2323
+ entry.contentVersion = entry.version;
2324
+ entry.readonly = !!result.readonly;
2325
+ entry.lastDismissedRemoteVersion = '';
2326
+ const hasNewerPendingWrite = !!(
2327
+ currentWrite
2328
+ && sentWrite
2329
+ && (
2330
+ currentWrite.content !== sentWrite.content
2331
+ || currentWrite.expectedVersion
2332
+ !== sentWrite.expectedVersion
2333
+ || currentWrite.force !== sentWrite.force
2334
+ )
2335
+ );
2336
+ if (hasNewerPendingWrite) {
2337
+ this.queuePendingFileWrite(
2338
+ session,
2339
+ filePath,
2340
+ currentWrite.content,
2341
+ {
2342
+ expectedVersion: entry.version,
2343
+ blocked: false,
2344
+ force: currentWrite.force
2345
+ }
2346
+ );
2347
+ } else {
2348
+ this.clearPendingFileWrite(session.key, filePath);
2349
+ }
2350
+ this.updateActiveEditorReadOnlyState(
2351
+ session,
2352
+ filePath,
2353
+ entry.readonly
2354
+ );
2355
+ continue;
2356
+ }
2357
+ if (result.status === 'conflict') {
2358
+ this.queuePendingFileWrite(
2359
+ session,
2360
+ filePath,
2361
+ this.getCurrentTextFileContent(filePath, session),
2362
+ {
2363
+ expectedVersion: typeof result.version === 'string'
2364
+ ? result.version
2365
+ : entry.version || '',
2366
+ blocked: true,
2367
+ force: false
2368
+ }
2369
+ );
2370
+ await this.resolveTextFileConflict(
2371
+ session,
2372
+ filePath,
2373
+ result,
2374
+ 'save-conflict'
2375
+ );
2376
+ continue;
2377
+ }
2378
+ this.queuePendingFileWrite(
2379
+ session,
2380
+ filePath,
2381
+ this.getCurrentTextFileContent(filePath, session),
2382
+ {
2383
+ blocked: true
2384
+ }
2385
+ );
2386
+ alert(result?.error || 'Failed to save file.', {
2387
+ type: 'error',
2388
+ title: 'Save Error'
2389
+ });
2390
+ }
2391
+ }
2392
+ }
2393
+
2394
+ async checkActiveFileVersion() {
2395
+ if (
2396
+ this.fileVersionCheckPromise
2397
+ || document.visibilityState === 'hidden'
2398
+ || isConfirmModalOpen()
2399
+ ) {
2400
+ return;
2401
+ }
2402
+ const session = this.currentSession;
2403
+ const filePath = session?.editorState?.activeFilePath || '';
2404
+ if (!this.isActiveTextFile(session, filePath)) {
2405
+ return;
2406
+ }
2407
+ const entry = this.getTextFileEntry(filePath, session);
2408
+ if (!entry || entry.readonly) {
2409
+ return;
2410
+ }
2411
+ this.fileVersionCheckPromise = (async () => {
2412
+ try {
2413
+ const info = await this.readTextFileInfo(session, filePath);
2414
+ if (
2415
+ !info
2416
+ || typeof info.version !== 'string'
2417
+ || !info.version
2418
+ || info.version === entry.version
2419
+ || info.version === entry.lastDismissedRemoteVersion
2420
+ ) {
2421
+ return;
2422
+ }
2423
+ const pendingWrite = this.getPendingFileWrite(session, filePath);
2424
+ if (pendingWrite?.blocked) {
2425
+ return;
2426
+ }
2427
+ await this.resolveTextFileConflict(
2428
+ session,
2429
+ filePath,
2430
+ info,
2431
+ 'remote-change'
2432
+ );
2433
+ } catch (error) {
2434
+ console.warn('Failed to check file version:', error);
2435
+ }
2436
+ })();
2437
+ try {
2438
+ await this.fileVersionCheckPromise;
2439
+ } finally {
2440
+ this.fileVersionCheckPromise = null;
2441
+ }
2442
+ }
2443
+
2444
+ clearPendingFileWrite(sessionKey, filePath) {
2445
+ const pending = pendingChanges.sessions.get(sessionKey);
2446
+ pending?.fileWrites?.delete(filePath);
2447
+ }
2448
+
2449
+ remapTreePath(pathValue, oldPath, newPath, isDirectory) {
2450
+ if (typeof pathValue !== 'string' || pathValue.length === 0) {
2451
+ return pathValue;
2452
+ }
2453
+ if (pathValue === oldPath) {
2454
+ return newPath;
2455
+ }
2456
+ if (
2457
+ isDirectory
2458
+ && pathValue.startsWith(`${oldPath}/`)
2459
+ ) {
2460
+ return `${newPath}${pathValue.slice(oldPath.length)}`;
2461
+ }
2462
+ return pathValue;
2463
+ }
2464
+
2465
+ remapWorkspaceTabKey(key, oldPath, newPath, isDirectory) {
2466
+ if (!isFileWorkspaceTabKey(key)) return key;
2467
+ const filePath = workspaceKeyToFilePath(key);
2468
+ const nextPath = this.remapTreePath(
2469
+ filePath,
2470
+ oldPath,
2471
+ newPath,
2472
+ isDirectory
2473
+ );
2474
+ if (!nextPath) {
2475
+ return key;
2476
+ }
2477
+ return isMarkdownPreviewWorkspaceTabKey(key)
2478
+ ? makeMarkdownPreviewWorkspaceTabKey(nextPath)
2479
+ : makeFileWorkspaceTabKey(nextPath);
2480
+ }
2481
+
2482
+ cloneRenamedModelEntry(entry, nextPath) {
2483
+ if (!entry || typeof entry !== 'object') return entry;
2484
+ const nextEntry = {
2485
+ ...entry
2486
+ };
2487
+ if (nextEntry.model) {
2488
+ let nextContent = nextEntry.content;
2489
+ try {
2490
+ if (typeof nextEntry.model.getValue === 'function') {
2491
+ nextContent = nextEntry.model.getValue();
2492
+ }
2493
+ } catch {
2494
+ // Ignore content extraction failure and keep cached content.
2495
+ }
2496
+ nextEntry.content = nextContent;
2497
+
2498
+ if (
2499
+ this.monacoInstance
2500
+ && typeof nextEntry.model.getLanguageId === 'function'
1535
2501
  ) {
1536
2502
  const oldModel = nextEntry.model;
1537
2503
  const languageId = oldModel.getLanguageId();
@@ -1712,6 +2678,17 @@ class EditorManager {
1712
2678
  visualChanged = true;
1713
2679
  }
1714
2680
 
2681
+ const nextMarkdownSplitPath = this.remapTreePath(
2682
+ this.getMarkdownSplitPath(session),
2683
+ oldPath,
2684
+ newPath,
2685
+ isDirectory
2686
+ );
2687
+ if (nextMarkdownSplitPath !== this.getMarkdownSplitPath(session)) {
2688
+ session.workspaceState.markdownSplitPath = nextMarkdownSplitPath;
2689
+ visualChanged = true;
2690
+ }
2691
+
1715
2692
  const nextActiveTabKey = this.remapWorkspaceTabKey(
1716
2693
  session.workspaceState.activeTabKey,
1717
2694
  oldPath,
@@ -1840,6 +2817,17 @@ class EditorManager {
1840
2817
  visualChanged = true;
1841
2818
  }
1842
2819
 
2820
+ if (
2821
+ this.pathMatchesTarget(
2822
+ this.getMarkdownSplitPath(session),
2823
+ targetPath,
2824
+ isDirectory
2825
+ )
2826
+ ) {
2827
+ session.workspaceState.markdownSplitPath = '';
2828
+ visualChanged = true;
2829
+ }
2830
+
1843
2831
  if (session.editorState.viewStates.size > 0) {
1844
2832
  const nextViewStates = new Map();
1845
2833
  let changed = false;
@@ -2138,24 +3126,7 @@ class EditorManager {
2138
3126
  }
2139
3127
 
2140
3128
  refreshSessionTree(session) {
2141
- if (!session || !session.fileTreeElement) return;
2142
- session.fileTreeRenderToken = (session.fileTreeRenderToken || 0) + 1;
2143
- const renderToken = session.fileTreeRenderToken;
2144
- const scrollTop = session.fileTreeElement.scrollTop;
2145
- void this.renderTree(
2146
- session.cwd,
2147
- session.fileTreeElement,
2148
- session,
2149
- renderToken
2150
- ).finally(() => {
2151
- if (
2152
- session.fileTreeElement
2153
- && session.fileTreeRenderToken === renderToken
2154
- ) {
2155
- session.fileTreeElement.scrollTop = scrollTop;
2156
- }
2157
- });
2158
- this.updateTreeAutoRefresh();
3129
+ this.requestSessionTreeRefresh(session);
2159
3130
  }
2160
3131
 
2161
3132
  isSessionTreeVisible(session) {
@@ -2167,63 +3138,220 @@ class EditorManager {
2167
3138
  }
2168
3139
 
2169
3140
  refreshVisibleSessionTrees() {
2170
- for (const session of state.sessions.values()) {
2171
- if (this.canRefreshSessionTree(session)) {
2172
- this.requestSessionTreeRefresh(session);
2173
- }
2174
- }
3141
+ this.requestVisibleTreeRefresh();
2175
3142
  }
2176
3143
 
2177
3144
  requestSessionTreeRefresh(session, { force = false } = {}) {
2178
- if (!force && !this.canRefreshSessionTree(session)) {
3145
+ if (!session?.fileTreeElement) {
2179
3146
  this.updateTreeAutoRefresh();
2180
3147
  return;
2181
3148
  }
2182
- if (session.fileTreeRefreshQueued) return;
2183
- session.fileTreeRefreshQueued = true;
2184
- requestAnimationFrame(() => {
2185
- session.fileTreeRefreshQueued = false;
2186
- if (force || this.canRefreshSessionTree(session)) {
2187
- this.refreshSessionTree(session);
2188
- } else {
2189
- this.updateTreeAutoRefresh();
2190
- }
2191
- });
2192
- }
2193
-
2194
- updateTreeAutoRefresh() {
2195
- const shouldRun = (
2196
- document.visibilityState === 'visible'
2197
- && Array.from(state.sessions.values()).some(
2198
- (session) => this.canRefreshSessionTree(session)
2199
- )
2200
- );
2201
- if (shouldRun && !this.treeRefreshTimer) {
2202
- this.treeRefreshTimer = window.setInterval(() => {
2203
- if (document.visibilityState !== 'visible') {
2204
- this.updateTreeAutoRefresh();
2205
- return;
2206
- }
2207
- const hasVisibleTrees = Array.from(
2208
- state.sessions.values()
2209
- ).some((session) => this.canRefreshSessionTree(session));
2210
- if (!hasVisibleTrees) {
2211
- this.updateTreeAutoRefresh();
2212
- return;
2213
- }
2214
- this.refreshVisibleSessionTrees();
2215
- }, FILE_TREE_REFRESH_INTERVAL_MS);
3149
+ if (!force && !this.canRefreshSessionTree(session)) {
3150
+ this.updateTreeAutoRefresh();
2216
3151
  return;
2217
3152
  }
2218
- if (!shouldRun && this.treeRefreshTimer) {
2219
- window.clearInterval(this.treeRefreshTimer);
2220
- this.treeRefreshTimer = null;
3153
+ if (force) {
3154
+ this.pendingForcedTreeRefreshSessions.add(session.key);
2221
3155
  }
3156
+ this.scheduleTreeRefreshBatch();
2222
3157
  }
2223
3158
 
2224
- setSelectedTreePath(session, path, { preserveFocus = false } = {}) {
2225
- if (!session) return;
2226
- const nextPath = typeof path === 'string' ? path : '';
3159
+ requestVisibleTreeRefresh() {
3160
+ this.scheduleTreeRefreshBatch();
3161
+ }
3162
+
3163
+ scheduleTreeRefreshBatch() {
3164
+ if (this.treeRefreshBatchQueued) {
3165
+ return;
3166
+ }
3167
+ this.treeRefreshBatchQueued = true;
3168
+ requestAnimationFrame(() => {
3169
+ this.treeRefreshBatchQueued = false;
3170
+ void this.flushTreeRefreshBatch();
3171
+ });
3172
+ }
3173
+
3174
+ getTreeRefreshRequestKey(server, dirPath) {
3175
+ return `${server?.id || 'main'}:${dirPath}`;
3176
+ }
3177
+
3178
+ getSessionTreeRefreshPaths(session) {
3179
+ if (!session?.cwd) {
3180
+ return [];
3181
+ }
3182
+ return uniqueStringList([
3183
+ session.cwd,
3184
+ ...(session.sharedWorkspaceState?.expandedPaths || [])
3185
+ ]);
3186
+ }
3187
+
3188
+ collectTreeRefreshSessions() {
3189
+ const sessions = [];
3190
+ for (const session of state.sessions.values()) {
3191
+ if (this.canRefreshSessionTree(session)) {
3192
+ sessions.push(session);
3193
+ continue;
3194
+ }
3195
+ if (
3196
+ this.pendingForcedTreeRefreshSessions.has(session.key)
3197
+ && this.isSessionTreeVisible(session)
3198
+ ) {
3199
+ sessions.push(session);
3200
+ }
3201
+ }
3202
+ return sessions;
3203
+ }
3204
+
3205
+ async fetchTreeDirectoryListing(server, dirPath) {
3206
+ const key = this.getTreeRefreshRequestKey(server, dirPath);
3207
+ const existing = this.treeDirectoryFetches.get(key);
3208
+ if (existing) {
3209
+ return existing;
3210
+ }
3211
+
3212
+ const request = (async () => {
3213
+ const response = await server.fetch(
3214
+ `/api/fs/list?path=${encodeURIComponent(dirPath)}`
3215
+ );
3216
+ if (!response.ok) {
3217
+ throw new Error(`Failed to list path: ${dirPath}`);
3218
+ }
3219
+ const payload = await response.json();
3220
+ return {
3221
+ files: Array.isArray(payload)
3222
+ ? payload
3223
+ : Array.isArray(payload?.items)
3224
+ ? payload.items
3225
+ : [],
3226
+ creatable: Array.isArray(payload)
3227
+ ? false
3228
+ : !!payload?.creatable
3229
+ };
3230
+ })().finally(() => {
3231
+ this.treeDirectoryFetches.delete(key);
3232
+ });
3233
+
3234
+ this.treeDirectoryFetches.set(key, request);
3235
+ return request;
3236
+ }
3237
+
3238
+ async flushTreeRefreshBatch() {
3239
+ if (this.treeRefreshInFlight) {
3240
+ this.treeRefreshRerunRequested = true;
3241
+ return;
3242
+ }
3243
+
3244
+ const sessions = this.collectTreeRefreshSessions();
3245
+ this.pendingForcedTreeRefreshSessions.clear();
3246
+ if (sessions.length === 0) {
3247
+ this.updateTreeAutoRefresh();
3248
+ return;
3249
+ }
3250
+
3251
+ this.treeRefreshInFlight = true;
3252
+ const renderPlans = sessions.map((session) => {
3253
+ session.fileTreeRenderToken = (session.fileTreeRenderToken || 0) + 1;
3254
+ return {
3255
+ session,
3256
+ renderToken: session.fileTreeRenderToken,
3257
+ scrollTop: session.fileTreeElement?.scrollTop || 0
3258
+ };
3259
+ });
3260
+
3261
+ const requestEntries = new Map();
3262
+ for (const { session } of renderPlans) {
3263
+ for (const dirPath of this.getSessionTreeRefreshPaths(session)) {
3264
+ requestEntries.set(
3265
+ this.getTreeRefreshRequestKey(session.server, dirPath),
3266
+ {
3267
+ server: session.server,
3268
+ dirPath
3269
+ }
3270
+ );
3271
+ }
3272
+ }
3273
+
3274
+ const directorySnapshots = new Map();
3275
+ await Promise.all(
3276
+ Array.from(requestEntries.entries()).map(
3277
+ async ([key, entry]) => {
3278
+ try {
3279
+ directorySnapshots.set(
3280
+ key,
3281
+ await this.fetchTreeDirectoryListing(
3282
+ entry.server,
3283
+ entry.dirPath
3284
+ )
3285
+ );
3286
+ } catch (error) {
3287
+ console.error(
3288
+ 'Failed to fetch tree directory:',
3289
+ entry.dirPath,
3290
+ error
3291
+ );
3292
+ }
3293
+ }
3294
+ )
3295
+ );
3296
+
3297
+ for (const plan of renderPlans) {
3298
+ const { session, renderToken, scrollTop } = plan;
3299
+ if (!session.fileTreeElement) {
3300
+ continue;
3301
+ }
3302
+ this.renderTreeFromSnapshots(
3303
+ session.cwd,
3304
+ session.fileTreeElement,
3305
+ session,
3306
+ directorySnapshots,
3307
+ renderToken
3308
+ );
3309
+ if (session.fileTreeRenderToken === renderToken) {
3310
+ session.fileTreeElement.scrollTop = scrollTop;
3311
+ }
3312
+ }
3313
+
3314
+ this.treeRefreshInFlight = false;
3315
+ this.updateTreeAutoRefresh();
3316
+ if (this.treeRefreshRerunRequested) {
3317
+ this.treeRefreshRerunRequested = false;
3318
+ this.scheduleTreeRefreshBatch();
3319
+ }
3320
+ }
3321
+
3322
+ updateTreeAutoRefresh() {
3323
+ const shouldRun = (
3324
+ document.visibilityState === 'visible'
3325
+ && Array.from(state.sessions.values()).some(
3326
+ (session) => this.canRefreshSessionTree(session)
3327
+ )
3328
+ );
3329
+ if (shouldRun && !this.treeRefreshTimer) {
3330
+ this.treeRefreshTimer = window.setInterval(() => {
3331
+ if (document.visibilityState !== 'visible') {
3332
+ this.updateTreeAutoRefresh();
3333
+ return;
3334
+ }
3335
+ const hasVisibleTrees = Array.from(
3336
+ state.sessions.values()
3337
+ ).some((session) => this.canRefreshSessionTree(session));
3338
+ if (!hasVisibleTrees) {
3339
+ this.updateTreeAutoRefresh();
3340
+ return;
3341
+ }
3342
+ this.requestVisibleTreeRefresh();
3343
+ }, FILE_TREE_REFRESH_INTERVAL_MS);
3344
+ return;
3345
+ }
3346
+ if (!shouldRun && this.treeRefreshTimer) {
3347
+ window.clearInterval(this.treeRefreshTimer);
3348
+ this.treeRefreshTimer = null;
3349
+ }
3350
+ }
3351
+
3352
+ setSelectedTreePath(session, path, { preserveFocus = false } = {}) {
3353
+ if (!session) return;
3354
+ const nextPath = typeof path === 'string' ? path : '';
2227
3355
  if (session.selectedTreePath === nextPath) return;
2228
3356
  session.selectedTreePath = nextPath;
2229
3357
  if (preserveFocus && nextPath) {
@@ -2405,7 +3533,7 @@ class EditorManager {
2405
3533
  isDirectory: !!payload.isDirectory,
2406
3534
  renameable: true
2407
3535
  });
2408
- } catch (error) {
3536
+ } catch (_error) {
2409
3537
  alert(error.message || 'Failed to create path', {
2410
3538
  type: 'error',
2411
3539
  title: 'Files'
@@ -2477,7 +3605,7 @@ class EditorManager {
2477
3605
  );
2478
3606
  this.requestSessionTreeRefresh(session);
2479
3607
  session.fileTreeElement?.focus({ preventScroll: true });
2480
- } catch (error) {
3608
+ } catch (_error) {
2481
3609
  alert(error.message || 'Failed to delete path', {
2482
3610
  type: 'error',
2483
3611
  title: 'Files'
@@ -2652,7 +3780,7 @@ class EditorManager {
2652
3780
  list.appendChild(row);
2653
3781
  }
2654
3782
 
2655
- updateTreeItem(li, file, session, renderToken) {
3783
+ updateTreeItem(li, file, session) {
2656
3784
  li.dataset.path = file.path;
2657
3785
  li.dataset.isDirectory = file.isDirectory ? '1' : '0';
2658
3786
  li.dataset.renameable = file.renameable ? '1' : '0';
@@ -2870,7 +3998,7 @@ class EditorManager {
2870
3998
  });
2871
3999
 
2872
4000
  icon.innerHTML = this.getIcon(file.name, true, true);
2873
- await this.renderTree(file.path, li, session, renderToken);
4001
+ this.requestSessionTreeRefresh(session);
2874
4002
  this.updateTreeAutoRefresh();
2875
4003
  session.fileTreeElement?.focus({ preventScroll: true });
2876
4004
  return;
@@ -2914,7 +4042,7 @@ class EditorManager {
2914
4042
  }
2915
4043
  }
2916
4044
 
2917
- reconcileTreeList(list, dirPath, files, creatable, session, renderToken) {
4045
+ reconcileTreeList(list, dirPath, files, creatable, session) {
2918
4046
  const existingItems = new Map();
2919
4047
  Array.from(list.children).forEach((child) => {
2920
4048
  if (child.tagName === 'LI' && child.dataset.path) {
@@ -2930,7 +4058,7 @@ class EditorManager {
2930
4058
  } else {
2931
4059
  existingItems.delete(file.path);
2932
4060
  }
2933
- this.updateTreeItem(li, file, session, renderToken);
4061
+ this.updateTreeItem(li, file, session);
2934
4062
  orderedItems.push(li);
2935
4063
  }
2936
4064
 
@@ -2962,12 +4090,32 @@ class EditorManager {
2962
4090
  });
2963
4091
 
2964
4092
  this.editor.onDidChangeModelContent(() => {
4093
+ if (this.suppressFileWriteCapture) return;
2965
4094
  if (!this.currentSession) return;
2966
4095
  const filePath = this.currentSession.editorState.activeFilePath;
2967
4096
  if (!filePath) return;
2968
-
2969
- const pending = getPendingSession(this.currentSession.key);
2970
- pending.fileWrites.set(filePath, this.editor.getValue());
4097
+ const entry = this.getTextFileEntry(filePath, this.currentSession);
4098
+ if (!entry) return;
4099
+ const nextContent = this.editor.getValue();
4100
+ if (
4101
+ nextContent === (entry.content || '')
4102
+ && (entry.contentVersion || '') === (entry.version || '')
4103
+ ) {
4104
+ this.clearPendingFileWrite(this.currentSession.key, filePath);
4105
+ return;
4106
+ }
4107
+ entry.lastDismissedRemoteVersion = '';
4108
+ this.queuePendingFileWrite(
4109
+ this.currentSession,
4110
+ filePath,
4111
+ nextContent
4112
+ );
4113
+ if (isSupportedMarkdownPath(filePath)) {
4114
+ this.scheduleMarkdownPreviewRender(
4115
+ filePath,
4116
+ this.currentSession
4117
+ );
4118
+ }
2971
4119
  });
2972
4120
 
2973
4121
  monaco.editor.defineTheme('solarized-dark', {
@@ -3005,6 +4153,611 @@ class EditorManager {
3005
4153
  });
3006
4154
  }
3007
4155
 
4156
+ clearMarkdownPreview() {
4157
+ const state = this.markdownPreviewState;
4158
+ state.renderToken += 1;
4159
+ clearTimeout(state.renderTimer);
4160
+ state.renderTimer = 0;
4161
+ state.path = '';
4162
+ state.sessionKey = '';
4163
+ state.pendingHash = '';
4164
+ if (this.markdownPreviewContent) {
4165
+ this.markdownPreviewContent.innerHTML = '';
4166
+ }
4167
+ if (this.markdownPreviewScroll) {
4168
+ this.markdownPreviewScroll.scrollTop = 0;
4169
+ }
4170
+ }
4171
+
4172
+ hideMarkdownPreview() {
4173
+ if (this.contentContainer) {
4174
+ this.contentContainer.classList.remove('markdown-split-active');
4175
+ }
4176
+ if (this.markdownPreviewContainer) {
4177
+ this.markdownPreviewContainer.style.display = 'none';
4178
+ }
4179
+ }
4180
+
4181
+ getMarkdownSourceContent(filePath, session = this.currentSession) {
4182
+ const entry = this.getModel(filePath, session);
4183
+ if (!entry || entry.type !== 'text') {
4184
+ return '';
4185
+ }
4186
+ if (entry.model && typeof entry.model.getValue === 'function') {
4187
+ return entry.model.getValue();
4188
+ }
4189
+ return typeof entry.content === 'string' ? entry.content : '';
4190
+ }
4191
+
4192
+ resolveMarkdownPreviewImageUrl(filePath, src, session) {
4193
+ const resolved = resolveMarkdownLocalTarget(filePath, src);
4194
+ if (resolved && isSupportedImagePath(resolved.path)) {
4195
+ return session.server.resolveUrl(
4196
+ `/api/fs/raw?path=${encodeURIComponent(resolved.path)}`
4197
+ + `&token=${session.server.token}`
4198
+ );
4199
+ }
4200
+ return src;
4201
+ }
4202
+
4203
+ decorateMarkdownPreviewContent(root, filePath, session) {
4204
+ if (!(root instanceof DocumentFragment) && !(root instanceof Element)) {
4205
+ return;
4206
+ }
4207
+ const headingIds = new Map();
4208
+ for (const heading of root.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
4209
+ const baseId = slugifyMarkdownHeading(heading.textContent || '')
4210
+ || 'section';
4211
+ const nextCount = (headingIds.get(baseId) || 0) + 1;
4212
+ headingIds.set(baseId, nextCount);
4213
+ heading.id = nextCount === 1
4214
+ ? baseId
4215
+ : `${baseId}-${nextCount}`;
4216
+ }
4217
+
4218
+ for (const image of root.querySelectorAll('img[src]')) {
4219
+ const src = String(image.getAttribute('src') || '').trim();
4220
+ if (!src) {
4221
+ continue;
4222
+ }
4223
+ image.loading = 'lazy';
4224
+ image.decoding = 'async';
4225
+ image.src = this.resolveMarkdownPreviewImageUrl(
4226
+ filePath,
4227
+ src,
4228
+ session
4229
+ );
4230
+ }
4231
+
4232
+ for (const link of root.querySelectorAll('a[href]')) {
4233
+ const href = String(link.getAttribute('href') || '').trim();
4234
+ if (!href) {
4235
+ continue;
4236
+ }
4237
+ const resolved = resolveMarkdownLocalTarget(filePath, href);
4238
+ if (resolved) {
4239
+ link.dataset.markdownLocalPath = resolved.path;
4240
+ link.dataset.markdownLocalHash = resolved.hash || '';
4241
+ continue;
4242
+ }
4243
+ if (!href.startsWith('#')) {
4244
+ link.target = '_blank';
4245
+ link.rel = 'noreferrer noopener';
4246
+ }
4247
+ }
4248
+ }
4249
+
4250
+ getAgentMarkdownBaseDirectory(agentTab, message) {
4251
+ const messageCwd = String(message?.cwd || '').trim();
4252
+ if (messageCwd) {
4253
+ return messageCwd;
4254
+ }
4255
+ const tabCwd = String(agentTab?.cwd || '').trim();
4256
+ if (tabCwd) {
4257
+ return tabCwd;
4258
+ }
4259
+ const session = this.currentSession;
4260
+ return String(session?.cwd || session?.initialCwd || '').trim();
4261
+ }
4262
+
4263
+ async enhanceAgentMarkdownBody(agentTab, message, body) {
4264
+ if (!(body instanceof HTMLElement) || !message?.text) {
4265
+ return;
4266
+ }
4267
+ const session = this.currentSession;
4268
+ if (!session) {
4269
+ return;
4270
+ }
4271
+
4272
+ const renderToken = `${Date.now()}:${Math.random()}`;
4273
+ body.dataset.markdownRenderToken = renderToken;
4274
+
4275
+ try {
4276
+ const { renderer } = await loadMarkdownPreviewBundle();
4277
+ if (
4278
+ !body.isConnected
4279
+ || body.dataset.markdownRenderToken !== renderToken
4280
+ ) {
4281
+ return;
4282
+ }
4283
+ const rendered = renderer.render(String(message.text || ''));
4284
+ const sanitized = DOMPurify.sanitize(rendered, {
4285
+ USE_PROFILES: {
4286
+ html: true,
4287
+ mathMl: true,
4288
+ svg: true
4289
+ }
4290
+ });
4291
+ const template = document.createElement('template');
4292
+ template.innerHTML = sanitized;
4293
+ const basePath = buildMarkdownContextBasePath(
4294
+ '',
4295
+ this.getAgentMarkdownBaseDirectory(agentTab, message)
4296
+ );
4297
+ this.decorateMarkdownPreviewContent(
4298
+ template.content,
4299
+ basePath,
4300
+ session
4301
+ );
4302
+ body.replaceChildren(template.content);
4303
+ } catch {
4304
+ // Keep the lightweight fallback rendering.
4305
+ }
4306
+ }
4307
+
4308
+ scrollMarkdownPreviewHash(hash) {
4309
+ const nextHash = String(hash || '').trim();
4310
+ if (!nextHash || !this.markdownPreviewScroll) {
4311
+ return;
4312
+ }
4313
+ const id = nextHash.startsWith('#') ? nextHash.slice(1) : nextHash;
4314
+ if (!id) {
4315
+ return;
4316
+ }
4317
+ const target = this.markdownPreviewContent?.querySelector(
4318
+ `#${CSS.escape(id)}`
4319
+ );
4320
+ if (!target) {
4321
+ return;
4322
+ }
4323
+ target.scrollIntoView({
4324
+ behavior: 'smooth',
4325
+ block: 'start'
4326
+ });
4327
+ }
4328
+
4329
+ scheduleMarkdownPreviewRender(filePath, session = this.currentSession) {
4330
+ if (
4331
+ !session
4332
+ || !filePath
4333
+ || !isSupportedMarkdownPath(filePath)
4334
+ ) {
4335
+ return;
4336
+ }
4337
+ const state = this.markdownPreviewState;
4338
+ clearTimeout(state.renderTimer);
4339
+ state.renderTimer = window.setTimeout(() => {
4340
+ if (
4341
+ this.currentSession?.key !== session.key
4342
+ || this.currentSession?.editorState.activeFilePath !== filePath
4343
+ ) {
4344
+ return;
4345
+ }
4346
+ const activeKey = this.getActiveWorkspaceTabKey(session);
4347
+ const shouldShow = (
4348
+ isMarkdownPreviewWorkspaceTabKey(activeKey)
4349
+ || this.isMarkdownSplitViewEnabled(session, filePath)
4350
+ );
4351
+ void this.renderMarkdownPreview(filePath, {
4352
+ session,
4353
+ show: shouldShow
4354
+ });
4355
+ }, 60);
4356
+ }
4357
+
4358
+ async renderMarkdownPreview(filePath, options = {}) {
4359
+ const session = options.session || this.currentSession;
4360
+ const show = options.show === true;
4361
+ if (
4362
+ !session
4363
+ || !filePath
4364
+ || !this.markdownPreviewContainer
4365
+ || !this.markdownPreviewContent
4366
+ || !isSupportedMarkdownPath(filePath)
4367
+ ) {
4368
+ return;
4369
+ }
4370
+ const state = this.markdownPreviewState;
4371
+ const renderToken = state.renderToken + 1;
4372
+ state.renderToken = renderToken;
4373
+ state.path = filePath;
4374
+ state.sessionKey = session.key;
4375
+ if (show) {
4376
+ this.markdownPreviewContainer.style.display = 'flex';
4377
+ }
4378
+
4379
+ try {
4380
+ const { renderer } = await loadMarkdownPreviewBundle();
4381
+ if (
4382
+ state.renderToken !== renderToken
4383
+ || state.path !== filePath
4384
+ || state.sessionKey !== session.key
4385
+ ) {
4386
+ return;
4387
+ }
4388
+ const source = this.getMarkdownSourceContent(filePath, session);
4389
+ const rendered = renderer.render(source || '');
4390
+ const sanitized = DOMPurify.sanitize(rendered, {
4391
+ USE_PROFILES: {
4392
+ html: true,
4393
+ mathMl: true,
4394
+ svg: true
4395
+ }
4396
+ });
4397
+ const template = document.createElement('template');
4398
+ template.innerHTML = sanitized;
4399
+ this.decorateMarkdownPreviewContent(
4400
+ template.content,
4401
+ filePath,
4402
+ session
4403
+ );
4404
+ this.markdownPreviewContent.replaceChildren(template.content);
4405
+ const pendingHash = state.pendingHash;
4406
+ state.pendingHash = '';
4407
+ if (pendingHash) {
4408
+ requestAnimationFrame(() => {
4409
+ this.scrollMarkdownPreviewHash(pendingHash);
4410
+ });
4411
+ }
4412
+ } catch (error) {
4413
+ console.error('Failed to render markdown preview:', error);
4414
+ this.markdownPreviewContent.innerHTML = '';
4415
+ const fallback = document.createElement('div');
4416
+ fallback.className = 'markdown-preview-error';
4417
+ fallback.textContent = 'Failed to render markdown preview.';
4418
+ this.markdownPreviewContent.appendChild(fallback);
4419
+ }
4420
+ }
4421
+
4422
+ async openLocalMarkdownLink(filePath, hash = '') {
4423
+ const session = this.currentSession;
4424
+ if (!session || !filePath) {
4425
+ return;
4426
+ }
4427
+ if (isSupportedMarkdownPath(filePath)) {
4428
+ this.markdownPreviewState.pendingHash = hash || '';
4429
+ await this.openFile(filePath, session, {
4430
+ activatePreview: true,
4431
+ focusEditor: false
4432
+ });
4433
+ return;
4434
+ }
4435
+ await this.openFile(filePath, session, {
4436
+ focusEditor: false
4437
+ });
4438
+ }
4439
+
4440
+ clearPdfPreview(preserveDocument = false) {
4441
+ const state = this.pdfPreviewState;
4442
+ state.renderToken += 1;
4443
+ clearTimeout(state.relayoutTimer);
4444
+ state.relayoutTimer = 0;
4445
+ if (!preserveDocument) {
4446
+ const documentRef = state.document;
4447
+ state.document = null;
4448
+ state.loadingTask = null;
4449
+ state.path = '';
4450
+ state.sessionKey = '';
4451
+ state.metadata = '';
4452
+ state.renderedWidth = 0;
4453
+ if (documentRef && typeof documentRef.destroy === 'function') {
4454
+ Promise.resolve(documentRef.destroy()).catch(() => {});
4455
+ }
4456
+ }
4457
+ if (this.pdfPreviewPages) {
4458
+ this.pdfPreviewPages.innerHTML = '';
4459
+ }
4460
+ this.setPdfPreviewStatus('', '');
4461
+ }
4462
+
4463
+ hidePdfPreview() {
4464
+ this.pdfPreviewContainer.style.display = 'none';
4465
+ }
4466
+
4467
+ getPdfPreviewUrl(filePath, session = this.currentSession) {
4468
+ if (!session) return '';
4469
+ return session.server.resolveUrl(
4470
+ `/api/fs/raw?path=${encodeURIComponent(filePath)}`
4471
+ + `&token=${session.server.token}`
4472
+ );
4473
+ }
4474
+
4475
+ getPdfPreviewTargetWidth() {
4476
+ if (!this.pdfPreviewPages) {
4477
+ return 0;
4478
+ }
4479
+ const width = this.pdfPreviewPages.clientWidth - 36;
4480
+ return Math.max(240, Math.floor(Math.min(width, 960)));
4481
+ }
4482
+
4483
+ setPdfPreviewStatus(primary = '', secondary = '') {
4484
+ const nextPrimary = String(primary || '').trim();
4485
+ const nextSecondary = String(secondary || '').trim();
4486
+ if (this.pdfPreviewStatusPrimary) {
4487
+ this.pdfPreviewStatusPrimary.textContent = nextPrimary;
4488
+ }
4489
+ if (this.pdfPreviewStatusSecondary) {
4490
+ this.pdfPreviewStatusSecondary.textContent = nextSecondary;
4491
+ this.pdfPreviewStatusSecondary.title = nextSecondary;
4492
+ }
4493
+ if (this.pdfPreviewStatus) {
4494
+ this.pdfPreviewStatus.classList.toggle(
4495
+ 'is-empty',
4496
+ !nextPrimary && !nextSecondary
4497
+ );
4498
+ }
4499
+ }
4500
+
4501
+ formatPdfByteSize(bytes) {
4502
+ if (!Number.isFinite(bytes) || bytes <= 0) {
4503
+ return '';
4504
+ }
4505
+ const units = ['B', 'KB', 'MB', 'GB'];
4506
+ let value = bytes;
4507
+ let unitIndex = 0;
4508
+ while (value >= 1024 && unitIndex < units.length - 1) {
4509
+ value /= 1024;
4510
+ unitIndex += 1;
4511
+ }
4512
+ const decimals = value >= 100 || unitIndex === 0 ? 0 : 1;
4513
+ return `${value.toFixed(decimals)} ${units[unitIndex]}`;
4514
+ }
4515
+
4516
+ describePdfPageSize(viewport) {
4517
+ if (!viewport) {
4518
+ return '';
4519
+ }
4520
+ const width = Math.min(viewport.width, viewport.height);
4521
+ const height = Math.max(viewport.width, viewport.height);
4522
+ const near = (targetWidth, targetHeight) => (
4523
+ Math.abs(width - targetWidth) < 2
4524
+ && Math.abs(height - targetHeight) < 2
4525
+ );
4526
+ if (near(595.276, 841.89)) return 'A4';
4527
+ if (near(612, 792)) return 'Letter';
4528
+ return '';
4529
+ }
4530
+
4531
+ async loadPdfMetadata(documentRef) {
4532
+ const parts = [];
4533
+ try {
4534
+ const meta = await documentRef.getMetadata();
4535
+ const version = String(meta?.info?.PDFFormatVersion || '').trim();
4536
+ parts.push(version ? `PDF ${version}` : 'PDF');
4537
+ } catch {
4538
+ parts.push('PDF');
4539
+ }
4540
+
4541
+ try {
4542
+ const firstPage = await documentRef.getPage(1);
4543
+ const pageSize = this.describePdfPageSize(
4544
+ firstPage.getViewport({ scale: 1 })
4545
+ );
4546
+ if (pageSize) {
4547
+ parts.push(pageSize);
4548
+ }
4549
+ } catch {
4550
+ // Ignore optional page-size metadata failures.
4551
+ }
4552
+
4553
+ try {
4554
+ const downloadInfo = await documentRef.getDownloadInfo();
4555
+ const byteSize = this.formatPdfByteSize(downloadInfo?.length);
4556
+ if (byteSize) {
4557
+ parts.push(byteSize);
4558
+ }
4559
+ } catch {
4560
+ // Ignore optional size metadata failures.
4561
+ }
4562
+
4563
+ return parts.join(' · ');
4564
+ }
4565
+
4566
+ schedulePdfPreviewRelayout() {
4567
+ const state = this.pdfPreviewState;
4568
+ if (
4569
+ !this.pdfPreviewContainer
4570
+ || this.pdfPreviewContainer.style.display === 'none'
4571
+ || !state.document
4572
+ || !state.path
4573
+ ) {
4574
+ return;
4575
+ }
4576
+ clearTimeout(state.relayoutTimer);
4577
+ state.relayoutTimer = window.setTimeout(() => {
4578
+ const nextWidth = this.getPdfPreviewTargetWidth();
4579
+ if (
4580
+ nextWidth > 0
4581
+ && Math.abs(nextWidth - state.renderedWidth) > 24
4582
+ ) {
4583
+ void this.renderPdfPreview(state.path);
4584
+ }
4585
+ }, 120);
4586
+ }
4587
+
4588
+ async loadPdfDocument(filePath, session, renderToken) {
4589
+ const state = this.pdfPreviewState;
4590
+ const url = this.getPdfPreviewUrl(filePath, session);
4591
+ const pdfjsLib = await loadPdfJs();
4592
+ if (state.renderToken !== renderToken) {
4593
+ return null;
4594
+ }
4595
+
4596
+ let loadingTask = pdfjsLib.getDocument({
4597
+ url
4598
+ });
4599
+ state.loadingTask = loadingTask;
4600
+ try {
4601
+ return await loadingTask.promise;
4602
+ } catch (_error) {
4603
+ if (state.renderToken !== renderToken) {
4604
+ return null;
4605
+ }
4606
+ loadingTask = pdfjsLib.getDocument({
4607
+ url,
4608
+ disableWorker: true
4609
+ });
4610
+ state.loadingTask = loadingTask;
4611
+ return await loadingTask.promise;
4612
+ }
4613
+ }
4614
+
4615
+ async renderPdfPreview(filePath) {
4616
+ const session = this.currentSession;
4617
+ if (!session || !filePath) {
4618
+ return;
4619
+ }
4620
+ const state = this.pdfPreviewState;
4621
+ const renderToken = state.renderToken + 1;
4622
+ const targetSessionKey = session.key;
4623
+ const nextWidth = this.getPdfPreviewTargetWidth();
4624
+ if (nextWidth <= 0) {
4625
+ requestAnimationFrame(() => {
4626
+ if (
4627
+ this.currentSession?.key === targetSessionKey
4628
+ && this.currentSession?.editorState.activeFilePath === filePath
4629
+ ) {
4630
+ void this.renderPdfPreview(filePath);
4631
+ }
4632
+ });
4633
+ return;
4634
+ }
4635
+
4636
+ if (
4637
+ state.path !== filePath
4638
+ || state.sessionKey !== targetSessionKey
4639
+ ) {
4640
+ this.clearPdfPreview();
4641
+ } else {
4642
+ this.clearPdfPreview(true);
4643
+ }
4644
+ state.renderToken = renderToken;
4645
+ state.path = filePath;
4646
+ state.sessionKey = targetSessionKey;
4647
+ this.setPdfPreviewStatus('Loading PDF…', '');
4648
+
4649
+ try {
4650
+ let documentRef = state.document;
4651
+ if (!documentRef) {
4652
+ documentRef = await this.loadPdfDocument(
4653
+ filePath,
4654
+ session,
4655
+ renderToken
4656
+ );
4657
+ if (!documentRef || state.renderToken !== renderToken) {
4658
+ return;
4659
+ }
4660
+ state.document = documentRef;
4661
+ state.metadata = await this.loadPdfMetadata(documentRef);
4662
+ if (state.renderToken !== renderToken) {
4663
+ return;
4664
+ }
4665
+ }
4666
+
4667
+ state.renderedWidth = nextWidth;
4668
+ if (this.pdfPreviewPages) {
4669
+ this.pdfPreviewPages.innerHTML = '';
4670
+ }
4671
+ const pageCount = documentRef.numPages;
4672
+ this.setPdfPreviewStatus(
4673
+ `${pageCount} page${pageCount === 1 ? '' : 's'}`,
4674
+ state.metadata || 'PDF'
4675
+ );
4676
+
4677
+ for (let pageNumber = 1; pageNumber <= documentRef.numPages; pageNumber += 1) {
4678
+ if (state.renderToken !== renderToken) {
4679
+ return;
4680
+ }
4681
+ const page = await documentRef.getPage(pageNumber);
4682
+ if (state.renderToken !== renderToken) {
4683
+ return;
4684
+ }
4685
+ const baseViewport = page.getViewport({ scale: 1 });
4686
+ const scale = nextWidth / baseViewport.width;
4687
+ const viewport = page.getViewport({ scale });
4688
+ const canvas = document.createElement('canvas');
4689
+ const context = canvas.getContext('2d', {
4690
+ alpha: false
4691
+ });
4692
+ if (!context) {
4693
+ throw new Error('Failed to create PDF canvas context');
4694
+ }
4695
+ const outputScale = Math.max(1, window.devicePixelRatio || 1);
4696
+ canvas.width = Math.ceil(viewport.width * outputScale);
4697
+ canvas.height = Math.ceil(viewport.height * outputScale);
4698
+ canvas.style.width = `${Math.ceil(viewport.width)}px`;
4699
+ canvas.style.height = `${Math.ceil(viewport.height)}px`;
4700
+ context.setTransform(outputScale, 0, 0, outputScale, 0, 0);
4701
+ const textLayer = document.createElement('div');
4702
+ textLayer.className = 'textLayer';
4703
+ const sheet = document.createElement('div');
4704
+ sheet.className = 'pdf-preview-sheet';
4705
+ sheet.style.width = `${Math.ceil(viewport.width)}px`;
4706
+ sheet.style.height = `${Math.ceil(viewport.height)}px`;
4707
+ sheet.style.setProperty('--user-unit', '1');
4708
+ sheet.style.setProperty('--scale-factor', String(scale));
4709
+ sheet.style.setProperty(
4710
+ '--total-scale-factor',
4711
+ String(scale)
4712
+ );
4713
+ sheet.style.setProperty('--scale-round-x', '1px');
4714
+ sheet.style.setProperty('--scale-round-y', '1px');
4715
+ await page.render({
4716
+ canvasContext: context,
4717
+ viewport
4718
+ }).promise;
4719
+ if (state.renderToken !== renderToken) {
4720
+ return;
4721
+ }
4722
+ const textContent = await page.getTextContent();
4723
+ if (state.renderToken !== renderToken) {
4724
+ return;
4725
+ }
4726
+ const textLayerBuilder = new pdfjsLib.TextLayer({
4727
+ textContentSource: textContent,
4728
+ container: textLayer,
4729
+ viewport
4730
+ });
4731
+ await textLayerBuilder.render();
4732
+ if (state.renderToken !== renderToken) {
4733
+ return;
4734
+ }
4735
+ const wrapper = document.createElement('div');
4736
+ wrapper.className = 'pdf-preview-page';
4737
+ wrapper.dataset.pageNumber = String(pageNumber);
4738
+ sheet.appendChild(canvas);
4739
+ sheet.appendChild(textLayer);
4740
+ wrapper.appendChild(sheet);
4741
+ this.pdfPreviewPages?.appendChild(wrapper);
4742
+ }
4743
+ } catch (error) {
4744
+ console.error('Failed to render PDF preview:', error);
4745
+ if (state.renderToken !== renderToken) {
4746
+ return;
4747
+ }
4748
+ this.clearPdfPreview();
4749
+ this.hidePdfPreview();
4750
+ alert(
4751
+ `Failed to load PDF: ${filePath.split('/').pop()}`,
4752
+ {
4753
+ type: 'error',
4754
+ title: 'PDF Preview Error'
4755
+ }
4756
+ );
4757
+ this.closeFile(filePath);
4758
+ }
4759
+ }
4760
+
3008
4761
  updateEditorPaneVisibility() {
3009
4762
  if (!this.currentSession) return;
3010
4763
  const state = this.currentSession.editorState;
@@ -3089,6 +4842,7 @@ class EditorManager {
3089
4842
  }
3090
4843
 
3091
4844
  this.currentSession = session;
4845
+ this.syncMarkdownSplitSupport(session);
3092
4846
  if (!session) {
3093
4847
  this.pane.style.display = 'none';
3094
4848
  this.resizer.style.display = 'none';
@@ -3126,10 +4880,13 @@ class EditorManager {
3126
4880
  layout() {
3127
4881
  // console.log('[Editor] layout called');
3128
4882
  if (!this.currentSession) return;
4883
+ this.syncMarkdownSplitSupport(this.currentSession);
3129
4884
  this.currentSession.fitMainTerminalIfVisible();
3130
4885
  if (this.editor && this.pane.style.display !== 'none') {
3131
- const width = this.pane.clientWidth;
3132
- const height = this.pane.clientHeight - 35; // Subtract fixed safety margin
4886
+ const width = this.monacoContainer?.clientWidth
4887
+ || this.pane.clientWidth;
4888
+ const height = this.monacoContainer?.clientHeight
4889
+ || (this.pane.clientHeight - 35);
3133
4890
 
3134
4891
  if (width > 0 && height > 0) {
3135
4892
  this.editor.layout({ width, height });
@@ -3137,56 +4894,56 @@ class EditorManager {
3137
4894
  this.editor.layout();
3138
4895
  }
3139
4896
  }
4897
+ this.schedulePdfPreviewRelayout();
3140
4898
  }
3141
4899
 
3142
- async renderTree(
4900
+ renderTreeFromSnapshots(
3143
4901
  dirPath,
3144
4902
  container,
3145
4903
  session,
4904
+ directorySnapshots,
3146
4905
  renderToken = session?.fileTreeRenderToken || 0
3147
4906
  ) {
3148
- try {
3149
- const res = await session.server.fetch(
3150
- `/api/fs/list?path=${encodeURIComponent(dirPath)}`
3151
- );
3152
- if (!res.ok) return;
3153
- const payload = await res.json();
3154
- const files = Array.isArray(payload)
3155
- ? payload
3156
- : Array.isArray(payload?.items)
3157
- ? payload.items
3158
- : [];
3159
- const creatable = Array.isArray(payload)
3160
- ? false
3161
- : !!payload?.creatable;
3162
- if ((session.fileTreeRenderToken || 0) !== renderToken) return;
3163
-
3164
- const list = this.ensureTreeList(container);
3165
- this.reconcileTreeList(
3166
- list,
3167
- dirPath,
3168
- files,
3169
- creatable,
3170
- session,
3171
- renderToken
3172
- );
3173
- if ((session.fileTreeRenderToken || 0) !== renderToken) return;
4907
+ const listing = directorySnapshots.get(
4908
+ this.getTreeRefreshRequestKey(session.server, dirPath)
4909
+ );
4910
+ if (!listing) {
4911
+ return;
4912
+ }
4913
+ if ((session.fileTreeRenderToken || 0) !== renderToken) {
4914
+ return;
4915
+ }
3174
4916
 
3175
- for (const file of files) {
3176
- if (
3177
- file.isDirectory
3178
- && this.getTreeItemExpanded(file.path, session)
3179
- ) {
3180
- const item = Array.from(list.children).find(
3181
- (child) => child.dataset.path === file.path
4917
+ const list = this.ensureTreeList(container);
4918
+ this.reconcileTreeList(
4919
+ list,
4920
+ dirPath,
4921
+ listing.files,
4922
+ listing.creatable,
4923
+ session
4924
+ );
4925
+ if ((session.fileTreeRenderToken || 0) !== renderToken) {
4926
+ return;
4927
+ }
4928
+
4929
+ for (const file of listing.files) {
4930
+ if (
4931
+ file.isDirectory
4932
+ && this.getTreeItemExpanded(file.path, session)
4933
+ ) {
4934
+ const item = Array.from(list.children).find(
4935
+ (child) => child.dataset.path === file.path
4936
+ );
4937
+ if (item) {
4938
+ this.renderTreeFromSnapshots(
4939
+ file.path,
4940
+ item,
4941
+ session,
4942
+ directorySnapshots,
4943
+ renderToken
3182
4944
  );
3183
- if (item) {
3184
- void this.renderTree(file.path, item, session, renderToken);
3185
- }
3186
4945
  }
3187
4946
  }
3188
- } catch (err) {
3189
- console.error('Failed to render tree:', err);
3190
4947
  }
3191
4948
  }
3192
4949
 
@@ -3209,34 +4966,31 @@ class EditorManager {
3209
4966
  const state = targetSession.editorState;
3210
4967
  const wasOpen = state.openFiles.includes(filePath);
3211
4968
  const isImage = isSupportedImagePath(filePath);
4969
+ const isPdf = isSupportedPdfPath(filePath);
3212
4970
 
3213
- if (!this.getModel(filePath)) {
4971
+ if (!this.getModel(filePath, targetSession)) {
3214
4972
  let model = null;
3215
4973
  let content = null;
3216
4974
  let readonly = false;
4975
+ let version = '';
4976
+ let size = 0;
4977
+ let mtimeMs = 0;
3217
4978
 
3218
- if (!isImage) {
4979
+ if (!isImage && !isPdf) {
3219
4980
  try {
3220
- const res = await targetSession.server.fetch(
3221
- `/api/fs/read?path=${encodeURIComponent(filePath)}`
4981
+ const data = await this.readTextFileSnapshot(
4982
+ targetSession,
4983
+ filePath
3222
4984
  );
3223
- if (res.status === 415) {
3224
- await showConfirmModal({
3225
- title: 'Unsupported File Type',
3226
- message: 'This file type is not supported yet.',
3227
- note: 'Only text files and supported images can be opened right now.',
3228
- confirmLabel: 'OK',
3229
- hideCancel: true,
3230
- returnFocus: document.activeElement
3231
- });
3232
- return;
3233
- }
3234
- if (!res.ok) {
3235
- throw new Error('Failed to read file');
3236
- }
3237
- const data = await res.json();
3238
4985
  content = data.content;
3239
4986
  readonly = data.readonly;
4987
+ version = typeof data.version === 'string'
4988
+ ? data.version
4989
+ : '';
4990
+ size = Number.isFinite(data.size) ? data.size : 0;
4991
+ mtimeMs = Number.isFinite(data.mtimeMs)
4992
+ ? data.mtimeMs
4993
+ : 0;
3240
4994
 
3241
4995
  if (this.monacoInstance) {
3242
4996
  const uri = this.monacoInstance.Uri.file(filePath);
@@ -3249,6 +5003,17 @@ class EditorManager {
3249
5003
  }
3250
5004
  }
3251
5005
  } catch (err) {
5006
+ if (err?.message === 'Unsupported file type') {
5007
+ await showConfirmModal({
5008
+ title: 'Unsupported File Type',
5009
+ message: 'This file type is not supported yet.',
5010
+ note: 'Only text files, supported images, and PDFs can be opened right now.',
5011
+ confirmLabel: 'OK',
5012
+ hideCancel: true,
5013
+ returnFocus: document.activeElement
5014
+ });
5015
+ return;
5016
+ }
3252
5017
  alert(`Failed to open file: ${err.message}`, { type: 'error', title: 'Error' });
3253
5018
  this.closeFile(filePath);
3254
5019
  return;
@@ -3256,11 +5021,16 @@ class EditorManager {
3256
5021
  }
3257
5022
 
3258
5023
  this.setModel(filePath, {
3259
- type: isImage ? 'image' : 'text',
5024
+ type: isImage ? 'image' : isPdf ? 'pdf' : 'text',
3260
5025
  model: model,
3261
5026
  content: content,
3262
- readonly: readonly
3263
- });
5027
+ readonly: readonly,
5028
+ version,
5029
+ contentVersion: version,
5030
+ size,
5031
+ mtimeMs,
5032
+ lastDismissedRemoteVersion: ''
5033
+ }, targetSession);
3264
5034
  }
3265
5035
 
3266
5036
  let touchedWorkspace = false;
@@ -3272,7 +5042,11 @@ class EditorManager {
3272
5042
 
3273
5043
  this.updateEditorPaneVisibility();
3274
5044
 
3275
- this.activateFileTab(filePath, false, options);
5045
+ if (options.activatePreview && isSupportedMarkdownPath(filePath)) {
5046
+ this.activateMarkdownPreviewTab(filePath, false);
5047
+ } else {
5048
+ this.activateFileTab(filePath, false, options);
5049
+ }
3276
5050
  if (touchedWorkspace) {
3277
5051
  targetSession.saveState({ touchWorkspace: true });
3278
5052
  }
@@ -3281,6 +5055,9 @@ class EditorManager {
3281
5055
  closeFile(filePath) {
3282
5056
  if (!this.currentSession) return;
3283
5057
  const state = this.currentSession.editorState;
5058
+ if (this.getMarkdownSplitPath(this.currentSession) === filePath) {
5059
+ this.currentSession.workspaceState.markdownSplitPath = '';
5060
+ }
3284
5061
 
3285
5062
  const index = state.openFiles.indexOf(filePath);
3286
5063
  let touchedWorkspace = false;
@@ -3319,8 +5096,10 @@ class EditorManager {
3319
5096
 
3320
5097
  renderEditorTabs() {
3321
5098
  if (!this.currentSession) return;
5099
+ this.syncMarkdownSplitSupport(this.currentSession);
3322
5100
  const state = this.currentSession.editorState;
3323
5101
  const activeWorkspaceTabKey = this.getActiveWorkspaceTabKey();
5102
+ const splitPath = this.getMarkdownSplitPath(this.currentSession);
3324
5103
 
3325
5104
  this.tabsContainer.innerHTML = '';
3326
5105
  if (this.hasCompactWorkspaceTabs(this.currentSession)) {
@@ -3349,11 +5128,28 @@ class EditorManager {
3349
5128
  }
3350
5129
 
3351
5130
  for (const path of state.openFiles) {
5131
+ const splitEnabled = this.isMarkdownSplitViewEnabled(
5132
+ this.currentSession,
5133
+ path
5134
+ );
3352
5135
  const tab = document.createElement('div');
3353
5136
  tab.className = 'editor-tab';
3354
- if (makeFileWorkspaceTabKey(path) === activeWorkspaceTabKey) {
5137
+ if (
5138
+ makeFileWorkspaceTabKey(path) === activeWorkspaceTabKey
5139
+ || (
5140
+ splitEnabled
5141
+ && makeMarkdownPreviewWorkspaceTabKey(path)
5142
+ === activeWorkspaceTabKey
5143
+ )
5144
+ ) {
3355
5145
  tab.classList.add('active');
3356
5146
  }
5147
+ if (isSupportedMarkdownPath(path)) {
5148
+ tab.classList.add('bound-tab', 'bound-tab-primary');
5149
+ }
5150
+ if (splitEnabled) {
5151
+ tab.classList.add('is-split');
5152
+ }
3357
5153
 
3358
5154
  const fileModel = this.getModel(path);
3359
5155
  if (fileModel && fileModel.readonly) {
@@ -3375,16 +5171,96 @@ class EditorManager {
3375
5171
  e.stopPropagation();
3376
5172
  this.closeFile(path);
3377
5173
  };
5174
+ let unsplitBtn = null;
5175
+
5176
+ if (splitEnabled) {
5177
+ unsplitBtn = document.createElement('span');
5178
+ unsplitBtn.className = 'tab-action-btn markdown-unsplit-btn';
5179
+ unsplitBtn.innerHTML = MARKDOWN_SPLIT_DISABLE_ICON_SVG;
5180
+ unsplitBtn.title = 'Restore tabbed markdown view';
5181
+ unsplitBtn.onclick = (e) => {
5182
+ e.stopPropagation();
5183
+ this.setMarkdownSplitView(
5184
+ path,
5185
+ false,
5186
+ this.currentSession
5187
+ );
5188
+ this.activateFileTab(path, false, {
5189
+ focusEditor: false
5190
+ });
5191
+ };
5192
+ tab.appendChild(unsplitBtn);
5193
+ }
3378
5194
 
3379
5195
  tab.onclick = () => this.activateFileTab(path);
3380
5196
  bindSingleTapActivation(tab, () => this.activateFileTab(path), {
3381
- ignoreSelector: '.close-btn'
5197
+ ignoreSelector: '.close-btn, .tab-action-btn'
3382
5198
  });
3383
5199
 
3384
5200
  tab.appendChild(icon);
3385
5201
  tab.appendChild(span);
5202
+ if (unsplitBtn) {
5203
+ tab.appendChild(unsplitBtn);
5204
+ }
3386
5205
  tab.appendChild(closeBtn);
3387
5206
  this.tabsContainer.appendChild(tab);
5207
+
5208
+ if (
5209
+ isSupportedMarkdownPath(path)
5210
+ && !splitEnabled
5211
+ ) {
5212
+ const previewTab = document.createElement('div');
5213
+ previewTab.className = 'editor-tab markdown-preview-tab bound-tab bound-tab-secondary';
5214
+ if (
5215
+ makeMarkdownPreviewWorkspaceTabKey(path)
5216
+ === activeWorkspaceTabKey
5217
+ ) {
5218
+ previewTab.classList.add('active');
5219
+ }
5220
+
5221
+ const previewIcon = document.createElement('span');
5222
+ previewIcon.className = 'file-editor-tab-icon';
5223
+ previewIcon.innerHTML = MARKDOWN_PREVIEW_ICON_SVG;
5224
+
5225
+ const previewLabel = document.createElement('span');
5226
+ previewLabel.textContent = 'Preview';
5227
+ let splitBtn = null;
5228
+
5229
+ if (
5230
+ path !== splitPath
5231
+ && canUseMarkdownSplitTabsMode()
5232
+ ) {
5233
+ splitBtn = document.createElement('span');
5234
+ splitBtn.className = 'tab-action-btn markdown-split-btn';
5235
+ splitBtn.innerHTML = MARKDOWN_SPLIT_ENABLE_ICON_SVG;
5236
+ splitBtn.title = 'Show markdown editor and preview side by side';
5237
+ splitBtn.onclick = (event) => {
5238
+ event.stopPropagation();
5239
+ this.setMarkdownSplitView(
5240
+ path,
5241
+ true,
5242
+ this.currentSession
5243
+ );
5244
+ };
5245
+ previewTab.appendChild(splitBtn);
5246
+ }
5247
+
5248
+ previewTab.onclick = () => this.activateMarkdownPreviewTab(path);
5249
+ bindSingleTapActivation(
5250
+ previewTab,
5251
+ () => this.activateMarkdownPreviewTab(path),
5252
+ {
5253
+ ignoreSelector: '.tab-action-btn'
5254
+ }
5255
+ );
5256
+
5257
+ previewTab.appendChild(previewIcon);
5258
+ previewTab.appendChild(previewLabel);
5259
+ if (splitBtn) {
5260
+ previewTab.appendChild(splitBtn);
5261
+ }
5262
+ this.tabsContainer.appendChild(previewTab);
5263
+ }
3388
5264
  }
3389
5265
 
3390
5266
  for (const agentTab of getAgentTabsForSession(this.currentSession)) {
@@ -3403,7 +5279,10 @@ class EditorManager {
3403
5279
  );
3404
5280
 
3405
5281
  const label = document.createElement('span');
3406
- label.textContent = getAgentDisplayLabel(agentTab);
5282
+ tab.title = String(getAgentDisplayLabel(agentTab) || '').trim();
5283
+ label.textContent = formatWorkspaceTabTitle(
5284
+ getAgentDisplayLabel(agentTab)
5285
+ );
3407
5286
 
3408
5287
  const closeBtn = document.createElement('span');
3409
5288
  closeBtn.className = 'close-btn';
@@ -3436,6 +5315,13 @@ class EditorManager {
3436
5315
  this.activateAgentTab(workspaceTabKey, isRestore);
3437
5316
  return;
3438
5317
  }
5318
+ if (isMarkdownPreviewWorkspaceTabKey(workspaceTabKey)) {
5319
+ this.activateMarkdownPreviewTab(
5320
+ workspaceKeyToFilePath(workspaceTabKey),
5321
+ isRestore
5322
+ );
5323
+ return;
5324
+ }
3439
5325
  this.activateFileTab(workspaceKeyToFilePath(workspaceTabKey), isRestore);
3440
5326
  }
3441
5327
 
@@ -3461,22 +5347,78 @@ class EditorManager {
3461
5347
  TERMINAL_WORKSPACE_TAB_KEY;
3462
5348
  this.currentSession.needsAttention = false;
3463
5349
  if (!isRestore) {
3464
- this.currentSession.saveState();
5350
+ this.currentSession.saveState({ touchWorkspace: true });
3465
5351
  }
3466
5352
  this.renderEditorTabs();
3467
5353
  this.currentSession.updateTabUI();
3468
5354
  this.monacoContainer.style.display = 'none';
3469
5355
  this.imagePreviewContainer.style.display = 'none';
5356
+ this.hidePdfPreview();
5357
+ this.hideMarkdownPreview();
3470
5358
  this.agentContainer.style.display = 'none';
3471
5359
  this.emptyState.style.display = 'none';
3472
- this.syncTerminalWorkspacePlacement(TERMINAL_WORKSPACE_TAB_KEY);
5360
+ this.syncTerminalWorkspacePlacement(TERMINAL_WORKSPACE_TAB_KEY);
5361
+
5362
+ requestAnimationFrame(() => {
5363
+ if (this.currentSession.fitMainTerminalIfVisible()) {
5364
+ this.currentSession.mainTerm.focus();
5365
+ }
5366
+ this.currentSession.reportResize();
5367
+ });
5368
+ }
5369
+
5370
+ activateMarkdownPreviewTab(filePath, isRestore = false) {
5371
+ if (!this.currentSession || !filePath) return;
5372
+
5373
+ const state = this.currentSession.editorState;
5374
+ if (!isRestore && state.activeFilePath && state.activeFilePath !== filePath) {
5375
+ const currentGlobal = this.getModel(state.activeFilePath);
5376
+ if (currentGlobal && currentGlobal.type === 'text' && this.editor) {
5377
+ state.viewStates.set(
5378
+ state.activeFilePath,
5379
+ this.editor.saveViewState()
5380
+ );
5381
+ }
5382
+ }
5383
+
5384
+ state.activeFilePath = filePath;
5385
+ this.currentSession.workspaceState.activeTabKey =
5386
+ makeMarkdownPreviewWorkspaceTabKey(filePath);
5387
+ this.currentSession.workspaceState.lastNonTerminalTabKey =
5388
+ makeMarkdownPreviewWorkspaceTabKey(filePath);
5389
+ if (!isRestore) {
5390
+ this.currentSession.saveState({ touchWorkspace: true });
5391
+ }
5392
+ const file = this.getModel(filePath);
5393
+
5394
+ this.renderEditorTabs();
5395
+ this.emptyState.style.display = 'none';
5396
+ this.syncTerminalWorkspacePlacement();
3473
5397
 
3474
- requestAnimationFrame(() => {
3475
- if (this.currentSession.fitMainTerminalIfVisible()) {
3476
- this.currentSession.mainTerm.focus();
3477
- }
3478
- this.currentSession.reportResize();
3479
- });
5398
+ if (!file) {
5399
+ void this.openFile(filePath, true, {
5400
+ activatePreview: true,
5401
+ focusEditor: false
5402
+ });
5403
+ return;
5404
+ }
5405
+
5406
+ this.agentContainer.style.display = 'none';
5407
+ this.imagePreviewContainer.style.display = 'none';
5408
+ this.hidePdfPreview();
5409
+ if (this.isMarkdownSplitViewEnabled(this.currentSession, filePath)) {
5410
+ this.showMarkdownSplitView(filePath, {
5411
+ session: this.currentSession,
5412
+ focusEditor: false
5413
+ });
5414
+ } else {
5415
+ this.contentContainer.classList.remove('markdown-split-active');
5416
+ this.monacoContainer.style.display = 'none';
5417
+ this.markdownPreviewContainer.style.display = 'flex';
5418
+ void this.renderMarkdownPreview(filePath, {
5419
+ show: true
5420
+ });
5421
+ }
3480
5422
  }
3481
5423
 
3482
5424
  activateFileTab(filePath, isRestore = false, options = {}) {
@@ -3496,7 +5438,9 @@ class EditorManager {
3496
5438
  this.currentSession.workspaceState.activeTabKey = makeFileWorkspaceTabKey(filePath);
3497
5439
  this.currentSession.workspaceState.lastNonTerminalTabKey =
3498
5440
  makeFileWorkspaceTabKey(filePath);
3499
- this.currentSession.saveState();
5441
+ if (!isRestore) {
5442
+ this.currentSession.saveState({ touchWorkspace: true });
5443
+ }
3500
5444
  const file = this.getModel(filePath);
3501
5445
 
3502
5446
  this.renderEditorTabs();
@@ -3511,7 +5455,9 @@ class EditorManager {
3511
5455
  if (file.type === 'image') {
3512
5456
  this.agentContainer.style.display = 'none';
3513
5457
  this.monacoContainer.style.display = 'none';
5458
+ this.hideMarkdownPreview();
3514
5459
  this.imagePreviewContainer.style.display = 'flex';
5460
+ this.hidePdfPreview();
3515
5461
 
3516
5462
  this.imagePreview.onerror = () => {
3517
5463
  alert(`Failed to load image: ${filePath.split('/').pop()}`, { type: 'error', title: 'Error' });
@@ -3522,9 +5468,55 @@ class EditorManager {
3522
5468
  this.imagePreview.src = this.currentSession.server.resolveUrl(
3523
5469
  `/api/fs/raw?path=${encodeURIComponent(filePath)}&token=${this.currentSession.server.token}`
3524
5470
  );
5471
+ } else if (file.type === 'pdf') {
5472
+ this.agentContainer.style.display = 'none';
5473
+ this.monacoContainer.style.display = 'none';
5474
+ this.imagePreviewContainer.style.display = 'none';
5475
+ this.hideMarkdownPreview();
5476
+ this.pdfPreviewContainer.style.display = 'flex';
5477
+ void this.renderPdfPreview(filePath);
5478
+ } else if (isSupportedMarkdownPath(filePath)) {
5479
+ if (this.isMarkdownSplitViewEnabled(this.currentSession, filePath)) {
5480
+ this.showMarkdownSplitView(filePath, {
5481
+ session: this.currentSession,
5482
+ focusEditor
5483
+ });
5484
+ this.scheduleMarkdownPreviewRender(
5485
+ filePath,
5486
+ this.currentSession
5487
+ );
5488
+ return;
5489
+ }
5490
+ this.agentContainer.style.display = 'none';
5491
+ this.imagePreviewContainer.style.display = 'none';
5492
+ this.hidePdfPreview();
5493
+ this.hideMarkdownPreview();
5494
+ this.monacoContainer.style.display = 'block';
5495
+ if (!file.model && file.content !== null && this.monacoInstance) {
5496
+ file.model = this.monacoInstance.editor.createModel(
5497
+ file.content,
5498
+ undefined,
5499
+ this.monacoInstance.Uri.file(filePath)
5500
+ );
5501
+ }
5502
+ if (this.editor && file.model) {
5503
+ this.editor.setModel(file.model);
5504
+ this.editor.updateOptions({ readOnly: !!file.readonly });
5505
+ const savedViewState = state.viewStates.get(filePath);
5506
+ if (savedViewState) {
5507
+ this.editor.restoreViewState(savedViewState);
5508
+ }
5509
+ if (focusEditor) {
5510
+ this.editor.focus();
5511
+ }
5512
+ requestAnimationFrame(() => this.editor.layout());
5513
+ }
5514
+ this.scheduleMarkdownPreviewRender(filePath, this.currentSession);
3525
5515
  } else {
3526
5516
  this.agentContainer.style.display = 'none';
3527
5517
  this.imagePreviewContainer.style.display = 'none';
5518
+ this.hidePdfPreview();
5519
+ this.hideMarkdownPreview();
3528
5520
  this.monacoContainer.style.display = 'block';
3529
5521
 
3530
5522
  if (!file.model && file.content !== null && this.monacoInstance) {
@@ -3545,6 +5537,7 @@ class EditorManager {
3545
5537
  // Force layout to ensure content is visible
3546
5538
  requestAnimationFrame(() => this.editor.layout());
3547
5539
  }
5540
+ void this.checkActiveFileVersion();
3548
5541
  }
3549
5542
  }
3550
5543
 
@@ -3581,15 +5574,21 @@ class EditorManager {
3581
5574
  this.currentSession.workspaceState.lastNonTerminalTabKey = agentTabKey;
3582
5575
  noteRecentAgentTab(this.currentSession, agentTabKey);
3583
5576
  agentTab.needsAttention = false;
3584
- this.currentSession.saveState();
5577
+ if (!isRestore) {
5578
+ this.currentSession.saveState({ touchWorkspace: true });
5579
+ }
3585
5580
  this.renderEditorTabs();
3586
5581
  this.currentSession.updateTabUI();
3587
5582
  this.syncTerminalWorkspacePlacement(agentTabKey);
3588
5583
  this.monacoContainer.style.display = 'none';
3589
5584
  this.imagePreviewContainer.style.display = 'none';
5585
+ this.hidePdfPreview();
5586
+ this.hideMarkdownPreview();
3590
5587
  this.emptyState.style.display = 'none';
3591
5588
  this.agentContainer.style.display = 'flex';
3592
- this.renderAgentPanel(agentTab);
5589
+ this.renderAgentPanel(agentTab, {
5590
+ reason: isRestore ? 'activate-restore' : 'activate'
5591
+ });
3593
5592
  }
3594
5593
 
3595
5594
  async closeAgentTab(agentTabKey) {
@@ -3599,7 +5598,7 @@ class EditorManager {
3599
5598
  removeAgentTab(agentTabKey);
3600
5599
  }
3601
5600
 
3602
- renderAgentPanel(agentTab) {
5601
+ renderAgentPanel(agentTab, options = {}) {
3603
5602
  this.disposeAgentEmbeddedEditors();
3604
5603
  const previousLayout = this.captureAgentTranscriptLayout();
3605
5604
  const previousScrollTop = previousLayout?.scrollTop || 0;
@@ -3666,12 +5665,29 @@ class EditorManager {
3666
5665
 
3667
5666
  this.agentTranscript.innerHTML = '';
3668
5667
  const timeline = getAgentTimelineItems(agentTab);
5668
+ const shouldPinToBottom = wasNearBottom || (
5669
+ agentTab.scrollToBottomOnNextRender
5670
+ && isAgentTranscriptWindowNearLatest(
5671
+ agentTab,
5672
+ timeline.length
5673
+ )
5674
+ );
5675
+ const transcriptWindow = getAgentTranscriptWindow(
5676
+ agentTab,
5677
+ timeline.length,
5678
+ { pinToBottom: shouldPinToBottom }
5679
+ );
5680
+ const visibleTimeline = timeline.slice(
5681
+ transcriptWindow.start,
5682
+ transcriptWindow.end
5683
+ );
3669
5684
  if (timeline.length === 0) {
3670
5685
  this.agentTranscript.appendChild(
3671
5686
  this.buildAgentEmptyState(agentTab)
3672
5687
  );
3673
5688
  } else {
3674
- for (const [index, entry] of timeline.entries()) {
5689
+ for (const [index, entry] of visibleTimeline.entries()) {
5690
+ const timelineIndex = transcriptWindow.start + index;
3675
5691
  let node = null;
3676
5692
  if (entry.type === 'message') {
3677
5693
  node = this.buildAgentMessageNode(agentTab, entry.value);
@@ -3689,8 +5705,12 @@ class EditorManager {
3689
5705
  );
3690
5706
  }
3691
5707
  if (node) {
5708
+ node.dataset.timelineKey = getAgentTimelineItemKey(
5709
+ entry,
5710
+ timelineIndex
5711
+ );
3692
5712
  if (
3693
- index > 0
5713
+ timelineIndex > 0
3694
5714
  && entry.type === 'message'
3695
5715
  && String(entry.value?.role || '').toLowerCase()
3696
5716
  === 'user'
@@ -3701,13 +5721,19 @@ class EditorManager {
3701
5721
  }
3702
5722
  }
3703
5723
  }
3704
- const shouldPinToBottom = agentTab.scrollToBottomOnNextRender
3705
- || wasNearBottom;
3706
- if (shouldPinToBottom) {
5724
+ if (options.preserveTranscriptAnchor) {
5725
+ const restored = this.restoreAgentTranscriptAnchor(
5726
+ options.preserveTranscriptAnchor
5727
+ );
5728
+ if (!restored) {
5729
+ this.agentTranscript.scrollTop = previousScrollTop;
5730
+ }
5731
+ } else if (shouldPinToBottom) {
3707
5732
  this.agentTranscript.scrollTop = this.agentTranscript.scrollHeight;
3708
5733
  agentTab.scrollToBottomOnNextRender = false;
3709
5734
  } else {
3710
5735
  this.agentTranscript.scrollTop = previousScrollTop;
5736
+ agentTab.scrollToBottomOnNextRender = false;
3711
5737
  }
3712
5738
  this.updateAgentScrollBottomButton();
3713
5739
  this.rememberAgentTranscriptLayout();
@@ -3725,6 +5751,95 @@ class EditorManager {
3725
5751
  this.scheduleAgentTranscriptViewportUpdate(shouldPinToBottom);
3726
5752
  }
3727
5753
 
5754
+ async loadOlderAgentTimeline(agentTab) {
5755
+ if (!agentTab || agentTab.historyWindowLoading) {
5756
+ return;
5757
+ }
5758
+ const timeline = getAgentTimelineItems(agentTab);
5759
+ const transcriptWindow = getAgentTranscriptWindow(
5760
+ agentTab,
5761
+ timeline.length
5762
+ );
5763
+ if (transcriptWindow.start <= 0) {
5764
+ return;
5765
+ }
5766
+ agentTab.scrollToBottomOnNextRender = false;
5767
+ const currentWindowSize = transcriptWindow.end
5768
+ - transcriptWindow.start;
5769
+ const step = Math.min(
5770
+ AGENT_TRANSCRIPT_WINDOW_STEP,
5771
+ transcriptWindow.start
5772
+ );
5773
+ const nextStart = Math.max(0, transcriptWindow.start - step);
5774
+ const anchor = this.captureAgentTranscriptAnchor(
5775
+ getAgentTimelineItemKey(
5776
+ timeline[transcriptWindow.start],
5777
+ transcriptWindow.start
5778
+ )
5779
+ );
5780
+ agentTab.historyWindowLoading = true;
5781
+ agentTab.historyWindowStart = nextStart;
5782
+ agentTab.historyWindowEnd = Math.min(
5783
+ timeline.length,
5784
+ nextStart + currentWindowSize
5785
+ );
5786
+ try {
5787
+ this.renderAgentPanel(agentTab, {
5788
+ reason: 'history-older',
5789
+ preserveTranscriptAnchor: anchor
5790
+ });
5791
+ } finally {
5792
+ agentTab.historyWindowLoading = false;
5793
+ }
5794
+ }
5795
+
5796
+ async loadNewerAgentTimeline(agentTab) {
5797
+ if (!agentTab || agentTab.historyWindowLoading) {
5798
+ return;
5799
+ }
5800
+ const timeline = getAgentTimelineItems(agentTab);
5801
+ const transcriptWindow = getAgentTranscriptWindow(
5802
+ agentTab,
5803
+ timeline.length
5804
+ );
5805
+ if (transcriptWindow.end >= timeline.length) {
5806
+ return;
5807
+ }
5808
+ agentTab.scrollToBottomOnNextRender = false;
5809
+ const currentWindowSize = transcriptWindow.end
5810
+ - transcriptWindow.start;
5811
+ const step = Math.min(
5812
+ AGENT_TRANSCRIPT_WINDOW_STEP,
5813
+ timeline.length - transcriptWindow.end
5814
+ );
5815
+ const nextEnd = Math.min(
5816
+ timeline.length,
5817
+ transcriptWindow.end + step
5818
+ );
5819
+ const nextStart = Math.max(0, nextEnd - currentWindowSize);
5820
+ const anchorIndex = Math.max(
5821
+ transcriptWindow.start,
5822
+ transcriptWindow.end - 1
5823
+ );
5824
+ const anchor = this.captureAgentTranscriptAnchor(
5825
+ getAgentTimelineItemKey(
5826
+ timeline[anchorIndex],
5827
+ anchorIndex
5828
+ )
5829
+ );
5830
+ agentTab.historyWindowLoading = true;
5831
+ agentTab.historyWindowStart = nextStart;
5832
+ agentTab.historyWindowEnd = nextEnd;
5833
+ try {
5834
+ this.renderAgentPanel(agentTab, {
5835
+ reason: 'history-newer',
5836
+ preserveTranscriptAnchor: anchor
5837
+ });
5838
+ } finally {
5839
+ agentTab.historyWindowLoading = false;
5840
+ }
5841
+ }
5842
+
3728
5843
  refreshAgentTimelineTimestamps() {
3729
5844
  if (!this.agentContainer || this.agentContainer.style.display === 'none') {
3730
5845
  return;
@@ -4028,6 +6143,7 @@ class EditorManager {
4028
6143
  ) {
4029
6144
  body.classList.add('markdown');
4030
6145
  body.innerHTML = renderAgentMessageMarkdown(message.text || '');
6146
+ void this.enhanceAgentMarkdownBody(agentTab, message, body);
4031
6147
  } else {
4032
6148
  body.classList.add('plain');
4033
6149
  body.textContent = message.text || '';
@@ -4113,9 +6229,27 @@ class EditorManager {
4113
6229
  toolStatusClass
4114
6230
  );
4115
6231
  details.appendChild(summary);
4116
- details.appendChild(
4117
- this.buildAgentSectionBody(details, section)
4118
- );
6232
+ const bodyHost = document.createElement('div');
6233
+ bodyHost.className = 'agent-tool-call-section-content';
6234
+ const mountBody = () => {
6235
+ if (bodyHost.dataset.mounted === 'true') {
6236
+ return;
6237
+ }
6238
+ bodyHost.dataset.mounted = 'true';
6239
+ bodyHost.appendChild(
6240
+ this.buildAgentSectionBody(details, section)
6241
+ );
6242
+ };
6243
+ details.appendChild(bodyHost);
6244
+ if (details.open) {
6245
+ queueMicrotask(mountBody);
6246
+ } else {
6247
+ details.addEventListener('toggle', () => {
6248
+ if (details.open) {
6249
+ mountBody();
6250
+ }
6251
+ }, { once: true });
6252
+ }
4119
6253
  sectionContainer.appendChild(details);
4120
6254
  }
4121
6255
  node.appendChild(sectionContainer);
@@ -4207,9 +6341,27 @@ class EditorManager {
4207
6341
  permission.status || 'pending'
4208
6342
  );
4209
6343
  details.appendChild(summary);
4210
- details.appendChild(
4211
- this.buildAgentSectionBody(details, section)
4212
- );
6344
+ const bodyHost = document.createElement('div');
6345
+ bodyHost.className = 'agent-tool-call-section-content';
6346
+ const mountBody = () => {
6347
+ if (bodyHost.dataset.mounted === 'true') {
6348
+ return;
6349
+ }
6350
+ bodyHost.dataset.mounted = 'true';
6351
+ bodyHost.appendChild(
6352
+ this.buildAgentSectionBody(details, section)
6353
+ );
6354
+ };
6355
+ details.appendChild(bodyHost);
6356
+ if (details.open) {
6357
+ queueMicrotask(mountBody);
6358
+ } else {
6359
+ details.addEventListener('toggle', () => {
6360
+ if (details.open) {
6361
+ mountBody();
6362
+ }
6363
+ }, { once: true });
6364
+ }
4213
6365
  sectionContainer.appendChild(details);
4214
6366
  }
4215
6367
  card.appendChild(sectionContainer);
@@ -4904,6 +7056,51 @@ class EditorManager {
4904
7056
  };
4905
7057
  }
4906
7058
 
7059
+ findAgentTranscriptNodeByKey(timelineKey = '') {
7060
+ if (!this.agentTranscript || !timelineKey) {
7061
+ return null;
7062
+ }
7063
+ for (const node of this.agentTranscript.children) {
7064
+ if (node?.dataset?.timelineKey === timelineKey) {
7065
+ return node;
7066
+ }
7067
+ }
7068
+ return null;
7069
+ }
7070
+
7071
+ captureAgentTranscriptAnchor(timelineKey = '') {
7072
+ const node = this.findAgentTranscriptNodeByKey(timelineKey);
7073
+ if (!node || !this.agentTranscript) {
7074
+ return null;
7075
+ }
7076
+ return {
7077
+ timelineKey,
7078
+ scrollTop: this.agentTranscript.scrollTop,
7079
+ offsetTop: node.offsetTop
7080
+ };
7081
+ }
7082
+
7083
+ restoreAgentTranscriptAnchor(anchor = null) {
7084
+ if (!anchor || !this.agentTranscript) {
7085
+ return false;
7086
+ }
7087
+ const node = this.findAgentTranscriptNodeByKey(
7088
+ anchor.timelineKey || ''
7089
+ );
7090
+ if (!node) {
7091
+ return false;
7092
+ }
7093
+ const previousOffsetTop = Number.isFinite(anchor.offsetTop)
7094
+ ? anchor.offsetTop
7095
+ : 0;
7096
+ const previousScrollTop = Number.isFinite(anchor.scrollTop)
7097
+ ? anchor.scrollTop
7098
+ : 0;
7099
+ this.agentTranscript.scrollTop = previousScrollTop
7100
+ + (node.offsetTop - previousOffsetTop);
7101
+ return true;
7102
+ }
7103
+
4907
7104
  rememberAgentTranscriptLayout() {
4908
7105
  this.agentTranscriptLayout = this.captureAgentTranscriptLayout();
4909
7106
  }
@@ -4924,6 +7121,32 @@ class EditorManager {
4924
7121
  }
4925
7122
 
4926
7123
  scrollAgentTranscriptToBottom() {
7124
+ const activeTab = getActiveAgentTab();
7125
+ if (activeTab) {
7126
+ const total = getAgentTimelineItems(activeTab).length;
7127
+ const transcriptWindow = getAgentTranscriptWindow(
7128
+ activeTab,
7129
+ total,
7130
+ { pinToBottom: false }
7131
+ );
7132
+ const latestWindow = getAgentTranscriptWindow(
7133
+ null,
7134
+ total,
7135
+ { pinToBottom: true }
7136
+ );
7137
+ const alreadyLatest = transcriptWindow.start === latestWindow.start
7138
+ && transcriptWindow.end === latestWindow.end;
7139
+ if (!alreadyLatest) {
7140
+ activeTab.historyWindowStart = latestWindow.start;
7141
+ activeTab.historyWindowEnd = latestWindow.end;
7142
+ activeTab.scrollToBottomOnNextRender = true;
7143
+ this.renderAgentPanel(activeTab, {
7144
+ reason: 'scroll-latest'
7145
+ });
7146
+ return;
7147
+ }
7148
+ activeTab.scrollToBottomOnNextRender = false;
7149
+ }
4927
7150
  if (!this.agentTranscript) return;
4928
7151
  this.agentTranscript.scrollTop = this.agentTranscript.scrollHeight;
4929
7152
  this.updateAgentScrollBottomButton();
@@ -5292,20 +7515,57 @@ class EditorManager {
5292
7515
  const session = agentTab.getLinkedSession();
5293
7516
  if (!session) return;
5294
7517
  try {
7518
+ let targetAgentTab = null;
7519
+ let targetPromptDraft = '';
5295
7520
  if (command.openTabKey) {
5296
7521
  const existingTab = state.agentTabs.get(command.openTabKey);
5297
7522
  const existingSession = existingTab?.getLinkedSession() || null;
5298
7523
  if (existingTab && existingSession) {
5299
- await activateAgentTab(existingSession, existingTab, {
7524
+ targetPromptDraft = String(
7525
+ existingTab.promptDraft || ''
7526
+ );
7527
+ targetAgentTab = await activateAgentTab(
7528
+ existingSession,
7529
+ existingTab,
7530
+ {
5300
7531
  switchSession: true
5301
- });
7532
+ }
7533
+ );
5302
7534
  } else {
5303
- await resumeAgentTabFromHistory(session, agentTab, command);
7535
+ targetAgentTab = await resumeAgentTabFromHistory(
7536
+ session,
7537
+ agentTab,
7538
+ command
7539
+ );
7540
+ targetPromptDraft = String(
7541
+ targetAgentTab?.promptDraft || ''
7542
+ );
5304
7543
  }
5305
7544
  } else {
5306
- await resumeAgentTabFromHistory(session, agentTab, command);
7545
+ targetAgentTab = await resumeAgentTabFromHistory(
7546
+ session,
7547
+ agentTab,
7548
+ command
7549
+ );
7550
+ targetPromptDraft = String(
7551
+ targetAgentTab?.promptDraft || ''
7552
+ );
5307
7553
  }
5308
- this.hideAgentCommandMenu();
7554
+ const targetPromptIntent = getAgentPromptIntent(
7555
+ targetAgentTab,
7556
+ targetPromptDraft
7557
+ );
7558
+ if (targetPromptIntent.kind === 'resume') {
7559
+ targetPromptDraft = '';
7560
+ if (targetAgentTab) {
7561
+ targetAgentTab.promptDraft = '';
7562
+ }
7563
+ }
7564
+ agentTab.promptDraft = '';
7565
+ this.setAgentPromptValue(
7566
+ targetPromptDraft,
7567
+ targetAgentTab || getActiveAgentTab() || agentTab
7568
+ );
5309
7569
  } catch (error) {
5310
7570
  alert(error.message, {
5311
7571
  type: 'error',
@@ -5327,6 +7587,8 @@ class EditorManager {
5327
7587
  showEmptyState() {
5328
7588
  this.monacoContainer.style.display = 'none';
5329
7589
  this.imagePreviewContainer.style.display = 'none';
7590
+ this.hidePdfPreview();
7591
+ this.hideMarkdownPreview();
5330
7592
  this.agentContainer.style.display = 'none';
5331
7593
  this.emptyState.style.display = 'flex';
5332
7594
  this.syncTerminalWorkspacePlacement('');
@@ -5994,13 +8256,31 @@ class Session {
5994
8256
  : Array.from(this.server.expandedPaths)
5995
8257
  }
5996
8258
  );
8259
+ const sharedActiveWorkspaceTabKey = typeof (
8260
+ this.sharedWorkspaceState.activeWorkspaceTabKey
8261
+ ) === 'string'
8262
+ ? this.sharedWorkspaceState.activeWorkspaceTabKey
8263
+ : '';
8264
+ const initialActiveWorkspaceTabKey = (
8265
+ isFileWorkspaceTabKey(sharedActiveWorkspaceTabKey)
8266
+ && !this.sharedWorkspaceState.openFiles.includes(
8267
+ workspaceKeyToFilePath(sharedActiveWorkspaceTabKey)
8268
+ )
8269
+ )
8270
+ ? ''
8271
+ : sharedActiveWorkspaceTabKey;
8272
+ const preferredActiveFilePath = isFileWorkspaceTabKey(
8273
+ initialActiveWorkspaceTabKey
8274
+ )
8275
+ ? workspaceKeyToFilePath(initialActiveWorkspaceTabKey)
8276
+ : legacyEditorState.activeFilePath;
5997
8277
  const initialActiveFilePath = (
5998
- typeof legacyEditorState.activeFilePath === 'string'
8278
+ typeof preferredActiveFilePath === 'string'
5999
8279
  && this.sharedWorkspaceState.openFiles.includes(
6000
- legacyEditorState.activeFilePath
8280
+ preferredActiveFilePath
6001
8281
  )
6002
8282
  )
6003
- ? legacyEditorState.activeFilePath
8283
+ ? preferredActiveFilePath
6004
8284
  : (this.sharedWorkspaceState.openFiles[0] || null);
6005
8285
 
6006
8286
  this.editorState = {
@@ -6011,15 +8291,15 @@ class Session {
6011
8291
  viewStates: new Map() // Path -> ViewState
6012
8292
  };
6013
8293
  this.workspaceState = {
6014
- activeTabKey: legacyEditorState.activeWorkspaceTabKey
8294
+ activeTabKey: initialActiveWorkspaceTabKey
6015
8295
  || (initialActiveFilePath
6016
8296
  ? makeFileWorkspaceTabKey(initialActiveFilePath)
6017
8297
  : ''),
6018
- lastNonTerminalTabKey: legacyEditorState.activeWorkspaceTabKey
8298
+ lastNonTerminalTabKey: initialActiveWorkspaceTabKey
6019
8299
  && !isTerminalWorkspaceTabKey(
6020
- legacyEditorState.activeWorkspaceTabKey
8300
+ initialActiveWorkspaceTabKey
6021
8301
  )
6022
- ? legacyEditorState.activeWorkspaceTabKey
8302
+ ? initialActiveWorkspaceTabKey
6023
8303
  : (initialActiveFilePath
6024
8304
  ? makeFileWorkspaceTabKey(initialActiveFilePath)
6025
8305
  : ''),
@@ -6029,7 +8309,8 @@ class Session {
6029
8309
  ? legacyEditorState.recentAgentTabKeys.filter(
6030
8310
  (key) => typeof key === 'string' && key.length > 0
6031
8311
  )
6032
- : []
8312
+ : [],
8313
+ markdownSplitPath: this.sharedWorkspaceState.markdownSplitPath || ''
6033
8314
  };
6034
8315
 
6035
8316
  this.layoutState = {
@@ -6166,6 +8447,7 @@ class Session {
6166
8447
  this.sharedWorkspaceState = normalized;
6167
8448
  this.editorState.isVisible = normalized.isVisible;
6168
8449
  this.editorState.openFiles = [...normalized.openFiles];
8450
+ this.workspaceState.markdownSplitPath = normalized.markdownSplitPath;
6169
8451
 
6170
8452
  if (
6171
8453
  this.editorState.activeFilePath
@@ -6180,7 +8462,13 @@ class Session {
6180
8462
  const activeKey = this.workspaceState.activeTabKey || '';
6181
8463
  if (isFileWorkspaceTabKey(activeKey)) {
6182
8464
  const filePath = workspaceKeyToFilePath(activeKey);
6183
- if (!this.editorState.openFiles.includes(filePath)) {
8465
+ if (
8466
+ !this.editorState.openFiles.includes(filePath)
8467
+ || (
8468
+ isMarkdownPreviewWorkspaceTabKey(activeKey)
8469
+ && !isSupportedMarkdownPath(filePath)
8470
+ )
8471
+ ) {
6184
8472
  this.workspaceState.activeTabKey = resolveFallbackActiveKey();
6185
8473
  }
6186
8474
  } else if (
@@ -6194,7 +8482,13 @@ class Session {
6194
8482
  this.workspaceState.lastNonTerminalTabKey || '';
6195
8483
  if (isFileWorkspaceTabKey(lastNonTerminalKey)) {
6196
8484
  const filePath = workspaceKeyToFilePath(lastNonTerminalKey);
6197
- if (!this.editorState.openFiles.includes(filePath)) {
8485
+ if (
8486
+ !this.editorState.openFiles.includes(filePath)
8487
+ || (
8488
+ isMarkdownPreviewWorkspaceTabKey(lastNonTerminalKey)
8489
+ && !isSupportedMarkdownPath(filePath)
8490
+ )
8491
+ ) {
6198
8492
  this.workspaceState.lastNonTerminalTabKey = '';
6199
8493
  }
6200
8494
  }
@@ -6358,10 +8652,11 @@ class Session {
6358
8652
 
6359
8653
  const titleEl = tab.querySelector('.title');
6360
8654
  const titleTextEl = tab.querySelector('.tab-title-text');
8655
+ const displayTitle = formatWorkspaceTabTitle(this.title);
6361
8656
  if (titleTextEl) {
6362
- titleTextEl.textContent = this.title;
8657
+ titleTextEl.textContent = displayTitle;
6363
8658
  } else if (titleEl) {
6364
- titleEl.textContent = this.title;
8659
+ titleEl.textContent = displayTitle;
6365
8660
  }
6366
8661
 
6367
8662
  const titleIconEl = tab.querySelector('.tab-status-icon');
@@ -6790,6 +9085,9 @@ class AgentTab {
6790
9085
  this.scrollToBottomOnNextRender = true;
6791
9086
  this.busySyncTimer = null;
6792
9087
  this.planHistory = [];
9088
+ this.historyWindowStart = -1;
9089
+ this.historyWindowEnd = -1;
9090
+ this.historyWindowLoading = false;
6793
9091
  this.resumeSessions = [];
6794
9092
  this.resumeSessionsLoadedAt = 0;
6795
9093
  this.resumeSessionsPromise = null;
@@ -7818,6 +10116,25 @@ function getActiveServer() {
7818
10116
  return getActiveSession()?.server || getMainServer();
7819
10117
  }
7820
10118
 
10119
+ function getDocumentTitle() {
10120
+ const server = getActiveServer();
10121
+ if (!server) {
10122
+ return 'Tabminal';
10123
+ }
10124
+ const host = String(getDisplayHost(server) || '').trim();
10125
+ if (!host || host.toLowerCase() === 'unknown') {
10126
+ return 'Tabminal';
10127
+ }
10128
+ return `Tabminal: ${host}`;
10129
+ }
10130
+
10131
+ function updateDocumentTitle() {
10132
+ const nextTitle = getDocumentTitle();
10133
+ if (document.title !== nextTitle) {
10134
+ document.title = nextTitle;
10135
+ }
10136
+ }
10137
+
7821
10138
  function getSessionsForServer(serverId) {
7822
10139
  return Array.from(state.sessions.values()).filter(
7823
10140
  session => session.serverId === serverId
@@ -7984,6 +10301,9 @@ function getWorkspaceTabKeysForSession(session) {
7984
10301
  }
7985
10302
  for (const path of session.editorState?.openFiles || []) {
7986
10303
  keys.push(makeFileWorkspaceTabKey(path));
10304
+ if (isSupportedMarkdownPath(path)) {
10305
+ keys.push(makeMarkdownPreviewWorkspaceTabKey(path));
10306
+ }
7987
10307
  }
7988
10308
  for (const agentTab of getAgentTabsForSession(session)) {
7989
10309
  keys.push(agentTab.key);
@@ -8147,6 +10467,7 @@ function normalizeAgentSessionCapabilities(sessionCapabilities) {
8147
10467
  return {
8148
10468
  load: !!source.load,
8149
10469
  list: !!source.list,
10470
+ listAll: !!source.listAll,
8150
10471
  resume: !!source.resume,
8151
10472
  fork: !!source.fork
8152
10473
  };
@@ -8564,6 +10885,9 @@ function getAgentResumeSuggestions(agentTab, promptValue, sessions = []) {
8564
10885
  const intent = getAgentPromptIntent(agentTab, promptValue);
8565
10886
  if (intent.kind !== 'resume') return [];
8566
10887
  const query = String(intent.query || '').toLowerCase();
10888
+ const currentCwd = String(
10889
+ agentTab?.cwd || agentTab?.getLinkedSession?.()?.cwd || ''
10890
+ ).trim().toLowerCase();
8567
10891
  const openSessions = getOpenAgentSessionsForServer(
8568
10892
  agentTab?.serverId,
8569
10893
  agentTab?.agentId
@@ -8577,24 +10901,30 @@ function getAgentResumeSuggestions(agentTab, promptValue, sessions = []) {
8577
10901
  ).toLowerCase();
8578
10902
  const cwd = String(session.cwd || '').toLowerCase();
8579
10903
  const sessionId = String(session.sessionId || '').toLowerCase();
10904
+ const cwdMatch = !!currentCwd && cwd === currentCwd;
8580
10905
  const titleMatch = !query || displayName.includes(query);
8581
- const otherMatch = !query || cwd.includes(query) || sessionId.includes(query);
10906
+ const otherMatch = !query
10907
+ || cwd.includes(query)
10908
+ || sessionId.includes(query);
8582
10909
  return {
8583
10910
  session,
8584
10911
  index,
10912
+ cwdMatch,
8585
10913
  titleMatch,
8586
10914
  matched: titleMatch || otherMatch
8587
10915
  };
8588
10916
  })
8589
10917
  .filter(({ matched }) => matched)
8590
10918
  .sort((left, right) => {
10919
+ if (left.cwdMatch !== right.cwdMatch) {
10920
+ return left.cwdMatch ? -1 : 1;
10921
+ }
8591
10922
  if (left.titleMatch !== right.titleMatch) {
8592
10923
  return left.titleMatch ? -1 : 1;
8593
10924
  }
8594
10925
  return left.index - right.index;
8595
10926
  })
8596
10927
  .map(({ session }) => session)
8597
- .slice(0, 12)
8598
10928
  .map((session) => ({
8599
10929
  ...session,
8600
10930
  openTabKey: openSessions.get(session.sessionId)?.key || '',
@@ -8891,6 +11221,106 @@ function getAgentTimelineItems(agentTab) {
8891
11221
  return items;
8892
11222
  }
8893
11223
 
11224
+ function getAgentTimelineItemKey(entry, absoluteIndex = 0) {
11225
+ if (!entry) {
11226
+ return `unknown:${absoluteIndex}`;
11227
+ }
11228
+ const order = Number.isFinite(entry.order) ? entry.order : -1;
11229
+ return `${entry.type}:${order}:${absoluteIndex}`;
11230
+ }
11231
+
11232
+ function formatWorkspaceTabTitle(
11233
+ value,
11234
+ maxLength = WORKSPACE_TAB_TITLE_MAX_LENGTH
11235
+ ) {
11236
+ const text = String(value || '');
11237
+ const characters = Array.from(text);
11238
+ if (characters.length <= maxLength) {
11239
+ return text;
11240
+ }
11241
+ if (maxLength <= 3) {
11242
+ return '.'.repeat(Math.max(0, maxLength));
11243
+ }
11244
+ return `${characters.slice(0, maxLength - 3).join('')}...`;
11245
+ }
11246
+
11247
+ function getAgentTranscriptWindow(
11248
+ agentTab,
11249
+ totalCount = 0,
11250
+ options = {}
11251
+ ) {
11252
+ const total = Number.isFinite(totalCount)
11253
+ ? Math.max(0, totalCount)
11254
+ : 0;
11255
+ const windowSize = Math.min(total, AGENT_TRANSCRIPT_INITIAL_VISIBLE_BLOCKS);
11256
+ const latestStart = Math.max(0, total - windowSize);
11257
+ const latestWindow = {
11258
+ start: latestStart,
11259
+ end: total
11260
+ };
11261
+ if (!agentTab) {
11262
+ return latestWindow;
11263
+ }
11264
+ if (windowSize === 0) {
11265
+ agentTab.historyWindowStart = 0;
11266
+ agentTab.historyWindowEnd = 0;
11267
+ return { start: 0, end: 0 };
11268
+ }
11269
+ if (options.pinToBottom) {
11270
+ agentTab.historyWindowStart = latestWindow.start;
11271
+ agentTab.historyWindowEnd = latestWindow.end;
11272
+ return latestWindow;
11273
+ }
11274
+ let start = Number.isFinite(agentTab.historyWindowStart)
11275
+ ? Math.max(0, Math.floor(agentTab.historyWindowStart))
11276
+ : latestWindow.start;
11277
+ let end = Number.isFinite(agentTab.historyWindowEnd)
11278
+ ? Math.max(start, Math.floor(agentTab.historyWindowEnd))
11279
+ : latestWindow.end;
11280
+ if (end > total) {
11281
+ end = total;
11282
+ }
11283
+ if (end - start !== windowSize) {
11284
+ if (total <= windowSize) {
11285
+ start = 0;
11286
+ end = total;
11287
+ } else if (end >= total) {
11288
+ end = total;
11289
+ start = latestWindow.start;
11290
+ } else if (start <= 0) {
11291
+ start = 0;
11292
+ end = windowSize;
11293
+ } else {
11294
+ end = Math.min(total, start + windowSize);
11295
+ start = Math.max(0, end - windowSize);
11296
+ }
11297
+ }
11298
+ agentTab.historyWindowStart = start;
11299
+ agentTab.historyWindowEnd = end;
11300
+ return { start, end };
11301
+ }
11302
+
11303
+ function isAgentTranscriptWindowNearLatest(agentTab, totalCount = 0) {
11304
+ const total = Number.isFinite(totalCount)
11305
+ ? Math.max(0, totalCount)
11306
+ : 0;
11307
+ if (!agentTab) {
11308
+ return true;
11309
+ }
11310
+ if (
11311
+ !Number.isFinite(agentTab.historyWindowStart)
11312
+ || !Number.isFinite(agentTab.historyWindowEnd)
11313
+ || agentTab.historyWindowStart < 0
11314
+ || agentTab.historyWindowEnd < 0
11315
+ ) {
11316
+ return true;
11317
+ }
11318
+ return agentTab.historyWindowEnd >= Math.max(
11319
+ 0,
11320
+ total - AGENT_TRANSCRIPT_FOLLOW_LATEST_TOLERANCE
11321
+ );
11322
+ }
11323
+
8894
11324
  function normalizePlanStatusClass(status = '') {
8895
11325
  const value = String(status || '').toLowerCase();
8896
11326
  if (value === 'completed') return 'completed';
@@ -11135,7 +13565,7 @@ async function syncAgentsForServer(server, { force = false } = {}) {
11135
13565
  && state.agentTabs.has(activeKey)
11136
13566
  ) {
11137
13567
  noteRecentAgentTab(session, activeKey);
11138
- session.saveState();
13568
+ session.saveState({ touchWorkspace: true });
11139
13569
  }
11140
13570
  }
11141
13571
 
@@ -11169,7 +13599,7 @@ async function syncAgentsForServer(server, { force = false } = {}) {
11169
13599
  getAgentTabsForSession(preferredSession)[0]?.key || ''
11170
13600
  );
11171
13601
  }
11172
- preferredSession.saveState();
13602
+ preferredSession.saveState({ touchWorkspace: true });
11173
13603
  if (state.activeSessionKey === preferredSession.key) {
11174
13604
  restoreWorkspaceForSession(preferredSession);
11175
13605
  } else {
@@ -11208,7 +13638,7 @@ async function activateAgentTab(session, agentTab, options = {}) {
11208
13638
  }
11209
13639
  session.workspaceState.activeTabKey = agentTab.key;
11210
13640
  noteRecentAgentTab(session, agentTab.key);
11211
- session.saveState();
13641
+ session.saveState({ touchWorkspace: true });
11212
13642
  if (state.activeSessionKey === session.key) {
11213
13643
  restoreWorkspaceForSession(session);
11214
13644
  requestAnimationFrame(() => {
@@ -11481,6 +13911,7 @@ async function syncServer(server) {
11481
13911
  }
11482
13912
 
11483
13913
  const updates = { sessions: [] };
13914
+ const sentFileWrites = new Map();
11484
13915
  for (const [sessionKey, pending] of pendingChanges.sessions) {
11485
13916
  const { serverId, sessionId } = splitSessionKey(sessionKey);
11486
13917
  if (serverId !== server.id) continue;
@@ -11497,10 +13928,31 @@ async function syncServer(server) {
11497
13928
  hasUpdate = true;
11498
13929
  }
11499
13930
  if (pending.fileWrites && pending.fileWrites.size > 0) {
11500
- sessionUpdate.fileWrites = Array.from(
13931
+ const fileWrites = Array.from(
11501
13932
  pending.fileWrites.entries()
11502
- ).map(([path, content]) => ({ path, content }));
11503
- hasUpdate = true;
13933
+ )
13934
+ .map(([path, write]) => ({
13935
+ path,
13936
+ write: editorManager.normalizePendingFileWrite(write)
13937
+ }))
13938
+ .filter(({ write }) => !write.blocked);
13939
+ if (fileWrites.length > 0) {
13940
+ sessionUpdate.fileWrites = fileWrites.map(
13941
+ ({ path, write }) => ({
13942
+ path,
13943
+ content: write.content,
13944
+ expectedVersion: write.expectedVersion,
13945
+ force: write.force === true
13946
+ })
13947
+ );
13948
+ sentFileWrites.set(
13949
+ sessionUpdate.id,
13950
+ new Map(
13951
+ fileWrites.map(({ path, write }) => [path, write])
13952
+ )
13953
+ );
13954
+ hasUpdate = true;
13955
+ }
11504
13956
  }
11505
13957
 
11506
13958
  if (hasUpdate) {
@@ -11544,11 +13996,6 @@ async function syncServer(server) {
11544
13996
 
11545
13997
  if (update.resize) delete pending.resize;
11546
13998
  if (update.workspaceState) delete pending.workspaceState;
11547
- if (update.fileWrites) {
11548
- for (const file of update.fileWrites) {
11549
- pending.fileWrites.delete(file.path);
11550
- }
11551
- }
11552
13999
  }
11553
14000
 
11554
14001
  const data = await response.json();
@@ -11575,6 +14022,33 @@ async function syncServer(server) {
11575
14022
  const sessions = Array.isArray(data) ? data : data.sessions;
11576
14023
  reconcileSessions(server, sessions || []);
11577
14024
  reconcileAgentInventory(server, data.agents);
14025
+ await editorManager.applyFileWriteResults(
14026
+ server,
14027
+ Array.isArray(data?.fileWriteResults)
14028
+ ? data.fileWriteResults
14029
+ : [],
14030
+ sentFileWrites
14031
+ );
14032
+
14033
+ for (const [sessionId, writes] of sentFileWrites.entries()) {
14034
+ const pending = pendingChanges.sessions.get(
14035
+ makeSessionKey(server.id, sessionId)
14036
+ );
14037
+ if (!pending?.fileWrites) {
14038
+ continue;
14039
+ }
14040
+ for (const [path] of writes.entries()) {
14041
+ if (!pending.fileWrites.has(path)) {
14042
+ continue;
14043
+ }
14044
+ const current = editorManager.normalizePendingFileWrite(
14045
+ pending.fileWrites.get(path)
14046
+ );
14047
+ if (!current.blocked) {
14048
+ pending.fileWrites.delete(path);
14049
+ }
14050
+ }
14051
+ }
11578
14052
  } catch (error) {
11579
14053
  if (!wasReconnecting) {
11580
14054
  console.warn(
@@ -12096,7 +14570,7 @@ function reconcileSessions(server, remoteSessions) {
12096
14570
  }
12097
14571
  }
12098
14572
 
12099
- async function createNewSession(server = getActiveServer()) {
14573
+ async function createNewSession(server = getActiveServer(), options = {}) {
12100
14574
  if (!server) return;
12101
14575
  if (server.needsLogin || !server.isAuthenticated) {
12102
14576
  const password = window.prompt(`Password for ${getDisplayHost(server)}`);
@@ -12104,16 +14578,15 @@ async function createNewSession(server = getActiveServer()) {
12104
14578
  await server.login(password);
12105
14579
  }
12106
14580
  try {
12107
- const options = {};
12108
- const activeSession = getActiveSession();
12109
- if (activeSession && activeSession.serverId === server.id && activeSession.cwd) {
12110
- options.cwd = activeSession.cwd;
14581
+ const request = {};
14582
+ if (typeof options.cwd === 'string' && options.cwd.trim()) {
14583
+ request.cwd = options.cwd.trim();
12111
14584
  }
12112
14585
 
12113
14586
  const response = await server.fetch('/api/sessions', {
12114
14587
  method: 'POST',
12115
14588
  headers: { 'Content-Type': 'application/json' },
12116
- body: JSON.stringify(options)
14589
+ body: JSON.stringify(request)
12117
14590
  });
12118
14591
  if (!response.ok) throw new Error('Failed to create session');
12119
14592
  const newSession = await response.json();
@@ -12141,6 +14614,7 @@ function removeSession(key) {
12141
14614
 
12142
14615
  // #region UI Logic
12143
14616
  function renderTabs() {
14617
+ updateDocumentTitle();
12144
14618
  if (!tabListEl) return;
12145
14619
 
12146
14620
  const newTabItem = document.getElementById('new-tab-item');
@@ -12561,7 +15035,8 @@ const confirmModalState = {
12561
15035
  resolve: null,
12562
15036
  returnFocus: null,
12563
15037
  preferredFocus: 'confirm',
12564
- hideCancel: false
15038
+ hideCancel: false,
15039
+ allowDismiss: true
12565
15040
  };
12566
15041
 
12567
15042
  function isConfirmModalOpen() {
@@ -12600,6 +15075,7 @@ function settleConfirmModal(result) {
12600
15075
  confirmModalState.returnFocus = null;
12601
15076
  confirmModalState.preferredFocus = 'confirm';
12602
15077
  confirmModalState.hideCancel = false;
15078
+ confirmModalState.allowDismiss = true;
12603
15079
  if (returnFocus instanceof HTMLElement) {
12604
15080
  requestAnimationFrame(() => {
12605
15081
  try {
@@ -12617,8 +15093,11 @@ function showConfirmModal({
12617
15093
  message = '',
12618
15094
  note = '',
12619
15095
  confirmLabel = 'Confirm',
15096
+ cancelLabel = 'Cancel',
12620
15097
  danger = false,
12621
15098
  hideCancel = false,
15099
+ preferredFocus = 'confirm',
15100
+ allowDismiss = true,
12622
15101
  returnFocus = null
12623
15102
  } = {}) {
12624
15103
  if (
@@ -12638,13 +15117,17 @@ function showConfirmModal({
12638
15117
  confirmModalMessage.textContent = message;
12639
15118
  confirmModalNote.textContent = note;
12640
15119
  confirmModalNote.style.display = note ? '' : 'none';
15120
+ confirmModalCancel.textContent = cancelLabel;
12641
15121
  confirmModalCancel.style.display = hideCancel ? 'none' : '';
12642
15122
  confirmModalConfirm.textContent = confirmLabel;
12643
15123
  confirmModalConfirm.classList.toggle('danger-button', danger);
12644
15124
  confirmModal.style.display = 'flex';
12645
15125
  confirmModalState.returnFocus = returnFocus;
12646
15126
  confirmModalState.hideCancel = hideCancel;
12647
- confirmModalState.preferredFocus = 'confirm';
15127
+ confirmModalState.preferredFocus = preferredFocus === 'cancel'
15128
+ ? 'cancel'
15129
+ : 'confirm';
15130
+ confirmModalState.allowDismiss = allowDismiss !== false;
12648
15131
  requestAnimationFrame(() => {
12649
15132
  getConfirmModalPreferredButton()?.focus({ preventScroll: true });
12650
15133
  });
@@ -12679,6 +15162,7 @@ function moveConfirmModalFocus(delta) {
12679
15162
  }
12680
15163
 
12681
15164
  function renderServerControls() {
15165
+ updateDocumentTitle();
12682
15166
  if (!serverControlsEl) return;
12683
15167
  serverControlsEl.innerHTML = '';
12684
15168
 
@@ -12834,12 +15318,14 @@ window.addEventListener('focus', () => {
12834
15318
  enterAppNotificationQuietPeriod();
12835
15319
  editorManager.refreshVisibleSessionTrees();
12836
15320
  editorManager.updateTreeAutoRefresh();
15321
+ void editorManager.checkActiveFileVersion();
12837
15322
  });
12838
15323
  window.addEventListener('pageshow', () => {
12839
15324
  noteAppInteraction();
12840
15325
  enterAppNotificationQuietPeriod();
12841
15326
  editorManager.refreshVisibleSessionTrees();
12842
15327
  editorManager.updateTreeAutoRefresh();
15328
+ void editorManager.checkActiveFileVersion();
12843
15329
  });
12844
15330
 
12845
15331
  document.addEventListener('click', () => {
@@ -12851,6 +15337,7 @@ document.addEventListener('visibilitychange', () => {
12851
15337
  enterAppNotificationQuietPeriod();
12852
15338
  clearVisibleAttentionState();
12853
15339
  editorManager.refreshVisibleSessionTrees();
15340
+ void editorManager.checkActiveFileVersion();
12854
15341
  }
12855
15342
  editorManager.updateTreeAutoRefresh();
12856
15343
  });
@@ -13048,19 +15535,19 @@ window.addEventListener('tabminal:layout-modechange', () => {
13048
15535
  && terminalEl.contains(activeElement)
13049
15536
  );
13050
15537
 
13051
- if (isForcedTerminalWorkspaceMode()) {
13052
- if (terminalHasFocus) {
13053
- session.workspaceState.activeTabKey = TERMINAL_WORKSPACE_TAB_KEY;
13054
- session.saveState();
15538
+ if (isForcedTerminalWorkspaceMode()) {
15539
+ if (terminalHasFocus) {
15540
+ session.workspaceState.activeTabKey = TERMINAL_WORKSPACE_TAB_KEY;
15541
+ session.saveState({ touchWorkspace: true });
15542
+ }
15543
+ } else if (
15544
+ !editorManager.isTerminalTabPinned(session)
15545
+ && isTerminalWorkspaceTabKey(session.workspaceState?.activeTabKey || '')
15546
+ ) {
15547
+ session.workspaceState.activeTabKey =
15548
+ editorManager.getPreferredNonTerminalWorkspaceTabKey(session);
15549
+ session.saveState({ touchWorkspace: true });
13055
15550
  }
13056
- } else if (
13057
- !editorManager.isTerminalTabPinned(session)
13058
- && isTerminalWorkspaceTabKey(session.workspaceState?.activeTabKey || '')
13059
- ) {
13060
- session.workspaceState.activeTabKey =
13061
- editorManager.getPreferredNonTerminalWorkspaceTabKey(session);
13062
- session.saveState();
13063
- }
13064
15551
 
13065
15552
  editorManager.switchTo(session);
13066
15553
  editorManager.updateEditorPaneVisibility();
@@ -13281,7 +15768,10 @@ if (
13281
15768
  });
13282
15769
 
13283
15770
  confirmModal.addEventListener('click', (event) => {
13284
- if (event.target === confirmModal) {
15771
+ if (
15772
+ event.target === confirmModal
15773
+ && confirmModalState.allowDismiss
15774
+ ) {
13285
15775
  settleConfirmModal(false);
13286
15776
  }
13287
15777
  });
@@ -13292,6 +15782,11 @@ if (
13292
15782
 
13293
15783
  confirmModal.addEventListener('keydown', (event) => {
13294
15784
  if (event.key === 'Escape') {
15785
+ if (!confirmModalState.allowDismiss) {
15786
+ event.preventDefault();
15787
+ event.stopPropagation();
15788
+ return;
15789
+ }
13295
15790
  event.preventDefault();
13296
15791
  settleConfirmModal(false);
13297
15792
  return;