@myrialabs/clopen 0.1.4 → 0.1.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 (54) hide show
  1. package/backend/lib/chat/stream-manager.ts +8 -0
  2. package/backend/lib/database/migrations/022_add_snapshot_changes_column.ts +35 -0
  3. package/backend/lib/database/migrations/index.ts +7 -0
  4. package/backend/lib/database/queries/snapshot-queries.ts +7 -4
  5. package/backend/lib/files/file-watcher.ts +34 -0
  6. package/backend/lib/project/status-manager.ts +6 -4
  7. package/backend/lib/snapshot/snapshot-service.ts +471 -316
  8. package/backend/lib/terminal/pty-session-manager.ts +1 -32
  9. package/backend/ws/chat/stream.ts +45 -2
  10. package/backend/ws/snapshot/restore.ts +77 -67
  11. package/backend/ws/system/operations.ts +95 -0
  12. package/frontend/App.svelte +24 -7
  13. package/frontend/lib/components/chat/ChatInterface.svelte +14 -14
  14. package/frontend/lib/components/chat/input/ChatInput.svelte +2 -2
  15. package/frontend/lib/components/chat/input/components/ChatInputActions.svelte +1 -1
  16. package/frontend/lib/components/chat/input/components/EngineModelPicker.svelte +8 -3
  17. package/frontend/lib/components/chat/input/composables/use-textarea-resize.svelte.ts +12 -2
  18. package/frontend/lib/components/chat/tools/AskUserQuestionTool.svelte +3 -8
  19. package/frontend/lib/components/checkpoint/TimelineModal.svelte +222 -30
  20. package/frontend/lib/components/common/ConnectionBanner.svelte +55 -0
  21. package/frontend/lib/components/common/MonacoEditor.svelte +14 -0
  22. package/frontend/lib/components/common/UpdateBanner.svelte +88 -0
  23. package/frontend/lib/components/common/xterm/XTerm.svelte +9 -0
  24. package/frontend/lib/components/common/xterm/xterm-service.ts +9 -0
  25. package/frontend/lib/components/git/DiffViewer.svelte +16 -2
  26. package/frontend/lib/components/history/HistoryModal.svelte +8 -4
  27. package/frontend/lib/components/settings/SettingsModal.svelte +2 -2
  28. package/frontend/lib/components/settings/appearance/AppearanceSettings.svelte +59 -0
  29. package/frontend/lib/components/settings/general/GeneralSettings.svelte +6 -1
  30. package/frontend/lib/components/settings/general/UpdateSettings.svelte +123 -0
  31. package/frontend/lib/components/terminal/Terminal.svelte +1 -7
  32. package/frontend/lib/components/workspace/DesktopNavigator.svelte +11 -19
  33. package/frontend/lib/components/workspace/MobileNavigator.svelte +4 -15
  34. package/frontend/lib/components/workspace/WorkspaceLayout.svelte +1 -1
  35. package/frontend/lib/components/workspace/panels/FilesPanel.svelte +3 -2
  36. package/frontend/lib/components/workspace/panels/GitPanel.svelte +3 -2
  37. package/frontend/lib/services/notification/global-stream-monitor.ts +56 -16
  38. package/frontend/lib/services/snapshot/snapshot.service.ts +71 -32
  39. package/frontend/lib/stores/core/app.svelte.ts +47 -0
  40. package/frontend/lib/stores/core/presence.svelte.ts +80 -1
  41. package/frontend/lib/stores/core/projects.svelte.ts +10 -2
  42. package/frontend/lib/stores/core/sessions.svelte.ts +15 -2
  43. package/frontend/lib/stores/features/settings.svelte.ts +10 -1
  44. package/frontend/lib/stores/features/terminal.svelte.ts +6 -0
  45. package/frontend/lib/stores/ui/connection.svelte.ts +40 -0
  46. package/frontend/lib/stores/ui/update.svelte.ts +124 -0
  47. package/frontend/lib/stores/ui/workspace.svelte.ts +4 -3
  48. package/frontend/lib/utils/ws.ts +5 -1
  49. package/index.html +1 -1
  50. package/package.json +1 -1
  51. package/shared/types/database/schema.ts +18 -0
  52. package/shared/types/stores/settings.ts +4 -0
  53. package/shared/utils/ws-client.ts +16 -2
  54. package/vite.config.ts +1 -0
@@ -83,7 +83,7 @@
83
83
  role="dialog"
84
84
  aria-labelledby="settings-title"
