@myrialabs/clopen 0.1.2 → 0.1.4

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 (63) hide show
  1. package/CONTRIBUTING.md +40 -355
  2. package/README.md +46 -113
  3. package/backend/lib/engine/adapters/opencode/message-converter.ts +53 -2
  4. package/backend/lib/engine/adapters/opencode/stream.ts +89 -5
  5. package/backend/lib/mcp/config.ts +7 -3
  6. package/backend/lib/mcp/servers/helper.ts +25 -14
  7. package/backend/lib/mcp/servers/index.ts +7 -2
  8. package/backend/lib/project/status-manager.ts +221 -181
  9. package/frontend/lib/components/chat/ChatInterface.svelte +7 -0
  10. package/frontend/lib/components/chat/input/components/EngineModelPicker.svelte +16 -9
  11. package/frontend/lib/components/chat/message/ChatMessages.svelte +16 -4
  12. package/frontend/lib/components/chat/tools/AgentTool.svelte +12 -11
  13. package/frontend/lib/components/chat/tools/BashOutputTool.svelte +3 -3
  14. package/frontend/lib/components/chat/tools/BashTool.svelte +4 -4
  15. package/frontend/lib/components/chat/tools/CustomMcpTool.svelte +3 -1
  16. package/frontend/lib/components/chat/tools/EditTool.svelte +6 -6
  17. package/frontend/lib/components/chat/tools/ExitPlanModeTool.svelte +1 -1
  18. package/frontend/lib/components/chat/tools/GlobTool.svelte +12 -12
  19. package/frontend/lib/components/chat/tools/GrepTool.svelte +5 -5
  20. package/frontend/lib/components/chat/tools/ListMcpResourcesTool.svelte +1 -1
  21. package/frontend/lib/components/chat/tools/NotebookEditTool.svelte +6 -6
  22. package/frontend/lib/components/chat/tools/ReadMcpResourceTool.svelte +2 -2
  23. package/frontend/lib/components/chat/tools/ReadTool.svelte +4 -4
  24. package/frontend/lib/components/chat/tools/TaskStopTool.svelte +1 -1
  25. package/frontend/lib/components/chat/tools/TaskTool.svelte +1 -1
  26. package/frontend/lib/components/chat/tools/TodoWriteTool.svelte +4 -4
  27. package/frontend/lib/components/chat/tools/WebSearchTool.svelte +1 -1
  28. package/frontend/lib/components/chat/tools/WriteTool.svelte +3 -3
  29. package/frontend/lib/components/chat/tools/components/CodeBlock.svelte +3 -3
  30. package/frontend/lib/components/chat/tools/components/DiffBlock.svelte +2 -2
  31. package/frontend/lib/components/chat/tools/components/FileHeader.svelte +1 -1
  32. package/frontend/lib/components/chat/tools/components/InfoLine.svelte +2 -2
  33. package/frontend/lib/components/chat/tools/components/StatsBadges.svelte +1 -1
  34. package/frontend/lib/components/chat/tools/components/TerminalCommand.svelte +5 -5
  35. package/frontend/lib/components/common/Button.svelte +1 -1
  36. package/frontend/lib/components/common/Card.svelte +3 -3
  37. package/frontend/lib/components/common/Input.svelte +3 -3
  38. package/frontend/lib/components/common/LoadingSpinner.svelte +1 -1
  39. package/frontend/lib/components/common/Select.svelte +6 -6
  40. package/frontend/lib/components/common/Textarea.svelte +3 -3
  41. package/frontend/lib/components/files/FileViewer.svelte +1 -1
  42. package/frontend/lib/components/git/ChangesSection.svelte +2 -4
  43. package/frontend/lib/components/preview/browser/BrowserPreview.svelte +9 -29
  44. package/frontend/lib/components/preview/browser/components/Container.svelte +17 -0
  45. package/frontend/lib/components/preview/browser/components/VirtualCursor.svelte +2 -2
  46. package/frontend/lib/components/settings/appearance/AppearanceSettings.svelte +0 -6
  47. package/frontend/lib/components/settings/appearance/LayoutPresetSettings.svelte +15 -15
  48. package/frontend/lib/components/settings/appearance/LayoutPreview.svelte +2 -2
  49. package/frontend/lib/components/settings/engines/AIEnginesSettings.svelte +1 -1
  50. package/frontend/lib/components/workspace/DesktopNavigator.svelte +380 -383
  51. package/frontend/lib/components/workspace/MobileNavigator.svelte +391 -395
  52. package/frontend/lib/components/workspace/PanelHeader.svelte +623 -505
  53. package/frontend/lib/components/workspace/ViewMenu.svelte +9 -25
  54. package/frontend/lib/components/workspace/layout/split-pane/Layout.svelte +29 -4
  55. package/frontend/lib/components/workspace/panels/ChatPanel.svelte +3 -2
  56. package/frontend/lib/services/notification/global-stream-monitor.ts +77 -86
  57. package/frontend/lib/services/project/status.service.ts +160 -159
  58. package/frontend/lib/stores/ui/workspace.svelte.ts +326 -283
  59. package/package.json +1 -1
  60. package/scripts/pre-publish-check.sh +0 -142
  61. package/scripts/setup-hooks.sh +0 -134
  62. package/scripts/validate-branch-name.sh +0 -47
  63. package/scripts/validate-commit-msg.sh +0 -42
