@myrialabs/clopen 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/backend/chat/stream-manager.ts +136 -10
  2. package/backend/database/queries/session-queries.ts +9 -0
  3. package/backend/engine/adapters/claude/error-handler.ts +7 -2
  4. package/backend/engine/adapters/claude/stream.ts +21 -3
  5. package/backend/engine/adapters/opencode/message-converter.ts +37 -2
  6. package/backend/index.ts +25 -3
  7. package/backend/preview/browser/browser-preview-service.ts +16 -17
  8. package/backend/preview/browser/browser-video-capture.ts +199 -156
  9. package/backend/preview/browser/scripts/video-stream.ts +3 -5
  10. package/backend/snapshot/helpers.ts +15 -2
  11. package/backend/ws/snapshot/restore.ts +43 -2
  12. package/backend/ws/user/crud.ts +6 -3
  13. package/frontend/components/chat/input/ChatInput.svelte +6 -1
  14. package/frontend/components/chat/input/components/ChatInputActions.svelte +10 -0
  15. package/frontend/components/chat/message/MessageBubble.svelte +22 -1
  16. package/frontend/components/chat/tools/components/FileHeader.svelte +19 -5
  17. package/frontend/components/chat/tools/components/TerminalCommand.svelte +2 -2
  18. package/frontend/components/chat/widgets/FloatingTodoList.svelte +30 -26
  19. package/frontend/components/common/media/MediaPreview.svelte +187 -0
  20. package/frontend/components/files/FileViewer.svelte +23 -144
  21. package/frontend/components/git/DiffViewer.svelte +50 -130
  22. package/frontend/components/git/FileChangeItem.svelte +22 -0
  23. package/frontend/components/preview/browser/BrowserPreview.svelte +1 -0
  24. package/frontend/components/preview/browser/components/Canvas.svelte +110 -21
  25. package/frontend/components/preview/browser/components/Container.svelte +2 -1
  26. package/frontend/components/preview/browser/components/Toolbar.svelte +1 -8
  27. package/frontend/components/preview/browser/core/coordinator.svelte.ts +12 -4
  28. package/frontend/components/terminal/TerminalTabs.svelte +1 -2
  29. package/frontend/components/workspace/DesktopNavigator.svelte +27 -1
  30. package/frontend/components/workspace/WorkspaceLayout.svelte +3 -1
  31. package/frontend/components/workspace/panels/FilesPanel.svelte +77 -1
  32. package/frontend/components/workspace/panels/GitPanel.svelte +72 -28
  33. package/frontend/services/chat/chat.service.ts +6 -1
  34. package/frontend/services/preview/browser/browser-webcodecs.service.ts +13 -5
  35. package/frontend/stores/core/files.svelte.ts +15 -1
  36. package/frontend/stores/ui/todo-panel.svelte.ts +39 -0
  37. package/frontend/utils/file-type.ts +68 -0
  38. package/index.html +1 -0
  39. package/package.json +1 -1
  40. package/shared/constants/binary-extensions.ts +40 -0
  41. package/shared/types/messaging/tool.ts +1 -0
  42. package/shared/utils/file-type-detection.ts +9 -1
  43. package/static/manifest.json +16 -0
@@ -47,17 +47,24 @@
47
47
  let isRecovering = $state(false); // Track recovery attempts
48
48
  let connectionFailed = $state(false); // Track if connection actually failed (not just slow)
49
49
  let hasRequestedScreencastRefresh = false; // Track if we've already requested refresh for this stream
50
+ let screencastRefreshCount = 0; // Track retry count for stuck detection
50
51
  let navigationJustCompleted = false; // Track if navigation just completed (for fast refresh)
51
52
 
53
+ // Canvas snapshot storage for instant tab switching
54
+ // Stores a clone of the canvas per sessionId so switching back shows content immediately
55
+ const canvasSnapshots = new Map<string, HTMLCanvasElement>();
56
+ const MAX_SNAPSHOTS = 10;
57
+ let hasRestoredSnapshot = false; // Prevents canvas clear/reset during streaming start
58
+
52
59
  // Recovery is only triggered by ACTUAL failures, not timeouts