85
85
  tabindex="-1"
86
- class="flex flex-col w-full max-w-225 h-[85vh] max-h-175 bg-slate-50 dark:bg-slate-950 border border-violet-500/20 rounded-2xl overflow-hidden shadow-[0_25px_50px_-12px_rgba(0,0,0,0.25)] dark:shadow-[0_25px_50px_-12px_rgba(0,0,0,0.5)] max-md:max-w-full max-md:h-screen max-md:max-h-screen max-md:rounded-none"
86
+ class="flex flex-col w-full max-w-225 h-[85dvh] max-h-175 bg-slate-50 dark:bg-slate-950 border border-violet-500/20 rounded-2xl overflow-hidden shadow-[0_25px_50px_-12px_rgba(0,0,0,0.25)] dark:shadow-[0_25px_50px_-12px_rgba(0,0,0,0.5)] max-md:max-w-full max-md:h-dvh max-md:max-h-dvh max-md:rounded-none"
87
87
  onclick={(e) => e.stopPropagation()}
88
88
  onkeydown={(e) => e.stopPropagation()}
89
89
  in:scale={{ duration: 250, easing: cubicOut, start: 0.95 }}
@@ -100,7 +100,7 @@
100
100
  onclick={() => (isMobileMenuOpen = !isMobileMenuOpen)}
101
101
  aria-label="Toggle menu"
102
102
  >
103
- <Icon name={isMobileMenuOpen ? 'lucide:x' : 'lucide:menu'} class="w-5 h-5" />
103
+ <Icon name={isMobileMenuOpen ? 'lucide:arrow-left' : 'lucide:menu'} class="w-5 h-5" />
104
104
  </button>
105
105
  <h2
106
106
  id="settings-title"
@@ -1,6 +1,20 @@
1
1
  <script lang="ts">
2
2
  import { themeStore, toggleDarkMode } from '$frontend/lib/stores/ui/theme.svelte';
3
+ import { settings, updateSettings, applyFontSize } from '$frontend/lib/stores/features/settings.svelte';
3
4
  import Icon from '../../common/Icon.svelte';
5
+
6
+ const FONT_SIZE_MIN = 10;
7
+ const FONT_SIZE_MAX = 20;
8
+
9
+ function handleFontSizeChange(e: Event) {
10
+ const value = Number((e.target as HTMLInputElement).value);
11
+ applyFontSize(value);
12
+ updateSettings({ fontSize: value });
13
+ }
14
+
15
+ function fontSizePercent() {
16
+ return ((settings.fontSize - FONT_SIZE_MIN) / (FONT_SIZE_MAX - FONT_SIZE_MIN)) * 100;
17
+ }
4
18
  </script>
5
19
 
6
20
  <div class="py-1">
@@ -41,5 +55,50 @@
41
55
  ></span>
42
56
  </label>
43
57
  </div>
58
+
59
+ <!-- Font Size -->
60
+ <div
61
+ class="flex flex-col gap-3 p-4 bg-slate-100/80 dark:bg-slate-800/80 border border-slate-200 dark:border-slate-800 rounded-xl transition-all duration-150 hover:border-violet-500/20"
62
+ >
63
+ <div class="flex items-center gap-3.5">
64
+ <div
65
+ class="flex items-center justify-center w-10 h-10 bg-violet-500/10 dark:bg-violet-500/15 rounded-lg text-violet-600 dark:text-violet-400"
66
+ >
67
+ <Icon name="lucide:type" class="w-5 h-5" />
68
+ </div>
69
+ <div class="flex flex-col gap-0.5 flex-1">
70
+ <div class="text-sm font-semibold text-slate-900 dark:text-slate-100">Font Size</div>
71
+ <div class="text-xs text-slate-600 dark:text-slate-500">Adjust the base font size of the application</div>
72
+ </div>
73
+ <div class="text-sm font-semibold text-violet-600 dark:text-violet-400 shrink-0 w-10 text-right">
74
+ {settings.fontSize}px
75
+ </div>
76
+ </div>
77
+
78
+ <div class="flex items-center gap-2.5 px-0.5">
79
+ <span class="text-xs text-slate-500 dark:text-slate-500 shrink-0">A</span>
80
+ <div class="relative flex-1 h-1.5">
81
+ <div class="absolute inset-0 bg-slate-300 dark:bg-slate-700 rounded-full"></div>
82
+ <div
83
+ class="absolute inset-y-0 left-0 bg-gradient-to-r from-violet-500 to-purple-500 rounded-full"
84
+ style="width: {fontSizePercent()}%"
85
+ ></div>
86
+ <input
87
+ type="range"
88
+ min={FONT_SIZE_MIN}
89
+ max={FONT_SIZE_MAX}
90
+ step="1"
91
+ value={settings.fontSize}
92
+ oninput={handleFontSizeChange}
93
+ class="absolute inset-0 w-full opacity-0 cursor-pointer h-full"
94
+ />
95
+ <div
96
+ class="absolute top-1/2 -translate-y-1/2 w-4 h-4 bg-white border-2 border-violet-500 rounded-full shadow-sm pointer-events-none"
97
+ style="left: calc({fontSizePercent()}% - {fontSizePercent() / 100 * 16}px)"
98
+ ></div>
99
+ </div>
100
+ <span class="text-base text-slate-500 dark:text-slate-500 shrink-0">A</span>
101
+ </div>
102
+ </div>
44
103
  </div>