@@ -1,395 +1,391 @@
1
- <script lang="ts">
2
- import Icon from '$frontend/lib/components/common/Icon.svelte';
3
- import Modal from '$frontend/lib/components/common/Modal.svelte';
4
- import Dialog from '$frontend/lib/components/common/Dialog.svelte';
5
- import {
6
- workspaceState,
7
- setActiveMobilePanel,
8
- type PanelId
9
- } from '$frontend/lib/stores/ui/workspace.svelte';
10
- import { projectState, removeProject } from '$frontend/lib/stores/core/projects.svelte';
11
- import { presenceState } from '$frontend/lib/stores/core/presence.svelte';
12
- import { openSettingsModal } from '$frontend/lib/stores/ui/settings-modal.svelte';
13
- import { addNotification } from '$frontend/lib/stores/ui/notification.svelte';
14
- import type { IconName } from '$shared/types/ui/icons';
15
- import TunnelButton from '$frontend/lib/components/tunnel/TunnelButton.svelte';
16
- import TunnelModal from '$frontend/lib/components/tunnel/TunnelModal.svelte';
17
- import type { Project } from '$shared/types/database/schema';
18
- import FolderBrowser from '$frontend/lib/components/common/FolderBrowser.svelte';
19
- import ProjectUserAvatars from '$frontend/lib/components/common/ProjectUserAvatars.svelte';
20
- import { sessionState } from '$frontend/lib/stores/core/sessions.svelte';
21
- import { getSessionProcessState } from '$frontend/lib/stores/core/app.svelte';
22
- import ws from '$frontend/lib/utils/ws';
23
- import { debug } from '$shared/utils/logger';
24
-
25
- // Modal states
26
- let showTunnelModal = $state(false);
27
-
28
- // Project dropdown state
29
- let showProjectMenu = $state(false);
30
- let showFolderBrowser = $state(false);
31
- let showDeleteDialog = $state(false);
32
- let projectToDelete = $state<Project | null>(null);
33
- let searchQuery = $state('');
34
-
35
- // Get current project status from shared store
36
- const currentProjectStatus = $derived(
37
- projectState.currentProject?.id
38
- ? presenceState.statuses.get(projectState.currentProject.id)
39
- : undefined
40
- );
41
-
42
- const panels: { id: PanelId; icon: IconName; label: string }[] = [
43
- { id: 'chat', icon: 'lucide:bot', label: 'AI' },
44
- { id: 'files', icon: 'lucide:folder', label: 'Files' },
45
- { id: 'git', icon: 'lucide:git-branch', label: 'Source Control' },
46
- { id: 'terminal', icon: 'lucide:terminal', label: 'Terminal' },
47
- { id: 'preview', icon: 'lucide:globe', label: 'Preview' }
48
- ];
49
-
50
- // Filtered projects based on search query
51
- const filteredProjects = $derived(() => {
52
- if (!searchQuery.trim()) return projectState.projects;
53
- const query = searchQuery.toLowerCase();
54
- return projectState.projects.filter(
55
- (p) => p.name.toLowerCase().includes(query) || p.path.toLowerCase().includes(query)
56
- );
57
- });
58
-
59
- function selectPanel(panelId: PanelId) {
60
- setActiveMobilePanel(panelId);
61
- }
62
-
63
- function toggleProjectMenu() {
64
- showProjectMenu = !showProjectMenu;
65
- if (!showProjectMenu) {
66
- searchQuery = '';
67
- }
68
- }
69
-
70
- // Get status color from presence data (single source of truth)
71
- // Green only when the project is the CURRENT project AND the current chat session has an active stream
72
- function getStatusColor(projectId: string): string {
73
- const currentProjectId = projectState.currentProject?.id;
74
- if (projectId !== currentProjectId) return 'bg-slate-500/30';
75
- const status = presenceState.statuses.get(projectId);
76
- const currentChatSessionId = sessionState.currentSession?.id;
77
- if (!status?.streams || !currentChatSessionId) return 'bg-slate-500/30';
78
- const hasActiveForSession = status.streams.some(
79
- (s: any) => s.status === 'active' && s.chatSessionId === currentChatSessionId
80
- );
81
- if (hasActiveForSession) return currentChatSessionId && getSessionProcessState(currentChatSessionId).isWaitingInput ? 'bg-amber-500' : 'bg-emerald-500';
82
- return 'bg-slate-500/30';
83
- }
84
-
85
- function openAddProject() {
86
- showProjectMenu = false;
87
- showFolderBrowser = true;
88
- }
89
-
90
- function closeFolderBrowser() {
91
- showFolderBrowser = false;
92
- }
93
-
94
- function closeProjectMenu() {
95
- showProjectMenu = false;
96
- searchQuery = '';
97
- }
98
-
99
- function handleDeleteClick(project: Project, event: MouseEvent) {
100
- event.stopPropagation();
101
- projectToDelete = project;
102
- showDeleteDialog = true;
103
- }
104
-
105
- async function confirmDeleteProject() {
106
- if (!projectToDelete) return;
107
- const deleteId = projectToDelete.id!;
108
-
109
- try {
110
- await ws.http('projects:delete', { id: deleteId });
111
- removeProject(deleteId);
112
- showDeleteDialog = false;
113
- projectToDelete = null;
114
- } catch (error) {
115
- debug.error('workspace', 'Failed to delete project:', error);
116
- addNotification({
117
- type: 'error',
118
- title: 'Error',
119
- message: 'Failed to delete project',
120
- duration: 5000
121
- });
122
- }
123
- }
124
-
125
- function closeDeleteDialog() {
126
- showDeleteDialog = false;
127
- projectToDelete = null;
128
- }
129
-
130
- async function createProjectFromFolder(folderPath: string, folderName: string) {
131
- try {
132
- showFolderBrowser = false;
133
-
134
- const projects = await ws.http('projects:list', {});
135
-
136
- const existingProject = projects
137
- ? projects.find((p: any) => p.path === folderPath)
138
- : null;
139
-
140
- if (existingProject) {
141
- const { setCurrentProject } = await import('$frontend/lib/stores/core/projects.svelte');
142
- setCurrentProject(existingProject);
143
- return;
144
- }
145
-
146
- const newProject = await ws.http('projects:create', { name: folderName, path: folderPath });
147
-
148
- if (newProject) {
149
- const { setCurrentProject } = await import('$frontend/lib/stores/core/projects.svelte');
150
- setCurrentProject(newProject);
151
- }
152
- } catch (error) {
153
- console.error('Failed to create project:', error);
154
- }
155
- }
156
- </script>
157
-
158
- <header
159
- class="flex items-center bg-white/90 dark:bg-slate-900/98 py-2 px-3 gap-2 relative z-30"
160
- >
161
- <!-- Project Selector -->
162
- <button
163
- type="button"
164
- class="flex items-center gap-2 px-3 py-2.5 bg-slate-100/80 dark:bg-slate-800/80 border border-slate-200 dark:border-slate-800 rounded-lg text-slate-900 dark:text-slate-100 text-sm font-medium cursor-pointer transition-all duration-150 flex-1 min-w-0 active:bg-violet-500/10"
165
- onclick={toggleProjectMenu}
166
- aria-expanded={showProjectMenu}
167
- aria-haspopup="menu"
168
- >
169
- <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>
170
- <span class="flex-1 text-left overflow-hidden text-ellipsis whitespace-nowrap">
171
- {projectState.currentProject?.name ?? 'No Project'}
172
- </span>
173
- <div class="shrink-0" onclick={(e) => e.stopPropagation()}>
174
- <ProjectUserAvatars projectStatus={currentProjectStatus} maxVisible={2} />
175
- </div>
176
- <Icon name="lucide:chevron-down" class="w-3 h-3 opacity-60 shrink-0" />
177
- </button>
178
-
179
- <!-- Panel Tabs (Icon Only) -->
180
- <div
181
- class="flex gap-1 bg-slate-100/80 dark:bg-slate-800/50 p-1 border border-slate-200 dark:border-slate-800 rounded-lg"
182
- role="tablist"
183
- aria-label="Panel Tabs"
184
- >
185
- {#each panels as panel}
186
- <button
187
- type="button"
188
- class="flex items-center justify-center w-9 h-8 bg-transparent border-none rounded-md text-slate-500 cursor-pointer transition-all duration-150
189
- {workspaceState.activeMobilePanel === panel.id
190
- ? 'bg-violet-500/10 dark:bg-violet-500/20 text-slate-900 dark:text-slate-100 shadow-violet-500/20'
191
- : 'active:bg-violet-500/10'}"
192
- role="tab"
193
- aria-selected={workspaceState.activeMobilePanel === panel.id}
194
- aria-controls={`panel-${panel.id}`}
195
- aria-label={panel.label}
196
- title={panel.label}
197
- onclick={() => selectPanel(panel.id)}
198
- >
199
- <Icon name={panel.icon} class="w-5 h-5" />
200
- </button>
201
- {/each}
202
- </div>
203
-
204
- <!-- Action Buttons -->
205
- <div
206
- class="flex gap-1 bg-slate-100/80 dark:bg-slate-800/50 p-1 border border-slate-200 dark:border-slate-800 rounded-lg"
207
- role="tablist"
208
- aria-label="Action Buttons"
209
- >
210
- <!-- Tunnel Button -->
211
- <TunnelButton collapsed={true} onClick={() => (showTunnelModal = true)} mobile={true} />
212
-
213
- <!-- Settings Button -->
214
- <button
215
- type="button"
216
- class="flex items-center justify-center w-9 h-8 bg-transparent border-none rounded-md text-slate-500 cursor-pointer transition-all duration-150 active:bg-violet-500/10"
217
- role="tab"
218
- onclick={() => openSettingsModal()}
219
- aria-label="Settings"
220
- title="Settings"
221
- >
222
- <Icon name="lucide:settings" class="w-5 h-5" />
223
- </button>
224
- </div>
225
- </header>
226
-
227
- <!-- Project Selection Modal -->
228
- <Modal bind:isOpen={showProjectMenu} onClose={closeProjectMenu} size="md">
229
- {#snippet header()}
230
- <div class="flex items-center justify-between px-4 py-3 md:px-6 md:py-4">
231
- <h2 class="text-base md:text-lg font-bold text-slate-900 dark:text-slate-100">Projects</h2>
232
- <div class="flex items-center gap-2">
233
- <button
234
- type="button"
235
- class="flex items-center justify-center w-8 h-8 bg-violet-500/10 dark:bg-violet-500/15 border border-violet-500/20 rounded-lg text-violet-600 dark:text-violet-400 cursor-pointer transition-all duration-150 hover:bg-violet-500/20"
236
- onclick={openAddProject}
237
- aria-label="Add project"
238
- title="Add project"
239
- >
240
- <Icon name="lucide:plus" class="w-4 h-4" />
241
- </button>
242
- <button
243
- type="button"
244
- class="p-1.5 md:p-2 rounded-lg text-slate-500 hover:text-slate-900 dark:hover:text-slate-100 hover:bg-violet-500/10 transition-colors"
245
- onclick={closeProjectMenu}
246
- aria-label="Close modal"
247
- >
248
- <svg class="w-4 h-4 md:w-5 md:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
249
- <path
250
- stroke-linecap="round"
251
- stroke-linejoin="round"
252
- stroke-width="2"
253
- d="M6 18L18 6M6 6l12 12"
254
- />
255
- </svg>
256
- </button>
257
- </div>
258
- </div>
259
- {/snippet}
260
-
261
- {#snippet children()}
262
- <!-- Search Box -->
263
- {#if projectState.projects.length > 0}
264
- <div class="mb-4">
265
- <div
266
- class="flex items-center gap-2 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"
267
- >
268
- <Icon name="lucide:search" class="w-4 h-4 text-slate-500 dark:text-slate-400 shrink-0" />
269
- <input
270
- type="text"
271
- bind:value={searchQuery}
272
- placeholder="Search projects..."
273
- class="flex-1 bg-transparent border-none outline-none text-slate-900 dark:text-slate-100 text-sm placeholder:text-slate-500 dark:placeholder:text-slate-400"
274
- />
275
- {#if searchQuery}
276
- <button
277
- type="button"
278
- class="flex items-center justify-center w-5 h-5 bg-transparent border-none rounded text-slate-400 cursor-pointer transition-all duration-150 hover:text-slate-600 dark:hover:text-slate-300"
279
- onclick={() => (searchQuery = '')}
280
- aria-label="Clear search"
281
- >
282
- <Icon name="lucide:x" class="w-3.5 h-3.5" />
283
- </button>
284
- {/if}
285
- </div>
286
- </div>
287
- {/if}
288
-
289
- {#if projectState.projects.length === 0}
290
- <div class="flex flex-col items-center gap-3 py-8 text-slate-600 dark:text-slate-500 text-sm">
291
- <Icon name="lucide:folder-x" class="w-12 h-12 text-slate-400 opacity-40" />
292
- <p class="font-medium">No projects yet</p>
293
- <p class="text-xs text-slate-500 dark:text-slate-500">Create your first project below</p>
294
- </div>
295
- {:else}
296
- <div class="space-y-2">
297
- {#each filteredProjects() as project (project.id)}
298
- {@const isActive = projectState.currentProject?.id === project.id}
299
- <div
300
- class="flex items-center gap-2 w-full p-3 bg-transparent border border-slate-200 dark:border-slate-700 rounded-lg text-slate-900 dark:text-slate-100 text-sm text-left transition-all duration-150
301
- {isActive
302
- ? 'border-violet-300 dark:border-violet-600 bg-violet-50 dark:bg-violet-900/10'
303
- : ''}"
304
- >
305
- <button
306
- type="button"
307
- class="flex items-center gap-3 flex-1 min-w-0 bg-transparent border-none cursor-pointer text-left"
308
- onclick={() => {
309
- import('$frontend/lib/stores/core/projects.svelte').then((m) =>
310
- m.setCurrentProject(project)
311
- );
312
- closeProjectMenu();
313
- }}
314
- >
315
- <div
316
- class="relative w-8 h-8 {isActive
317
- ? 'bg-violet-200 dark:bg-violet-800/30'
318
- : 'bg-violet-100 dark:bg-violet-900/20'} rounded-lg flex items-center justify-center flex-shrink-0"
319
- >
320
- <Icon name="lucide:folder" class="text-violet-600 dark:text-violet-400 w-4 h-4" />
321
- <span
322
- 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 ?? '')}"
323
- ></span>
324
- </div>
325
- <div class="flex-1 min-w-0">
326
- <div class="flex items-center gap-2">
327
- <p class="font-semibold text-slate-900 dark:text-slate-100 truncate">
328
- {project.name}
329
- </p>
330
- {#if isActive}
331
- <span
332
- class="inline-flex items-center gap-1 px-2 py-0.5 bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-300 text-xs font-medium rounded-full"
333
- >
334
- <Icon name="lucide:circle-check" class="w-3 h-3" />
335
- Active
336
- </span>
337
- {/if}
338
- </div>
339
- <p class="text-xs text-slate-500 dark:text-slate-400 truncate font-mono">
340
- {project.path}
341
- </p>
342
- </div>
343
- </button>
344
- <ProjectUserAvatars projectStatus={presenceState.statuses.get(project.id ?? '')} maxVisible={2} />
345
- <button
346
- type="button"
347
- class="flex items-center justify-center w-8 h-8 bg-transparent border-none rounded-lg text-slate-400 dark:text-slate-500 cursor-pointer transition-all duration-150 hover:bg-red-500/15 hover:text-red-500 shrink-0"
348
- onclick={(e) => handleDeleteClick(project, e)}
349
- aria-label="Delete project"
350
- title="Delete"
351
- >
352
- <Icon name="lucide:trash-2" class="w-4 h-4" />
353
- </button>
354
- </div>
355
- {:else}
356
- <div
357
- class="flex flex-col items-center gap-2 py-8 text-slate-500 dark:text-slate-400 text-sm"
358
- >
359
- <Icon name="lucide:search-x" class="w-10 h-10 opacity-40" />
360
- <p class="font-medium">No projects found</p>
361
- <button
362
- type="button"
363
- class="text-xs text-violet-600 dark:text-violet-400 underline cursor-pointer hover:text-violet-700 dark:hover:text-violet-300"
364
- onclick={() => (searchQuery = '')}
365
- >
366
- Clear search
367
- </button>
368
- </div>
369
- {/each}
370
- </div>
371
- {/if}
372
- {/snippet}
373
- </Modal>
374
-
375
- <!-- Folder Browser -->
376
- <FolderBrowser
377
- bind:isOpen={showFolderBrowser}
378
- onClose={closeFolderBrowser}
379
- onSelect={createProjectFromFolder}
380
- />
381
-
382
- <!-- Delete Confirmation Dialog -->
383
- <Dialog
384
- bind:isOpen={showDeleteDialog}
385
- onClose={closeDeleteDialog}
386
- type="error"
387
- title="Delete Project"
388
- message='This will remove "{projectToDelete?.name}" from your project list. The actual project files on disk will not be deleted.'
389
- confirmText="Delete"
390
- cancelText="Cancel"
391
- onConfirm={confirmDeleteProject}
392
- />
393
-
394
- <!-- Tunnel Modal -->
395
- <TunnelModal bind:isOpen={showTunnelModal} onClose={() => (showTunnelModal = false)} />
1
+ <script lang="ts">
2
+ import Icon from '$frontend/lib/components/common/Icon.svelte';
3
+ import Modal from '$frontend/lib/components/common/Modal.svelte';
4
+ import Dialog from '$frontend/lib/components/common/Dialog.svelte';
5
+ import {
6
+ workspaceState,
7
+ setActiveMobilePanel,
8
+ type PanelId
9
+ } from '$frontend/lib/stores/ui/workspace.svelte';
10
+ import { projectState, removeProject } from '$frontend/lib/stores/core/projects.svelte';
11
+ import { presenceState } from '$frontend/lib/stores/core/presence.svelte';
12
+ import { openSettingsModal } from '$frontend/lib/stores/ui/settings-modal.svelte';
13
+ import { addNotification } from '$frontend/lib/stores/ui/notification.svelte';
14
+ import type { IconName } from '$shared/types/ui/icons';
15
+ import TunnelButton from '$frontend/lib/components/tunnel/TunnelButton.svelte';
16
+ import TunnelModal from '$frontend/lib/components/tunnel/TunnelModal.svelte';
17
+ import type { Project } from '$shared/types/database/schema';
18
+ import FolderBrowser from '$frontend/lib/components/common/FolderBrowser.svelte';
19
+ import ProjectUserAvatars from '$frontend/lib/components/common/ProjectUserAvatars.svelte';
20
+ import ws from '$frontend/lib/utils/ws';
21
+ import { debug } from '$shared/utils/logger';
22
+
23
+ // Modal states
24
+ let showTunnelModal = $state(false);
25
+
26
+ // Project dropdown state
27
+ let showProjectMenu = $state(false);
28
+ let showFolderBrowser = $state(false);
29
+ let showDeleteDialog = $state(false);
30
+ let projectToDelete = $state<Project | null>(null);
31
+ let searchQuery = $state('');
32
+
33
+ // Get current project status from shared store
34
+ const currentProjectStatus = $derived(
35
+ projectState.currentProject?.id
36
+ ? presenceState.statuses.get(projectState.currentProject.id)
37
+ : undefined
38
+ );
39
+
40
+ const panels: { id: PanelId; icon: IconName; label: string }[] = [
41
+ { id: 'chat', icon: 'lucide:bot', label: 'AI' },
42
+ { id: 'files', icon: 'lucide:folder', label: 'Files' },
43
+ { id: 'git', icon: 'lucide:git-branch', label: 'Source Control' },
44
+ { id: 'terminal', icon: 'lucide:terminal', label: 'Terminal' },
45
+ { id: 'preview', icon: 'lucide:globe', label: 'Preview' }
46
+ ];
47
+
48
+ // Filtered projects based on search query
49
+ const filteredProjects = $derived(() => {
50
+ if (!searchQuery.trim()) return projectState.projects;
51
+ const query = searchQuery.toLowerCase();
52
+ return projectState.projects.filter(
53
+ (p) => p.name.toLowerCase().includes(query) || p.path.toLowerCase().includes(query)
54
+ );
55
+ });
56
+
57
+ function selectPanel(panelId: PanelId) {
58
+ setActiveMobilePanel(panelId);
59
+ }
60
+
61
+ function toggleProjectMenu() {
62
+ showProjectMenu = !showProjectMenu;
63
+ if (!showProjectMenu) {
64
+ searchQuery = '';
65
+ }
66
+ }
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
+ }
80
+
81
+ function openAddProject() {
82
+ showProjectMenu = false;
83
+ showFolderBrowser = true;
84
+ }
85
+
86
+ function closeFolderBrowser() {
87
+ showFolderBrowser = false;
88
+ }
89
+
90
+ function closeProjectMenu() {
91
+ showProjectMenu = false;
92
+ searchQuery = '';
93
+ }
94
+
95
+ function handleDeleteClick(project: Project, event: MouseEvent) {
96
+ event.stopPropagation();
97
+ projectToDelete = project;
98
+ showDeleteDialog = true;
99
+ }
100
+
101
+ async function confirmDeleteProject() {
102
+ if (!projectToDelete) return;
103
+ const deleteId = projectToDelete.id!;
104
+
105
+ try {
106
+ await ws.http('projects:delete', { id: deleteId });
107
+ removeProject(deleteId);
108
+ showDeleteDialog = false;
109
+ projectToDelete = null;
110
+ } catch (error) {
111
+ debug.error('workspace', 'Failed to delete project:', error);
112
+ addNotification({
113
+ type: 'error',
114
+ title: 'Error',
115
+ message: 'Failed to delete project',
116
+ duration: 5000
117
+ });
118
+ }
119
+ }
120
+
121
+ function closeDeleteDialog() {
122
+ showDeleteDialog = false;
123
+ projectToDelete = null;
124
+ }
125
+
126
+ async function createProjectFromFolder(folderPath: string, folderName: string) {
127
+ try {
128
+ showFolderBrowser = false;
129
+
130
+ const projects = await ws.http('projects:list', {});
131
+
132
+ const existingProject = projects
133
+ ? projects.find((p: any) => p.path === folderPath)
134
+ : null;
135
+
136
+ if (existingProject) {
137
+ const { setCurrentProject } = await import('$frontend/lib/stores/core/projects.svelte');
138
+ setCurrentProject(existingProject);
139
+ return;
140
+ }
141
+
142
+ const newProject = await ws.http('projects:create', { name: folderName, path: folderPath });
143
+
144
+ if (newProject) {
145
+ const { setCurrentProject } = await import('$frontend/lib/stores/core/projects.svelte');
146
+ setCurrentProject(newProject);
147
+ }
148
+ } catch (error) {
149
+ console.error('Failed to create project:', error);
150
+ }
151
+ }
152
+ </script>
153
+
154
+ <header
155
+ class="flex items-center bg-white/90 dark:bg-slate-900/98 py-2 px-3 gap-2 relative z-30"
156
+ >
157
+ <!-- Project Selector -->
158
+ <button
159
+ type="button"
160
+ class="flex items-center gap-2 px-3 py-2.5 bg-slate-100/80 dark:bg-slate-800/80 border border-slate-200 dark:border-slate-800 rounded-lg text-slate-900 dark:text-slate-100 text-sm font-medium cursor-pointer transition-all duration-150 flex-1 min-w-0 active:bg-violet-500/10"
161
+ onclick={toggleProjectMenu}
162
+ aria-expanded={showProjectMenu}
163
+ aria-haspopup="menu"
164
+ >
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>
166
+ <span class="flex-1 text-left overflow-hidden text-ellipsis whitespace-nowrap">
167
+ {projectState.currentProject?.name ?? 'No Project'}
168
+ </span>
169
+ <div class="shrink-0" onclick={(e) => e.stopPropagation()}>
170
+ <ProjectUserAvatars projectStatus={currentProjectStatus} maxVisible={2} />
171
+ </div>
172
+ <Icon name="lucide:chevron-down" class="w-3 h-3 opacity-60 shrink-0" />
173
+ </button>
174
+
175
+ <!-- Panel Tabs (Icon Only) -->
176
+ <div
177
+ class="flex gap-1 bg-slate-100/80 dark:bg-slate-800/50 p-1 border border-slate-200 dark:border-slate-800 rounded-lg"
178
+ role="tablist"
179
+ aria-label="Panel Tabs"
180
+ >
181
+ {#each panels as panel}
182
+ <button
183
+ type="button"
184
+ class="flex items-center justify-center w-9 h-8 bg-transparent border-none rounded-md text-slate-500 cursor-pointer transition-all duration-150
185
+ {workspaceState.activeMobilePanel === panel.id
186
+ ? 'bg-violet-500/10 dark:bg-violet-500/20 text-slate-900 dark:text-slate-100 shadow-violet-500/20'
187
+ : 'active:bg-violet-500/10'}"
188
+ role="tab"
189
+ aria-selected={workspaceState.activeMobilePanel === panel.id}
190
+ aria-controls={`panel-${panel.id}`}
191
+ aria-label={panel.label}
192
+ title={panel.label}
193
+ onclick={() => selectPanel(panel.id)}
194
+ >
195
+ <Icon name={panel.icon} class="w-5 h-5" />
196
+ </button>
197
+ {/each}
198
+ </div>
199
+
200
+ <!-- Action Buttons -->
201
+ <div
202
+ class="flex gap-1 bg-slate-100/80 dark:bg-slate-800/50 p-1 border border-slate-200 dark:border-slate-800 rounded-lg"
203
+ role="tablist"
204
+ aria-label="Action Buttons"
205
+ >
206
+ <!-- Tunnel Button -->
207
+ <TunnelButton collapsed={true} onClick={() => (showTunnelModal = true)} mobile={true} />
208
+
209
+ <!-- Settings Button -->
210
+ <button
211
+ type="button"
212
+ class="flex items-center justify-center w-9 h-8 bg-transparent border-none rounded-md text-slate-500 cursor-pointer transition-all duration-150 active:bg-violet-500/10"
213
+ role="tab"
214
+ onclick={() => openSettingsModal()}
215
+ aria-label="Settings"
216
+ title="Settings"
217
+ >
218
+ <Icon name="lucide:settings" class="w-5 h-5" />
219
+ </button>
220
+ </div>
221
+ </header>
222
+
223
+ <!-- Project Selection Modal -->
224
+ <Modal bind:isOpen={showProjectMenu} onClose={closeProjectMenu} size="md">
225
+ {#snippet header()}
226
+ <div class="flex items-center justify-between px-4 py-3 md:px-6 md:py-4">
227
+ <h2 class="text-base md:text-lg font-bold text-slate-900 dark:text-slate-100">Projects</h2>
228
+ <div class="flex items-center gap-2">
229
+ <button
230
+ type="button"
231
+ class="flex items-center justify-center w-8 h-8 bg-violet-500/10 dark:bg-violet-500/15 border border-violet-500/20 rounded-lg text-violet-600 dark:text-violet-400 cursor-pointer transition-all duration-150 hover:bg-violet-500/20"
232
+ onclick={openAddProject}
233
+ aria-label="Add project"
234
+ title="Add project"
235
+ >
236
+ <Icon name="lucide:plus" class="w-4 h-4" />
237
+ </button>
238
+ <button
239
+ type="button"
240
+ class="p-1.5 md:p-2 rounded-lg text-slate-500 hover:text-slate-900 dark:hover:text-slate-100 hover:bg-violet-500/10 transition-colors"
241
+ onclick={closeProjectMenu}
242
+ aria-label="Close modal"
243
+ >
244
+ <svg class="w-4 h-4 md:w-5 md:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
245
+ <path
246
+ stroke-linecap="round"
247
+ stroke-linejoin="round"
248
+ stroke-width="2"
249
+ d="M6 18L18 6M6 6l12 12"
250
+ />
251
+ </svg>
252
+ </button>
253
+ </div>
254
+ </div>
255
+ {/snippet}
256
+
257
+ {#snippet children()}
258
+ <!-- Search Box -->
259
+ {#if projectState.projects.length > 0}
260
+ <div class="mb-4">
261
+ <div
262
+ class="flex items-center gap-2 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"
263
+ >
264
+ <Icon name="lucide:search" class="w-4 h-4 text-slate-500 dark:text-slate-400 shrink-0" />
265
+ <input
266
+ type="text"
267
+ bind:value={searchQuery}
268
+ placeholder="Search projects..."
269
+ class="flex-1 bg-transparent border-none outline-none text-slate-900 dark:text-slate-100 text-sm placeholder:text-slate-500 dark:placeholder:text-slate-400"
270
+ />
271
+ {#if searchQuery}
272
+ <button
273
+ type="button"
274
+ class="flex items-center justify-center w-5 h-5 bg-transparent border-none rounded text-slate-400 cursor-pointer transition-all duration-150 hover:text-slate-600 dark:hover:text-slate-300"
275
+ onclick={() => (searchQuery = '')}
276
+ aria-label="Clear search"
277
+ >
278
+ <Icon name="lucide:x" class="w-3.5 h-3.5" />
279
+ </button>
280
+ {/if}
281
+ </div>
282
+ </div>
283
+ {/if}
284
+
285
+ {#if projectState.projects.length === 0}
286
+ <div class="flex flex-col items-center gap-3 py-8 text-slate-600 dark:text-slate-500 text-sm">
287
+ <Icon name="lucide:folder-x" class="w-12 h-12 text-slate-400 opacity-40" />
288
+ <p class="font-medium">No projects yet</p>
289
+ <p class="text-xs text-slate-500 dark:text-slate-500">Create your first project below</p>
290
+ </div>
291
+ {:else}
292
+ <div class="space-y-2">
293
+ {#each filteredProjects() as project (project.id)}
294
+ {@const isActive = projectState.currentProject?.id === project.id}
295
+ <div
296
+ class="flex items-center gap-2 w-full p-3 bg-transparent border border-slate-200 dark:border-slate-700 rounded-lg text-slate-900 dark:text-slate-100 text-sm text-left transition-all duration-150
297
+ {isActive
298
+ ? 'border-violet-300 dark:border-violet-600 bg-violet-50 dark:bg-violet-900/10'
299
+ : ''}"
300
+ >
301
+ <button
302
+ type="button"
303
+ class="flex items-center gap-3 flex-1 min-w-0 bg-transparent border-none cursor-pointer text-left"
304
+ onclick={() => {
305
+ import('$frontend/lib/stores/core/projects.svelte').then((m) =>
306
+ m.setCurrentProject(project)
307
+ );
308
+ closeProjectMenu();
309
+ }}
310
+ >
311
+ <div
312
+ class="relative w-8 h-8 {isActive
313
+ ? 'bg-violet-200 dark:bg-violet-800/30'
314
+ : 'bg-violet-100 dark:bg-violet-900/20'} rounded-lg flex items-center justify-center flex-shrink-0"
315
+ >
316
+ <Icon name="lucide:folder" class="text-violet-600 dark:text-violet-400 w-4 h-4" />
317
+ <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 ?? '')}"
319
+ ></span>
320
+ </div>
321
+ <div class="flex-1 min-w-0">
322
+ <div class="flex items-center gap-2">
323
+ <p class="font-semibold text-slate-900 dark:text-slate-100 truncate">
324
+ {project.name}
325
+ </p>
326
+ {#if isActive}
327
+ <span
328
+ class="inline-flex items-center gap-1 px-2 py-0.5 bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-300 text-xs font-medium rounded-full"
329
+ >
330
+ <Icon name="lucide:circle-check" class="w-3 h-3" />
331
+ Active
332
+ </span>
333
+ {/if}
334
+ </div>
335
+ <p class="text-xs text-slate-500 dark:text-slate-400 truncate font-mono">
336
+ {project.path}
337
+ </p>
338
+ </div>
339
+ </button>
340
+ <ProjectUserAvatars projectStatus={presenceState.statuses.get(project.id ?? '')} maxVisible={2} />
341
+ <button
342
+ type="button"
343
+ class="flex items-center justify-center w-8 h-8 bg-transparent border-none rounded-lg text-slate-400 dark:text-slate-500 cursor-pointer transition-all duration-150 hover:bg-red-500/15 hover:text-red-500 shrink-0"
344
+ onclick={(e) => handleDeleteClick(project, e)}
345
+ aria-label="Delete project"
346
+ title="Delete"
347
+ >
348
+ <Icon name="lucide:trash-2" class="w-4 h-4" />
349
+ </button>
350
+ </div>
351
+ {:else}
352
+ <div
353
+ class="flex flex-col items-center gap-2 py-8 text-slate-500 dark:text-slate-400 text-sm"
354
+ >
355
+ <Icon name="lucide:search-x" class="w-10 h-10 opacity-40" />
356
+ <p class="font-medium">No projects found</p>
357
+ <button
358
+ type="button"
359
+ class="text-xs text-violet-600 dark:text-violet-400 underline cursor-pointer hover:text-violet-700 dark:hover:text-violet-300"
360
+ onclick={() => (searchQuery = '')}
361
+ >
362
+ Clear search
363
+ </button>
364
+ </div>
365
+ {/each}
366
+ </div>
367
+ {/if}
368
+ {/snippet}
369
+ </Modal>
370
+
371
+ <!-- Folder Browser -->
372
+ <FolderBrowser
373
+ bind:isOpen={showFolderBrowser}
374
+ onClose={closeFolderBrowser}
375
+ onSelect={createProjectFromFolder}
376
+ />
377
+
378
+ <!-- Delete Confirmation Dialog -->
379
+ <Dialog
380
+ bind:isOpen={showDeleteDialog}
381
+ onClose={closeDeleteDialog}
382
+ type="error"
383
+ title="Delete Project"
384
+ message='This will remove "{projectToDelete?.name}" from your project list. The actual project files on disk will not be deleted.'
385
+ confirmText="Delete"
386
+ cancelText="Cancel"
387
+ onConfirm={confirmDeleteProject}
388
+ />
389
+
390
+ <!-- Tunnel Modal -->
391
+ <TunnelModal bind:isOpen={showTunnelModal} onClose={() => (showTunnelModal = false)} />