53
60
  // - ICE connection failed
54
61
  // - WebCodecs connection closed unexpectedly
55
62
  // - Explicit errors
56
63
  const MAX_CONSECUTIVE_FAILURES = 2;
57
64
  const HEALTH_CHECK_INTERVAL = 2000; // Check every 2 seconds for connection health
58
- const FRAME_CHECK_INTERVAL = 500; // Check for first frame every 500ms (just for UI update, not recovery)
59
- const STUCK_STREAM_TIMEOUT = 5000; // Fallback: Request screencast refresh after 5 seconds of connected but no frame
60
- const NAVIGATION_FAST_REFRESH_DELAY = 500; // Fast refresh after navigation: 500ms
65
+ const FRAME_CHECK_INTERVAL = 100; // Fallback poll for first frame (primary path is onFirstFrame callback)
66
+ const STUCK_STREAM_TIMEOUT = 3000; // Fallback: Request screencast refresh after 3 seconds of connected but no frame
67
+ const NAVIGATION_FAST_REFRESH_DELAY = 300; // Fast refresh after navigation: 300ms
61
68
 
62
69
  // Sync isStreamReady with hasReceivedFirstFrame for parent component
63
70
  $effect(() => {
@@ -73,6 +80,10 @@
73
80
  if (lastProjectId && currentProjectId && lastProjectId !== currentProjectId) {
74
81
  debug.log('webcodecs', `🔄 Project changed (${lastProjectId} → ${currentProjectId}), destroying old WebCodecs service`);
75
82
 
83
+ // Clear canvas snapshots - they belong to old project's sessions
84
+ canvasSnapshots.clear();
85
+ hasRestoredSnapshot = false;
86
+
76
87
  // Destroy old service
77
88
  if (webCodecsService) {
78
89
  webCodecsService.destroy();
@@ -416,7 +427,10 @@
416
427
 
417
428
  isStartingStream = true;
418
429
  isStreamStarting = true; // Show loading overlay
419
- hasReceivedFirstFrame = false; // Reset first frame state
430
+ // Don't reset if we restored a snapshot - keep showing it
431
+ if (!hasRestoredSnapshot) {
432
+ hasReceivedFirstFrame = false; // Reset first frame state
433
+ }
420
434
 
421
435
  try {
422
436
  // If streaming a different session, stop first
@@ -480,6 +494,25 @@
480
494
  onStatsUpdate(stats);
481
495
  });
482
496
 
497
+ // Setup first frame handler - fires immediately when first frame decoded
498
+ // This eliminates the 500ms polling delay for hiding the loading overlay
499
+ webCodecsService.setFirstFrameHandler(() => {
500
+ if (!hasReceivedFirstFrame) {
501
+ debug.log('webcodecs', 'First frame callback - immediately updating UI');
502
+ hasReceivedFirstFrame = true;
503
+ consecutiveFailures = 0;
504
+ connectionFailed = false;
505
+ }
506
+
507
+ // Always reset reconnecting state on first real frame
508
+ // (outside !hasReceivedFirstFrame to handle snapshot + reconnect case)
509
+ if (isReconnecting) {
510
+ setTimeout(() => {
511
+ isReconnecting = false;
512
+ }, 300);
513
+ }
514
+ });
515
+
483
516
  // Setup cursor change handler
484
517
  webCodecsService.setOnCursorChange((cursor: string) => {
485
518
  updateCanvasCursor(cursor);
@@ -491,8 +524,8 @@
491
524
 
492
525
  let success = false;
493
526
  let retries = 0;
494
- const maxRetries = 5; // Increased for rapid tab switching
495
- const retryDelay = 500; // Increased delay for backend cleanup
527
+ const maxRetries = 5;
528
+ const retryDelay = 300;
496
529
 
497
530
  while (!success && retries < maxRetries) {
498
531
  try {
@@ -502,7 +535,8 @@
502
535
  isConnected = true;
503
536
  activeStreamingSessionId = sessionId;
504
537
  consecutiveFailures = 0; // Reset failure counter on success
505
- startHealthCheck(); // Start monitoring for stuck streams
538
+ startHealthCheck(hasRestoredSnapshot); // Skip first frame reset if snapshot
539
+ hasRestoredSnapshot = false; // Reset after using
506
540
  debug.log('webcodecs', 'Streaming started successfully');
507
541
  break;
508
542
  }
@@ -533,6 +567,7 @@
533
567
  } finally {
534
568
  isStartingStream = false;
535
569
  isStreamStarting = false; // Hide "Launching browser..." (but may still show "Connecting..." until first frame)
570
+ hasRestoredSnapshot = false; // Always reset in finally
536
571
  }
537
572
  }
538
573
 
@@ -561,6 +596,7 @@
561
596
  }
562
597
  connectionFailed = false;
563
598
  hasRequestedScreencastRefresh = false; // Reset for new stream
599
+ screencastRefreshCount = 0; // Reset retry counter
564
600
 
565
601
  const startTime = Date.now();
566
602
 
@@ -587,6 +623,7 @@
587
623
  consecutiveFailures = 0;
588
624
  connectionFailed = false;
589
625
  hasRequestedScreencastRefresh = false; // Reset on success
626
+ screencastRefreshCount = 0; // Reset retry counter on success
590
627
 
591
628
  // Reset reconnecting state after successful frame reception
592
629
  // This completes the fast reconnect cycle
@@ -621,10 +658,21 @@
621
658
  // STUCK STREAM DETECTION (FALLBACK): If connected but no first frame for too long,
622
659
  // request screencast refresh (hot-swap) to restart CDP screencast.
623
660
  // This handles cases where WebRTC is connected but CDP frames aren't flowing.
624
- if (stats?.isConnected && !stats?.firstFrameRendered && elapsed >= STUCK_STREAM_TIMEOUT && !hasRequestedScreencastRefresh) {
625
- debug.warn('webcodecs', `Stream appears stuck (connected but no frame for ${elapsed}ms), requesting screencast refresh`);
626
- hasRequestedScreencastRefresh = true;
627
- onRequestScreencastRefresh();
661
+ // Retries: 1st at 3s (screencast refresh), 2nd at 6s (another refresh), 3rd at 10s (full recovery)
662
+ if (stats?.isConnected && !stats?.firstFrameRendered && !hasRequestedScreencastRefresh) {
663
+ const MAX_SCREENCAST_RETRIES = 2;
664
+ const retryThreshold = STUCK_STREAM_TIMEOUT + (screencastRefreshCount * 3000); // 3s, 6s
665
+
666
+ if (elapsed >= retryThreshold && screencastRefreshCount < MAX_SCREENCAST_RETRIES) {
667
+ screencastRefreshCount++;
668
+ debug.warn('webcodecs', `Stream stuck (connected, no frame for ${elapsed}ms), screencast refresh attempt ${screencastRefreshCount}/${MAX_SCREENCAST_RETRIES}`);
669
+ onRequestScreencastRefresh();
670
+ } else if (elapsed >= 10000 && screencastRefreshCount >= MAX_SCREENCAST_RETRIES) {
671
+ // Screencast refreshes didn't help - attempt full recovery
672
+ debug.warn('webcodecs', `Stream still stuck after ${screencastRefreshCount} screencast refreshes (${elapsed}ms), attempting full recovery`);
673
+ hasRequestedScreencastRefresh = true; // Prevent further retries
674
+ attemptRecovery();
675
+ }
628
676
  }
629
677
 
630
678
  }, FRAME_CHECK_INTERVAL);
@@ -770,11 +818,11 @@
770
818
  lastStartRequestId = null; // Clear to allow new requests
771
819
  // Note: Don't reset hasReceivedFirstFrame here - let startStreaming do it
772
820
  // This prevents flashing when switching tabs
773
- // Clear canvas to prevent stale frames, BUT keep last frame during navigation
774
- if (!isNavigating) {
821
+ // Clear canvas to prevent stale frames, BUT keep last frame during navigation or snapshot restore
822
+ if (!isNavigating && !hasRestoredSnapshot) {
775
823
  clearCanvas();
776
824
  } else {
777
- debug.log('webcodecs', 'Skipping canvas clear during navigation - keeping last frame');
825
+ debug.log('webcodecs', `Skipping canvas clear - navigation: ${isNavigating}, snapshot: ${hasRestoredSnapshot}`);
778
826
  }
779
827
  }
780
828
  }
@@ -813,10 +861,52 @@
813
861
  const needsStreaming = !isWebCodecsActive || activeStreamingSessionId !== sessionId;
814
862
 
815
863
  if (needsStreaming) {
816
- // Clear canvas immediately when session changes to prevent stale frames
817
864
  if (activeStreamingSessionId !== sessionId) {
818
- clearCanvas();
819
- hasReceivedFirstFrame = false; // Reset to show loading overlay
865
+ // SNAPSHOT: Save current canvas before switching to new session
866
+ if (activeStreamingSessionId && hasReceivedFirstFrame && canvasElement.width > 0) {
867
+ try {
868
+ const clone = document.createElement('canvas');
869
+ clone.width = canvasElement.width;
870
+ clone.height = canvasElement.height;
871
+ const cloneCtx = clone.getContext('2d');
872
+ if (cloneCtx) {
873
+ cloneCtx.drawImage(canvasElement, 0, 0);
874
+ // Limit snapshot count
875
+ if (canvasSnapshots.size >= MAX_SNAPSHOTS) {
876
+ const firstKey = canvasSnapshots.keys().next().value;
877
+ if (firstKey) canvasSnapshots.delete(firstKey);
878
+ }
879
+ canvasSnapshots.set(activeStreamingSessionId, clone);
880
+ debug.log('webcodecs', `📸 Saved canvas snapshot for session ${activeStreamingSessionId}`);
881
+ }
882
+ } catch (e) {
883
+ debug.warn('webcodecs', 'Failed to capture canvas snapshot:', e);
884
+ }
885
+ }
886
+
887
+ // SNAPSHOT: Restore for new session if available
888
+ const existingSnapshot = canvasSnapshots.get(sessionId);
889
+ if (existingSnapshot) {
890
+ setupCanvasInternal(); // Ensure canvas dimensions are correct
891
+ try {
892
+ const ctx = canvasElement.getContext('2d');
893
+ if (ctx) {
894
+ ctx.drawImage(existingSnapshot, 0, 0, canvasElement.width, canvasElement.height);
895
+ hasRestoredSnapshot = true;
896
+ // Don't reset hasReceivedFirstFrame - snapshot is visible
897
+ debug.log('webcodecs', `📸 Restored canvas snapshot for session ${sessionId}`);
898
+ }
899
+ } catch (e) {
900
+ debug.warn('webcodecs', 'Failed to restore canvas snapshot:', e);
901
+ hasRestoredSnapshot = false;
902
+ clearCanvas();
903
+ hasReceivedFirstFrame = false;
904
+ }
905
+ } else {
906
+ hasRestoredSnapshot = false;
907
+ clearCanvas();
908
+ hasReceivedFirstFrame = false; // Reset to show loading overlay
909
+ }
820
910
  }
821
911
 
822
912
  // Stop existing streaming first if session changed
@@ -831,12 +921,10 @@
831
921
  await startStreaming();
832
922
  };
833
923
 
834
- // Longer delay to ensure backend session is fully ready
835
- // This is especially important during viewport/device change
836
- // when session is being recreated
924
+ // Small delay to ensure backend session is ready
837
925
  const timeout = setTimeout(() => {
838
926
  doStartStreaming();
839
- }, 200);
927
+ }, 50);
840
928
 