45
104
  </div>
@@ -1,9 +1,14 @@
1
1
  <script lang="ts">
2
2
  import DataManagementSettings from './DataManagementSettings.svelte';
3
3
  import AdvancedSettings from './AdvancedSettings.svelte';
4
+ import UpdateSettings from './UpdateSettings.svelte';
4
5
  </script>
5
6
 
6
- <DataManagementSettings />
7
+ <UpdateSettings />
8
+
9
+ <div class="mt-6">
10
+ <DataManagementSettings />
11
+ </div>
7
12
 
8
13
  <div class="mt-6">
9
14
  <AdvancedSettings />
@@ -0,0 +1,123 @@
1
+ <script lang="ts">
2
+ import { settings, updateSettings } from '$frontend/lib/stores/features/settings.svelte';
3
+ import { updateState, checkForUpdate, runUpdate } from '$frontend/lib/stores/ui/update.svelte';
4
+ import Icon from '../../common/Icon.svelte';
5
+
6
+ function toggleAutoUpdate() {
7
+ updateSettings({ autoUpdate: !settings.autoUpdate });
8
+ }
9
+
10
+ function handleCheckNow() {
11
+ checkForUpdate();
12
+ }
13
+
14
+ function handleUpdateNow() {
15
+ runUpdate();
16
+ }
17
+ </script>
18
+
19
+ <div class="py-1">
20
+ <h3 class="text-base font-bold text-slate-900 dark:text-slate-100 mb-1.5">Updates</h3>
21
+ <p class="text-sm text-slate-600 dark:text-slate-500 mb-5">Check for new versions and configure automatic updates</p>
22
+
23
+ <div class="flex flex-col gap-3.5">
24
+ <!-- Version Info -->
25
+ <div class="p-4 bg-slate-100/80 dark:bg-slate-800/80 border border-slate-200 dark:border-slate-800 rounded-xl">
26
+ <div class="flex items-start gap-3.5">
27
+ <div class="flex items-center justify-center w-10 h-10 rounded-lg shrink-0 bg-violet-400/15 text-violet-500">
28
+ <Icon name="lucide:package" class="w-5 h-5" />
29
+ </div>
30
+ <div class="flex flex-col gap-1 min-w-0 flex-1">
31
+ <div class="text-sm font-semibold text-slate-900 dark:text-slate-100">
32
+ @myrialabs/clopen
33
+ </div>
34
+ <div class="text-xs text-slate-600 dark:text-slate-500">
35
+ {#if updateState.currentVersion}
36
+ Current version: <span class="font-mono font-medium text-slate-700 dark:text-slate-400">v{updateState.currentVersion}</span>
37
+ {:else}
38
+ Version info will appear after checking
39
+ {/if}
40
+ </div>
41
+ {#if updateState.updateAvailable}
42
+ <div class="text-xs">
43
+ <span class="inline-flex items-center gap-1 px-1.5 py-0.5 bg-violet-500/15 text-violet-600 dark:text-violet-400 rounded text-2xs font-semibold">
44
+ v{updateState.latestVersion} available
45
+ </span>
46
+ </div>
47
+ {/if}
48
+ </div>
49
+
50
+ <div class="flex items-center gap-2 shrink-0">
51
+ {#if updateState.updateAvailable}
52
+ <button
53
+ type="button"
54
+ onclick={handleUpdateNow}
55
+ disabled={updateState.updating}
56
+ class="inline-flex items-center gap-1.5 py-2 px-3.5 bg-violet-500/10 border border-violet-500/20 rounded-lg text-violet-600 dark:text-violet-400 text-xs font-semibold cursor-pointer transition-all duration-150 hover:bg-violet-500/20 hover:border-violet-600/40 disabled:opacity-60 disabled:cursor-not-allowed"
57
+ >
58
+ {#if updateState.updating}
59
+ <div class="w-3.5 h-3.5 border-2 border-violet-600/30 border-t-violet-600 rounded-full animate-spin"></div>
60
+ Updating...
61
+ {:else}
62
+ <Icon name="lucide:download" class="w-3.5 h-3.5" />
63
+ Update
64
+ {/if}
65
+ </button>
66
+ {/if}
67
+ <button
68
+ type="button"
69
+ onclick={handleCheckNow}
70
+ disabled={updateState.checking}
71
+ class="inline-flex items-center gap-1.5 py-2 px-3.5 bg-slate-200/80 dark:bg-slate-700/80 border border-slate-300 dark:border-slate-600 rounded-lg text-slate-700 dark:text-slate-300 text-xs font-semibold cursor-pointer transition-all duration-150 hover:bg-slate-300 dark:hover:bg-slate-600 disabled:opacity-60 disabled:cursor-not-allowed"
72
+ >
73
+ {#if updateState.checking}
74
+ <div class="w-3.5 h-3.5 border-2 border-slate-600/30 border-t-slate-600 dark:border-slate-400/30 dark:border-t-slate-400 rounded-full animate-spin"></div>
75
+ Checking...
76
+ {:else}
77
+ <Icon name="lucide:refresh-cw" class="w-3.5 h-3.5" />
78
+ Check now
79
+ {/if}
80
+ </button>
81
+ </div>
82
+ </div>
83
+
84
+ {#if updateState.error}
85
+ <div class="mt-3 flex items-center gap-2 px-3 py-2 bg-red-500/10 border border-red-500/20 rounded-lg">
86
+ <Icon name="lucide:circle-alert" class="w-4 h-4 text-red-500 shrink-0" />
87
+ <span class="text-xs text-red-600 dark:text-red-400">{updateState.error}</span>
88
+ </div>
89
+ {/if}
90
+
91
+ {#if updateState.updateSuccess}
92
+ <div class="mt-3 flex items-center gap-2 px-3 py-2 bg-emerald-500/10 border border-emerald-500/20 rounded-lg">
93
+ <Icon name="lucide:circle-check" class="w-4 h-4 text-emerald-500 shrink-0" />
94
+ <span class="text-xs text-emerald-600 dark:text-emerald-400">Updated successfully — restart clopen to apply</span>
95
+ </div>
96
+ {/if}
97
+ </div>
98
+
99
+ <!-- Auto-Update Toggle -->
100
+ <div class="flex items-center justify-between gap-4 py-3 px-4 bg-slate-100/50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700/50 rounded-lg">
101
+ <div class="flex items-center gap-3">
102
+ <Icon name="lucide:refresh-cw" class="w-4.5 h-4.5 text-slate-600 dark:text-slate-400" />
103
+ <div class="text-left">
104
+ <div class="text-sm font-medium text-slate-900 dark:text-slate-100">Auto-update</div>
105
+ <div class="text-xs text-slate-600 dark:text-slate-400">Automatically install new versions when available</div>
106
+ </div>
107
+ </div>
108
+ <button
109
+ type="button"
110
+ role="switch"
111
+ aria-checked={settings.autoUpdate}
112
+ onclick={toggleAutoUpdate}
113
+ class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-violet-500/30
114
+ {settings.autoUpdate ? 'bg-violet-600' : 'bg-slate-300 dark:bg-slate-600'}"
115
+ >
116
+ <span
117
+ class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out
118
+ {settings.autoUpdate ? 'translate-x-5' : 'translate-x-0'}"
119
+ ></span>
120
+ </button>
121
+ </div>
122
+ </div>
123
+ </div>
@@ -284,16 +284,10 @@
284
284
  />
285
285
  </div>
286
286
  {:else}
287
- <!-- No active session -->
287
+ <!-- No active session - will auto-connect -->
288
288
  <div class="flex-1 flex items-center justify-center font-mono">
289
289
  <div class="text-center text-slate-600 dark:text-slate-400">
290
290
  <p>No terminal sessions available</p>
291
- <button
292
- class="mt-2 px-4 py-2 bg-violet-600 hover:bg-violet-700 text-white rounded-md transition-colors"
293
- onclick={handleNewSession}
294
- >
295
- Create New Terminal
296
- </button>
297
291
  </div>
298
292
  </div>
299
293
  {/if}
@@ -7,9 +7,10 @@
7
7
  import { addNotification } from '$frontend/lib/stores/ui/notification.svelte';
8
8
  import { openSettingsModal } from '$frontend/lib/stores/ui/settings-modal.svelte';
9
9
  import { projectStatusService } from '$frontend/lib/services/project';
10
- import { presenceState } from '$frontend/lib/stores/core/presence.svelte';
10
+ import { presenceState, getProjectStatusColor } from '$frontend/lib/stores/core/presence.svelte';
11
11
  import type { Project } from '$shared/types/database/schema';
12
12
  import { debug } from '$shared/utils/logger';
13
+ import { settings } from '$frontend/lib/stores/features/settings.svelte';
13
14
  import FolderBrowser from '$frontend/lib/components/common/FolderBrowser.svelte';
14
15
  import Dialog from '$frontend/lib/components/common/Dialog.svelte';
15
16
  import ViewMenu from '$frontend/lib/components/workspace/ViewMenu.svelte';
@@ -30,7 +31,7 @@
30
31
  const isCollapsed = $derived(workspaceState.navigatorCollapsed);
31
32
  const currentProjectId = $derived(projectState.currentProject?.id);
32
33
  const navigatorWidth = $derived(
33
- workspaceState.navigatorCollapsed ? 48 : workspaceState.navigatorWidth
34
+ workspaceState.navigatorCollapsed ? 48 : Math.round(workspaceState.navigatorWidth * (settings.fontSize / 13))
34
35
  );
35
36
 
36
37
  const filteredProjects = $derived(() => {
@@ -110,18 +111,7 @@
110
111
  }
111
112
  }
112
113
 
113
- // Get status color from presence data (single source of truth from backend)
114
- // Shows real-time status for ALL projects, not just the active one.
115
- // Uses backend-computed isWaitingInput so background sessions are accurate
116
- // even when the frontend hasn't received their chat events.
117
- function getStatusColor(projectId: string): string {
118
- const status = presenceState.statuses.get(projectId);
119
- if (!status?.streams) return 'bg-slate-500/30';
120
- const activeStreams = status.streams.filter((s: any) => s.status === 'active');
121
- if (activeStreams.length === 0) return 'bg-slate-500/30';
122
- const hasWaitingInput = activeStreams.some((s: any) => s.isWaitingInput);
123
- return hasWaitingInput ? 'bg-amber-500' : 'bg-emerald-500';
124
- }
114
+ // Status color for project indicator uses shared helper from presence store
125
115
 
126
116
  // Close folder browser
127
117
  function closeFolderBrowser() {
@@ -244,7 +234,7 @@
244
234
  <div class="relative shrink-0">
245
235
  <Icon name="lucide:folder" class="w-4 h-4" />
246
236
  <span
247
- class="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-slate-50 dark:border-slate-900/95 {getStatusColor(project.id ?? '')}"
237
+ class="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-slate-50 dark:border-slate-900/95 {getProjectStatusColor(project.id ?? '')}"
248
238
  ></span>
249
239
  </div>
250
240
 
@@ -301,7 +291,7 @@
301
291
  </footer>
302
292
  {:else}
303
293
  <!-- Collapsed State: Icon Buttons -->
304
- <div class="flex-1 flex flex-col items-center gap-2 py-4 px-2">
294
+ <div class="flex flex-col items-center pt-4 px-2 shrink-0">
305
295
  <button
306
296
  type="button"
307
297
  class="flex items-center justify-center w-9 h-9 bg-transparent border-none rounded-lg text-slate-500 cursor-pointer transition-all duration-150 relative hover:bg-violet-500/10 hover:text-slate-900 dark:hover:text-slate-100"
@@ -312,13 +302,15 @@
312
302
  </button>
313
303
 
314
304
  <div class="w-6 h-px bg-violet-500/10 my-1"></div>
305
+ </div>
315
306
 
316
- {#each existingProjects.slice(0, 5) as project (project.id)}
307
+ <div class="flex-1 flex flex-col items-center gap-2 px-2 pb-4 min-h-0 overflow-y-auto">
308
+ {#each existingProjects as project (project.id)}
317
309
  {@const projectStatus = presenceState.statuses.get(project.id ?? '')}
318
310
  {@const activeUserCount = (projectStatus?.activeUsers || []).length}
319
311
  <button
320
312
  type="button"
321
- class="flex items-center justify-center w-9 h-9 border-none rounded-lg cursor-pointer transition-all duration-150 relative font-semibold text-sm
313
+ class="flex items-center justify-center w-9 h-9 shrink-0 border-none rounded-lg cursor-pointer transition-all duration-150 relative font-semibold text-sm
322
314
  {currentProjectId === project.id
323
315
  ? 'bg-violet-500/10 dark:bg-violet-500/20 text-violet-700 dark:text-violet-300'
324
316
  : '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'}"
@@ -327,7 +319,7 @@
327
319
  >
328
320
  <span>{getProjectInitials(project.name)}</span>
329
321
  <span
330
- class="absolute bottom-1 right-1 w-2.5 h-2.5 rounded-full border-2 border-slate-50 dark:border-slate-900/95 {getStatusColor(project.id ?? '')}"
322
+ class="absolute bottom-1 right-1 w-2.5 h-2.5 rounded-full border-2 border-slate-50 dark:border-slate-900/95 {getProjectStatusColor(project.id ?? '')}"
331
323
  ></span>
332
324
  {#if activeUserCount > 0}
333
325
  <span
@@ -8,7 +8,7 @@
8
8
  type PanelId
9
9
  } from '$frontend/lib/stores/ui/workspace.svelte';
10
10
  import { projectState, removeProject } from '$frontend/lib/stores/core/projects.svelte';
11
- import { presenceState } from '$frontend/lib/stores/core/presence.svelte';
11
+ import { presenceState, getProjectStatusColor } from '$frontend/lib/stores/core/presence.svelte';
12
12
  import { openSettingsModal } from '$frontend/lib/stores/ui/settings-modal.svelte';
13
13
  import { addNotification } from '$frontend/lib/stores/ui/notification.svelte';
14
14
  import type { IconName } from '$shared/types/ui/icons';
@@ -65,18 +65,7 @@
65
65
  }
66
66
  }
67
67
 
68
- // Get status color from presence data (single source of truth from backend)
69
- // Shows real-time status for ALL projects, not just the active one.
70
- // Uses backend-computed isWaitingInput so background sessions are accurate
71
- // even when the frontend hasn't received their chat events.
72
- function getStatusColor(projectId: string): string {
73
- const status = presenceState.statuses.get(projectId);
74
- if (!status?.streams) return 'bg-slate-500/30';
75
- const activeStreams = status.streams.filter((s: any) => s.status === 'active');
76
- if (activeStreams.length === 0) return 'bg-slate-500/30';
77
- const hasWaitingInput = activeStreams.some((s: any) => s.isWaitingInput);
78
- return hasWaitingInput ? 'bg-amber-500' : 'bg-emerald-500';
79
- }
68
+ // Status color for project indicator uses shared helper from presence store
80
69
 
81
70
  function openAddProject() {
82
71
  showProjectMenu = false;
@@ -162,7 +151,7 @@
162
151
  aria-expanded={showProjectMenu}
163
152
  aria-haspopup="menu"
164
153
  >
165
- <div class="relative shrink-0"><Icon name="lucide:folder-open" class="w-4 h-4" /><span class="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-slate-100 dark:border-slate-800 {getStatusColor(projectState.currentProject?.id ?? '')}"></span></div>
154
+ <div class="relative shrink-0"><Icon name="lucide:folder-open" class="w-4 h-4" /><span class="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-slate-100 dark:border-slate-800 {getProjectStatusColor(projectState.currentProject?.id ?? '')}"></span></div>
166
155
  <span class="flex-1 text-left overflow-hidden text-ellipsis whitespace-nowrap">
167
156
  {projectState.currentProject?.name ?? 'No Project'}
168
157
  </span>
@@ -315,7 +304,7 @@
315
304
  >
316
305
  <Icon name="lucide:folder" class="text-violet-600 dark:text-violet-400 w-4 h-4" />
317
306
  <span
318
- class="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-white dark:border-slate-900 {getStatusColor(project.id ?? '')}"
307
+ class="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-white dark:border-slate-900 {getProjectStatusColor(project.id ?? '')}"
319
308
  ></span>
320
309
  </div>
321
310
  <div class="flex-1 min-w-0">
@@ -138,7 +138,7 @@
138
138
 
139
139
  <!-- Main Workspace Layout -->
140
140
  <div
141
- class="h-screen w-screen overflow-hidden {isMobile ? 'bg-white/90 dark:bg-slate-900/98' : 'bg-slate-50 dark:bg-slate-900/70'} text-slate-900 dark:text-slate-100 font-sans"
141
+ class="h-full w-full overflow-hidden {isMobile ? 'bg-white/90 dark:bg-slate-900/98' : 'bg-slate-50 dark:bg-slate-900/70'} text-slate-900 dark:text-slate-100 font-sans"
142
142
  >
143
143
  <!-- Skip link for accessibility -->
144
144
  <a
@@ -12,6 +12,7 @@
12
12
  import Dialog from '$frontend/lib/components/common/Dialog.svelte';
13
13
  import type { FileNode } from '$shared/types/filesystem';
14
14
  import { debug } from '$shared/utils/logger';
15
+ import { settings } from '$frontend/lib/stores/features/settings.svelte';
15
16
  import { onMount, onDestroy } from 'svelte';
16
17
  import ws from '$frontend/lib/utils/ws';
17
18
  import { showConfirm } from '$frontend/lib/stores/ui/dialog.svelte';
@@ -138,7 +139,7 @@
138
139
  // Container width detection for 2-column layout
139
140
  let containerRef = $state<HTMLDivElement | null>(null);
140
141
  let containerWidth = $state(0);
141
- const TWO_COLUMN_THRESHOLD = 800;
142
+ const TWO_COLUMN_THRESHOLD = $derived(Math.round(600 * (settings.fontSize / 13)));
142
143
 
143
144
  // FileTree ref
144
145
  let fileTreeRef = $state<any>(null);
@@ -1185,7 +1186,7 @@
1185
1186
  <!-- Tree panel: always rendered, hidden via CSS in 1-column viewer mode -->
1186
1187
  <div
1187
1188
  class={isTwoColumnMode
1188
- ? 'w-80 flex-shrink-0 h-full overflow-hidden border-r border-slate-200 dark:border-slate-700'
1189
+ ? 'w-72 flex-shrink-0 h-full overflow-hidden border-r border-slate-200 dark:border-slate-700'
1189
1190
  : (viewMode === 'tree' ? 'w-full h-full overflow-hidden' : 'hidden')}
1190
1191
  >
1191
1192
  <div class="h-full overflow-auto" bind:this={treeScrollContainer}>
@@ -5,6 +5,7 @@
5
5
  import { projectState } from '$frontend/lib/stores/core/projects.svelte';
6
6
  import { showError, showInfo } from '$frontend/lib/stores/ui/notification.svelte';
7
7
  import { debug } from '$shared/utils/logger';
8
+ import { settings } from '$frontend/lib/stores/features/settings.svelte';
8
9
  import ws from '$frontend/lib/utils/ws';
9
10
  import { getFileIcon } from '$frontend/lib/utils/file-icon-mappings';
10
11
  import { getGitStatusLabel, getGitStatusColor } from '$frontend/lib/utils/git-status';
@@ -131,7 +132,7 @@
131
132
  // Container width for responsive layout (same threshold as Files: 800)
132
133
  let containerRef = $state<HTMLDivElement | null>(null);
133
134
  let containerWidth = $state(0);
134
- const TWO_COLUMN_THRESHOLD = 800;
135
+ const TWO_COLUMN_THRESHOLD = $derived(Math.round(600 * (settings.fontSize / 13)));
135
136
  const isTwoColumnMode = $derived(containerWidth >= TWO_COLUMN_THRESHOLD);
136
137
 
137
138
  // Track last project for re-fetch
@@ -1504,7 +1505,7 @@
1504
1505
  <!-- Left panel: Changes list (w-80 like Files panel tree) -->
1505
1506
  <div
1506
1507
  class={isTwoColumnMode
1507
- ? 'w-80 flex-shrink-0 h-full overflow-hidden border-r border-slate-200 dark:border-slate-700 flex flex-col'
1508
+ ? 'w-72 flex-shrink-0 h-full overflow-hidden border-r border-slate-200 dark:border-slate-700 flex flex-col'
1508
1509
  : (viewMode === 'list' ? 'w-full h-full overflow-hidden flex flex-col' : 'hidden')}
1509
1510
  >
1510
1511
  {@render changesList()}
@@ -3,12 +3,13 @@
3
3
  *
4
4
  * Single source of truth for chat stream notifications (sound + push).
5
5
  *
6
- * Listens for chat:stream-finished events from the backend and triggers
7
- * notifications for ALL projects — both active and non-active.
6
+ * Listens for backend events and triggers notifications for ALL projects —
7
+ * both active and non-active:
8
+ * - chat:stream-finished → stream completed/errored/cancelled
9
+ * - chat:waiting-input → AskUserQuestion requires user input
8
10
  *
9
- * The backend uses ws.emit.projectMembers() to send this event to all
10
- * users who have been associated with the project, even if they switched
11
- * to a different project.
11
+ * The backend uses ws.emit.projectMembers() so notifications reach users
12
+ * even when they are on a different project or session.
12
13
  */
13
14
 
14
15
  import { soundNotification, pushNotification } from '$frontend/lib/services/notification';
@@ -19,27 +20,27 @@ import ws from '$frontend/lib/utils/ws';
19
20
  class GlobalStreamMonitor {
20
21
  private initialized = false;
21
22
 
23
+ /** Track notified AskUserQuestion tool_use IDs to ensure once-only notification */
24
+ private notifiedToolUseIds = new Set<string>();
25
+
22
26
  /**
23
- * Initialize the monitor - subscribes to the WS event.
27
+ * Initialize the monitor - subscribes to WS events.
24
28
  * Safe to call multiple times (idempotent).
25
- *
26
- * Triggers sound/push for ALL projects the user is a member of,
27
- * not just the active one — background streams deserve notifications too.
28
29
  */
29
30
  initialize(): void {
30
31
  if (this.initialized) return;
31
32
  this.initialized = true;
32
33
 
33
- debug.log('notification', 'GlobalStreamMonitor: Initializing WS listener');
34
+ debug.log('notification', 'GlobalStreamMonitor: Initializing WS listeners');
34
35
 
36
+ // Stream finished — notify on completion
35
37
  ws.on('chat:stream-finished', async (data) => {
36
- const { projectId, status, timestamp } = data;
38
+ const { projectId, status, chatSessionId } = data;
39
+
40
+ debug.log('notification', 'GlobalStreamMonitor: Stream finished', { projectId, status });
37
41
 
38
- debug.log('notification', 'GlobalStreamMonitor: Stream finished', {
39
- projectId,
40
- status,
41
- timestamp
42
- });
42
+ // Clean up notified IDs for this session (stream is done)
43
+ this.clearSessionNotifications(chatSessionId);
43
44
 
44
45
  // Play sound notification
45
46
  try {
@@ -63,12 +64,51 @@ class GlobalStreamMonitor {
63
64
  debug.error('notification', 'Error sending push notification:', error);
64
65
  }
65
66
  });
67
+
68
+ // Waiting for input — notify once per AskUserQuestion
69
+ ws.on('chat:waiting-input', async (data) => {
70
+ const { projectId, chatSessionId, toolUseId } = data;
71
+
72
+ // Deduplicate: only notify once per tool_use ID
73
+ if (this.notifiedToolUseIds.has(toolUseId)) return;
74
+ this.notifiedToolUseIds.add(toolUseId);
75
+
76
+ debug.log('notification', 'GlobalStreamMonitor: Waiting for input', { projectId, chatSessionId, toolUseId });
77
+
78
+ // Play sound notification
79
+ try {
80
+ await soundNotification.play();
81
+ } catch (error) {
82
+ debug.error('notification', 'Error playing sound notification:', error);
83
+ }
84
+
85
+ // Send push notification
86
+ try {
87
+ const projectName = projectState.projects.find(p => p.id === projectId)?.name || 'Unknown';
88
+ await pushNotification.sendChatComplete(`Waiting for your input in "${projectName}"`);
89
+ } catch (error) {
90
+ debug.error('notification', 'Error sending push notification:', error);
91
+ }
92
+ });
93
+ }
94
+
95
+ /**
96
+ * Remove tracked tool_use IDs for a finished session
97
+ */
98
+ private clearSessionNotifications(chatSessionId: string): void {
99
+ // Tool IDs are globally unique, so a simple clear-on-stream-finish
100
+ // is enough — they won't collide across sessions.
101
+ // For long-running apps, periodically trim to avoid unbounded growth.
102
+ if (this.notifiedToolUseIds.size > 500) {
103
+ this.notifiedToolUseIds.clear();
104
+ }
66
105
  }
67
106
 
68
107
  /**
69
108
  * Clear state (for cleanup/testing)
70
109
  */
71
110
  clear(): void {
111
+ this.notifiedToolUseIds.clear();
72
112
  debug.log('notification', 'GlobalStreamMonitor: Clearing state');
73
113
  }
74
114
  }