@myrialabs/clopen 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/backend/index.ts +4 -1
  2. package/backend/lib/engine/adapters/opencode/message-converter.ts +53 -2
  3. package/backend/lib/engine/adapters/opencode/stream.ts +89 -5
  4. package/backend/lib/project/status-manager.ts +221 -181
  5. package/frontend/lib/components/chat/message/ChatMessages.svelte +16 -4
  6. package/frontend/lib/components/chat/tools/AgentTool.svelte +12 -11
  7. package/frontend/lib/components/chat/tools/BashOutputTool.svelte +3 -3
  8. package/frontend/lib/components/chat/tools/BashTool.svelte +4 -4
  9. package/frontend/lib/components/chat/tools/CustomMcpTool.svelte +3 -1
  10. package/frontend/lib/components/chat/tools/EditTool.svelte +6 -6
  11. package/frontend/lib/components/chat/tools/ExitPlanModeTool.svelte +1 -1
  12. package/frontend/lib/components/chat/tools/GlobTool.svelte +12 -12
  13. package/frontend/lib/components/chat/tools/GrepTool.svelte +5 -5
  14. package/frontend/lib/components/chat/tools/ListMcpResourcesTool.svelte +1 -1
  15. package/frontend/lib/components/chat/tools/NotebookEditTool.svelte +6 -6
  16. package/frontend/lib/components/chat/tools/ReadMcpResourceTool.svelte +2 -2
  17. package/frontend/lib/components/chat/tools/ReadTool.svelte +4 -4
  18. package/frontend/lib/components/chat/tools/TaskStopTool.svelte +1 -1
  19. package/frontend/lib/components/chat/tools/TaskTool.svelte +1 -1
  20. package/frontend/lib/components/chat/tools/TodoWriteTool.svelte +4 -4
  21. package/frontend/lib/components/chat/tools/WebSearchTool.svelte +1 -1
  22. package/frontend/lib/components/chat/tools/WriteTool.svelte +3 -3
  23. package/frontend/lib/components/chat/tools/components/CodeBlock.svelte +3 -3
  24. package/frontend/lib/components/chat/tools/components/DiffBlock.svelte +2 -2
  25. package/frontend/lib/components/chat/tools/components/FileHeader.svelte +1 -1
  26. package/frontend/lib/components/chat/tools/components/InfoLine.svelte +2 -2
  27. package/frontend/lib/components/chat/tools/components/StatsBadges.svelte +1 -1
  28. package/frontend/lib/components/chat/tools/components/TerminalCommand.svelte +5 -5
  29. package/frontend/lib/components/common/Button.svelte +1 -1
  30. package/frontend/lib/components/common/Card.svelte +3 -3
  31. package/frontend/lib/components/common/Input.svelte +3 -3
  32. package/frontend/lib/components/common/LoadingSpinner.svelte +1 -1
  33. package/frontend/lib/components/common/Select.svelte +6 -6
  34. package/frontend/lib/components/common/Textarea.svelte +3 -3
  35. package/frontend/lib/components/files/FileViewer.svelte +1 -1
  36. package/frontend/lib/components/git/ChangesSection.svelte +2 -4
  37. package/frontend/lib/components/preview/browser/BrowserPreview.svelte +9 -29
  38. package/frontend/lib/components/preview/browser/components/Container.svelte +17 -0
  39. package/frontend/lib/components/preview/browser/components/VirtualCursor.svelte +2 -2
  40. package/frontend/lib/components/settings/appearance/AppearanceSettings.svelte +0 -6
  41. package/frontend/lib/components/settings/appearance/LayoutPresetSettings.svelte +15 -15
  42. package/frontend/lib/components/settings/appearance/LayoutPreview.svelte +2 -2
  43. package/frontend/lib/components/workspace/DesktopNavigator.svelte +380 -383
  44. package/frontend/lib/components/workspace/MobileNavigator.svelte +391 -395
  45. package/frontend/lib/components/workspace/PanelHeader.svelte +115 -4
  46. package/frontend/lib/components/workspace/ViewMenu.svelte +9 -25
  47. package/frontend/lib/components/workspace/layout/split-pane/Layout.svelte +29 -4
  48. package/frontend/lib/services/notification/global-stream-monitor.ts +77 -86
  49. package/frontend/lib/services/project/status.service.ts +160 -159
  50. package/frontend/lib/stores/ui/workspace.svelte.ts +326 -283
  51. package/package.json +1 -1