841
929
  return () => clearTimeout(timeout);
842
930
  }
@@ -1039,6 +1127,7 @@
1039
1127
 
1040
1128
  onDestroy(() => {
1041
1129
  stopHealthCheck(); // Stop health monitoring
1130
+ canvasSnapshots.clear(); // Free snapshot memory
1042
1131
  if (webCodecsService) {
1043
1132
  webCodecsService.destroy();
1044
1133
  webCodecsService = null;
@@ -52,8 +52,9 @@
52
52
  let previewContainer = $state<HTMLDivElement | undefined>();
53
53
 
54
54
  // Solid loading overlay: shown during initial load states
55
+ // Skip when lastFrameData exists (tab was previously loaded - snapshot handles display)
55
56
  const showSolidOverlay = $derived(
56
- isLaunchingBrowser || !sessionInfo || (!isStreamReady && !isNavigating && !isReconnecting)
57
+ isLaunchingBrowser || !sessionInfo || (!isStreamReady && !isNavigating && !isReconnecting && !lastFrameData)
57
58
  );
58
59
 
59
60
  // Navigation overlay state with debounce to prevent flickering during state transitions
@@ -222,7 +222,7 @@
222
222
  {@const isActive = tab.id === activeTabId}
223
223
  <button
224
224
  type="button"
225
- class="group relative flex items-center justify-center gap-1 px-2 py-2 text-xs font-medium transition-colors min-w-0 max-w-xs cursor-pointer
225
+ class="group relative flex items-center justify-center gap-1 pr-2 pl-3 py-2 text-xs font-medium transition-colors min-w-0 max-w-xs cursor-pointer
226
226
  {isActive
227
227
  ? 'text-violet-600 dark:text-violet-400'
228
228
  : 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300'}"
@@ -230,13 +230,6 @@
230
230
  role="tab"
231
231
  tabindex="0"
232
232
  >
233
- {#if tab.id === mcpControlledTabId}
234
- <Icon name="lucide:bot" class="w-3 h-3 flex-shrink-0 text-amber-500" />
235
- {:else if tab.isLoading}
236
- <Icon name="lucide:loader-circle" class="w-3 h-3 animate-spin flex-shrink-0" />
237
- {:else}
238
- <Icon name="lucide:globe" class="w-3 h-3 flex-shrink-0" />
239
- {/if}
240
233
  <span class="truncate max-w-28" title={tab.url}>
241
234
  {tab.title || 'New Tab'}
242
235
  </span>
@@ -486,7 +486,10 @@ export function createBrowserCoordinator(config: BrowserCoordinatorConfig) {
486
486
 
487
487
  if (!existingTabs || existingTabs.count === 0) {
488
488
  debug.log('preview', '📭 No existing sessions to recover');
489
- isRestoring = false;
489
+ // Notify parent about 0-tab recovery (enables empty tab creation)
490
+ if (onSessionsRecovered) {
491
+ onSessionsRecovered(0);
492
+ }
490
493
  return;
491
494
  }
492
495
 
@@ -781,7 +784,7 @@ export function createBrowserCoordinator(config: BrowserCoordinatorConfig) {
781
784
  const newProjectId = getProjectId();
782
785
  debug.log('preview', `🔄 Switching to project: ${newProjectId}`);
783
786
 
784
- // Set restore lock during project switch
787
+ // Set restore lock during entire project switch (prevents race conditions)
785
788
  isRestoring = true;
786
789
 
787
790
  try {
@@ -795,13 +798,18 @@ export function createBrowserCoordinator(config: BrowserCoordinatorConfig) {
795
798
  browserCleanup.clearAll();
796
799
 
797
800
  // Recover sessions from new project
798
- // Note: recoverExistingSessions will handle its own lock
799
- isRestoring = false;
800
801
  await recoverExistingSessions();
801
802
  } catch (error) {
802
803
  debug.error('preview', '❌ Error switching project:', error);
804
+ } finally {
803
805
  isRestoring = false;
804
806
  }
807
+
808
+ // After recovery and lock release, create empty tab if no tabs exist
809
+ if (tabManager.getAllTabs().length === 0) {
810
+ debug.log('preview', '📭 No tabs after project switch, creating empty tab');
811
+ createNewTab('');
812
+ }
805
813
  }
806
814
 
807
815
  return {
@@ -37,7 +37,7 @@
37
37
  {@const isActive = session.isActive}
38
38
  <button
39
39
  type="button"
40
- class="group relative flex items-center justify-center gap-1 px-2 py-2 text-xs font-medium transition-colors min-w-0 max-w-xs cursor-pointer
40
+ class="group relative flex items-center justify-center gap-1 pr-2 pl-3 py-2 text-xs font-medium transition-colors min-w-0 max-w-xs cursor-pointer
41
41
  {isActive
42
42
  ? 'text-violet-600 dark:text-violet-400'
43
43
  : 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300'}"
@@ -45,7 +45,6 @@
45
45
  role="tab"
46
46
  tabindex="0"
47
47
  >
48
- <Icon name="lucide:terminal" class="w-3 h-3 flex-shrink-0" />
49
48
  <span class="truncate max-w-28">{session.name}</span>
50
49
  <!-- Close button -->
51
50
  <span
@@ -26,6 +26,9 @@
26
26
  let projectToDelete = $state<Project | null>(null);
27
27
  let searchQuery = $state('');
28
28
  let showTunnelModal = $state(false);
29
+ let hoveredProject = $state<Project | null>(null);
30
+ let tooltipY = $state(0);
31
+ let tooltipX = $state(0);
29
32
 
30
33
  // Derived
31
34
  const isCollapsed = $derived(workspaceState.navigatorCollapsed);
@@ -145,6 +148,17 @@
145
148
  // Single word: take first 2 letters
146
149
  return name.substring(0, 2).toUpperCase();
147
150
  }
151
+
152
+ function showProjectTooltip(project: Project, event: MouseEvent) {
153
+ const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
154
+ tooltipX = rect.right + 8;
155
+ tooltipY = rect.top + rect.height / 2;
156
+ hoveredProject = project;
157
+ }
158
+
159
+ function hideProjectTooltip() {
160
+ hoveredProject = null;
161
+ }
148
162
  </script>
149
163
 
150
164
  <!-- Project Navigator Sidebar -->
@@ -315,7 +329,8 @@
315
329
  ? 'bg-violet-500/10 dark:bg-violet-500/20 text-violet-700 dark:text-violet-300'
316
330
  : 'bg-slate-200/50 dark:bg-slate-800/50 text-slate-600 dark:text-slate-400 hover:bg-violet-500/10 hover:text-slate-900 dark:hover:text-slate-100'}"
317
331
  onclick={() => selectProject(project)}
318
- title={project.name}
332
+ onmouseenter={(e) => showProjectTooltip(project, e)}
333
+ onmouseleave={hideProjectTooltip}
319
334
  >
320
335
  <span>{getProjectInitials(project.name)}</span>
321
336
  <span
@@ -349,6 +364,17 @@
349
364
  </nav>
350
365
  </aside>
351
366
 
367
+ <!-- Collapsed project tooltip (fixed position to avoid overflow clipping) -->
368
+ {#if hoveredProject}
369
+ <div
370
+ class="fixed z-50 pointer-events-none flex flex-col py-1.5 px-2.5 rounded-lg bg-white dark:bg-slate-700 border border-slate-200 dark:border-slate-600 shadow-lg whitespace-nowrap"
371
+ style="left: {tooltipX}px; top: {tooltipY}px; transform: translateY(-50%);"
372
+ >
373
+ <span class="text-xs font-semibold text-slate-900 dark:text-slate-100">{hoveredProject.name}</span>
374
+ <span class="text-3xs font-mono text-slate-500 dark:text-slate-400">{hoveredProject.path}</span>
375
+ </div>
376
+ {/if}
377
+
352
378
  <!-- Folder Browser (includes its own Modal) -->
353
379
  <FolderBrowser
354
380
  bind:isOpen={showFolderBrowser}
@@ -28,6 +28,7 @@
28
28
  import { initializeSessions } from '$frontend/stores/core/sessions.svelte';
29
29
  import { initializeNotifications, notificationStore } from '$frontend/stores/ui/notification.svelte';
30
30
  import { applyServerSettings, loadSystemSettings } from '$frontend/stores/features/settings.svelte';
31
+ import { applyTodoPanelState } from '$frontend/stores/ui/todo-panel.svelte';
31
32
  import { initPresence } from '$frontend/stores/core/presence.svelte';
32
33
  import ws from '$frontend/utils/ws';
33
34
  import { debug } from '$shared/utils/logger';
@@ -84,7 +85,7 @@
84
85
 
85
86
  // Step 3: Restore user state from server
86
87
  setProgress(30, 'Restoring state...');
87
- let serverState: { currentProjectId: string | null; lastView: string | null; settings: any; unreadSessions: any } | null = null;
88
+ let serverState: { currentProjectId: string | null; lastView: string | null; settings: any; unreadSessions: any; todoPanelState: any } | null = null;
88
89
  try {
89
90
  serverState = await ws.http('user:restore-state', {});
90
91
  debug.log('workspace', 'Server state restored:', serverState);
@@ -97,6 +98,7 @@
97
98
  if (serverState?.settings) {
98
99
  applyServerSettings(serverState.settings);
99
100
  }
101
+ applyTodoPanelState(serverState?.todoPanelState);
100
102
  restoreLastView(serverState?.lastView);
101
103
  restoreUnreadSessions(serverState?.unreadSessions);
102
104
  await loadSystemSettings();
@@ -18,6 +18,7 @@
18
18
  import { showConfirm } from '$frontend/stores/ui/dialog.svelte';
19
19
  import { getFileIcon } from '$frontend/utils/file-icon-mappings';
20
20
  import type { IconName } from '$shared/types/ui/icons';
21
+ import { fileState, clearRevealRequest } from '$frontend/stores/core/files.svelte';
21
22
 
22
23
  // Props
23
24
  interface Props {
@@ -51,6 +52,7 @@
51
52
  savedContent: string;
52
53
  isLoading: boolean;
53
54
  externallyChanged?: boolean;
55
+ isBinary?: boolean;
54
56
  }
55
57
 
56
58
  let openTabs = $state<EditorTab[]>([]);
@@ -68,6 +70,7 @@
68
70
  let displayFile = $state<FileNode | null>(null);
69
71
  let displayLoading = $state(false);
70
72
  let displayTargetLine = $state<number | undefined>(undefined);
73
+ let displayIsBinary = $state(false);
71
74
  let displayExternallyChanged = $state(false);
72
75
 
73
76
  // Sync display state when active tab changes
@@ -78,12 +81,14 @@
78
81
  displaySavedContent = activeTab.savedContent;
79
82
  displayLoading = activeTab.isLoading;
80
83
  displayExternallyChanged = activeTab.externallyChanged || false;
84
+ displayIsBinary = activeTab.isBinary || false;
81
85
  } else {
82
86
  displayFile = null;
83
87
  displayContent = '';
84
88
  displaySavedContent = '';
85
89
  displayLoading = false;
86
90
  displayExternallyChanged = false;
91
+ displayIsBinary = false;
87
92
  }
88
93
  });
89
94
 
@@ -341,9 +346,10 @@
341
346
  try {
342
347
  const data = await ws.http('files:read-file', { file_path: filePath });
343
348
  const content = data.content || '';
349
+ const isBinary = data.isBinary || false;
344
350
  openTabs = openTabs.map(t =>
345
351
  t.file.path === filePath
346
- ? { ...t, currentContent: content, savedContent: content, isLoading: false }
352
+ ? { ...t, currentContent: content, savedContent: content, isLoading: false, isBinary }
347
353
  : t
348
354
  );
349
355
  // Update display if this is the active tab
@@ -351,6 +357,7 @@
351
357
  displayContent = content;
352
358
  displaySavedContent = content;
353
359
  displayLoading = false;
360
+ displayIsBinary = isBinary;
354
361
  }
355
362
  return true;
356
363
  } catch (err) {
@@ -377,6 +384,7 @@
377
384
  displayContent = tab.currentContent;
378
385
  displaySavedContent = tab.savedContent;
379
386
  displayLoading = tab.isLoading;
387
+ displayIsBinary = tab.isBinary || false;
380
388
  }
381
389
  }
382
390
 
@@ -1086,6 +1094,72 @@
1086
1094
  prevTwoColumnMode = isTwoColumnMode;
1087
1095
  });
1088
1096
 
1097
+ // Reveal and open file in editor when requested from external components (e.g. chat tools)
1098
+ $effect(() => {
1099
+ const revealPath = fileState.revealRequest;
1100
+ if (!revealPath || !projectPath) return;
1101
+
1102
+ clearRevealRequest();
1103
+
1104
+ // Expand all parent directories in the tree
1105
+ const relativePath = revealPath.startsWith(projectPath)
1106
+ ? revealPath.slice(projectPath.length).replace(/^[/\\]/, '')
1107
+ : '';
1108
+ if (relativePath) {
1109
+ const parts = relativePath.split(/[/\\]/);
1110
+ let currentPath = projectPath;
1111
+ for (let i = 0; i < parts.length - 1; i++) {
1112
+ currentPath += '/' + parts[i];
1113
+ expandedFolders.add(currentPath);
1114
+ }
1115
+ expandedFolders = new Set(expandedFolders);
1116
+ }
1117
+
1118
+ // Open file in editor tab (handleFileOpen also handles missing tree nodes)
1119
+ revealAndOpenFile(revealPath);
1120
+ });
1121
+
1122
+ async function revealAndOpenFile(filePath: string) {
1123
+ const existingTab = openTabs.find(t => t.file.path === filePath);
1124
+ if (existingTab) {
1125
+ // Tab already open — just activate it
1126
+ activeTabPath = filePath;
1127
+ if (!isTwoColumnMode) viewMode = 'viewer';
1128
+ scrollToActiveFile(filePath);
1129
+ return;
1130
+ }
1131
+
1132
+ // Create new tab
1133
+ let file = findFileInTree(projectFiles, filePath);
1134
+ if (!file) {
1135
+ const fileName = filePath.split(/[/\\]/).pop() || 'Untitled';
1136
+ file = { name: fileName, path: filePath, type: 'file', size: 0, modified: new Date() };
1137
+ }
1138
+ const newTab: EditorTab = {
1139
+ file,
1140
+ currentContent: '',
1141
+ savedContent: '',
1142
+ isLoading: true
1143
+ };
1144
+ openTabs = [...openTabs, newTab];
1145
+ activeTabPath = filePath;
1146
+ if (!isTwoColumnMode) viewMode = 'viewer';
1147
+
1148
+ // Load content and verify file exists on disk
1149
+ const success = await loadTabContent(filePath);
1150
+ if (!success) {
1151
+ openTabs = openTabs.filter(t => t.file.path !== filePath);
1152
+ if (activeTabPath === filePath) {
1153
+ activeTabPath = openTabs.length > 0 ? openTabs[openTabs.length - 1].file.path : null;
1154
+ if (!activeTabPath && !isTwoColumnMode) viewMode = 'tree';
1155
+ }
1156
+ showErrorAlert('File no longer exists on disk.', 'File Not Found');
1157
+ return;
1158
+ }
1159
+
1160
+ scrollToActiveFile(filePath);
1161
+ }
1162
+
1089
1163
  // Save state to persistent storage on component destruction (mobile/desktop switch)
1090
1164
  onDestroy(() => {
1091
1165
  if (projectPath) {
@@ -1269,6 +1343,8 @@
1269
1343
  onToggleWordWrap={() => { wordWrapEnabled = !wordWrapEnabled; }}
1270
1344
  externallyChanged={displayExternallyChanged}
1271
1345
  onForceReload={forceReloadTab}
1346
+ isBinary={displayIsBinary}
1347
+ projectPath={projectPath}
1272
1348
  />
1273
1349
  </div>
1274
1350
  </div>