@myrialabs/clopen 0.1.7 → 0.1.9

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 (28) hide show
  1. package/backend/lib/database/migrations/023_create_user_unread_sessions_table.ts +32 -0
  2. package/backend/lib/database/migrations/index.ts +7 -0
  3. package/backend/lib/database/queries/session-queries.ts +37 -0
  4. package/backend/lib/git/git-service.ts +1 -0
  5. package/backend/ws/sessions/crud.ts +34 -2
  6. package/backend/ws/user/crud.ts +8 -4
  7. package/bun.lock +34 -12
  8. package/frontend/lib/components/common/MonacoEditor.svelte +6 -6
  9. package/frontend/lib/components/common/xterm/XTerm.svelte +27 -108
  10. package/frontend/lib/components/common/xterm/terminal-config.ts +2 -2
  11. package/frontend/lib/components/common/xterm/types.ts +1 -0
  12. package/frontend/lib/components/common/xterm/xterm-service.ts +69 -20
  13. package/frontend/lib/components/files/FileTree.svelte +4 -6
  14. package/frontend/lib/components/files/FileViewer.svelte +45 -101
  15. package/frontend/lib/components/git/CommitForm.svelte +1 -1
  16. package/frontend/lib/components/git/GitLog.svelte +141 -101
  17. package/frontend/lib/components/preview/browser/components/Toolbar.svelte +81 -72
  18. package/frontend/lib/components/settings/SettingsModal.svelte +1 -8
  19. package/frontend/lib/components/settings/engines/AIEnginesSettings.svelte +3 -3
  20. package/frontend/lib/components/terminal/Terminal.svelte +1 -1
  21. package/frontend/lib/components/terminal/TerminalTabs.svelte +28 -26
  22. package/frontend/lib/components/workspace/PanelHeader.svelte +639 -623
  23. package/frontend/lib/components/workspace/WorkspaceLayout.svelte +3 -2
  24. package/frontend/lib/components/workspace/panels/GitPanel.svelte +34 -92
  25. package/frontend/lib/stores/core/app.svelte.ts +46 -0
  26. package/frontend/lib/stores/core/sessions.svelte.ts +24 -3
  27. package/frontend/lib/stores/ui/workspace.svelte.ts +14 -14
  28. package/package.json +8 -6