@@ -1,383 +1,380 @@
1
- <script lang="ts">
2
- import { onMount } from 'svelte';
3
- import { fade } from 'svelte/transition';
4
- import Icon from '$frontend/lib/components/common/Icon.svelte';
5
- import { projectState, setCurrentProject, removeProject } from '$frontend/lib/stores/core/projects.svelte';
6
- import { workspaceState, toggleNavigator } from '$frontend/lib/stores/ui/workspace.svelte';
7
- import { addNotification } from '$frontend/lib/stores/ui/notification.svelte';
8
- import { openSettingsModal } from '$frontend/lib/stores/ui/settings-modal.svelte';
9
- import { projectStatusService } from '$frontend/lib/services/project';
10
- import { presenceState } from '$frontend/lib/stores/core/presence.svelte';
11
- import type { Project } from '$shared/types/database/schema';
12
- import { debug } from '$shared/utils/logger';
13
- import FolderBrowser from '$frontend/lib/components/common/FolderBrowser.svelte';
14
- import Dialog from '$frontend/lib/components/common/Dialog.svelte';
15
- import ViewMenu from '$frontend/lib/components/workspace/ViewMenu.svelte';
16
- import TunnelButton from '$frontend/lib/components/tunnel/TunnelButton.svelte';
17
- import TunnelModal from '$frontend/lib/components/tunnel/TunnelModal.svelte';
18
- import ProjectUserAvatars from '$frontend/lib/components/common/ProjectUserAvatars.svelte';
19
- import { sessionState } from '$frontend/lib/stores/core/sessions.svelte';
20
- import { getSessionProcessState } from '$frontend/lib/stores/core/app.svelte';
21
- import ws from '$frontend/lib/utils/ws';
22
-
23
- // State
24
- let existingProjects = $state<Project[]>([]);
25
- let showFolderBrowser = $state(false);
26
- let showDeleteDialog = $state(false);
27
- let projectToDelete = $state<Project | null>(null);
28
- let searchQuery = $state('');
29
- let showTunnelModal = $state(false);
30
-
31
- // Derived
32
- const isCollapsed = $derived(workspaceState.navigatorCollapsed);
33
- const currentProjectId = $derived(projectState.currentProject?.id);
34
- const navigatorWidth = $derived(
35
- workspaceState.navigatorCollapsed ? 48 : workspaceState.navigatorWidth
36
- );
37
-
38
- const filteredProjects = $derived(() => {
39
- if (!searchQuery.trim()) return existingProjects;
40
- const query = searchQuery.toLowerCase();
41
- return existingProjects.filter(
42
- (p) => p.name.toLowerCase().includes(query) || p.path.toLowerCase().includes(query)
43
- );
44
- });
45
-
46
- // Load projects
47
- async function loadProjects() {
48
- try {
49
- const projects = await ws.http('projects:list', {});
50
- if (Array.isArray(projects)) {
51
- existingProjects = projects;
52
- }
53
- } catch (error) {
54
- debug.error('workspace', 'Failed to load projects:', error);
55
- }
56
- }
57
-
58
- // Select project
59
- async function selectProject(project: Project) {
60
- await setCurrentProject(project);
61
- await projectStatusService.startTracking(project.id);
62
-
63
- // Update last opened (handled by projects:get in setCurrentProject)
64
- }
65
-
66
- // Create project from folder
67
- async function createProjectFromFolder(folderPath: string, folderName: string) {
68
- try {
69
- showFolderBrowser = false;
70
-
71
- // Check if already exists
72
- const existing = existingProjects.find((p) => p.path === folderPath);
73
- if (existing) {
74
- await selectProject(existing);
75
- return;
76
- }
77
-
78
- const newProject = await ws.http('projects:create', { name: folderName, path: folderPath });
79
-
80
- await setCurrentProject(newProject);
81
- await loadProjects();
82
- } catch (error) {
83
- debug.error('workspace', 'Failed to create project:', error);
84
- addNotification({
85
- type: 'error',
86
- title: 'Error',
87
- message: 'Failed to create project',
88
- duration: 5000
89
- });
90
- }
91
- }
92
-
93
- // Delete project
94
- async function confirmDeleteProject() {
95
- if (!projectToDelete) return;
96
- const deleteId = projectToDelete.id!;
97
-
98
- try {
99
- await ws.http('projects:delete', { id: deleteId });
100
- removeProject(deleteId);
101
- existingProjects = existingProjects.filter(p => p.id !== deleteId);
102
- showDeleteDialog = false;
103
- projectToDelete = null;
104
- } catch (error) {
105
- debug.error('workspace', 'Failed to delete project:', error);
106
- addNotification({
107
- type: 'error',
108
- title: 'Error',
109
- message: 'Failed to delete project',
110
- duration: 5000
111
- });
112
- }
113
- }
114
-
115
- // Get status color from presence data (single source of truth)
116
- // Green only when the project is the CURRENT project AND the current chat session has an active stream
117
- function getStatusColor(projectId: string): string {
118
- if (projectId !== currentProjectId) return 'bg-slate-500/30';
119
- const status = presenceState.statuses.get(projectId);
120
- const currentChatSessionId = sessionState.currentSession?.id;
121
- if (!status?.streams || !currentChatSessionId) return 'bg-slate-500/30';
122
- const hasActiveForSession = status.streams.some(
123
- (s: any) => s.status === 'active' && s.chatSessionId === currentChatSessionId
124
- );
125
- if (hasActiveForSession) return currentChatSessionId && getSessionProcessState(currentChatSessionId).isWaitingInput ? 'bg-amber-500' : 'bg-emerald-500';
126
- return 'bg-slate-500/30';
127
- }
128
-
129
- // Close folder browser
130
- function closeFolderBrowser() {
131
- showFolderBrowser = false;
132
- }
133
-
134
- // Close delete dialog
135
- function closeDeleteDialog() {
136
- showDeleteDialog = false;
137
- projectToDelete = null;
138
- }
139
-
140
- // Handle delete button click
141
- function handleDeleteClick(project: Project, event: MouseEvent) {
142
- event.stopPropagation();
143
- projectToDelete = project;
144
- showDeleteDialog = true;
145
- }
146
-
147
- onMount(async () => {
148
- await loadProjects();
149
- });
150
-
151
- // Get project initials (max 2 characters)
152
- function getProjectInitials(name: string): string {
153
- const words = name.trim().split(/[\s-_]+/);
154
- if (words.length >= 2) {
155
- // Multiple words: take first letter of first 2 words
156
- return (words[0][0] + words[1][0]).toUpperCase();
157
- }
158
- // Single word: take first 2 letters
159
- return name.substring(0, 2).toUpperCase();
160
- }
161
- </script>
162
-
163
- <!-- Project Navigator Sidebar -->
164
- <aside
165
- class="shrink-0 h-full bg-white dark:bg-slate-900/95 border-r border-slate-200 dark:border-slate-800 transition-[width] duration-200 z-20"
166
- style="width: {navigatorWidth}px"
167
- aria-label="Project Navigator"
168
- >
169
- <nav
170
- class="flex flex-col h-full bg-slate-50 dark:bg-slate-900/95 transition-all duration-200 {isCollapsed
171
- ? 'items-center'
172
- : ''}"
173
- >
174
- <!-- Header -->
175
- <header
176
- class="flex items-center justify-between p-4 border-b border-slate-200 dark:border-slate-800 {isCollapsed
177
- ? 'justify-center px-2'
178
- : ''}"
179
- >
180
- {#if !isCollapsed}
181
- <div class="flex items-center gap-2.5" in:fade={{ duration: 150 }}>
182
- <img src="/favicon.svg" alt="Clopen" class="w-8 h-8 rounded-lg" />
183
- <span class="text-base font-semibold text-slate-900 dark:text-slate-100">Clopen</span>
184
- </div>
185
- {/if}
186
-
187
- <button
188
- type="button"
189
- class="flex items-center justify-center w-8 h-8 bg-transparent border-none rounded-lg text-slate-500 cursor-pointer transition-all duration-150 hover:bg-violet-500/10 hover:text-slate-900 dark:hover:text-slate-100"
190
- onclick={toggleNavigator}
191
- aria-label={isCollapsed ? 'Expand navigator' : 'Collapse navigator'}
192
- title={isCollapsed ? 'Expand' : 'Collapse'}
193
- >
194
- <Icon
195
- name={isCollapsed ? 'lucide:panel-left-open' : 'lucide:panel-left-close'}
196
- class="w-5 h-5"
197
- />
198
- </button>
199
- </header>
200
-
201
- {#if !isCollapsed}
202
- <!-- Search -->
203
- <div
204
- class="flex items-center gap-2.5 mx-4 my-3 py-2.5 px-3.5 bg-slate-100/80 dark:bg-slate-800/80 border border-slate-200 dark:border-slate-800 rounded-lg"
205
- in:fade={{ duration: 150 }}
206
- >
207
- <Icon name="lucide:search" class="w-4 h-4 text-slate-600 dark:text-slate-500 shrink-0" />
208
- <input
209
- type="text"
210
- bind:value={searchQuery}
211
- placeholder="Search projects..."
212
- class="flex-1 bg-transparent border-none outline-none text-slate-900 dark:text-slate-100 text-sm placeholder:text-slate-600 dark:placeholder:text-slate-500"
213
- />
214
- </div>
215
-
216
- <!-- Projects List -->
217
- <div class="flex-1 flex flex-col min-h-0 px-3" in:fade={{ duration: 150 }}>
218
- <div
219
- class="flex items-center justify-between py-2 px-1 text-xs font-semibold text-slate-600 dark:text-slate-500 uppercase tracking-wider"
220
- >
221
- <span>Projects</span>
222
- <button
223
- type="button"
224
- class="flex items-center justify-center w-6 h-6 bg-transparent border-none rounded-md text-slate-600 dark:text-slate-500 cursor-pointer transition-all duration-150 hover:bg-violet-500/20 hover:text-violet-600"
225
- onclick={() => (showFolderBrowser = true)}
226
- aria-label="Add project"
227
- title="Add project"
228
- >
229
- <Icon name="lucide:plus" class="w-4 h-4" />
230
- </button>
231
- </div>
232
-
233
- <div class="flex-1 overflow-y-auto flex flex-col">
234
- {#each filteredProjects() as project (project.id)}
235
- <div
236
- class="flex items-center gap-2.5 py-2.5 px-3 bg-transparent border-none rounded-lg text-slate-600 dark:text-slate-400 text-sm text-left cursor-pointer transition-all duration-150 relative group
237
- hover:bg-violet-500/10
238
- {currentProjectId === project.id
239
- ? 'bg-violet-500/10 dark:bg-violet-500/20 text-slate-900 dark:text-slate-100'
240
- : ''}"
241
- role="button"
242
- title={project.path}
243
- tabindex="0"
244
- onclick={() => selectProject(project)}
245
- onkeydown={(e) => e.key === 'Enter' && selectProject(project)}
246
- >
247
- <div class="relative shrink-0">
248
- <Icon name="lucide:folder" class="w-4 h-4" />
249
- <span
250
- 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 ?? '')}"
251
- ></span>
252
- </div>
253
-
254
- <div class="flex-1 flex items-center justify-between gap-2 min-w-0">
255
- <div class="flex-1 min-w-0">
256
- <span class="block overflow-hidden text-ellipsis whitespace-nowrap">{project.name}</span>
257
- <span class="block text-3xs text-slate-400 dark:text-slate-500 overflow-hidden text-ellipsis whitespace-nowrap font-mono leading-tight">{project.path}</span>
258
- </div>
259
- <div class="flex items-center gap-1 shrink-0">
260
- <ProjectUserAvatars projectStatus={presenceState.statuses.get(project.id ?? '')} maxVisible={2} />
261
- <button
262
- type="button"
263
- class="flex items-center justify-center w-6 h-6 bg-transparent border-none rounded-md text-slate-400 dark:text-slate-600 cursor-pointer transition-all duration-150 hover:bg-red-500/20 hover:text-red-500 shrink-0"
264
- onclick={(e) => handleDeleteClick(project, e)}
265
- aria-label="Delete project"
266
- title="Delete"
267
- >
268
- <Icon name="lucide:trash-2" class="w-3.5 h-3.5" />
269
- </button>
270
- </div>
271
- </div>
272
- </div>
273
- {:else}
274
- <div
275
- class="flex flex-col items-center gap-3 py-8 px-4 text-slate-600 dark:text-slate-500 text-sm text-center"
276
- >
277
- <Icon name="lucide:folder-plus" class="w-8 h-8 opacity-40" />
278
- <span>No projects yet</span>
279
- <button
280
- type="button"
281
- class="py-2 px-4 bg-violet-500/10 dark:bg-violet-500/15 border border-violet-500/20 dark:border-violet-500/30 rounded-lg text-violet-600 text-xs font-medium cursor-pointer transition-all duration-150 hover:bg-violet-500/20 dark:hover:bg-violet-500/25"
282
- onclick={() => (showFolderBrowser = true)}
283
- >
284
- Add your first project
285
- </button>
286
- </div>
287
- {/each}
288
- </div>
289
- </div>
290
-
291
- <!-- Footer Actions -->
292
- <footer class="flex flex-col p-3 border-t border-slate-200 dark:border-slate-800" in:fade={{ duration: 150 }}>
293
- <ViewMenu />
294
- <TunnelButton onClick={() => (showTunnelModal = true)} />
295
-
296
- <button
297
- type="button"
298
- class="flex items-center gap-2.5 w-full py-2.5 px-3 bg-transparent border-none rounded-lg text-slate-500 text-sm cursor-pointer transition-all duration-150 hover:bg-violet-500/10 hover:text-slate-900 dark:hover:text-slate-100"
299
- onclick={() => openSettingsModal()}
300
- >
301
- <Icon name="lucide:settings" class="w-4 h-4" />
302
- <span>Settings</span>
303
- </button>
304
- </footer>
305
- {:else}
306
- <!-- Collapsed State: Icon Buttons -->
307
- <div class="flex-1 flex flex-col items-center gap-2 py-4 px-2">
308
- <button
309
- type="button"
310
- 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"
311
- onclick={() => (showFolderBrowser = true)}
312
- title="Add Project"
313
- >
314
- <Icon name="lucide:folder-plus" class="w-5 h-5" />
315
- </button>
316
-
317
- <div class="w-6 h-px bg-violet-500/10 my-1"></div>
318
-
319
- {#each existingProjects.slice(0, 5) as project (project.id)}
320
- {@const projectStatus = presenceState.statuses.get(project.id ?? '')}
321
- {@const activeUserCount = (projectStatus?.activeUsers || []).length}
322
- <button
323
- type="button"
324
- 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
325
- {currentProjectId === project.id
326
- ? 'bg-violet-500/10 dark:bg-violet-500/20 text-violet-700 dark:text-violet-300'
327
- : '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'}"
328
- onclick={() => selectProject(project)}
329
- title={project.name}
330
- >
331
- <span>{getProjectInitials(project.name)}</span>
332
- <span
333
- 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 ?? '')}"
334
- ></span>
335
- {#if activeUserCount > 0}
336
- <span
337
- class="absolute -top-1 -right-1 min-w-4 h-4 px-0.5 rounded-full bg-violet-500 text-white text-3xs font-bold flex items-center justify-center border-2 border-slate-50 dark:border-slate-900/95"
338
- >
339
- {activeUserCount}
340
- </span>
341
- {/if}
342
- </button>
343
- {/each}
344
- </div>
345
-
346
- <footer class="flex flex-col gap-2 py-3 px-2 border-t border-slate-200 dark:border-slate-800">
347
- <ViewMenu collapsed={true} />
348
- <TunnelButton collapsed={true} onClick={() => (showTunnelModal = true)} />
349
-
350
- <button
351
- type="button"
352
- 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"
353
- onclick={() => openSettingsModal()}
354
- title="Settings"
355
- >
356
- <Icon name="lucide:settings" class="w-5 h-5" />
357
- </button>
358
- </footer>
359
- {/if}
360
- </nav>
361
- </aside>
362
-
363
- <!-- Folder Browser (includes its own Modal) -->
364
- <FolderBrowser
365
- bind:isOpen={showFolderBrowser}
366
- onClose={closeFolderBrowser}
367
- onSelect={createProjectFromFolder}
368
- />
369
-
370
- <!-- Delete Confirmation Dialog -->
371
- <Dialog
372
- bind:isOpen={showDeleteDialog}
373
- onClose={closeDeleteDialog}
374
- type="error"
375
- title="Delete Project"
376
- message='This will remove "{projectToDelete?.name}" from your project list. The actual project files on disk will not be deleted.'
377
- confirmText="Delete"
378
- cancelText="Cancel"
379
- onConfirm={confirmDeleteProject}
380
- />
381
-
382
- <!-- Tunnel Modal -->
383
- <TunnelModal bind:isOpen={showTunnelModal} onClose={() => (showTunnelModal = false)} />
1
+ <script lang="ts">
2
+ import { onMount } from 'svelte';
3
+ import { fade } from 'svelte/transition';
4
+ import Icon from '$frontend/lib/components/common/Icon.svelte';
5
+ import { projectState, setCurrentProject, removeProject } from '$frontend/lib/stores/core/projects.svelte';
6
+ import { workspaceState, toggleNavigator } from '$frontend/lib/stores/ui/workspace.svelte';
7
+ import { addNotification } from '$frontend/lib/stores/ui/notification.svelte';
8
+ import { openSettingsModal } from '$frontend/lib/stores/ui/settings-modal.svelte';
9
+ import { projectStatusService } from '$frontend/lib/services/project';
10
+ import { presenceState } from '$frontend/lib/stores/core/presence.svelte';
11
+ import type { Project } from '$shared/types/database/schema';
12
+ import { debug } from '$shared/utils/logger';
13
+ import FolderBrowser from '$frontend/lib/components/common/FolderBrowser.svelte';
14
+ import Dialog from '$frontend/lib/components/common/Dialog.svelte';
15
+ import ViewMenu from '$frontend/lib/components/workspace/ViewMenu.svelte';
16
+ import TunnelButton from '$frontend/lib/components/tunnel/TunnelButton.svelte';
17
+ import TunnelModal from '$frontend/lib/components/tunnel/TunnelModal.svelte';
18
+ import ProjectUserAvatars from '$frontend/lib/components/common/ProjectUserAvatars.svelte';
19
+ import ws from '$frontend/lib/utils/ws';
20
+
21
+ // State
22
+ let existingProjects = $state<Project[]>([]);
23
+ let showFolderBrowser = $state(false);
24
+ let showDeleteDialog = $state(false);
25
+ let projectToDelete = $state<Project | null>(null);
26
+ let searchQuery = $state('');
27
+ let showTunnelModal = $state(false);
28
+
29
+ // Derived
30
+ const isCollapsed = $derived(workspaceState.navigatorCollapsed);
31
+ const currentProjectId = $derived(projectState.currentProject?.id);
32
+ const navigatorWidth = $derived(
33
+ workspaceState.navigatorCollapsed ? 48 : workspaceState.navigatorWidth
34
+ );
35
+
36
+ const filteredProjects = $derived(() => {
37
+ if (!searchQuery.trim()) return existingProjects;
38
+ const query = searchQuery.toLowerCase();
39
+ return existingProjects.filter(
40
+ (p) => p.name.toLowerCase().includes(query) || p.path.toLowerCase().includes(query)
41
+ );
42
+ });
43
+
44
+ // Load projects
45
+ async function loadProjects() {
46
+ try {
47
+ const projects = await ws.http('projects:list', {});
48
+ if (Array.isArray(projects)) {
49
+ existingProjects = projects;
50
+ }
51
+ } catch (error) {
52
+ debug.error('workspace', 'Failed to load projects:', error);
53
+ }
54
+ }
55
+
56
+ // Select project
57
+ async function selectProject(project: Project) {
58
+ await setCurrentProject(project);
59
+ await projectStatusService.startTracking(project.id);
60
+
61
+ // Update last opened (handled by projects:get in setCurrentProject)
62
+ }
63
+
64
+ // Create project from folder
65
+ async function createProjectFromFolder(folderPath: string, folderName: string) {
66
+ try {
67
+ showFolderBrowser = false;
68
+
69
+ // Check if already exists
70
+ const existing = existingProjects.find((p) => p.path === folderPath);
71
+ if (existing) {
72
+ await selectProject(existing);
73
+ return;
74
+ }
75
+
76
+ const newProject = await ws.http('projects:create', { name: folderName, path: folderPath });
77
+
78
+ await setCurrentProject(newProject);
79
+ await loadProjects();
80
+ } catch (error) {
81
+ debug.error('workspace', 'Failed to create project:', error);
82
+ addNotification({
83
+ type: 'error',
84
+ title: 'Error',
85
+ message: 'Failed to create project',
86
+ duration: 5000
87
+ });
88
+ }
89
+ }
90
+
91
+ // Delete project
92
+ async function confirmDeleteProject() {
93
+ if (!projectToDelete) return;
94
+ const deleteId = projectToDelete.id!;
95
+
96
+ try {
97
+ await ws.http('projects:delete', { id: deleteId });
98
+ removeProject(deleteId);
99
+ existingProjects = existingProjects.filter(p => p.id !== deleteId);
100
+ showDeleteDialog = false;
101
+ projectToDelete = null;
102
+ } catch (error) {
103
+ debug.error('workspace', 'Failed to delete project:', error);
104
+ addNotification({
105
+ type: 'error',
106
+ title: 'Error',
107
+ message: 'Failed to delete project',
108
+ duration: 5000
109
+ });
110
+ }
111
+ }
112
+
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
+ }
125
+
126
+ // Close folder browser
127
+ function closeFolderBrowser() {
128
+ showFolderBrowser = false;
129
+ }
130
+
131
+ // Close delete dialog
132
+ function closeDeleteDialog() {
133
+ showDeleteDialog = false;
134
+ projectToDelete = null;
135
+ }
136
+
137
+ // Handle delete button click
138
+ function handleDeleteClick(project: Project, event: MouseEvent) {
139
+ event.stopPropagation();
140
+ projectToDelete = project;
141
+ showDeleteDialog = true;
142
+ }
143
+
144
+ onMount(async () => {
145
+ await loadProjects();
146
+ });
147
+
148
+ // Get project initials (max 2 characters)
149
+ function getProjectInitials(name: string): string {
150
+ const words = name.trim().split(/[\s-_]+/);
151
+ if (words.length >= 2) {
152
+ // Multiple words: take first letter of first 2 words
153
+ return (words[0][0] + words[1][0]).toUpperCase();
154
+ }
155
+ // Single word: take first 2 letters
156
+ return name.substring(0, 2).toUpperCase();
157
+ }
158
+ </script>
159
+
160
+ <!-- Project Navigator Sidebar -->
161
+ <aside
162
+ class="shrink-0 h-full bg-white dark:bg-slate-900/95 border-r border-slate-200 dark:border-slate-800 transition-[width] duration-200 z-20"
163
+ style="width: {navigatorWidth}px"
164
+ aria-label="Project Navigator"
165
+ >
166
+ <nav
167
+ class="flex flex-col h-full bg-slate-50 dark:bg-slate-900/95 transition-all duration-200 {isCollapsed
168
+ ? 'items-center'
169
+ : ''}"
170
+ >
171
+ <!-- Header -->
172
+ <header
173
+ class="flex items-center justify-between p-4 border-b border-slate-200 dark:border-slate-800 {isCollapsed
174
+ ? 'justify-center px-2'
175
+ : ''}"
176
+ >
177
+ {#if !isCollapsed}
178
+ <div class="flex items-center gap-2.5" in:fade={{ duration: 150 }}>
179
+ <img src="/favicon.svg" alt="Clopen" class="w-8 h-8 rounded-lg" />
180
+ <span class="text-base font-semibold text-slate-900 dark:text-slate-100">Clopen</span>
181
+ </div>
182
+ {/if}
183
+
184
+ <button
185
+ type="button"
186
+ class="flex items-center justify-center w-8 h-8 bg-transparent border-none rounded-lg text-slate-500 cursor-pointer transition-all duration-150 hover:bg-violet-500/10 hover:text-slate-900 dark:hover:text-slate-100"
187
+ onclick={toggleNavigator}
188
+ aria-label={isCollapsed ? 'Expand navigator' : 'Collapse navigator'}
189
+ title={isCollapsed ? 'Expand' : 'Collapse'}
190
+ >
191
+ <Icon
192
+ name={isCollapsed ? 'lucide:panel-left-open' : 'lucide:panel-left-close'}
193
+ class="w-5 h-5"
194
+ />
195
+ </button>
196
+ </header>
197
+
198
+ {#if !isCollapsed}
199
+ <!-- Search -->
200
+ <div
201
+ class="flex items-center gap-2.5 mx-4 my-3 py-2.5 px-3.5 bg-slate-100/80 dark:bg-slate-800/80 border border-slate-200 dark:border-slate-800 rounded-lg"
202
+ in:fade={{ duration: 150 }}
203
+ >
204
+ <Icon name="lucide:search" class="w-4 h-4 text-slate-600 dark:text-slate-500 shrink-0" />
205
+ <input
206
+ type="text"
207
+ bind:value={searchQuery}
208
+ placeholder="Search projects..."
209
+ class="flex-1 bg-transparent border-none outline-none text-slate-900 dark:text-slate-100 text-sm placeholder:text-slate-600 dark:placeholder:text-slate-500"
210
+ />
211
+ </div>
212
+
213
+ <!-- Projects List -->
214
+ <div class="flex-1 flex flex-col min-h-0 px-3" in:fade={{ duration: 150 }}>
215
+ <div
216
+ class="flex items-center justify-between py-2 px-1 text-xs font-semibold text-slate-600 dark:text-slate-500 uppercase tracking-wider"
217
+ >
218
+ <span>Projects</span>
219
+ <button
220
+ type="button"
221
+ class="flex items-center justify-center w-6 h-6 bg-transparent border-none rounded-md text-slate-600 dark:text-slate-500 cursor-pointer transition-all duration-150 hover:bg-violet-500/20 hover:text-violet-600"
222
+ onclick={() => (showFolderBrowser = true)}
223
+ aria-label="Add project"
224
+ title="Add project"
225
+ >
226
+ <Icon name="lucide:plus" class="w-4 h-4" />
227
+ </button>
228
+ </div>
229
+
230
+ <div class="flex-1 overflow-y-auto flex flex-col">
231
+ {#each filteredProjects() as project (project.id)}
232
+ <div
233
+ class="flex items-center gap-2.5 py-2.5 px-3 bg-transparent border-none rounded-lg text-slate-600 dark:text-slate-400 text-sm text-left cursor-pointer transition-all duration-150 relative group
234
+ hover:bg-violet-500/10
235
+ {currentProjectId === project.id
236
+ ? 'bg-violet-500/10 dark:bg-violet-500/20 text-slate-900 dark:text-slate-100'
237
+ : ''}"
238
+ role="button"
239
+ title={project.path}
240
+ tabindex="0"
241
+ onclick={() => selectProject(project)}
242
+ onkeydown={(e) => e.key === 'Enter' && selectProject(project)}
243
+ >
244
+ <div class="relative shrink-0">
245
+ <Icon name="lucide:folder" class="w-4 h-4" />
246
+ <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 ?? '')}"
248
+ ></span>
249
+ </div>
250
+
251
+ <div class="flex-1 flex items-center justify-between gap-2 min-w-0">
252
+ <div class="flex-1 min-w-0">
253
+ <span class="block overflow-hidden text-ellipsis whitespace-nowrap">{project.name}</span>
254
+ <span class="block text-3xs text-slate-400 dark:text-slate-500 overflow-hidden text-ellipsis whitespace-nowrap font-mono leading-tight">{project.path}</span>
255
+ </div>
256
+ <div class="flex items-center gap-1 shrink-0">
257
+ <ProjectUserAvatars projectStatus={presenceState.statuses.get(project.id ?? '')} maxVisible={2} />
258
+ <button
259
+ type="button"
260
+ class="flex items-center justify-center w-6 h-6 bg-transparent border-none rounded-md text-slate-400 dark:text-slate-600 cursor-pointer transition-all duration-150 hover:bg-red-500/20 hover:text-red-500 shrink-0"
261
+ onclick={(e) => handleDeleteClick(project, e)}
262
+ aria-label="Delete project"
263
+ title="Delete"
264
+ >
265
+ <Icon name="lucide:trash-2" class="w-3.5 h-3.5" />
266
+ </button>
267
+ </div>
268
+ </div>
269
+ </div>
270
+ {:else}
271
+ <div
272
+ class="flex flex-col items-center gap-3 py-8 px-4 text-slate-600 dark:text-slate-500 text-sm text-center"
273
+ >
274
+ <Icon name="lucide:folder-plus" class="w-8 h-8 opacity-40" />
275
+ <span>No projects yet</span>
276
+ <button
277
+ type="button"
278
+ class="py-2 px-4 bg-violet-500/10 dark:bg-violet-500/15 border border-violet-500/20 dark:border-violet-500/30 rounded-lg text-violet-600 text-xs font-medium cursor-pointer transition-all duration-150 hover:bg-violet-500/20 dark:hover:bg-violet-500/25"
279
+ onclick={() => (showFolderBrowser = true)}
280
+ >
281
+ Add your first project
282
+ </button>
283
+ </div>
284
+ {/each}
285
+ </div>
286
+ </div>
287
+
288
+ <!-- Footer Actions -->
289
+ <footer class="flex flex-col p-3 border-t border-slate-200 dark:border-slate-800" in:fade={{ duration: 150 }}>
290
+ <ViewMenu />
291
+ <TunnelButton onClick={() => (showTunnelModal = true)} />
292
+
293
+ <button
294
+ type="button"
295
+ class="flex items-center gap-2.5 w-full py-2.5 px-3 bg-transparent border-none rounded-lg text-slate-500 text-sm cursor-pointer transition-all duration-150 hover:bg-violet-500/10 hover:text-slate-900 dark:hover:text-slate-100"
296
+ onclick={() => openSettingsModal()}
297
+ >
298
+ <Icon name="lucide:settings" class="w-4 h-4" />
299
+ <span>Settings</span>
300
+ </button>
301
+ </footer>
302
+ {:else}
303
+ <!-- Collapsed State: Icon Buttons -->
304
+ <div class="flex-1 flex flex-col items-center gap-2 py-4 px-2">
305
+ <button
306
+ type="button"
307
+ 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"
308
+ onclick={() => (showFolderBrowser = true)}
309
+ title="Add Project"
310
+ >
311
+ <Icon name="lucide:folder-plus" class="w-5 h-5" />
312
+ </button>
313
+
314
+ <div class="w-6 h-px bg-violet-500/10 my-1"></div>
315
+
316
+ {#each existingProjects.slice(0, 5) as project (project.id)}
317
+ {@const projectStatus = presenceState.statuses.get(project.id ?? '')}
318
+ {@const activeUserCount = (projectStatus?.activeUsers || []).length}
319
+ <button
320
+ 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
322
+ {currentProjectId === project.id
323
+ ? 'bg-violet-500/10 dark:bg-violet-500/20 text-violet-700 dark:text-violet-300'
324
+ : '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'}"
325
+ onclick={() => selectProject(project)}
326
+ title={project.name}
327
+ >
328
+ <span>{getProjectInitials(project.name)}</span>
329
+ <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 ?? '')}"
331
+ ></span>
332
+ {#if activeUserCount > 0}
333
+ <span
334
+ class="absolute -top-1 -right-1 min-w-4 h-4 px-0.5 rounded-full bg-violet-500 text-white text-3xs font-bold flex items-center justify-center border-2 border-slate-50 dark:border-slate-900/95"
335
+ >
336
+ {activeUserCount}
337
+ </span>
338
+ {/if}
339
+ </button>
340
+ {/each}
341
+ </div>
342
+
343
+ <footer class="flex flex-col gap-2 py-3 px-2 border-t border-slate-200 dark:border-slate-800">
344
+ <ViewMenu collapsed={true} />
345
+ <TunnelButton collapsed={true} onClick={() => (showTunnelModal = true)} />
346
+
347
+ <button
348
+ type="button"
349
+ 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"
350
+ onclick={() => openSettingsModal()}
351
+ title="Settings"
352
+ >
353
+ <Icon name="lucide:settings" class="w-5 h-5" />
354
+ </button>
355
+ </footer>
356
+ {/if}
357
+ </nav>
358
+ </aside>
359
+
360
+ <!-- Folder Browser (includes its own Modal) -->
361
+ <FolderBrowser
362
+ bind:isOpen={showFolderBrowser}
363
+ onClose={closeFolderBrowser}
364
+ onSelect={createProjectFromFolder}
365
+ />
366
+
367
+ <!-- Delete Confirmation Dialog -->
368
+ <Dialog
369
+ bind:isOpen={showDeleteDialog}
370
+ onClose={closeDeleteDialog}
371
+ type="error"
372
+ title="Delete Project"
373
+ message='This will remove "{projectToDelete?.name}" from your project list. The actual project files on disk will not be deleted.'
374
+ confirmText="Delete"
375
+ cancelText="Cancel"
376
+ onConfirm={confirmDeleteProject}
377
+ />
378
+
379
+ <!-- Tunnel Modal -->
380
+ <TunnelModal bind:isOpen={showTunnelModal} onClose={() => (showTunnelModal = false)} />