@agent-link/server 0.1.120 → 0.1.121

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-link/server",
3
- "version": "0.1.120",
3
+ "version": "0.1.121",
4
4
  "description": "AgentLink relay server",
5
5
  "license": "MIT",
6
6
  "repository": {
package/web/app.js CHANGED
@@ -17,6 +17,7 @@ import { createStreaming } from './modules/streaming.js';
17
17
  import { createSidebar } from './modules/sidebar.js';
18
18
  import { createConnection } from './modules/connection.js';
19
19
  import { createFileBrowser } from './modules/fileBrowser.js';
20
+ import { createFilePreview } from './modules/filePreview.js';
20
21
 
21
22
  // ── App ─────────────────────────────────────────────────────────────────────
22
23
  const App = {
@@ -102,10 +103,16 @@ const App = {
102
103
  const fileTreeRoot = ref(null);
103
104
  const fileTreeLoading = ref(false);
104
105
  const fileContextMenu = ref(null);
105
- const sidebarView = ref('sessions'); // 'sessions' | 'files' (mobile only)
106
+ const sidebarView = ref('sessions'); // 'sessions' | 'files' | 'preview' (mobile only)
106
107
  const isMobile = ref(window.innerWidth <= 768);
107
108
  const workdirMenuOpen = ref(false);
108
109
 
110
+ // File preview state
111
+ const previewPanelOpen = ref(false);
112
+ const previewPanelWidth = ref(parseInt(localStorage.getItem('agentlink-preview-panel-width'), 10) || 400);
113
+ const previewFile = ref(null);
114
+ const previewLoading = ref(false);
115
+
109
116
  // ── switchConversation: save current → load target ──
110
117
  // Defined here and used by sidebar.newConversation, sidebar.resumeSession, workdir_changed
111
118
  // Needs access to streaming / connection which are created later, so we use late-binding refs.
@@ -248,7 +255,7 @@ const App = {
248
255
  switchConversation,
249
256
  });
250
257
 
251
- const { connect, wsSend, closeWs, submitPassword, setDequeueNext, setFileBrowser, getToolMsgMap, restoreToolMsgMap, clearToolMsgMap } = createConnection({
258
+ const { connect, wsSend, closeWs, submitPassword, setDequeueNext, setFileBrowser, setFilePreview, getToolMsgMap, restoreToolMsgMap, clearToolMsgMap } = createConnection({
252
259
  status, agentName, hostname, workDir, sessionId, error,
253
260
  serverVersion, agentVersion, latency,
254
261
  messages, isProcessing, isCompacting, visibleLimit, queuedMessages, usageStats,
@@ -277,6 +284,13 @@ const App = {
277
284
  });
278
285
  setFileBrowser(fileBrowser);
279
286
 
287
+ // File preview module
288
+ const filePreview = createFilePreview({
289
+ wsSend, previewPanelOpen, previewPanelWidth, previewFile, previewLoading,
290
+ sidebarView, sidebarOpen, isMobile,
291
+ });
292
+ setFilePreview(filePreview);
293
+
280
294
  // Track mobile state on resize
281
295
  let _resizeHandler = () => { isMobile.value = window.innerWidth <= 768; };
282
296
  window.addEventListener('resize', _resizeHandler);
@@ -501,6 +515,8 @@ const App = {
501
515
  filePanelOpen, filePanelWidth, fileTreeRoot, fileTreeLoading, fileContextMenu,
502
516
  sidebarView, isMobile, fileBrowser,
503
517
  flattenedTree: fileBrowser.flattenedTree,
518
+ // File preview
519
+ previewPanelOpen, previewPanelWidth, previewFile, previewLoading, filePreview,
504
520
  workdirMenuOpen,
505
521
  toggleWorkdirMenu() { workdirMenuOpen.value = !workdirMenuOpen.value; },
506
522
  workdirMenuBrowse() {
@@ -578,7 +594,8 @@ const App = {
578
594
  class="file-tree-item"
579
595
  :class="{ folder: item.node.type === 'directory' }"
580
596
  :style="{ paddingLeft: (item.depth * 16 + 8) + 'px' }"
581
- @click="item.node.type === 'directory' ? fileBrowser.toggleFolder(item.node) : fileBrowser.onFileClick($event, item.node)"
597
+ @click="item.node.type === 'directory' ? fileBrowser.toggleFolder(item.node) : filePreview.openPreview(item.node.path)"
598
+ @contextmenu.prevent="item.node.type !== 'directory' ? fileBrowser.onFileClick($event, item.node) : null"
582
599
  >
583
600
  <span v-if="item.node.type === 'directory'" class="file-tree-arrow" :class="{ expanded: item.node.expanded }">&#9654;</span>
584
601
  <span v-else class="file-tree-file-icon">
@@ -593,6 +610,43 @@ const App = {
593
610
  </div>
594
611
  </div>
595
612
 
613
+ <!-- Mobile: file preview view -->
614
+ <div v-else-if="isMobile && sidebarView === 'preview'" class="file-preview-mobile">
615
+ <div class="file-preview-mobile-header">
616
+ <button class="file-panel-mobile-back" @click="filePreview.closePreview()">
617
+ <svg viewBox="0 0 24 24" width="14" height="14"><path fill="currentColor" d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
618
+ Files
619
+ </button>
620
+ <span v-if="previewFile" class="file-preview-mobile-size">
621
+ {{ filePreview.formatFileSize(previewFile.totalSize) }}
622
+ </span>
623
+ </div>
624
+ <div class="file-preview-mobile-filename" :title="previewFile?.filePath">
625
+ {{ previewFile?.fileName || 'Preview' }}
626
+ </div>
627
+ <div class="preview-panel-body">
628
+ <div v-if="previewLoading" class="preview-loading">Loading...</div>
629
+ <div v-else-if="previewFile?.error" class="preview-error">
630
+ {{ previewFile.error }}
631
+ </div>
632
+ <div v-else-if="previewFile?.encoding === 'base64' && previewFile?.content"
633
+ class="preview-image-container">
634
+ <img :src="'data:' + previewFile.mimeType + ';base64,' + previewFile.content"
635
+ :alt="previewFile.fileName" class="preview-image" />
636
+ </div>
637
+ <div v-else-if="previewFile?.content" class="preview-text-container">
638
+ <pre class="preview-code"><code v-html="filePreview.highlightCode(previewFile.content, previewFile.fileName)"></code></pre>
639
+ <div v-if="previewFile.truncated" class="preview-truncated-notice">
640
+ File truncated — showing first 100 KB of {{ filePreview.formatFileSize(previewFile.totalSize) }}
641
+ </div>
642
+ </div>
643
+ <div v-else-if="previewFile && !previewFile.content && !previewFile.error" class="preview-binary-info">
644
+ <p>Binary file — {{ previewFile.mimeType }}</p>
645
+ <p>{{ filePreview.formatFileSize(previewFile.totalSize) }}</p>
646
+ </div>
647
+ </div>
648
+ </div>
649
+
596
650
  <!-- Normal sidebar content (sessions view) -->
597
651
  <template v-else>
598
652
  <div class="sidebar-section">
@@ -742,7 +796,8 @@ const App = {
742
796
  class="file-tree-item"
743
797
  :class="{ folder: item.node.type === 'directory' }"
744
798
  :style="{ paddingLeft: (item.depth * 16 + 8) + 'px' }"
745
- @click="item.node.type === 'directory' ? fileBrowser.toggleFolder(item.node) : fileBrowser.onFileClick($event, item.node)"
799
+ @click="item.node.type === 'directory' ? fileBrowser.toggleFolder(item.node) : filePreview.openPreview(item.node.path)"
800
+ @contextmenu.prevent="item.node.type !== 'directory' ? fileBrowser.onFileClick($event, item.node) : null"
746
801
  >
747
802
  <span v-if="item.node.type === 'directory'" class="file-tree-arrow" :class="{ expanded: item.node.expanded }">&#9654;</span>
748
803
  <span v-else class="file-tree-file-icon">
@@ -970,6 +1025,50 @@ const App = {
970
1025
  </div>
971
1026
  </div>
972
1027
  </div>
1028
+
1029
+ <!-- Preview Panel (desktop) -->
1030
+ <Transition name="file-panel">
1031
+ <div v-if="previewPanelOpen && !isMobile" class="preview-panel" :style="{ width: previewPanelWidth + 'px' }">
1032
+ <div class="preview-panel-resize-handle"
1033
+ @mousedown="filePreview.onResizeStart($event)"
1034
+ @touchstart="filePreview.onResizeStart($event)"></div>
1035
+ <div class="preview-panel-header">
1036
+ <span class="preview-panel-filename" :title="previewFile?.filePath">
1037
+ {{ previewFile?.fileName || 'Preview' }}
1038
+ </span>
1039
+ <span v-if="previewFile" class="preview-panel-size">
1040
+ {{ filePreview.formatFileSize(previewFile.totalSize) }}
1041
+ </span>
1042
+ <button class="preview-panel-close" @click="filePreview.closePreview()" title="Close preview">&times;</button>
1043
+ </div>
1044
+ <div class="preview-panel-body">
1045
+ <div v-if="previewLoading" class="preview-loading">Loading...</div>
1046
+ <div v-else-if="previewFile?.error" class="preview-error">
1047
+ {{ previewFile.error }}
1048
+ </div>
1049
+ <div v-else-if="previewFile?.encoding === 'base64' && previewFile?.content"
1050
+ class="preview-image-container">
1051
+ <img :src="'data:' + previewFile.mimeType + ';base64,' + previewFile.content"
1052
+ :alt="previewFile.fileName" class="preview-image" />
1053
+ </div>
1054
+ <div v-else-if="previewFile?.content" class="preview-text-container">
1055
+ <pre class="preview-code"><code v-html="filePreview.highlightCode(previewFile.content, previewFile.fileName)"></code></pre>
1056
+ <div v-if="previewFile.truncated" class="preview-truncated-notice">
1057
+ File truncated — showing first 100 KB of {{ filePreview.formatFileSize(previewFile.totalSize) }}
1058
+ </div>
1059
+ </div>
1060
+ <div v-else-if="previewFile && !previewFile.content && !previewFile.error" class="preview-binary-info">
1061
+ <div class="preview-binary-icon">
1062
+ <svg viewBox="0 0 24 24" width="48" height="48"><path fill="currentColor" opacity="0.4" d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zM6 20V4h7v5h5v11H6z"/></svg>
1063
+ </div>
1064
+ <p>Binary file</p>
1065
+ <p class="preview-binary-meta">{{ previewFile.mimeType }}</p>
1066
+ <p class="preview-binary-meta">{{ filePreview.formatFileSize(previewFile.totalSize) }}</p>
1067
+ </div>
1068
+ </div>
1069
+ </div>
1070
+ </Transition>
1071
+
973
1072
  </div>
974
1073
 
975
1074
  <!-- Folder Picker Modal -->
@@ -33,6 +33,10 @@ export function createConnection(deps) {
33
33
  let fileBrowser = null;
34
34
  function setFileBrowser(fb) { fileBrowser = fb; }
35
35
 
36
+ // File preview — set after creation to resolve circular dependency
37
+ let filePreview = null;
38
+ function setFilePreview(fp) { filePreview = fp; }
39
+
36
40
  let ws = null;
37
41
  let sessionKey = null;
38
42
  let reconnectAttempts = 0;
@@ -137,6 +141,11 @@ export function createConnection(deps) {
137
141
  }
138
142
 
139
143
  if (msg.type === 'claude_output') {
144
+ // Safety net: restore processing state if output arrives after reconnect
145
+ if (!cache.isProcessing) {
146
+ cache.isProcessing = true;
147
+ processingConversations.value[convId] = true;
148
+ }
140
149
  const data = msg.data;
141
150
  if (!data) return;
142
151
  if (data.type === 'content_block_delta' && data.delta) {
@@ -349,6 +358,15 @@ export function createConnection(deps) {
349
358
  const data = msg.data;
350
359
  if (!data) return;
351
360
 
361
+ // Safety net: if streaming output arrives but isProcessing is false
362
+ // (e.g. after reconnect before active_conversations response), self-correct
363
+ if (!isProcessing.value) {
364
+ isProcessing.value = true;
365
+ if (currentConversationId && currentConversationId.value) {
366
+ processingConversations.value[currentConversationId.value] = true;
367
+ }
368
+ }
369
+
352
370
  if (data.type === 'content_block_delta' && data.delta) {
353
371
  streaming.appendPending(data.delta);
354
372
  streaming.startReveal();
@@ -490,6 +508,7 @@ export function createConnection(deps) {
490
508
  }
491
509
  sidebar.requestSessionList();
492
510
  startPing();
511
+ wsSend({ type: 'query_active_conversations' });
493
512
  } else {
494
513
  status.value = 'Waiting';
495
514
  error.value = 'Agent is not connected yet.';
@@ -532,6 +551,59 @@ export function createConnection(deps) {
532
551
  }
533
552
  sidebar.requestSessionList();
534
553
  startPing();
554
+ wsSend({ type: 'query_active_conversations' });
555
+ } else if (msg.type === 'active_conversations') {
556
+ // Agent's response is authoritative — first clear all processing state,
557
+ // then re-apply only for conversations the agent reports as active.
558
+ // This corrects any stale isProcessing=true left by the safety net or
559
+ // from turns that finished while the socket was down.
560
+ const activeSet = new Set();
561
+ const convs = msg.conversations || [];
562
+ for (const entry of convs) {
563
+ if (entry.conversationId) activeSet.add(entry.conversationId);
564
+ }
565
+
566
+ // Clear foreground
567
+ if (!activeSet.has(currentConversationId && currentConversationId.value)) {
568
+ isProcessing.value = false;
569
+ isCompacting.value = false;
570
+ }
571
+ // Clear all cached background conversations
572
+ if (conversationCache) {
573
+ for (const [convId, cached] of Object.entries(conversationCache.value)) {
574
+ if (!activeSet.has(convId)) {
575
+ cached.isProcessing = false;
576
+ cached.isCompacting = false;
577
+ }
578
+ }
579
+ }
580
+ // Clear processingConversations map
581
+ if (processingConversations) {
582
+ for (const convId of Object.keys(processingConversations.value)) {
583
+ if (!activeSet.has(convId)) {
584
+ processingConversations.value[convId] = false;
585
+ }
586
+ }
587
+ }
588
+
589
+ // Now set state for actually active conversations
590
+ for (const entry of convs) {
591
+ const convId = entry.conversationId;
592
+ if (!convId) continue;
593
+ if (currentConversationId && currentConversationId.value === convId) {
594
+ // Foreground conversation
595
+ isProcessing.value = true;
596
+ isCompacting.value = !!entry.isCompacting;
597
+ } else if (conversationCache && conversationCache.value[convId]) {
598
+ // Background conversation
599
+ const cached = conversationCache.value[convId];
600
+ cached.isProcessing = true;
601
+ cached.isCompacting = !!entry.isCompacting;
602
+ }
603
+ if (processingConversations) {
604
+ processingConversations.value[convId] = true;
605
+ }
606
+ }
535
607
  } else if (msg.type === 'error') {
536
608
  streaming.flushReveal();
537
609
  finalizeStreamingMsg(scheduleHighlight);
@@ -722,11 +794,14 @@ export function createConnection(deps) {
722
794
  .sort((a, b) => a.name.localeCompare(b.name));
723
795
  if (msg.dirPath != null) folderPickerPath.value = msg.dirPath;
724
796
  }
797
+ } else if (msg.type === 'file_content') {
798
+ if (filePreview) filePreview.handleFileContent(msg);
725
799
  } else if (msg.type === 'workdir_changed') {
726
800
  workDir.value = msg.workDir;
727
801
  localStorage.setItem(`agentlink-workdir-${sessionId.value}`, msg.workDir);
728
802
  sidebar.addToWorkdirHistory(msg.workDir);
729
803
  if (fileBrowser) fileBrowser.onWorkdirChanged();
804
+ if (filePreview) filePreview.onWorkdirChanged();
730
805
 
731
806
  // Multi-session: switch to a new blank conversation for the new workdir.
732
807
  // Background conversations keep running and receiving output in their cache.
@@ -801,5 +876,5 @@ export function createConnection(deps) {
801
876
  ws.send(JSON.stringify({ type: 'authenticate', password: pwd }));
802
877
  }
803
878
 
804
- return { connect, wsSend, closeWs, submitPassword, setDequeueNext, setFileBrowser, getToolMsgMap, restoreToolMsgMap, clearToolMsgMap };
879
+ return { connect, wsSend, closeWs, submitPassword, setDequeueNext, setFileBrowser, setFilePreview, getToolMsgMap, restoreToolMsgMap, clearToolMsgMap };
805
880
  }
@@ -0,0 +1,187 @@
1
+ // ── File Preview: panel state, content rendering, resize handle ────────
2
+
3
+ /**
4
+ * Creates the file preview controller.
5
+ * @param {object} deps - Reactive state and callbacks
6
+ */
7
+ export function createFilePreview(deps) {
8
+ const {
9
+ wsSend,
10
+ previewPanelOpen,
11
+ previewPanelWidth,
12
+ previewFile,
13
+ previewLoading,
14
+ sidebarView,
15
+ sidebarOpen,
16
+ isMobile,
17
+ } = deps;
18
+
19
+ // ── Open / Close ──
20
+
21
+ function openPreview(filePath) {
22
+ // Skip re-fetch if same file already loaded
23
+ if (previewFile.value && previewFile.value.filePath === filePath && !previewFile.value.error) {
24
+ if (isMobile.value) {
25
+ sidebarView.value = 'preview';
26
+ sidebarOpen.value = true;
27
+ } else {
28
+ previewPanelOpen.value = true;
29
+ }
30
+ return;
31
+ }
32
+ if (isMobile.value) {
33
+ sidebarView.value = 'preview';
34
+ sidebarOpen.value = true;
35
+ } else {
36
+ previewPanelOpen.value = true;
37
+ }
38
+ previewLoading.value = true;
39
+ previewFile.value = null;
40
+ wsSend({ type: 'read_file', filePath });
41
+ }
42
+
43
+ function closePreview() {
44
+ if (isMobile.value) {
45
+ sidebarView.value = 'files';
46
+ } else {
47
+ previewPanelOpen.value = false;
48
+ }
49
+ }
50
+
51
+ // ── Handle file_content response ──
52
+
53
+ function handleFileContent(msg) {
54
+ previewLoading.value = false;
55
+ previewFile.value = {
56
+ filePath: msg.filePath,
57
+ fileName: msg.fileName,
58
+ content: msg.content,
59
+ encoding: msg.encoding,
60
+ mimeType: msg.mimeType,
61
+ truncated: msg.truncated,
62
+ totalSize: msg.totalSize,
63
+ error: msg.error || null,
64
+ };
65
+ }
66
+
67
+ // ── Workdir changed → close preview ──
68
+
69
+ function onWorkdirChanged() {
70
+ previewPanelOpen.value = false;
71
+ previewFile.value = null;
72
+ previewLoading.value = false;
73
+ if (sidebarView.value === 'preview') {
74
+ sidebarView.value = 'sessions';
75
+ }
76
+ }
77
+
78
+ // ── Syntax highlighting helpers ──
79
+
80
+ const LANG_MAP = {
81
+ ts: 'typescript', tsx: 'typescript', js: 'javascript', jsx: 'javascript',
82
+ mjs: 'javascript', cjs: 'javascript', py: 'python', rb: 'ruby',
83
+ rs: 'rust', go: 'go', java: 'java', c: 'c', h: 'c',
84
+ cpp: 'cpp', hpp: 'cpp', cs: 'csharp', swift: 'swift', kt: 'kotlin',
85
+ lua: 'lua', r: 'r', sql: 'sql', sh: 'bash', bash: 'bash', zsh: 'bash',
86
+ fish: 'bash', ps1: 'powershell', bat: 'dos', cmd: 'dos',
87
+ json: 'json', json5: 'json', yaml: 'yaml', yml: 'yaml', toml: 'ini',
88
+ xml: 'xml', html: 'xml', htm: 'xml', css: 'css', scss: 'scss', less: 'less',
89
+ md: 'markdown', txt: 'plaintext', log: 'plaintext', graphql: 'graphql',
90
+ proto: 'protobuf', vue: 'xml', svelte: 'xml', ini: 'ini', cfg: 'ini',
91
+ conf: 'ini', env: 'bash',
92
+ };
93
+
94
+ function detectLanguage(fileName) {
95
+ const ext = (fileName || '').split('.').pop()?.toLowerCase();
96
+ return LANG_MAP[ext] || ext || 'plaintext';
97
+ }
98
+
99
+ function highlightCode(code, fileName) {
100
+ if (!code) return '';
101
+ if (!window.hljs) return escapeHtml(code);
102
+ const lang = detectLanguage(fileName);
103
+ try {
104
+ return window.hljs.highlight(code, { language: lang }).value;
105
+ } catch {
106
+ try {
107
+ return window.hljs.highlightAuto(code).value;
108
+ } catch {
109
+ return escapeHtml(code);
110
+ }
111
+ }
112
+ }
113
+
114
+ function escapeHtml(str) {
115
+ return str
116
+ .replace(/&/g, '&amp;')
117
+ .replace(/</g, '&lt;')
118
+ .replace(/>/g, '&gt;')
119
+ .replace(/"/g, '&quot;');
120
+ }
121
+
122
+ // ── File size formatting ──
123
+
124
+ function formatFileSize(bytes) {
125
+ if (bytes == null) return '';
126
+ if (bytes < 1024) return bytes + ' B';
127
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
128
+ return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
129
+ }
130
+
131
+ // ── Resize handle (mouse + touch) ──
132
+
133
+ let _resizing = false;
134
+ let _startX = 0;
135
+ let _startWidth = 0;
136
+ const MIN_WIDTH = 200;
137
+ const MAX_WIDTH = 800;
138
+
139
+ function onResizeStart(e) {
140
+ e.preventDefault();
141
+ _resizing = true;
142
+ _startX = e.type === 'touchstart' ? e.touches[0].clientX : e.clientX;
143
+ _startWidth = previewPanelWidth.value;
144
+ document.body.style.cursor = 'col-resize';
145
+ document.body.style.userSelect = 'none';
146
+ if (e.type === 'touchstart') {
147
+ document.addEventListener('touchmove', onResizeMove, { passive: false });
148
+ document.addEventListener('touchend', onResizeEnd);
149
+ } else {
150
+ document.addEventListener('mousemove', onResizeMove);
151
+ document.addEventListener('mouseup', onResizeEnd);
152
+ }
153
+ }
154
+
155
+ function onResizeMove(e) {
156
+ if (!_resizing) return;
157
+ if (e.type === 'touchmove') e.preventDefault();
158
+ const clientX = e.type === 'touchmove' ? e.touches[0].clientX : e.clientX;
159
+ // Left edge resize: dragging left = wider, dragging right = narrower
160
+ const delta = _startX - clientX;
161
+ const newWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, _startWidth + delta));
162
+ previewPanelWidth.value = newWidth;
163
+ }
164
+
165
+ function onResizeEnd() {
166
+ if (!_resizing) return;
167
+ _resizing = false;
168
+ document.body.style.cursor = '';
169
+ document.body.style.userSelect = '';
170
+ document.removeEventListener('mousemove', onResizeMove);
171
+ document.removeEventListener('mouseup', onResizeEnd);
172
+ document.removeEventListener('touchmove', onResizeMove);
173
+ document.removeEventListener('touchend', onResizeEnd);
174
+ localStorage.setItem('agentlink-preview-panel-width', String(previewPanelWidth.value));
175
+ }
176
+
177
+ return {
178
+ openPreview,
179
+ closePreview,
180
+ handleFileContent,
181
+ onWorkdirChanged,
182
+ detectLanguage,
183
+ highlightCode,
184
+ formatFileSize,
185
+ onResizeStart,
186
+ };
187
+ }
package/web/style.css CHANGED
@@ -2547,6 +2547,201 @@ body {
2547
2547
  opacity: 0.8;
2548
2548
  }
2549
2549
 
2550
+ /* ══════════════════════════════════════════
2551
+ File Preview Panel
2552
+ ══════════════════════════════════════════ */
2553
+ .preview-panel {
2554
+ width: 400px;
2555
+ flex-shrink: 0;
2556
+ position: relative;
2557
+ display: flex;
2558
+ flex-direction: column;
2559
+ overflow: hidden;
2560
+ border-left: 1px solid var(--border);
2561
+ background: var(--bg);
2562
+ }
2563
+
2564
+ .preview-panel-resize-handle {
2565
+ position: absolute;
2566
+ top: 0;
2567
+ left: -3px;
2568
+ width: 6px;
2569
+ height: 100%;
2570
+ cursor: col-resize;
2571
+ z-index: 10;
2572
+ background: transparent;
2573
+ transition: background 0.15s;
2574
+ }
2575
+
2576
+ .preview-panel-resize-handle:hover,
2577
+ .preview-panel-resize-handle:active {
2578
+ background: var(--accent);
2579
+ opacity: 0.4;
2580
+ }
2581
+
2582
+ .preview-panel-header {
2583
+ display: flex;
2584
+ align-items: center;
2585
+ gap: 0.5rem;
2586
+ padding: 0.5rem 0.75rem;
2587
+ border-bottom: 1px solid var(--border);
2588
+ background: var(--bg-primary);
2589
+ flex-shrink: 0;
2590
+ }
2591
+
2592
+ .preview-panel-filename {
2593
+ flex: 1;
2594
+ font-weight: 600;
2595
+ font-size: 0.85rem;
2596
+ overflow: hidden;
2597
+ text-overflow: ellipsis;
2598
+ white-space: nowrap;
2599
+ }
2600
+
2601
+ .preview-panel-size {
2602
+ font-size: 0.75rem;
2603
+ color: var(--text-secondary);
2604
+ flex-shrink: 0;
2605
+ }
2606
+
2607
+ .preview-panel-close {
2608
+ background: none;
2609
+ border: none;
2610
+ font-size: 1.2rem;
2611
+ cursor: pointer;
2612
+ color: var(--text-secondary);
2613
+ padding: 0 0.25rem;
2614
+ line-height: 1;
2615
+ }
2616
+
2617
+ .preview-panel-close:hover {
2618
+ color: var(--text);
2619
+ }
2620
+
2621
+ .preview-panel-body {
2622
+ flex: 1;
2623
+ overflow: auto;
2624
+ padding: 0;
2625
+ }
2626
+
2627
+ .preview-loading {
2628
+ display: flex;
2629
+ align-items: center;
2630
+ justify-content: center;
2631
+ height: 100%;
2632
+ color: var(--text-secondary);
2633
+ font-size: 0.85rem;
2634
+ }
2635
+
2636
+ .preview-error {
2637
+ padding: 1rem;
2638
+ color: var(--error, #ef4444);
2639
+ font-size: 0.85rem;
2640
+ }
2641
+
2642
+ .preview-text-container {
2643
+ overflow: auto;
2644
+ height: 100%;
2645
+ }
2646
+
2647
+ .preview-code {
2648
+ margin: 0;
2649
+ padding: 0.75rem;
2650
+ font-size: 0.8rem;
2651
+ line-height: 1.5;
2652
+ white-space: pre;
2653
+ overflow-x: auto;
2654
+ background: var(--bg);
2655
+ font-family: var(--font-mono, 'SF Mono', 'Cascadia Code', 'Fira Code', Consolas, monospace);
2656
+ }
2657
+
2658
+ .preview-code code {
2659
+ font-family: inherit;
2660
+ }
2661
+
2662
+ .preview-image-container {
2663
+ display: flex;
2664
+ align-items: center;
2665
+ justify-content: center;
2666
+ padding: 1rem;
2667
+ height: 100%;
2668
+ }
2669
+
2670
+ .preview-image {
2671
+ max-width: 100%;
2672
+ max-height: 100%;
2673
+ object-fit: contain;
2674
+ }
2675
+
2676
+ .preview-truncated-notice {
2677
+ padding: 0.5rem 0.75rem;
2678
+ font-size: 0.75rem;
2679
+ color: var(--text-secondary);
2680
+ border-top: 1px solid var(--border);
2681
+ background: var(--bg-secondary, var(--bg-primary));
2682
+ text-align: center;
2683
+ }
2684
+
2685
+ .preview-binary-info {
2686
+ display: flex;
2687
+ flex-direction: column;
2688
+ align-items: center;
2689
+ justify-content: center;
2690
+ height: 100%;
2691
+ gap: 0.5rem;
2692
+ color: var(--text-secondary);
2693
+ font-size: 0.85rem;
2694
+ }
2695
+
2696
+ .preview-binary-icon {
2697
+ opacity: 0.4;
2698
+ margin-bottom: 0.5rem;
2699
+ }
2700
+
2701
+ .preview-binary-meta {
2702
+ font-size: 0.75rem;
2703
+ color: var(--text-secondary);
2704
+ }
2705
+
2706
+ /* ── Mobile file preview in sidebar ── */
2707
+ .file-preview-mobile {
2708
+ display: flex;
2709
+ flex-direction: column;
2710
+ height: 100%;
2711
+ overflow: hidden;
2712
+ }
2713
+
2714
+ .file-preview-mobile-header {
2715
+ display: flex;
2716
+ align-items: center;
2717
+ justify-content: space-between;
2718
+ padding: 0.75rem;
2719
+ border-bottom: 1px solid var(--border);
2720
+ flex-shrink: 0;
2721
+ }
2722
+
2723
+ .file-preview-mobile-size {
2724
+ font-size: 0.75rem;
2725
+ color: var(--text-secondary);
2726
+ }
2727
+
2728
+ .file-preview-mobile-filename {
2729
+ padding: 0.5rem 0.75rem;
2730
+ font-size: 0.8rem;
2731
+ font-weight: 600;
2732
+ color: var(--text);
2733
+ border-bottom: 1px solid var(--border);
2734
+ white-space: nowrap;
2735
+ overflow: hidden;
2736
+ text-overflow: ellipsis;
2737
+ flex-shrink: 0;
2738
+ }
2739
+
2740
+ .file-preview-mobile .preview-panel-body {
2741
+ flex: 1;
2742
+ overflow: auto;
2743
+ }
2744
+
2550
2745
  /* ══════════════════════════════════════════
2551
2746
  Medium screens — file panel narrower
2552
2747
  ══════════════════════════════════════════ */
@@ -2554,6 +2749,9 @@ body {
2554
2749
  .file-panel {
2555
2750
  max-width: clamp(200px, 20vw, 280px);
2556
2751
  }
2752
+ .preview-panel {
2753
+ max-width: min(400px, 40vw);
2754
+ }
2557
2755
  }
2558
2756
 
2559
2757
  /* ══════════════════════════════════════════
@@ -2575,6 +2773,11 @@ body {
2575
2773
  display: none;
2576
2774
  }
2577
2775
 
2776
+ /* Preview panel hidden on mobile — shown inside sidebar instead */
2777
+ .preview-panel {
2778
+ display: none;
2779
+ }
2780
+
2578
2781
  /* Sidebar as fixed overlay */
2579
2782
  .sidebar {
2580
2783
  position: fixed;