@@ -1,623 +1,639 @@
1
- <script lang="ts">
2
- import { browser } from '$frontend/lib/app-environment';
3
- import { onMount, onDestroy } from 'svelte';
4
- import Icon from '$frontend/lib/components/common/Icon.svelte';
5
- import AvatarBubble from '$frontend/lib/components/common/AvatarBubble.svelte';
6
- import { sessionState } from '$frontend/lib/stores/core/sessions.svelte';
7
- import { projectState } from '$frontend/lib/stores/core/projects.svelte';
8
- import { presenceState } from '$frontend/lib/stores/core/presence.svelte';
9
- import { userStore } from '$frontend/lib/stores/features/user.svelte';
10
- import {
11
- workspaceState,
12
- type PanelId,
13
- swapPanel,
14
- splitPanel,
15
- closePanel,
16
- canClosePanel,
17
- PANEL_OPTIONS
18
- } from '$frontend/lib/stores/ui/workspace.svelte';
19
- import type { IconName } from '$shared/types/ui/icons';
20
- import type { DeviceSize } from '$frontend/lib/constants/preview';
21
-
22
- import { DEVICE_VIEWPORTS } from '$frontend/lib/constants/preview';
23
-
24
- interface Props {
25
- panelId: PanelId;
26
- chatPanelRef?: any;
27
- filesPanelRef?: any;
28
- terminalPanelRef?: any;
29
- previewPanelRef?: any;
30
- gitPanelRef?: any;
31
- onHistoryOpen?: () => void;
32
- }
33
-
34
- const {
35
- panelId,
36
- chatPanelRef,
37
- filesPanelRef,
38
- terminalPanelRef,
39
- previewPanelRef,
40
- gitPanelRef,
41
- onHistoryOpen
42
- }: Props = $props();
43
-
44
- const panel = $derived(workspaceState.panels[panelId]);
45
- const iconName = $derived((panel?.icon ?? 'lucide:box') as IconName);
46
-
47
- // Mobile detection
48
- let isMobile = $state(false);
49
-
50
- // Chat session users (other users in the same chat session, excluding self)
51
- const chatSessionUsers = $derived.by(() => {
52
- if (panelId !== 'chat') return [];
53
- const projectId = projectState.currentProject?.id;
54
- const chatSessionId = sessionState.currentSession?.id;
55
- const currentUserId = userStore.currentUser?.id;
56
- if (!projectId || !chatSessionId) return [];
57
- const status = presenceState.statuses.get(projectId);
58
- if (!status?.chatSessionUsers) return [];
59
- const users = status.chatSessionUsers[chatSessionId] || [];
60
- return currentUserId ? users.filter(u => u.userId !== currentUserId) : users;
61
- });
62
-
63
- // Chat session users popover
64
- let showChatUsersPopover = $state(false);
65
- let chatUsersContainer = $state<HTMLDivElement | null>(null);
66
-
67
- function toggleChatUsersPopover(e: MouseEvent) {
68
- e.stopPropagation();
69
- showChatUsersPopover = !showChatUsersPopover;
70
- }
71
-
72
- $effect(() => {
73
- if (showChatUsersPopover) {
74
- const handleClickOutside = (e: MouseEvent) => {
75
- if (chatUsersContainer && !chatUsersContainer.contains(e.target as Node)) {
76
- showChatUsersPopover = false;
77
- }
78
- };
79
- document.addEventListener('click', handleClickOutside, true);
80
- return () => document.removeEventListener('click', handleClickOutside, true);
81
- }
82
- });
83
-
84
- // Panel actions menu state
85
- let showActionsMenu = $state(false);
86
- let actionsButtonRef = $state<HTMLButtonElement | null>(null);
87
- let menuPosition = $state({ top: 0, left: 0 });
88
-
89
- function toggleActionsMenu(e: MouseEvent) {
90
- e.stopPropagation();
91
- if (!showActionsMenu && actionsButtonRef) {
92
- const rect = actionsButtonRef.getBoundingClientRect();
93
- menuPosition = { top: rect.bottom + 4, left: rect.left };
94
- }
95
- showActionsMenu = !showActionsMenu;
96
- }
97
-
98
- function closeActionsMenu() {
99
- showActionsMenu = false;
100
- }
101
-
102
- function handleSwap(newPanelId: PanelId) {
103
- swapPanel(panelId, newPanelId);
104
- closeActionsMenu();
105
- }
106
-
107
- function handleSplit(direction: 'vertical' | 'horizontal') {
108
- splitPanel(panelId, direction);
109
- closeActionsMenu();
110
- }
111
-
112
- function handleClose() {
113
- closePanel(panelId);
114
- closeActionsMenu();
115
- }
116
-
117
- // Preview panel device dropdown state
118
- let showDeviceDropdown = $state(false);
119
-
120
- // Git remote dropdown state
121
- let showRemoteDropdown = $state(false);
122
-
123
- function toggleDeviceDropdown() {
124
- showDeviceDropdown = !showDeviceDropdown;
125
- }
126
-
127
- function closeDeviceDropdown() {
128
- showDeviceDropdown = false;
129
- }
130
-
131
- function selectDevice(size: DeviceSize) {
132
- previewPanelRef?.panelActions?.setDeviceSize(size);
133
- closeDeviceDropdown();
134
- }
135
-
136
- function handleResize() {
137
- if (browser) {
138
- isMobile = window.innerWidth < 1024;
139
- }
140
- }
141
-
142
- onMount(() => {
143
- handleResize();
144
- if (browser) {
145
- window.addEventListener('resize', handleResize);
146
- }
147
- });
148
-
149
- onDestroy(() => {
150
- if (browser) {
151
- window.removeEventListener('resize', handleResize);
152
- }
153
- });
154
- </script>
155
-
156
- <header
157
- class="flex items-center justify-between shrink-0 {isMobile
158
- ? 'h-11 pb-2 px-4 bg-white/90 dark:bg-slate-900/98 border-b border-slate-200 dark:border-slate-800'
159
- : 'py-2.5 px-3.5 bg-slate-100 dark:bg-slate-800/80 border-b border-slate-200 dark:border-slate-800'}"
160
- >
161
- <div class="flex items-center text-sm font-medium text-slate-900 dark:text-slate-100">
162
- <!-- Panel layout actions (⋮ menu) -->
163
- {#if !isMobile}
164
- <div class="relative -ml-0.5 mr-2 border-slate-200 dark:border-slate-700">
165
- <button
166
- bind:this={actionsButtonRef}
167
- type="button"
168
- class="flex items-center justify-center w-6 h-6 bg-transparent border-none rounded-md text-slate-400 cursor-pointer transition-all duration-150 hover:bg-violet-500/10 hover:text-slate-700 dark:hover:text-slate-200"
169
- onclick={toggleActionsMenu}
170
- title="Panel actions"
171
- >
172
- <Icon name="lucide:ellipsis-vertical" class="w-3.5 h-3.5" />
173
- </button>
174
-
175
- {#if showActionsMenu}
176
- <div class="fixed inset-0 z-40" onclick={closeActionsMenu}></div>
177
- <div class="fixed z-50 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg shadow-lg overflow-hidden min-w-44 py-1" style="top: {menuPosition.top}px; left: {menuPosition.left}px;">
178
- <!-- Switch to section -->
179
- <div class="px-3 py-1.5 text-3xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider">
180
- Switch to
181
- </div>
182
- {#each PANEL_OPTIONS as option}
183
- <button
184
- type="button"
185
- class="flex items-center gap-2.5 w-full px-3 py-1.5 text-left text-xs bg-transparent border-none cursor-pointer transition-all duration-150 hover:bg-violet-500/10
186
- {option.id === panelId ? 'text-violet-600 dark:text-violet-400 font-medium' : 'text-slate-700 dark:text-slate-300'}"
187
- onclick={() => handleSwap(option.id)}
188
- disabled={option.id === panelId}
189
- >
190
- <Icon name={option.icon} class="w-3.5 h-3.5" />
191
- <span class="flex-1">{option.title}</span>
192
- {#if option.id === panelId}
193
- <Icon name="lucide:check" class="w-3 h-3 text-violet-600 dark:text-violet-400" />
194
- {/if}
195
- </button>
196
- {/each}
197
-
198
- <!-- Divider -->
199
- <div class="my-1 border-t border-slate-200 dark:border-slate-700"></div>
200
-
201
- <!-- Split actions -->
202
- <button
203
- type="button"
204
- class="flex items-center gap-2.5 w-full px-3 py-1.5 text-left text-xs bg-transparent border-none cursor-pointer transition-all duration-150 hover:bg-violet-500/10 text-slate-700 dark:text-slate-300"
205
- onclick={() => handleSplit('vertical')}
206
- >
207
- <Icon name="lucide:columns-2" class="w-3.5 h-3.5" />
208
- <span>Split Right</span>
209
- </button>
210
- <button
211
- type="button"
212
- class="flex items-center gap-2.5 w-full px-3 py-1.5 text-left text-xs bg-transparent border-none cursor-pointer transition-all duration-150 hover:bg-violet-500/10 text-slate-700 dark:text-slate-300"
213
- onclick={() => handleSplit('horizontal')}
214
- >
215
- <Icon name="lucide:rows-2" class="w-3.5 h-3.5" />
216
- <span>Split Down</span>
217
- </button>
218
-
219
- <!-- Divider -->
220
- <div class="my-1 border-t border-slate-200 dark:border-slate-700"></div>
221
-
222
- <!-- Close -->
223
- <button
224
- type="button"
225
- class="flex items-center gap-2.5 w-full px-3 py-1.5 text-left text-xs bg-transparent border-none cursor-pointer transition-all duration-150 hover:bg-red-500/10 text-red-600 dark:text-red-400 disabled:opacity-40 disabled:cursor-not-allowed"
226
- onclick={handleClose}
227
- disabled={!canClosePanel()}
228
- title={!canClosePanel() ? 'Cannot close last panel' : 'Close this panel'}
229
- >
230
- <Icon name="lucide:x" class="w-3.5 h-3.5" />
231
- <span>Close Panel</span>
232
- </button>
233
- </div>
234
- {/if}
235
- </div>
236
- {/if}
237
- <Icon name={iconName} class="w-4 h-4 text-violet-600" />
238
- <span class="ml-2.5">{panel?.title ?? 'Panel'}</span>
239
- </div>
240
-
241
- <div class="flex items-center">
242
- <!-- Panel-specific actions -->
243
- <div class="flex items-center gap-1.5">
244
- {#if panelId === 'chat'}
245
- {#if chatSessionUsers.length > 0}
246
- <div class="relative" bind:this={chatUsersContainer}>
247
- <div class="flex items-center -space-x-1.5 mr-1 cursor-pointer" title="Users in this session" onclick={toggleChatUsersPopover}>
248
- {#each chatSessionUsers.slice(0, 3) as user}
249
- <AvatarBubble {user} size="sm" />
250
- {/each}
251
- {#if chatSessionUsers.length > 3}
252
- <span class="w-5 h-5 rounded-full bg-gradient-to-br from-slate-500 to-slate-600 dark:from-slate-600 dark:to-slate-700 text-white text-4xs font-bold flex items-center justify-center border-2 border-white dark:border-slate-800 z-10">
253
- +{chatSessionUsers.length - 3}
254
- </span>
255
- {/if}
256
- </div>
257
- {#if showChatUsersPopover}
258
- <div class="absolute top-full right-0 mt-2 py-2 px-1 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg shadow-lg z-50 min-w-[160px]">
259
- <div class="px-2 pb-1.5 text-left text-xs font-semibold text-slate-500 dark:text-slate-400">
260
- In this session ({chatSessionUsers.length})
261
- </div>
262
- {#each chatSessionUsers as user}
263
- <div class="flex items-center gap-2 px-2 py-1.5 rounded-md">
264
- <AvatarBubble {user} size="sm" showName={true} />
265
- </div>
266
- {/each}
267
- </div>
268
- {/if}
269
- </div>
270
- {/if}
271
- <button
272
- type="button"
273
- class="flex items-center justify-center {isMobile ? 'w-9 h-9' : 'w-6 h-6'} bg-transparent border-none rounded-md text-slate-500 cursor-pointer transition-all duration-150 hover:bg-violet-500/10 hover:text-slate-900 dark:hover:text-slate-100"
274
- onclick={onHistoryOpen}
275
- title="Switch Session"
276
- >
277
- <Icon name="lucide:history" class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
278
- </button>
279
- {#if sessionState.messages.length > 0}
280
- <button
281
- type="button"
282
- class="flex items-center justify-center {isMobile ? 'w-9 h-9' : 'w-6 h-6'} bg-transparent border-none rounded-md text-slate-500 cursor-pointer transition-all duration-150 hover:bg-violet-500/10 hover:text-slate-900 dark:hover:text-slate-100"
283
- onclick={() => chatPanelRef?.panelActions?.checkpoints()}
284
- title="Restore Checkpoint"
285
- >
286
- <Icon name="lucide:undo-2" class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
287
- </button>
288
- {/if}
289
- <button
290
- type="button"
291
- class="flex items-center justify-center {isMobile ? 'w-9 h-9' : 'w-6 h-6'} bg-transparent border-none rounded-md text-slate-500 cursor-pointer transition-all duration-150 hover:bg-violet-500/10 hover:text-slate-900 dark:hover:text-slate-100"
292
- onclick={() => chatPanelRef?.panelActions?.newChat()}
293
- title="New Chat"
294
- >
295
- <Icon name="lucide:plus" class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
296
- </button>
297
- {:else if panelId === 'files'}
298
- <!-- Hide view mode toggles when in two-column mode -->
299
- {#if !filesPanelRef?.panelActions?.isTwoColumnMode()}
300
- <div class="flex gap-1 bg-slate-100/80 dark:bg-slate-800/50 rounded-md">
301
- <button
302
- type="button"
303
- class="flex items-center justify-center {isMobile ? 'w-9 h-9' : 'w-6 h-6'} bg-transparent border-none rounded text-slate-500 cursor-pointer transition-all duration-150 hover:bg-violet-500/10 hover:text-slate-900 dark:hover:text-slate-100 disabled:opacity-40 disabled:cursor-not-allowed
304
- {filesPanelRef?.panelActions?.getViewMode() === 'tree'
305
- ? 'bg-violet-500/15 dark:bg-violet-500/25 text-violet-600'
306
- : ''}"
307
- onclick={() => filesPanelRef?.panelActions?.setViewMode('tree')}
308
- title="Tree View"
309
- >
310
- <Icon name="lucide:folder-tree" class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
311
- </button>
312
- <button
313
- type="button"
314
- class="flex items-center justify-center {isMobile ? 'w-9 h-9' : 'w-6 h-6'} bg-transparent border-none rounded text-slate-500 cursor-pointer transition-all duration-150 hover:bg-violet-500/10 hover:text-slate-900 dark:hover:text-slate-100 disabled:opacity-40 disabled:cursor-not-allowed
315
- {filesPanelRef?.panelActions?.getViewMode() === 'viewer'
316
- ? 'bg-violet-500/15 dark:bg-violet-500/25 text-violet-600'
317
- : ''}"
318
- onclick={() => filesPanelRef?.panelActions?.setViewMode('viewer')}
319
- disabled={!filesPanelRef?.panelActions?.canShowViewer()}
320
- title="File Viewer"
321
- >
322
- <Icon name="lucide:file-code" class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
323
- </button>
324
- </div>
325
- {/if}
326
- {:else if panelId === 'terminal'}
327
- <button
328
- type="button"
329
- class="flex items-center justify-center {isMobile ? 'w-9 h-9' : 'w-6 h-6'} bg-transparent border-none rounded-md text-slate-500 cursor-pointer transition-all duration-150 hover:bg-violet-500/10 hover:text-slate-900 dark:hover:text-slate-100"
330
- onclick={() => terminalPanelRef?.panelActions?.handleClear()}
331
- title="Clear Terminal"
332
- >
333
- <Icon name="lucide:trash-2" class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
334
- </button>
335
- {#if !isMobile || terminalPanelRef?.panelActions?.isExecuting()}
336
- <button
337
- type="button"
338
- class="flex items-center justify-center {isMobile ? 'w-9 h-9' : 'w-6 h-6'} bg-transparent border-none rounded-md {isMobile ? 'text-red-600 dark:text-red-400' : 'text-slate-500'} cursor-pointer transition-all duration-150 hover:bg-red-500/10 hover:text-red-600 dark:hover:text-red-400 disabled:opacity-50 disabled:cursor-not-allowed"
339
- onclick={() => terminalPanelRef?.panelActions?.handleCancel()}
340
- disabled={terminalPanelRef?.panelActions?.isCancelling()}
341
- title="{isMobile ? 'Cancel Command (Ctrl+C)' : 'Send Ctrl+C Signal'}"
342
- >
343
- {#if terminalPanelRef?.panelActions?.isCancelling()}
344
- <div
345
- class="{isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} border-2 border-red-500/20 border-t-red-600 rounded-full animate-spin"
346
- ></div>
347
- {:else}
348
- <Icon name="lucide:square" class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
349
- {/if}
350
- </button>
351
- {/if}
352
- {:else if panelId === 'preview'}
353
- <!-- Connection status indicator -->
354
- <!-- {@const sessionInfo = previewPanelRef?.panelActions?.getSessionInfo()}
355
- {@const isStreamReady = previewPanelRef?.panelActions?.getIsStreamReady()}
356
- {@const errorMessage = previewPanelRef?.panelActions?.getErrorMessage()}
357
- {@const url = previewPanelRef?.panelActions?.getUrl()}
358
-
359
- {#if url}
360
- <div class="flex items-center gap-1.5 {isMobile ? 'px-2 h-9' : 'px-2 h-6'} rounded-md">
361
- <div class="relative">
362
- {#if !sessionInfo}
363
- <span class="w-2 h-2 rounded-full block bg-amber-400"></span>
364
- {:else if errorMessage}
365
- <span class="w-2 h-2 rounded-full block bg-red-500"></span>
366
- {:else if isStreamReady}
367
- <span class="w-2 h-2 rounded-full block bg-emerald-500"></span>
368
- <span class="absolute inset-0 w-2 h-2 bg-emerald-500 rounded-full animate-ping opacity-75"></span>
369
- {:else}
370
- <span class="w-2 h-2 rounded-full block bg-blue-400 animate-pulse"></span>
371
- {/if}
372
- </div>
373
- <span class="text-xs font-medium {
374
- !sessionInfo ? 'text-amber-600 dark:text-amber-400' :
375
- errorMessage ? 'text-red-600 dark:text-red-400' :
376
- isStreamReady ? 'text-emerald-600 dark:text-emerald-400' :
377
- 'text-blue-600 dark:text-blue-400'
378
- }">
379
- {!sessionInfo ? 'Ready' : errorMessage ? 'Offline' : isStreamReady ? 'Online' : 'Connecting'}
380
- </span>
381
- </div>
382
- {/if} -->
383
-
384
- <!-- Device size dropdown -->
385
- <div class="relative {isMobile ? '' : 'mr-1.5'}">
386
- <button
387
- type="button"
388
- class="flex items-center justify-center gap-1.5 {isMobile ? 'px-2 h-9' : 'px-1 h-6'} bg-transparent border-none rounded-md text-slate-500 cursor-pointer transition-all duration-150 hover:bg-violet-500/10 hover:text-slate-900 dark:hover:text-slate-100"
389
- onclick={toggleDeviceDropdown}
390
- title="Select device size"
391
- >
392
- {#if previewPanelRef?.panelActions?.getDeviceSize() === 'desktop'}
393
- <Icon name="lucide:monitor" class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
394
- <span class="text-xs font-medium">Desktop</span>
395
- {:else if previewPanelRef?.panelActions?.getDeviceSize() === 'laptop'}
396
- <Icon name="lucide:laptop" class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
397
- <span class="text-xs font-medium">Laptop</span>
398
- {:else if previewPanelRef?.panelActions?.getDeviceSize() === 'tablet'}
399
- <Icon name="lucide:tablet" class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
400
- <span class="text-xs font-medium">Tablet</span>
401
- {:else}
402
- <Icon name="lucide:smartphone" class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
403
- <span class="text-xs font-medium">Mobile</span>
404
- {/if}
405
- <Icon name="lucide:chevron-down" class={isMobile ? 'w-3.5 h-3.5' : 'w-3 h-3'} />
406
- </button>
407
-
408
- <!-- Dropdown menu -->
409
- {#if showDeviceDropdown}
410
- <div
411
- class="fixed inset-0 z-40"
412
- onclick={closeDeviceDropdown}
413
- ></div>
414
- <div class="absolute top-full right-0 mt-1 z-50 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg shadow-lg overflow-hidden {isMobile ? 'min-w-44' : 'min-w-40'}">
415
- <button
416
- type="button"
417
- class="flex items-center gap-2.5 w-full px-3 {isMobile ? 'py-2.5' : 'py-2'} text-left text-sm bg-transparent border-none cursor-pointer transition-all duration-150 hover:bg-violet-500/10 {previewPanelRef?.panelActions?.getDeviceSize() === 'desktop' ? 'bg-violet-500/5 text-violet-600' : 'text-slate-700 dark:text-slate-300'}"
418
- onclick={() => selectDevice('desktop')}
419
- >
420
- <Icon name="lucide:monitor" class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
421
- <div class="flex-1">
422
- <div class="font-medium">Desktop</div>
423
- <div class="text-xs text-slate-500 dark:text-slate-400">
424
- {DEVICE_VIEWPORTS.desktop.width}×{DEVICE_VIEWPORTS.desktop.height}
425
- </div>
426
- </div>
427
- {#if previewPanelRef?.panelActions?.getDeviceSize() === 'desktop'}
428
- <Icon name="lucide:check" class="{isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} text-violet-600" />
429
- {/if}
430
- </button>
431
- <button
432
- type="button"
433
- class="flex items-center gap-2.5 w-full px-3 {isMobile ? 'py-2.5' : 'py-2'} text-left text-sm bg-transparent border-none cursor-pointer transition-all duration-150 hover:bg-violet-500/10 {previewPanelRef?.panelActions?.getDeviceSize() === 'laptop' ? 'bg-violet-500/5 text-violet-600' : 'text-slate-700 dark:text-slate-300'}"
434
- onclick={() => selectDevice('laptop')}
435
- >
436
- <Icon name="lucide:laptop" class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
437
- <div class="flex-1">
438
- <div class="font-medium">Laptop</div>
439
- <div class="text-xs text-slate-500 dark:text-slate-400">
440
- {DEVICE_VIEWPORTS.laptop.width}×{DEVICE_VIEWPORTS.laptop.height}
441
- </div>
442
- </div>
443
- {#if previewPanelRef?.panelActions?.getDeviceSize() === 'laptop'}
444
- <Icon name="lucide:check" class="{isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} text-violet-600" />
445
- {/if}
446
- </button>
447
- <button
448
- type="button"
449
- class="flex items-center gap-2.5 w-full px-3 {isMobile ? 'py-2.5' : 'py-2'} text-left text-sm bg-transparent border-none cursor-pointer transition-all duration-150 hover:bg-violet-500/10 {previewPanelRef?.panelActions?.getDeviceSize() === 'tablet' ? 'bg-violet-500/5 text-violet-600' : 'text-slate-700 dark:text-slate-300'}"
450
- onclick={() => selectDevice('tablet')}
451
- >
452
- <Icon name="lucide:tablet" class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
453
- <div class="flex-1">
454
- <div class="font-medium">Tablet</div>
455
- <div class="text-xs text-slate-500 dark:text-slate-400">
456
- {DEVICE_VIEWPORTS.tablet.width}×{DEVICE_VIEWPORTS.tablet.height}
457
- </div>
458
- </div>
459
- {#if previewPanelRef?.panelActions?.getDeviceSize() === 'tablet'}
460
- <Icon name="lucide:check" class="{isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} text-violet-600" />
461
- {/if}
462
- </button>
463
- <button
464
- type="button"
465
- class="flex items-center gap-2.5 w-full px-3 {isMobile ? 'py-2.5' : 'py-2'} text-left text-sm bg-transparent border-none cursor-pointer transition-all duration-150 hover:bg-violet-500/10 {previewPanelRef?.panelActions?.getDeviceSize() === 'mobile' ? 'bg-violet-500/5 text-violet-600' : 'text-slate-700 dark:text-slate-300'}"
466
- onclick={() => selectDevice('mobile')}
467
- >
468
- <Icon name="lucide:smartphone" class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
469
- <div class="flex-1">
470
- <div class="font-medium">Mobile</div>
471
- <div class="text-xs text-slate-500 dark:text-slate-400">
472
- {DEVICE_VIEWPORTS.mobile.width}×{DEVICE_VIEWPORTS.mobile.height}
473
- </div>
474
- </div>
475
- {#if previewPanelRef?.panelActions?.getDeviceSize() === 'mobile'}
476
- <Icon name="lucide:check" class="{isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} text-violet-600" />
477
- {/if}
478
- </button>
479
- </div>
480
- {/if}
481
- </div>
482
-
483
- <!-- Rotation toggle -->
484
- <button
485
- type="button"
486
- class="flex items-center justify-center gap-1.5 {isMobile ? 'px-2 h-9' : 'px-1 h-6'} bg-transparent border-none rounded-md text-slate-500 cursor-pointer transition-all duration-150 hover:bg-violet-500/10 hover:text-slate-900 dark:hover:text-slate-100"
487
- onclick={() => previewPanelRef?.panelActions?.toggleRotation()}
488
- title="Toggle orientation"
489
- >
490
- <Icon name="lucide:rotate-cw" class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
491
- <span class="text-xs font-medium">
492
- {previewPanelRef?.panelActions?.getRotation() === 'portrait' ? 'Portrait' : 'Landscape'}
493
- </span>
494
- </button>
495
-
496
- <!-- Scale info badge -->
497
- <div class="flex items-center gap-1.5 {isMobile ? 'px-2.5 h-9 bg-transparent' : 'px-2 h-6 bg-slate-100/60 dark:bg-slate-800/40'} rounded-md text-xs font-medium text-slate-500">
498
- <Icon name="lucide:move-diagonal" class={isMobile ? 'w-4 h-4' : 'w-3.5 h-3.5'} />
499
- <span>{Math.round((previewPanelRef?.panelActions?.getScale() || 1) * 100)}%</span>
500
- </div>
501
- {:else if panelId === 'git'}
502
- <!-- View mode toggles (only in single-column mode, like Files panel) -->
503
- {#if !gitPanelRef?.panelActions?.isTwoColumnMode()}
504
- <div class="flex gap-1 bg-slate-100/80 dark:bg-slate-800/50 rounded-md">
505
- <button
506
- type="button"
507
- class="flex items-center justify-center {isMobile ? 'w-9 h-9' : 'w-6 h-6'} bg-transparent border-none rounded text-slate-500 cursor-pointer transition-all duration-150 hover:bg-violet-500/10 hover:text-slate-900 dark:hover:text-slate-100
508
- {gitPanelRef?.panelActions?.getViewMode() === 'list'
509
- ? 'bg-violet-500/15 dark:bg-violet-500/25 text-violet-600'
510
- : ''}"
511
- onclick={() => gitPanelRef?.panelActions?.setViewMode('list')}
512
- title="Changes List"
513
- >
514
- <Icon name="lucide:list" class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
515
- </button>
516
- <button
517
- type="button"
518
- class="flex items-center justify-center {isMobile ? 'w-9 h-9' : 'w-6 h-6'} bg-transparent border-none rounded text-slate-500 cursor-pointer transition-all duration-150 hover:bg-violet-500/10 hover:text-slate-900 dark:hover:text-slate-100 disabled:opacity-40 disabled:cursor-not-allowed
519
- {gitPanelRef?.panelActions?.getViewMode() === 'diff'
520
- ? 'bg-violet-500/15 dark:bg-violet-500/25 text-violet-600'
521
- : ''}"
522
- onclick={() => gitPanelRef?.panelActions?.setViewMode('diff')}
523
- disabled={!gitPanelRef?.panelActions?.canShowDiff()}
524
- title="Diff Viewer"
525
- >
526
- <Icon name="lucide:file-diff" class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
527
- </button>
528
- </div>
529
- {/if}
530
- {@const hasRemotes = gitPanelRef?.panelActions?.getHasRemotes()}
531
- {@const remoteName = gitPanelRef?.panelActions?.getSelectedRemote() || 'origin'}
532
- {@const gitRemotes = gitPanelRef?.panelActions?.getRemotes() || []}
533
-
534
- <!-- Remote selector -->
535
- {#if hasRemotes}
536
- <div class="relative">
537
- <button
538
- type="button"
539
- class="flex items-center gap-1 {isMobile ? 'h-9 px-2' : 'h-6 px-1.5'} bg-transparent border-none rounded-md text-slate-500 cursor-pointer transition-all duration-150 hover:bg-violet-500/10 hover:text-slate-900 dark:hover:text-slate-100"
540
- onclick={() => showRemoteDropdown = !showRemoteDropdown}
541
- title="Select remote"
542
- >
543
- <Icon name="lucide:globe" class={isMobile ? 'w-4 h-4' : 'w-3.5 h-3.5'} />
544
- <span class="text-xs font-medium">{remoteName}</span>
545
- {#if gitRemotes.length > 1}
546
- <Icon name="lucide:chevron-down" class="w-3 h-3" />
547
- {/if}
548
- </button>
549
-
550
- {#if showRemoteDropdown && gitRemotes.length > 1}
551
- <div class="fixed inset-0 z-40" onclick={() => showRemoteDropdown = false}></div>
552
- <div class="absolute top-full right-0 mt-1 z-50 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg shadow-lg overflow-hidden min-w-36">
553
- {#each gitRemotes as remote (remote.name)}
554
- <button
555
- type="button"
556
- class="flex items-center gap-2 w-full px-3 py-2 text-left text-xs bg-transparent border-none cursor-pointer transition-all duration-150 hover:bg-violet-500/10
557
- {remote.name === remoteName ? 'text-violet-600 font-medium' : 'text-slate-700 dark:text-slate-300'}"
558
- onclick={() => { gitPanelRef?.panelActions?.setSelectedRemote(remote.name); showRemoteDropdown = false; }}
559
- >
560
- <Icon name="lucide:globe" class="w-3.5 h-3.5 shrink-0" />
561
- <div class="flex-1 min-w-0">
562
- <div>{remote.name}</div>
563
- <div class="text-3xs text-slate-400 truncate font-mono">{remote.fetchUrl}</div>
564
- </div>
565
- {#if remote.name === remoteName}
566
- <Icon name="lucide:check" class="w-3.5 h-3.5 text-violet-600 shrink-0" />
567
- {/if}
568
- </button>
569
- {/each}
570
- </div>
571
- {/if}
572
- </div>
573
- {:else if gitPanelRef?.panelActions?.getIsRepo()}
574
- <span class="text-xs text-slate-400 italic px-1">no remote</span>
575
- {/if}
576
-
577
- <!-- Fetch -->
578
- <button
579
- type="button"
580
- class="flex items-center justify-center gap-1 {isMobile ? 'h-9 px-2' : 'h-6 px-1.5'} bg-transparent border-none rounded-md text-slate-500 cursor-pointer transition-all duration-150 hover:bg-violet-500/10 hover:text-slate-900 dark:hover:text-slate-100 disabled:opacity-50 disabled:cursor-not-allowed"
581
- onclick={() => gitPanelRef?.panelActions?.fetch()}
582
- disabled={gitPanelRef?.panelActions?.getIsFetching() || !hasRemotes}
583
- title={hasRemotes ? `Fetch from ${remoteName}` : 'No remote configured'}
584
- >
585
- {#if gitPanelRef?.panelActions?.getIsFetching()}
586
- <div class="{isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} border-2 border-slate-300/30 border-t-slate-500 rounded-full animate-spin"></div>
587
- {:else}
588
- <Icon name="lucide:cloud-download" class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
589
- {/if}
590
- </button>
591
- <!-- Pull -->
592
- <button
593
- type="button"
594
- class="flex items-center justify-center gap-1 {isMobile ? 'h-9 px-2' : 'h-6 px-1.5'} bg-transparent border-none rounded-md text-slate-500 cursor-pointer transition-all duration-150 hover:bg-violet-500/10 hover:text-slate-900 dark:hover:text-slate-100 disabled:opacity-50 disabled:cursor-not-allowed"
595
- onclick={() => gitPanelRef?.panelActions?.pull()}
596
- disabled={gitPanelRef?.panelActions?.getIsPulling() || !hasRemotes}
597
- title={hasRemotes ? `Pull from ${remoteName}` : 'No remote configured'}
598
- >
599
- {#if gitPanelRef?.panelActions?.getIsPulling()}
600
- <div class="{isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} border-2 border-slate-300/30 border-t-slate-500 rounded-full animate-spin"></div>
601
- {:else}
602
- <Icon name="lucide:arrow-down-to-line" class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
603
- {/if}
604
- </button>
605
- <!-- Push -->
606
- <button
607
- type="button"
608
- class="flex items-center justify-center gap-1 {isMobile ? 'h-9 px-2' : 'h-6 px-1.5'} bg-transparent border-none rounded-md text-slate-500 cursor-pointer transition-all duration-150 hover:bg-violet-500/10 hover:text-slate-900 dark:hover:text-slate-100 disabled:opacity-50 disabled:cursor-not-allowed"
609
- onclick={() => gitPanelRef?.panelActions?.push()}
610
- disabled={gitPanelRef?.panelActions?.getIsPushing() || !hasRemotes}
611
- title={hasRemotes ? `Push to ${remoteName}` : 'No remote configured'}
612
- >
613
- {#if gitPanelRef?.panelActions?.getIsPushing()}
614
- <div class="{isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} border-2 border-slate-300/30 border-t-slate-500 rounded-full animate-spin"></div>
615
- {:else}
616
- <Icon name="lucide:arrow-up-from-line" class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
617
- {/if}
618
- </button>
619
- {/if}
620
- </div>
621
-
622
- </div>
623
- </header>
1
+ <script lang="ts">
2
+ import { browser } from '$frontend/lib/app-environment';
3
+ import { onMount, onDestroy } from 'svelte';
4
+ import Icon from '$frontend/lib/components/common/Icon.svelte';
5
+ import AvatarBubble from '$frontend/lib/components/common/AvatarBubble.svelte';
6
+ import { sessionState } from '$frontend/lib/stores/core/sessions.svelte';
7
+ import { projectState } from '$frontend/lib/stores/core/projects.svelte';
8
+ import { presenceState } from '$frontend/lib/stores/core/presence.svelte';
9
+ import { userStore } from '$frontend/lib/stores/features/user.svelte';
10
+ import {
11
+ workspaceState,
12
+ type PanelId,
13
+ swapPanel,
14
+ splitPanel,
15
+ closePanel,
16
+ canClosePanel,
17
+ PANEL_OPTIONS
18
+ } from '$frontend/lib/stores/ui/workspace.svelte';
19
+ import type { IconName } from '$shared/types/ui/icons';
20
+ import type { DeviceSize } from '$frontend/lib/constants/preview';
21
+
22
+ import { DEVICE_VIEWPORTS } from '$frontend/lib/constants/preview';
23
+
24
+ interface Props {
25
+ panelId: PanelId;
26
+ chatPanelRef?: any;
27
+ filesPanelRef?: any;
28
+ terminalPanelRef?: any;
29
+ previewPanelRef?: any;
30
+ gitPanelRef?: any;
31
+ onHistoryOpen?: () => void;
32
+ }
33
+
34
+ const {
35
+ panelId,
36
+ chatPanelRef,
37
+ filesPanelRef,
38
+ terminalPanelRef,
39
+ previewPanelRef,
40
+ gitPanelRef,
41
+ onHistoryOpen
42
+ }: Props = $props();
43
+
44
+ const panel = $derived(workspaceState.panels[panelId]);
45
+ const iconName = $derived((panel?.icon ?? 'lucide:box') as IconName);
46
+
47
+ // Mobile detection
48
+ let isMobile = $state(false);
49
+
50
+ // Chat session users (other users in the same chat session, excluding self)
51
+ const chatSessionUsers = $derived.by(() => {
52
+ if (panelId !== 'chat') return [];
53
+ const projectId = projectState.currentProject?.id;
54
+ const chatSessionId = sessionState.currentSession?.id;
55
+ const currentUserId = userStore.currentUser?.id;
56
+ if (!projectId || !chatSessionId) return [];
57
+ const status = presenceState.statuses.get(projectId);
58
+ if (!status?.chatSessionUsers) return [];
59
+ const users = status.chatSessionUsers[chatSessionId] || [];
60
+ return currentUserId ? users.filter(u => u.userId !== currentUserId) : users;
61
+ });
62
+
63
+ // Chat session users popover
64
+ let showChatUsersPopover = $state(false);
65
+ let chatUsersContainer = $state<HTMLDivElement | null>(null);
66
+
67
+ function toggleChatUsersPopover(e: MouseEvent) {
68
+ e.stopPropagation();
69
+ showChatUsersPopover = !showChatUsersPopover;
70
+ }
71
+
72
+ $effect(() => {
73
+ if (showChatUsersPopover) {
74
+ const handleClickOutside = (e: MouseEvent) => {
75
+ if (chatUsersContainer && !chatUsersContainer.contains(e.target as Node)) {
76
+ showChatUsersPopover = false;
77
+ }
78
+ };
79
+ document.addEventListener('click', handleClickOutside, true);
80
+ return () => document.removeEventListener('click', handleClickOutside, true);
81
+ }
82
+ });
83
+
84
+ // Panel actions menu state
85
+ let showActionsMenu = $state(false);
86
+ let actionsButtonRef = $state<HTMLButtonElement | null>(null);
87
+ let menuPosition = $state({ top: 0, left: 0 });
88
+
89
+ function toggleActionsMenu(e: MouseEvent) {
90
+ e.stopPropagation();
91
+ if (!showActionsMenu && actionsButtonRef) {
92
+ const rect = actionsButtonRef.getBoundingClientRect();
93
+ menuPosition = { top: rect.bottom + 4, left: rect.left };
94
+ }
95
+ showActionsMenu = !showActionsMenu;
96
+ }
97
+
98
+ function closeActionsMenu() {
99
+ showActionsMenu = false;
100
+ }
101
+
102
+ function handleSwap(newPanelId: PanelId) {
103
+ swapPanel(panelId, newPanelId);
104
+ closeActionsMenu();
105
+ }
106
+
107
+ function handleSplit(direction: 'vertical' | 'horizontal') {
108
+ splitPanel(panelId, direction);
109
+ closeActionsMenu();
110
+ }
111
+
112
+ function handleClose() {
113
+ closePanel(panelId);
114
+ closeActionsMenu();
115
+ }
116
+
117
+ // Preview panel device dropdown state
118
+ let showDeviceDropdown = $state(false);
119
+
120
+ // Git remote dropdown state
121
+ let showRemoteDropdown = $state(false);
122
+
123
+ function toggleDeviceDropdown() {
124
+ showDeviceDropdown = !showDeviceDropdown;
125
+ }
126
+
127
+ function closeDeviceDropdown() {
128
+ showDeviceDropdown = false;
129
+ }
130
+
131
+ function selectDevice(size: DeviceSize) {
132
+ previewPanelRef?.panelActions?.setDeviceSize(size);
133
+ closeDeviceDropdown();
134
+ }
135
+
136
+ function handleResize() {
137
+ if (browser) {
138
+ isMobile = window.innerWidth < 1024;
139
+ }
140
+ }
141
+
142
+ onMount(() => {
143
+ handleResize();
144
+ if (browser) {
145
+ window.addEventListener('resize', handleResize);
146
+ }
147
+ });
148
+
149
+ onDestroy(() => {
150
+ if (browser) {
151
+ window.removeEventListener('resize', handleResize);
152
+ }
153
+ });
154
+ </script>
155
+
156
+ <header
157
+ class="flex items-center justify-between shrink-0 {isMobile
158
+ ? 'h-11 pb-2 px-4 bg-white/90 dark:bg-slate-900/98 border-b border-slate-200 dark:border-slate-800'
159
+ : 'py-2.5 px-3.5 bg-slate-100 dark:bg-slate-800/80 border-b border-slate-200 dark:border-slate-800'}"
160
+ >
161
+ <div class="flex items-center text-sm font-medium text-slate-900 dark:text-slate-100">
162
+ <!-- Panel layout actions (⋮ menu) -->
163
+ {#if !isMobile}
164
+ <div class="relative -ml-0.5 mr-2 border-slate-200 dark:border-slate-700">
165
+ <button
166
+ bind:this={actionsButtonRef}
167
+
168
+ type="button"
169
+ class="flex items-center justify-center w-6 h-6 bg-transparent border-none rounded-md text-slate-400 cursor-pointer transition-all duration-150 hover:bg-violet-500/10 hover:text-slate-700 dark:hover:text-slate-200"
170
+ onclick={toggleActionsMenu}
171
+ title="Panel actions"
172
+ >
173
+ <Icon name="lucide:ellipsis-vertical" class="w-3.5 h-3.5" />
174
+ </button>
175
+
176
+ {#if showActionsMenu}
177
+ <div class="fixed inset-0 z-40" onclick={closeActionsMenu}></div>
178
+ <div class="fixed z-50 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg shadow-lg overflow-hidden min-w-44 py-1" style="top: {menuPosition.top}px; left: {menuPosition.left}px;">
179
+ <!-- Switch to section -->
180
+ <div class="px-3 py-1.5 text-3xs font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-wider">
181
+ Switch to
182
+ </div>
183
+ {#each PANEL_OPTIONS as option}
184
+ <button
185
+ type="button"
186
+ class="flex items-center gap-2.5 w-full px-3 py-1.5 text-left text-xs bg-transparent border-none cursor-pointer transition-all duration-150 hover:bg-violet-500/10
187
+ {option.id === panelId ? 'text-violet-600 dark:text-violet-400 font-medium' : 'text-slate-700 dark:text-slate-300'}"
188
+ onclick={() => handleSwap(option.id)}
189
+ disabled={option.id === panelId}
190
+ >
191
+ <Icon name={option.icon} class="w-3.5 h-3.5" />
192
+ <span class="flex-1">{option.title}</span>
193
+ {#if option.id === panelId}
194
+ <Icon name="lucide:check" class="w-3 h-3 text-violet-600 dark:text-violet-400" />
195
+ {/if}
196
+ </button>
197
+ {/each}
198
+
199
+ <!-- Divider -->
200
+ <div class="my-1 border-t border-slate-200 dark:border-slate-700"></div>
201
+
202
+ <!-- Split actions -->
203
+ <button
204
+ type="button"
205
+ class="flex items-center gap-2.5 w-full px-3 py-1.5 text-left text-xs bg-transparent border-none cursor-pointer transition-all duration-150 hover:bg-violet-500/10 text-slate-700 dark:text-slate-300"
206
+ onclick={() => handleSplit('vertical')}
207
+ >
208
+ <Icon name="lucide:columns-2" class="w-3.5 h-3.5" />
209
+ <span>Split Right</span>
210
+ </button>
211
+ <button
212
+ type="button"
213
+ class="flex items-center gap-2.5 w-full px-3 py-1.5 text-left text-xs bg-transparent border-none cursor-pointer transition-all duration-150 hover:bg-violet-500/10 text-slate-700 dark:text-slate-300"
214
+ onclick={() => handleSplit('horizontal')}
215
+ >
216
+ <Icon name="lucide:rows-2" class="w-3.5 h-3.5" />
217
+ <span>Split Down</span>
218
+ </button>
219
+
220
+ <!-- Divider -->
221
+ <div class="my-1 border-t border-slate-200 dark:border-slate-700"></div>
222
+
223
+ <!-- Close -->
224
+ <button
225
+ type="button"
226
+ class="flex items-center gap-2.5 w-full px-3 py-1.5 text-left text-xs bg-transparent border-none cursor-pointer transition-all duration-150 hover:bg-red-500/10 text-red-600 dark:text-red-400 disabled:opacity-40 disabled:cursor-not-allowed"
227
+ onclick={handleClose}
228
+ disabled={!canClosePanel()}
229
+ title={!canClosePanel() ? 'Cannot close last panel' : 'Close this panel'}
230
+ >
231
+ <Icon name="lucide:x" class="w-3.5 h-3.5" />
232
+ <span>Close Panel</span>
233
+ </button>
234
+ </div>
235
+ {/if}
236
+ </div>
237
+ {/if}
238
+ <Icon name={iconName} class="w-4 h-4 text-violet-600" />
239
+ <span class="ml-2.5">{panel?.title ?? 'Panel'}</span>
240
+ </div>
241
+
242
+ <div class="flex items-center">
243
+ <!-- Panel-specific actions -->
244
+ <div class="flex items-center gap-1.5">
245
+ {#if panelId === 'chat'}
246
+ {#if chatSessionUsers.length > 0}
247
+ <div class="relative" bind:this={chatUsersContainer}>
248
+ <div class="flex items-center -space-x-1.5 mr-1 cursor-pointer" title="Users in this session" onclick={toggleChatUsersPopover}>
249
+ {#each chatSessionUsers.slice(0, 3) as user}
250
+ <AvatarBubble {user} size="sm" />
251
+ {/each}
252
+ {#if chatSessionUsers.length > 3}
253
+ <span class="w-5 h-5 rounded-full bg-gradient-to-br from-slate-500 to-slate-600 dark:from-slate-600 dark:to-slate-700 text-white text-4xs font-bold flex items-center justify-center border-2 border-white dark:border-slate-800 z-10">
254
+ +{chatSessionUsers.length - 3}
255
+ </span>
256
+ {/if}
257
+ </div>
258
+ {#if showChatUsersPopover}
259
+ <div class="absolute top-full right-0 mt-2 py-2 px-1 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg shadow-lg z-50 min-w-[160px]">
260
+ <div class="px-2 pb-1.5 text-left text-xs font-semibold text-slate-500 dark:text-slate-400">
261
+ In this session ({chatSessionUsers.length})
262
+ </div>
263
+ {#each chatSessionUsers as user}
264
+ <div class="flex items-center gap-2 px-2 py-1.5 rounded-md">
265
+ <AvatarBubble {user} size="sm" showName={true} />
266
+ </div>
267
+ {/each}
268
+ </div>
269
+ {/if}
270
+ </div>
271
+ {/if}
272
+ <button
273
+ type="button"
274
+ class="flex items-center justify-center {isMobile ? 'w-9 h-9' : 'w-6 h-6'} bg-transparent border-none rounded-md text-slate-500 cursor-pointer transition-all duration-150 hover:bg-violet-500/10 hover:text-slate-900 dark:hover:text-slate-100"
275
+ onclick={onHistoryOpen}
276
+ title="Switch Session"
277
+ >
278
+ <Icon name="lucide:history" class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
279
+ </button>
280
+ {#if sessionState.messages.length > 0}
281
+ <button
282
+ type="button"
283
+ class="flex items-center justify-center {isMobile ? 'w-9 h-9' : 'w-6 h-6'} bg-transparent border-none rounded-md text-slate-500 cursor-pointer transition-all duration-150 hover:bg-violet-500/10 hover:text-slate-900 dark:hover:text-slate-100"
284
+ onclick={() => chatPanelRef?.panelActions?.checkpoints()}
285
+ title="Restore Checkpoint"
286
+ >
287
+ <Icon name="lucide:undo-2" class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
288
+ </button>
289
+ {/if}
290
+ <button
291
+ type="button"
292
+ class="flex items-center justify-center {isMobile ? 'w-9 h-9' : 'w-6 h-6'} bg-transparent border-none rounded-md text-slate-500 cursor-pointer transition-all duration-150 hover:bg-violet-500/10 hover:text-slate-900 dark:hover:text-slate-100"
293
+ onclick={() => chatPanelRef?.panelActions?.newChat()}
294
+ title="New Chat"
295
+ >
296
+ <Icon name="lucide:plus" class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
297
+ </button>
298
+ {:else if panelId === 'files'}
299
+ <!-- Hide view mode toggles when in two-column mode -->
300
+ {#if !filesPanelRef?.panelActions?.isTwoColumnMode()}
301
+ <div class="flex gap-1 bg-slate-100/80 dark:bg-slate-800/50 rounded-md">
302
+ <button
303
+ type="button"
304
+ class="flex items-center justify-center {isMobile ? 'w-9 h-9' : 'w-6 h-6'} bg-transparent border-none rounded text-slate-500 cursor-pointer transition-all duration-150 hover:bg-violet-500/10 hover:text-slate-900 dark:hover:text-slate-100 disabled:opacity-40 disabled:cursor-not-allowed
305
+ {filesPanelRef?.panelActions?.getViewMode() === 'tree'
306
+ ? 'bg-violet-500/15 dark:bg-violet-500/25 text-violet-600'
307
+ : ''}"
308
+ onclick={() => filesPanelRef?.panelActions?.setViewMode('tree')}
309
+ title="Tree View"
310
+ >
311
+ <Icon name="lucide:folder-tree" class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
312
+ </button>
313
+ <button
314
+ type="button"
315
+ class="flex items-center justify-center {isMobile ? 'w-9 h-9' : 'w-6 h-6'} bg-transparent border-none rounded text-slate-500 cursor-pointer transition-all duration-150 hover:bg-violet-500/10 hover:text-slate-900 dark:hover:text-slate-100 disabled:opacity-40 disabled:cursor-not-allowed
316
+ {filesPanelRef?.panelActions?.getViewMode() === 'viewer'
317
+ ? 'bg-violet-500/15 dark:bg-violet-500/25 text-violet-600'
318
+ : ''}"
319
+ onclick={() => filesPanelRef?.panelActions?.setViewMode('viewer')}
320
+ disabled={!filesPanelRef?.panelActions?.canShowViewer()}
321
+ title="File Viewer"
322
+ >
323
+ <Icon name="lucide:file-code" class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
324
+ </button>
325
+ </div>
326
+ {/if}
327
+ {:else if panelId === 'terminal'}
328
+ <button
329
+ type="button"
330
+ class="flex items-center justify-center {isMobile ? 'w-9 h-9' : 'w-6 h-6'} bg-transparent border-none rounded-md text-slate-500 cursor-pointer transition-all duration-150 hover:bg-violet-500/10 hover:text-slate-900 dark:hover:text-slate-100"
331
+ onclick={() => terminalPanelRef?.panelActions?.handleClear()}
332
+ title="Clear Terminal"
333
+ >
334
+ <Icon name="lucide:trash-2" class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
335
+ </button>
336
+ {#if !isMobile || terminalPanelRef?.panelActions?.isExecuting()}
337
+ <button
338
+ type="button"
339
+ class="flex items-center justify-center {isMobile ? 'w-9 h-9' : 'w-6 h-6'} bg-transparent border-none rounded-md {isMobile ? 'text-red-600 dark:text-red-400' : 'text-slate-500'} cursor-pointer transition-all duration-150 hover:bg-red-500/10 hover:text-red-600 dark:hover:text-red-400 disabled:opacity-50 disabled:cursor-not-allowed"
340
+ onclick={() => terminalPanelRef?.panelActions?.handleCancel()}
341
+ disabled={terminalPanelRef?.panelActions?.isCancelling()}
342
+ title="{isMobile ? 'Cancel Command (Ctrl+C)' : 'Send Ctrl+C Signal'}"
343
+ >
344
+ {#if terminalPanelRef?.panelActions?.isCancelling()}
345
+ <div
346
+ class="{isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} border-2 border-red-500/20 border-t-red-600 rounded-full animate-spin"
347
+ ></div>
348
+ {:else}
349
+ <Icon name="lucide:square" class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
350
+ {/if}
351
+ </button>
352
+ {/if}
353
+ {:else if panelId === 'preview'}
354
+ <!-- Connection status indicator -->
355
+ <!-- {@const sessionInfo = previewPanelRef?.panelActions?.getSessionInfo()}
356
+ {@const isStreamReady = previewPanelRef?.panelActions?.getIsStreamReady()}
357
+ {@const errorMessage = previewPanelRef?.panelActions?.getErrorMessage()}
358
+ {@const url = previewPanelRef?.panelActions?.getUrl()}
359
+
360
+ {#if url}
361
+ <div class="flex items-center gap-1.5 {isMobile ? 'px-2 h-9' : 'px-2 h-6'} rounded-md">
362
+ <div class="relative">
363
+ {#if !sessionInfo}
364
+ <span class="w-2 h-2 rounded-full block bg-amber-400"></span>
365
+ {:else if errorMessage}
366
+ <span class="w-2 h-2 rounded-full block bg-red-500"></span>
367
+ {:else if isStreamReady}
368
+ <span class="w-2 h-2 rounded-full block bg-emerald-500"></span>
369
+ <span class="absolute inset-0 w-2 h-2 bg-emerald-500 rounded-full animate-ping opacity-75"></span>
370
+ {:else}
371
+ <span class="w-2 h-2 rounded-full block bg-blue-400 animate-pulse"></span>
372
+ {/if}
373
+ </div>
374
+ <span class="text-xs font-medium {
375
+ !sessionInfo ? 'text-amber-600 dark:text-amber-400' :
376
+ errorMessage ? 'text-red-600 dark:text-red-400' :
377
+ isStreamReady ? 'text-emerald-600 dark:text-emerald-400' :
378
+ 'text-blue-600 dark:text-blue-400'
379
+ }">
380
+ {!sessionInfo ? 'Ready' : errorMessage ? 'Offline' : isStreamReady ? 'Online' : 'Connecting'}
381
+ </span>
382
+ </div>
383
+ {/if} -->
384
+
385
+ <!-- Device size dropdown -->
386
+ <div class="relative {isMobile ? '' : 'mr-1.5'}">
387
+ <button
388
+ type="button"
389
+ class="flex items-center justify-center gap-1.5 {isMobile ? 'px-2 h-9' : 'px-1 h-6'} bg-transparent border-none rounded-md text-slate-500 cursor-pointer transition-all duration-150 hover:bg-violet-500/10 hover:text-slate-900 dark:hover:text-slate-100"
390
+ onclick={toggleDeviceDropdown}
391
+ title="Select device size"
392
+ >
393
+ {#if previewPanelRef?.panelActions?.getDeviceSize() === 'desktop'}
394
+ <Icon name="lucide:monitor" class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
395
+ <span class="text-xs font-medium">Desktop</span>
396
+ {:else if previewPanelRef?.panelActions?.getDeviceSize() === 'laptop'}
397
+ <Icon name="lucide:laptop" class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
398
+ <span class="text-xs font-medium">Laptop</span>
399
+ {:else if previewPanelRef?.panelActions?.getDeviceSize() === 'tablet'}
400
+ <Icon name="lucide:tablet" class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
401
+ <span class="text-xs font-medium">Tablet</span>
402
+ {:else}
403
+ <Icon name="lucide:smartphone" class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
404
+ <span class="text-xs font-medium">Mobile</span>
405
+ {/if}
406
+ <Icon name="lucide:chevron-down" class={isMobile ? 'w-3.5 h-3.5' : 'w-3 h-3'} />
407
+ </button>
408
+
409
+ <!-- Dropdown menu -->
410
+ {#if showDeviceDropdown}
411
+ <div
412
+ class="fixed inset-0 z-40"
413
+ onclick={closeDeviceDropdown}
414
+ ></div>
415
+ <div class="absolute top-full right-0 mt-1 z-50 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg shadow-lg overflow-hidden {isMobile ? 'min-w-44' : 'min-w-40'}">
416
+ <button
417
+ type="button"
418
+ class="flex items-center gap-2.5 w-full px-3 {isMobile ? 'py-2.5' : 'py-2'} text-left text-sm bg-transparent border-none cursor-pointer transition-all duration-150 hover:bg-violet-500/10 {previewPanelRef?.panelActions?.getDeviceSize() === 'desktop' ? 'bg-violet-500/5 text-violet-600' : 'text-slate-700 dark:text-slate-300'}"
419
+ onclick={() => selectDevice('desktop')}
420
+ >
421
+ <Icon name="lucide:monitor" class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
422
+ <div class="flex-1">
423
+ <div class="font-medium">Desktop</div>
424
+ <div class="text-xs text-slate-500 dark:text-slate-400">
425
+ {DEVICE_VIEWPORTS.desktop.width}×{DEVICE_VIEWPORTS.desktop.height}
426
+ </div>
427
+ </div>
428
+ {#if previewPanelRef?.panelActions?.getDeviceSize() === 'desktop'}
429
+ <Icon name="lucide:check" class="{isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} text-violet-600" />
430
+ {/if}
431
+ </button>
432
+ <button
433
+ type="button"
434
+ class="flex items-center gap-2.5 w-full px-3 {isMobile ? 'py-2.5' : 'py-2'} text-left text-sm bg-transparent border-none cursor-pointer transition-all duration-150 hover:bg-violet-500/10 {previewPanelRef?.panelActions?.getDeviceSize() === 'laptop' ? 'bg-violet-500/5 text-violet-600' : 'text-slate-700 dark:text-slate-300'}"
435
+ onclick={() => selectDevice('laptop')}
436
+ >
437
+ <Icon name="lucide:laptop" class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
438
+ <div class="flex-1">
439
+ <div class="font-medium">Laptop</div>
440
+ <div class="text-xs text-slate-500 dark:text-slate-400">
441
+ {DEVICE_VIEWPORTS.laptop.width}×{DEVICE_VIEWPORTS.laptop.height}
442
+ </div>
443
+ </div>
444
+ {#if previewPanelRef?.panelActions?.getDeviceSize() === 'laptop'}
445
+ <Icon name="lucide:check" class="{isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} text-violet-600" />
446
+ {/if}
447
+ </button>
448
+ <button
449
+ type="button"
450
+ class="flex items-center gap-2.5 w-full px-3 {isMobile ? 'py-2.5' : 'py-2'} text-left text-sm bg-transparent border-none cursor-pointer transition-all duration-150 hover:bg-violet-500/10 {previewPanelRef?.panelActions?.getDeviceSize() === 'tablet' ? 'bg-violet-500/5 text-violet-600' : 'text-slate-700 dark:text-slate-300'}"
451
+ onclick={() => selectDevice('tablet')}
452
+ >
453
+ <Icon name="lucide:tablet" class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
454
+ <div class="flex-1">
455
+ <div class="font-medium">Tablet</div>
456
+ <div class="text-xs text-slate-500 dark:text-slate-400">
457
+ {DEVICE_VIEWPORTS.tablet.width}×{DEVICE_VIEWPORTS.tablet.height}
458
+ </div>
459
+ </div>
460
+ {#if previewPanelRef?.panelActions?.getDeviceSize() === 'tablet'}
461
+ <Icon name="lucide:check" class="{isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} text-violet-600" />
462
+ {/if}
463
+ </button>
464
+ <button
465
+ type="button"
466
+ class="flex items-center gap-2.5 w-full px-3 {isMobile ? 'py-2.5' : 'py-2'} text-left text-sm bg-transparent border-none cursor-pointer transition-all duration-150 hover:bg-violet-500/10 {previewPanelRef?.panelActions?.getDeviceSize() === 'mobile' ? 'bg-violet-500/5 text-violet-600' : 'text-slate-700 dark:text-slate-300'}"
467
+ onclick={() => selectDevice('mobile')}
468
+ >
469
+ <Icon name="lucide:smartphone" class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
470
+ <div class="flex-1">
471
+ <div class="font-medium">Mobile</div>
472
+ <div class="text-xs text-slate-500 dark:text-slate-400">
473
+ {DEVICE_VIEWPORTS.mobile.width}×{DEVICE_VIEWPORTS.mobile.height}
474
+ </div>
475
+ </div>
476
+ {#if previewPanelRef?.panelActions?.getDeviceSize() === 'mobile'}
477
+ <Icon name="lucide:check" class="{isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} text-violet-600" />
478
+ {/if}
479
+ </button>
480
+ </div>
481
+ {/if}
482
+ </div>
483
+
484
+ <!-- Rotation toggle -->
485
+ <button
486
+ type="button"
487
+ class="flex items-center justify-center gap-1.5 {isMobile ? 'px-2 h-9' : 'px-1 h-6'} bg-transparent border-none rounded-md text-slate-500 cursor-pointer transition-all duration-150 hover:bg-violet-500/10 hover:text-slate-900 dark:hover:text-slate-100"
488
+ onclick={() => previewPanelRef?.panelActions?.toggleRotation()}
489
+ title="Toggle orientation"
490
+ >
491
+ <Icon name="lucide:rotate-cw" class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
492
+ <span class="text-xs font-medium">
493
+ {previewPanelRef?.panelActions?.getRotation() === 'portrait' ? 'Portrait' : 'Landscape'}
494
+ </span>
495
+ </button>
496
+
497
+ <!-- Scale info badge -->
498
+ <div class="flex items-center gap-1.5 {isMobile ? 'px-2.5 h-9 bg-transparent' : 'px-2 h-6 bg-slate-100/60 dark:bg-slate-800/40'} rounded-md text-xs font-medium text-slate-500">
499
+ <Icon name="lucide:move-diagonal" class={isMobile ? 'w-4 h-4' : 'w-3.5 h-3.5'} />
500
+ <span>{Math.round((previewPanelRef?.panelActions?.getScale() || 1) * 100)}%</span>
501
+ </div>
502
+ {:else if panelId === 'git'}
503
+ <!-- View mode toggles (only in single-column mode, like Files panel) -->
504
+ {#if !gitPanelRef?.panelActions?.isTwoColumnMode()}
505
+ <div class="flex gap-1 bg-slate-100/80 dark:bg-slate-800/50 rounded-md">
506
+ <button
507
+ type="button"
508
+ class="flex items-center justify-center {isMobile ? 'w-9 h-9' : 'w-6 h-6'} bg-transparent border-none rounded text-slate-500 cursor-pointer transition-all duration-150 hover:bg-violet-500/10 hover:text-slate-900 dark:hover:text-slate-100
509
+ {gitPanelRef?.panelActions?.getViewMode() === 'list'
510
+ ? 'bg-violet-500/15 dark:bg-violet-500/25 text-violet-600'
511
+ : ''}"
512
+ onclick={() => gitPanelRef?.panelActions?.setViewMode('list')}
513
+ title="Changes List"
514
+ >
515
+ <Icon name="lucide:list" class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
516
+ </button>
517
+ <button
518
+ type="button"
519
+ class="flex items-center justify-center {isMobile ? 'w-9 h-9' : 'w-6 h-6'} bg-transparent border-none rounded text-slate-500 cursor-pointer transition-all duration-150 hover:bg-violet-500/10 hover:text-slate-900 dark:hover:text-slate-100 disabled:opacity-40 disabled:cursor-not-allowed
520
+ {gitPanelRef?.panelActions?.getViewMode() === 'diff'
521
+ ? 'bg-violet-500/15 dark:bg-violet-500/25 text-violet-600'
522
+ : ''}"
523
+ onclick={() => gitPanelRef?.panelActions?.setViewMode('diff')}
524
+ disabled={!gitPanelRef?.panelActions?.canShowDiff()}
525
+ title="Diff Viewer"
526
+ >
527
+ <Icon name="lucide:file-diff" class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
528
+ </button>
529
+ </div>
530
+ {/if}
531
+
532
+ <!-- Branch switch button -->
533
+ {#if gitPanelRef?.panelActions?.getIsRepo()}
534
+ {@const branchInfo = gitPanelRef?.panelActions?.getBranchInfo()}
535
+ <button
536
+ type="button"
537
+ class="flex items-center gap-1 {isMobile ? 'h-9 px-2' : 'h-6 px-1.5'} bg-slate-100 dark:bg-slate-800/60 border-none rounded-md text-slate-700 dark:text-slate-300 cursor-pointer transition-all duration-150 hover:bg-violet-500/10 hover:text-violet-600 dark:hover:text-violet-400"
538
+ onclick={() => gitPanelRef?.panelActions?.openBranchManager()}
539
+ title="Switch Branch"
540
+ >
541
+ <Icon name="lucide:git-branch" class={isMobile ? 'w-4 h-4' : 'w-3.5 h-3.5'} />
542
+ <span class="text-xs font-medium truncate max-w-24">{branchInfo?.current || '...'}</span>
543
+ </button>
544
+ {/if}
545
+
546
+ {@const hasRemotes = gitPanelRef?.panelActions?.getHasRemotes()}
547
+ {@const remoteName = gitPanelRef?.panelActions?.getSelectedRemote() || 'origin'}
548
+ {@const gitRemotes = gitPanelRef?.panelActions?.getRemotes() || []}
549
+
550
+ <!-- Remote selector -->
551
+ {#if hasRemotes}
552
+ <div class="relative">
553
+ <button
554
+ type="button"
555
+ class="flex items-center gap-1 {isMobile ? 'h-9 px-2' : 'h-6 px-1.5'} bg-transparent border-none rounded-md text-slate-500 cursor-pointer transition-all duration-150 hover:bg-violet-500/10 hover:text-slate-900 dark:hover:text-slate-100"
556
+ onclick={() => showRemoteDropdown = !showRemoteDropdown}
557
+ title="Select remote"
558
+ >
559
+ <Icon name="lucide:globe" class={isMobile ? 'w-4 h-4' : 'w-3.5 h-3.5'} />
560
+ <span class="text-xs font-medium">{remoteName}</span>
561
+ {#if gitRemotes.length > 1}
562
+ <Icon name="lucide:chevron-down" class="w-3 h-3" />
563
+ {/if}
564
+ </button>
565
+
566
+ {#if showRemoteDropdown && gitRemotes.length > 1}
567
+ <div class="fixed inset-0 z-40" onclick={() => showRemoteDropdown = false}></div>
568
+ <div class="absolute top-full right-0 mt-1 z-50 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg shadow-lg overflow-hidden min-w-36">
569
+ {#each gitRemotes as remote (remote.name)}
570
+ <button
571
+ type="button"
572
+ class="flex items-center gap-2 w-full px-3 py-2 text-left text-xs bg-transparent border-none cursor-pointer transition-all duration-150 hover:bg-violet-500/10
573
+ {remote.name === remoteName ? 'text-violet-600 font-medium' : 'text-slate-700 dark:text-slate-300'}"
574
+ onclick={() => { gitPanelRef?.panelActions?.setSelectedRemote(remote.name); showRemoteDropdown = false; }}
575
+ >
576
+ <Icon name="lucide:globe" class="w-3.5 h-3.5 shrink-0" />
577
+ <div class="flex-1 min-w-0">
578
+ <div>{remote.name}</div>
579
+ <div class="text-3xs text-slate-400 truncate font-mono">{remote.fetchUrl}</div>
580
+ </div>
581
+ {#if remote.name === remoteName}
582
+ <Icon name="lucide:check" class="w-3.5 h-3.5 text-violet-600 shrink-0" />
583
+ {/if}
584
+ </button>
585
+ {/each}
586
+ </div>
587
+ {/if}
588
+ </div>
589
+ {:else if gitPanelRef?.panelActions?.getIsRepo()}
590
+ <span class="text-xs text-slate-400 italic px-1">no remote</span>
591
+ {/if}
592
+
593
+ <!-- Fetch -->
594
+ <button
595
+ type="button"
596
+ class="flex items-center justify-center gap-1 {isMobile ? 'h-9 px-2' : 'h-6 px-1.5'} bg-transparent border-none rounded-md text-slate-500 cursor-pointer transition-all duration-150 hover:bg-violet-500/10 hover:text-slate-900 dark:hover:text-slate-100 disabled:opacity-50 disabled:cursor-not-allowed"
597
+ onclick={() => gitPanelRef?.panelActions?.fetch()}
598
+ disabled={gitPanelRef?.panelActions?.getIsFetching() || !hasRemotes}
599
+ title={hasRemotes ? `Fetch from ${remoteName}` : 'No remote configured'}
600
+ >
601
+ {#if gitPanelRef?.panelActions?.getIsFetching()}
602
+ <div class="{isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} border-2 border-slate-300/30 border-t-slate-500 rounded-full animate-spin"></div>
603
+ {:else}
604
+ <Icon name="lucide:cloud-download" class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
605
+ {/if}
606
+ </button>
607
+ <!-- Pull -->
608
+ <button
609
+ type="button"
610
+ class="flex items-center justify-center gap-1 {isMobile ? 'h-9 px-2' : 'h-6 px-1.5'} bg-transparent border-none rounded-md text-slate-500 cursor-pointer transition-all duration-150 hover:bg-violet-500/10 hover:text-slate-900 dark:hover:text-slate-100 disabled:opacity-50 disabled:cursor-not-allowed"
611
+ onclick={() => gitPanelRef?.panelActions?.pull()}
612
+ disabled={gitPanelRef?.panelActions?.getIsPulling() || !hasRemotes}
613
+ title={hasRemotes ? `Pull from ${remoteName}` : 'No remote configured'}
614
+ >
615
+ {#if gitPanelRef?.panelActions?.getIsPulling()}
616
+ <div class="{isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} border-2 border-slate-300/30 border-t-slate-500 rounded-full animate-spin"></div>
617
+ {:else}
618
+ <Icon name="lucide:arrow-down-to-line" class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
619
+ {/if}
620
+ </button>
621
+ <!-- Push -->
622
+ <button
623
+ type="button"
624
+ class="flex items-center justify-center gap-1 {isMobile ? 'h-9 px-2' : 'h-6 px-1.5'} bg-transparent border-none rounded-md text-slate-500 cursor-pointer transition-all duration-150 hover:bg-violet-500/10 hover:text-slate-900 dark:hover:text-slate-100 disabled:opacity-50 disabled:cursor-not-allowed"
625
+ onclick={() => gitPanelRef?.panelActions?.push()}
626
+ disabled={gitPanelRef?.panelActions?.getIsPushing() || !hasRemotes}
627
+ title={hasRemotes ? `Push to ${remoteName}` : 'No remote configured'}
628
+ >
629
+ {#if gitPanelRef?.panelActions?.getIsPushing()}
630
+ <div class="{isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} border-2 border-slate-300/30 border-t-slate-500 rounded-full animate-spin"></div>
631
+ {:else}
632
+ <Icon name="lucide:arrow-up-from-line" class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
633
+ {/if}
634
+ </button>
635
+ {/if}
636
+ </div>
637
+
638
+ </div>
639
+ </header>