@myrialabs/clopen 0.1.4 → 0.1.5

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 (37) hide show
  1. package/backend/lib/chat/stream-manager.ts +8 -0
  2. package/backend/lib/database/migrations/022_add_snapshot_changes_column.ts +35 -0
  3. package/backend/lib/database/migrations/index.ts +7 -0
  4. package/backend/lib/database/queries/snapshot-queries.ts +7 -4
  5. package/backend/lib/files/file-watcher.ts +34 -0
  6. package/backend/lib/project/status-manager.ts +6 -4
  7. package/backend/lib/snapshot/snapshot-service.ts +471 -316
  8. package/backend/lib/terminal/pty-session-manager.ts +1 -32
  9. package/backend/ws/chat/stream.ts +45 -2
  10. package/backend/ws/snapshot/restore.ts +77 -67
  11. package/frontend/lib/components/chat/ChatInterface.svelte +14 -14
  12. package/frontend/lib/components/chat/input/ChatInput.svelte +2 -2
  13. package/frontend/lib/components/chat/input/components/ChatInputActions.svelte +1 -1
  14. package/frontend/lib/components/chat/input/components/EngineModelPicker.svelte +8 -3
  15. package/frontend/lib/components/chat/input/composables/use-textarea-resize.svelte.ts +12 -2
  16. package/frontend/lib/components/chat/tools/AskUserQuestionTool.svelte +3 -8
  17. package/frontend/lib/components/checkpoint/TimelineModal.svelte +222 -30
  18. package/frontend/lib/components/common/MonacoEditor.svelte +14 -0
  19. package/frontend/lib/components/common/xterm/XTerm.svelte +9 -0
  20. package/frontend/lib/components/common/xterm/xterm-service.ts +9 -0
  21. package/frontend/lib/components/git/DiffViewer.svelte +16 -2
  22. package/frontend/lib/components/history/HistoryModal.svelte +3 -4
  23. package/frontend/lib/components/settings/appearance/AppearanceSettings.svelte +59 -0
  24. package/frontend/lib/components/terminal/Terminal.svelte +1 -7
  25. package/frontend/lib/components/workspace/DesktopNavigator.svelte +11 -19
  26. package/frontend/lib/components/workspace/MobileNavigator.svelte +4 -15
  27. package/frontend/lib/components/workspace/panels/FilesPanel.svelte +3 -2
  28. package/frontend/lib/components/workspace/panels/GitPanel.svelte +3 -2
  29. package/frontend/lib/services/notification/global-stream-monitor.ts +56 -16
  30. package/frontend/lib/services/snapshot/snapshot.service.ts +71 -32
  31. package/frontend/lib/stores/core/presence.svelte.ts +63 -1
  32. package/frontend/lib/stores/features/settings.svelte.ts +9 -1
  33. package/frontend/lib/stores/features/terminal.svelte.ts +6 -0
  34. package/frontend/lib/stores/ui/workspace.svelte.ts +4 -3
  35. package/package.json +1 -1
  36. package/shared/types/database/schema.ts +18 -0
  37. package/shared/types/stores/settings.ts +2 -0
@@ -2,6 +2,7 @@
2
2
  import Modal from '$frontend/lib/components/common/Modal.svelte';
3
3
  import Dialog from '$frontend/lib/components/common/Dialog.svelte';
4
4
  import Icon from '$frontend/lib/components/common/Icon.svelte';
5
+ import DiffBlock from '$frontend/lib/components/chat/tools/components/DiffBlock.svelte';
5
6
  import TimelineGraph from './timeline/TimelineGraph.svelte';
6
7
  import { sessionState, loadMessagesForSession } from '$frontend/lib/stores/core/sessions.svelte';
7
8
  import { appState } from '$frontend/lib/stores/core/app.svelte';
@@ -10,6 +11,7 @@
10
11
  import { buildGraph } from './timeline/graph-builder';
11
12
  import { startAnimation } from './timeline/animation';
12
13
  import { snapshotService } from '$frontend/lib/services/snapshot/snapshot.service';
14
+ import type { RestoreConflict, ConflictResolution } from '$frontend/lib/services/snapshot/snapshot.service';
13
15
  import type { TimelineResponse, GraphNode, GraphEdge, VersionGroup, AnimationState } from './timeline/types';
14
16
 
15
17
  let {
@@ -32,6 +34,13 @@
32
34
  let showConfirmDialog = $state(false);
33
35
  let pendingNode = $state<GraphNode | null>(null);
34
36
 
37
+ // Conflict resolution modal state
38
+ let showConflictModal = $state(false);
39
+ let conflictList = $state<RestoreConflict[]>([]);
40
+ let conflictResolutions = $state<ConflictResolution>({});
41
+ let conflictCheckingNode = $state<GraphNode | null>(null);
42
+ let expandedDiffs = $state<Set<string>>(new Set());
43
+
35
44
  // Graph visualization state
36
45
  let graphNodes = $state<GraphNode[]>([]);
37
46
  let graphEdges = $state<GraphEdge[]>([]);
@@ -58,7 +67,7 @@
58
67
  let previousMessageCount = $state(0);
59
68
  let isInitialLoad = $state(true);
60
69
 
61
- // Track if modal was opened (for detecting modal open event)
70
+ // Track if modal was opened
62
71
  let wasOpen = $state(false);
63
72
 
64
73
  // Get the session ID to use (override or current)
@@ -66,7 +75,6 @@
66
75
 
67
76
  // Load timeline data when modal opens
68
77
  $effect(() => {
69
- // Detect modal opening (transition from closed to open)
70
78
  if (isOpen && !wasOpen && sessionId) {
71
79
  isContentReady = false;
72
80
  hasScrolledToBottom = false;
@@ -149,7 +157,6 @@
149
157
  function rebuildGraph(captureOldState = false) {
150
158
  if (!timelineData) return;
151
159
 
152
- // Capture old state for animation (FLIP technique)
153
160
  if (captureOldState && !animationState.isAnimating) {
154
161
  animationState.oldNodePositions = new Map(
155
162
  graphNodes.map(n => [n.id, { x: n.x, y: n.y }])
@@ -168,20 +175,64 @@
168
175
  svgHeight = graphData.svgHeight;
169
176
  }
170
177
 
171
- // Handle node click - show confirmation dialog
172
- function handleNodeClick(node: GraphNode) {
178
+ // Handle node click - check conflicts first, then show appropriate dialog
179
+ async function handleNodeClick(node: GraphNode) {
173
180
  if (readonly) return;
174
181
  if (processingAction || node.isCurrent || animationState.isAnimating || appState.isLoading) return;
175
182
 
176
- pendingNode = node;
177
- showConfirmDialog = true;
183
+ const currentSessionId = sessionState.currentSession?.id;
184
+ if (!currentSessionId) return;
185
+
186
+ // Check for conflicts before showing confirmation
187
+ try {
188
+ const conflictCheck = await snapshotService.checkConflicts(
189
+ node.checkpoint.messageId,
190
+ currentSessionId
191
+ );
192
+
193
+ if (conflictCheck.hasConflicts) {
194
+ // Show conflict resolution modal
195
+ conflictList = conflictCheck.conflicts;
196
+ conflictResolutions = {};
197
+ expandedDiffs = new Set();
198
+ // Default all to 'keep' (safer default)
199
+ for (const conflict of conflictCheck.conflicts) {
200
+ conflictResolutions[conflict.filepath] = 'keep';
201
+ }
202
+ conflictCheckingNode = node;
203
+ showConflictModal = true;
204
+ } else {
205
+ // No conflicts - show simple confirmation dialog
206
+ pendingNode = node;
207
+ showConfirmDialog = true;
208
+ }
209
+ } catch (error) {
210
+ debug.error('snapshot', 'Error checking conflicts:', error);
211
+ // Fallback to simple confirmation on error
212
+ pendingNode = node;
213
+ showConfirmDialog = true;
214
+ }
178
215
  }
179
216
 
180
- // Perform restore checkpoint after confirmation
217
+ // Execute restore after conflict resolution
218
+ async function confirmConflictRestore() {
219
+ if (!conflictCheckingNode) return;
220
+
221
+ showConflictModal = false;
222
+ const node = conflictCheckingNode;
223
+ conflictCheckingNode = null;
224
+
225
+ await executeRestore(node, conflictResolutions);
226
+ }
227
+
228
+ // Execute restore after simple confirmation
181
229
  async function confirmRestore() {
182
230
  if (!pendingNode) return;
231
+ await executeRestore(pendingNode);
232
+ }
183
233
 
184
- const node = pendingNode;
234
+ // Shared restore execution logic
235
+ async function executeRestore(node: GraphNode, resolutions?: ConflictResolution) {
185
236
  processingAction = true;
186
237
 
187
238
  // Capture current state for animation
@@ -202,10 +253,10 @@
202
253
  throw new Error('No active session');
203
254
  }
204
255
 
205
- // Unified restore - works for all nodes
206
256
  await snapshotService.restore(
207
257
  node.checkpoint.messageId,
208
- sessionState.currentSession.id
258
+ sessionState.currentSession.id,
259
+ resolutions
209
260
  );
210
261
 
211
262
  // Reload messages
@@ -214,7 +265,6 @@
214
265
  // Reload timeline
215
266
  const newTimelineData = await snapshotService.getTimeline(sessionState.currentSession.id);
216
267
 
217
- // Check if structure changed
218
268
  if (JSON.stringify(newTimelineData.nodes) !== JSON.stringify(timelineData?.nodes)) {
219
269
  timelineData = newTimelineData;
220
270
  rebuildGraph();
@@ -270,6 +320,36 @@
270
320
  if (text.length <= maxLength) return text;
271
321
  return text.substring(0, maxLength) + '...';
272
322
  }
323
+
324
+ function toggleDiff(filepath: string) {
325
+ const next = new Set(expandedDiffs);
326
+ if (next.has(filepath)) {
327
+ next.delete(filepath);
328
+ } else {
329
+ next.add(filepath);
330
+ }
331
+ expandedDiffs = next;
332
+ }
333
+
334
+ function formatFilePath(filepath: string): string {
335
+ const parts = filepath.split('/');
336
+ if (parts.length <= 2) return filepath;
337
+ return '.../' + parts.slice(-2).join('/');
338
+ }
339
+
340
+ function formatTimestamp(iso: string): string {
341
+ try {
342
+ const date = new Date(iso);
343
+ return date.toLocaleString(undefined, {
344
+ month: 'short',
345
+ day: 'numeric',
346
+ hour: '2-digit',
347
+ minute: '2-digit'
348
+ });
349
+ } catch {
350
+ return iso;
351
+ }
352
+ }
273
353
  </script>
274
354
 
275
355
  <Modal bind:isOpen={isOpen} bind:contentRef={scrollContainer} size="lg" onClose={onClose}>
@@ -335,22 +415,6 @@
335
415
  isDisabled={appState.isLoading}
336
416
  onNodeClick={handleNodeClick}
337
417
  />
338
-
339
- <!-- Legend -->
340
- <!-- <div class="mt-4 p-4 bg-white dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 flex flex-wrap items-center gap-6 text-xs">
341
- <div class="flex items-center gap-2">
342
- <div class="w-3 h-3 rounded-full bg-green-500 ring-2 ring-green-300 dark:ring-green-700"></div>
343
- <span class="text-slate-600 dark:text-slate-400">Current Position</span>
344
- </div>
345
- <div class="flex items-center gap-2">
346
- <div class="w-3 h-3 rounded-full bg-blue-500"></div>
347
- <span class="text-slate-600 dark:text-slate-400">Active Path</span>
348
- </div>
349
- <div class="flex items-center gap-2">
350
- <div class="w-3 h-3 rounded-full bg-slate-300 dark:bg-slate-600 opacity-60"></div>
351
- <span class="text-slate-600 dark:text-slate-400">Orphaned</span>
352
- </div>
353
- </div> -->
354
418
  </div>
355
419
  {:else if timelineData && graphNodes.length === 0}
356
420
  <div class="text-center py-12">
@@ -375,7 +439,7 @@
375
439
  {/snippet}
376
440
  </Modal>
377
441
 
378
- <!-- Confirmation Dialog -->
442
+ <!-- Simple Confirmation Dialog (no conflicts) -->
379
443
  <Dialog
380
444
  bind:isOpen={showConfirmDialog}
381
445
  type="warning"
@@ -383,7 +447,7 @@
383
447
  message={pendingNode
384
448
  ? `Are you sure you want to restore to this checkpoint?
385
449
  "${getTruncatedMessage(pendingNode.checkpoint.messageText)}"
386
- This will restore your conversation and files to this point.`
450
+ This will restore your files to this point within this session.`
387
451
  : ''}
388
452
  confirmText="Restore"
389
453
  cancelText="Cancel"
@@ -394,3 +458,131 @@ This will restore your conversation and files to this point.`
394
458
  pendingNode = null;
395
459
  }}
396
460
  />
461
+
462
+ <!-- Conflict Resolution Modal -->
463
+ <Modal
464
+ bind:isOpen={showConflictModal}
465
+ size="md"
466
+ onClose={() => {
467
+ showConflictModal = false;
468
+ conflictCheckingNode = null;
469
+ conflictList = [];
470
+ conflictResolutions = {};
471
+ expandedDiffs = new Set();
472
+ }}
473
+ >
474
+ {#snippet header()}
475
+ <div class="flex items-start gap-4 px-4 py-3 md:px-6 md:py-4">
476
+ <div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700/50 rounded-xl p-3">
477
+ <Icon name="lucide:triangle-alert" class="w-6 h-6 text-amber-600 dark:text-amber-400" />
478
+ </div>
479
+ <div class="flex-1">
480
+ <h3 id="modal-title" class="text-lg font-semibold text-slate-900 dark:text-slate-100">
481
+ Restore Conflict Detected
482
+ </h3>
483
+ <p class="text-sm text-slate-500 dark:text-slate-400 mt-1">
484
+ The following files were also modified in other sessions. Choose how to handle each file:
485
+ </p>
486
+ </div>
487
+ </div>
488
+ {/snippet}
489
+
490
+ {#snippet children()}
491
+ <div class="space-y-3">
492
+ {#each conflictList as conflict}
493
+ <div class="border border-slate-200 dark:border-slate-700 rounded-lg p-3">
494
+ <div class="flex items-center justify-between gap-2 mb-2">
495
+ <div class="flex items-center gap-2 min-w-0">
496
+ <Icon name="lucide:file-warning" class="w-4 h-4 text-amber-500 shrink-0" />
497
+ <span class="text-sm font-medium text-slate-800 dark:text-slate-200 truncate" title={conflict.filepath}>
498
+ {formatFilePath(conflict.filepath)}
499
+ </span>
500
+ </div>
501
+ {#if conflict.restoreContent && conflict.currentContent}
502
+ <button
503
+ class="shrink-0 flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-md transition-colors
504
+ {expandedDiffs.has(conflict.filepath)
505
+ ? 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border border-blue-300 dark:border-blue-600'
506
+ : 'bg-slate-50 dark:bg-slate-800 text-slate-600 dark:text-slate-400 border border-slate-200 dark:border-slate-700 hover:bg-slate-100 dark:hover:bg-slate-700'
507
+ }"
508
+ onclick={() => toggleDiff(conflict.filepath)}
509
+ >
510
+ <Icon name="lucide:git-compare" class="w-3 h-3" />
511
+ {expandedDiffs.has(conflict.filepath) ? 'Hide Diff' : 'View Diff'}
512
+ </button>
513
+ {/if}
514
+ </div>
515
+ <p class="text-xs text-slate-500 dark:text-slate-400 mb-2">
516
+ Modified by another session on {formatTimestamp(conflict.modifiedAt)}
517
+ </p>
518
+
519
+ <!-- Diff View -->
520
+ {#if expandedDiffs.has(conflict.filepath) && conflict.restoreContent && conflict.currentContent}
521
+ <div class="mb-3">
522
+ <div class="flex items-center gap-3 mb-1.5 text-[10px] text-slate-500 dark:text-slate-400">
523
+ <span class="flex items-center gap-1">
524
+ <span class="inline-block w-2 h-2 rounded-sm bg-red-400"></span>
525
+ Restore version
526
+ </span>
527
+ <span class="flex items-center gap-1">
528
+ <span class="inline-block w-2 h-2 rounded-sm bg-green-400"></span>
529
+ Current version
530
+ </span>
531
+ </div>
532
+ <DiffBlock
533
+ oldString={conflict.restoreContent}
534
+ newString={conflict.currentContent}
535
+ />
536
+ </div>
537
+ {/if}
538
+
539
+ <div class="flex gap-2">
540
+ <button
541
+ class="relative px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700"
542
+ onclick={() => { conflictResolutions[conflict.filepath] = 'restore'; }}
543
+ >
544
+ {#if conflictResolutions[conflict.filepath] === 'restore'}
545
+ <span class="absolute -top-1.5 -right-1.5 w-5 h-5 flex items-center justify-center rounded-full bg-green-500 dark:bg-green-600 border-2 border-white dark:border-slate-900">
546
+ <Icon name="lucide:check" class="w-3 h-3 text-white" />
547
+ </span>
548
+ {/if}
549
+ Restore
550
+ </button>
551
+ <button
552
+ class="relative px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700"
553
+ onclick={() => { conflictResolutions[conflict.filepath] = 'keep'; }}
554
+ >
555
+ {#if conflictResolutions[conflict.filepath] === 'keep'}
556
+ <span class="absolute -top-1.5 -right-1.5 w-5 h-5 flex items-center justify-center rounded-full bg-green-500 dark:bg-green-600 border-2 border-white dark:border-slate-900">
557
+ <Icon name="lucide:check" class="w-3 h-3 text-white" />
558
+ </span>
559
+ {/if}
560
+ Keep Current
561
+ </button>
562
+ </div>
563
+ </div>
564
+ {/each}
565
+ </div>
566
+ {/snippet}
567
+
568
+ {#snippet footer()}
569
+ <button
570
+ onclick={() => {
571
+ showConflictModal = false;
572
+ conflictCheckingNode = null;
573
+ conflictList = [];
574
+ conflictResolutions = {};
575
+ expandedDiffs = new Set();
576
+ }}
577
+ class="px-6 py-2.5 bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 text-slate-700 dark:text-slate-300 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-all duration-200 font-semibold"
578
+ >
579
+ Cancel
580
+ </button>
581
+ <button
582
+ onclick={confirmConflictRestore}
583
+ class="px-6 py-2.5 bg-amber-600 hover:bg-amber-700 text-white rounded-lg transition-all duration-200 font-semibold"
584
+ >
585
+ Proceed with Restore
586
+ </button>
587
+ {/snippet}
588
+ </Modal>
@@ -3,6 +3,7 @@
3
3
  import loader from '@monaco-editor/loader';
4
4
  import type { editor } from 'monaco-editor';
5
5
  import { themeStore } from '$frontend/lib/stores/ui/theme.svelte';
6
+ import { settings } from '$frontend/lib/stores/features/settings.svelte';
6
7
  import { debug } from '$shared/utils/logger';
7
8
 
8
9
  interface Props {
@@ -375,6 +376,8 @@
375
376
 
376
377
  const createEditorOptions: (value: string, lang: string, theme: string) => editor.IStandaloneEditorConstructionOptions = (value, lang, theme) => ({
377
378
  ...EDITOR_CONFIG,
379
+ fontSize: Math.round(settings.fontSize * 0.9),
380
+ lineHeight: Math.round(settings.fontSize * 0.9 * 1.5),
378
381
  value,
379
382
  language: lang,
380
383
  theme,
@@ -472,6 +475,17 @@
472
475
  }
473
476
  });
474
477
 
478
+ // Update font size when setting changes
479
+ $effect(() => {
480
+ const size = settings.fontSize;
481
+ if (monacoEditor && isInitialized) {
482
+ monacoEditor.updateOptions({
483
+ fontSize: Math.round(size * 0.9),
484
+ lineHeight: Math.round(size * 0.9 * 1.5)
485
+ });
486
+ }
487
+ });
488
+
475
489
  // Cleanup function
476
490
  function cleanup() {
477
491
  if (resizeObserver) {
@@ -11,6 +11,7 @@
11
11
  import { terminalStore } from '$frontend/lib/stores/features/terminal.svelte';
12
12
  import { backgroundTerminalService } from '$frontend/lib/services/terminal/background';
13
13
  import { terminalService } from '$frontend/lib/services/terminal';
14
+ import { settings } from '$frontend/lib/stores/features/settings.svelte';
14
15
 
15
16
  // Import CSS directly - Vite will handle it properly
16
17
  import 'xterm/css/xterm.css';
@@ -399,6 +400,14 @@
399
400
  lastExecutingState = sessionExecuting;
400
401
  });
401
402
 
403
+ // Reactively update terminal font size when setting changes
404
+ $effect(() => {
405
+ const size = settings.fontSize;
406
+ if (isInitialized) {
407
+ xtermService.updateFontSize(size, session?.id);
408
+ }
409
+ });
410
+
402
411
  // Save terminal buffer before switching away
403
412
  function saveCurrentBuffer(sessionId: string) {
404
413
  // No longer saving buffer since we re-render from session lines
@@ -179,6 +179,15 @@ export class XTermService {
179
179
  // No-op in interactive mode
180
180
  }
181
181
 
182
+ /**
183
+ * Update terminal font size and refit to container
184
+ */
185
+ updateFontSize(size: number, sessionId?: string): void {
186
+ if (!this.terminal) return;
187
+ this.terminal.options.fontSize = size;
188
+ this.fit(sessionId);
189
+ }
190
+
182
191
  /**
183
192
  * Show prompt (compatibility method - not needed in interactive mode)
184
193
  */
@@ -5,6 +5,7 @@
5
5
  import { getGitStatusBadgeLabel, getGitStatusBadgeColor } from '$frontend/lib/utils/git-status';
6
6
  import { themeStore } from '$frontend/lib/stores/ui/theme.svelte';
7
7
  import { projectState } from '$frontend/lib/stores/core/projects.svelte';
8
+ import { settings } from '$frontend/lib/stores/features/settings.svelte';
8
9
  import loader from '@monaco-editor/loader';
9
10
  import type { editor } from 'monaco-editor';
10
11
  import type { GitFileDiff } from '$shared/types/git';
@@ -216,10 +217,11 @@
216
217
  theme: isDark ? 'diff-dark' : 'diff-light',
217
218
  readOnly: true,
218
219
  renderSideBySide: true,
220
+ renderSideBySideInlineBreakpoint: Math.round(600 * (settings.fontSize / 13)),
219
221
  minimap: { enabled: false },
220
222
  scrollBeyondLastLine: false,
221
- fontSize: 12,
222
- lineHeight: 20,
223
+ fontSize: Math.round(settings.fontSize * 0.9),
224
+ lineHeight: Math.round(settings.fontSize * 0.9 * 1.5),
223
225
  renderOverviewRuler: false,
224
226
  enableSplitViewResizing: true,
225
227
  automaticLayout: true,
@@ -246,6 +248,18 @@
246
248
  }
247
249
  });
248
250
 
251
+ // Update font size when setting changes
252
+ $effect(() => {
253
+ const size = settings.fontSize;
254
+ if (diffEditorInstance) {
255
+ diffEditorInstance.updateOptions({
256
+ fontSize: Math.round(size * 0.9),
257
+ lineHeight: Math.round(size * 0.9 * 1.5),
258
+ renderSideBySideInlineBreakpoint: Math.round(600 * (size / 13))
259
+ });
260
+ }
261
+ });
262
+
249
263
  // Reinitialize when diff or selected file changes
250
264
  $effect(() => {
251
265
  if (activeDiff && containerRef) {
@@ -10,9 +10,8 @@
10
10
  import AvatarBubble from '$frontend/lib/components/common/AvatarBubble.svelte';
11
11
  import Modal from '$frontend/lib/components/common/Modal.svelte';
12
12
  import Dialog from '$frontend/lib/components/common/Dialog.svelte';
13
- import { presenceState } from '$frontend/lib/stores/core/presence.svelte';
13
+ import { presenceState, isSessionWaitingInput } from '$frontend/lib/stores/core/presence.svelte';
14
14
  import { userStore } from '$frontend/lib/stores/features/user.svelte';
15
- import { getSessionProcessState } from '$frontend/lib/stores/core/app.svelte';
16
15
  import { debug } from '$shared/utils/logger';
17
16
 
18
17
  interface Props {
@@ -474,7 +473,7 @@
474
473
  <Icon name="lucide:message-square" class="text-violet-600 dark:text-violet-400 w-4 h-4" />
475
474
  {#if streaming}
476
475
  <span
477
- class="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-white dark:border-slate-900 {getSessionProcessState(session.id).isWaitingInput ? 'bg-amber-500' : 'bg-emerald-500'}"
476
+ class="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-white dark:border-slate-900 {isSessionWaitingInput(session.id, projectState.currentProject?.id) ? 'bg-amber-500' : 'bg-emerald-500'}"
478
477
  ></span>
479
478
  {:else}
480
479
  <span
@@ -512,7 +511,7 @@
512
511
  {/if}
513
512
  </div>
514
513
  {#if streaming}
515
- {#if getSessionProcessState(session.id).isWaitingInput}
514
+ {#if isSessionWaitingInput(session.id, projectState.currentProject?.id)}
516
515
  <p class="text-xs text-amber-500 dark:text-amber-400 mt-0.5 flex items-center gap-1.5">
517
516
  <Icon name="lucide:message-circle-question-mark" class="w-3 h-3 shrink-0" />
518
517
  Waiting for input...
@@ -1,6 +1,20 @@
1
1
  <script lang="ts">
2
2
  import { themeStore, toggleDarkMode } from '$frontend/lib/stores/ui/theme.svelte';
3
+ import { settings, updateSettings, applyFontSize } from '$frontend/lib/stores/features/settings.svelte';
3
4
  import Icon from '../../common/Icon.svelte';
5
+
6
+ const FONT_SIZE_MIN = 10;
7
+ const FONT_SIZE_MAX = 20;
8
+
9
+ function handleFontSizeChange(e: Event) {
10
+ const value = Number((e.target as HTMLInputElement).value);
11
+ applyFontSize(value);
12
+ updateSettings({ fontSize: value });
13
+ }
14
+
15
+ function fontSizePercent() {
16
+ return ((settings.fontSize - FONT_SIZE_MIN) / (FONT_SIZE_MAX - FONT_SIZE_MIN)) * 100;
17
+ }
4
18
  </script>
5
19
 
6
20
  <div class="py-1">
@@ -41,5 +55,50 @@
41
55
  ></span>
42
56
  </label>
43
57
  </div>
58
+
59
+ <!-- Font Size -->
60
+ <div
61
+ class="flex flex-col gap-3 p-4 bg-slate-100/80 dark:bg-slate-800/80 border border-slate-200 dark:border-slate-800 rounded-xl transition-all duration-150 hover:border-violet-500/20"
62
+ >
63
+ <div class="flex items-center gap-3.5">
64
+ <div
65
+ class="flex items-center justify-center w-10 h-10 bg-violet-500/10 dark:bg-violet-500/15 rounded-lg text-violet-600 dark:text-violet-400"
66
+ >
67
+ <Icon name="lucide:type" class="w-5 h-5" />
68
+ </div>
69
+ <div class="flex flex-col gap-0.5 flex-1">
70
+ <div class="text-sm font-semibold text-slate-900 dark:text-slate-100">Font Size</div>
71
+ <div class="text-xs text-slate-600 dark:text-slate-500">Adjust the base font size of the application</div>
72
+ </div>
73
+ <div class="text-sm font-semibold text-violet-600 dark:text-violet-400 shrink-0 w-10 text-right">
74
+ {settings.fontSize}px
75
+ </div>
76
+ </div>
77
+
78
+ <div class="flex items-center gap-2.5 px-0.5">
79
+ <span class="text-xs text-slate-500 dark:text-slate-500 shrink-0">A</span>
80
+ <div class="relative flex-1 h-1.5">
81
+ <div class="absolute inset-0 bg-slate-300 dark:bg-slate-700 rounded-full"></div>
82
+ <div
83
+ class="absolute inset-y-0 left-0 bg-gradient-to-r from-violet-500 to-purple-500 rounded-full"
84
+ style="width: {fontSizePercent()}%"
85
+ ></div>
86
+ <input
87
+ type="range"
88
+ min={FONT_SIZE_MIN}
89
+ max={FONT_SIZE_MAX}
90
+ step="1"
91
+ value={settings.fontSize}
92
+ oninput={handleFontSizeChange}
93
+ class="absolute inset-0 w-full opacity-0 cursor-pointer h-full"
94
+ />
95
+ <div
96
+ class="absolute top-1/2 -translate-y-1/2 w-4 h-4 bg-white border-2 border-violet-500 rounded-full shadow-sm pointer-events-none"
97
+ style="left: calc({fontSizePercent()}% - {fontSizePercent() / 100 * 16}px)"
98
+ ></div>
99
+ </div>
100
+ <span class="text-base text-slate-500 dark:text-slate-500 shrink-0">A</span>
101
+ </div>
102
+ </div>
44
103
  </div>
45
104
  </div>
@@ -284,16 +284,10 @@
284
284
  />
285
285
  </div>
286
286
  {:else}
287
- <!-- No active session -->
287
+ <!-- No active session - will auto-connect -->
288
288
  <div class="flex-1 flex items-center justify-center font-mono">
289
289
  <div class="text-center text-slate-600 dark:text-slate-400">
290
290
  <p>No terminal sessions available</p>
291
- <button
292
- class="mt-2 px-4 py-2 bg-violet-600 hover:bg-violet-700 text-white rounded-md transition-colors"
293
- onclick={handleNewSession}
294
- >
295
- Create New Terminal
296
- </button>
297
291
  </div>
298
292
  </div>
299
293
  {/if}
@@ -7,9 +7,10 @@
7
7
  import { addNotification } from '$frontend/lib/stores/ui/notification.svelte';
8
8
  import { openSettingsModal } from '$frontend/lib/stores/ui/settings-modal.svelte';
9
9
  import { projectStatusService } from '$frontend/lib/services/project';
10
- import { presenceState } from '$frontend/lib/stores/core/presence.svelte';
10
+ import { presenceState, getProjectStatusColor } from '$frontend/lib/stores/core/presence.svelte';
11
11
  import type { Project } from '$shared/types/database/schema';
12
12
  import { debug } from '$shared/utils/logger';
13
+ import { settings } from '$frontend/lib/stores/features/settings.svelte';
13
14
  import FolderBrowser from '$frontend/lib/components/common/FolderBrowser.svelte';
14
15
  import Dialog from '$frontend/lib/components/common/Dialog.svelte';
15
16
  import ViewMenu from '$frontend/lib/components/workspace/ViewMenu.svelte';
@@ -30,7 +31,7 @@
30
31
  const isCollapsed = $derived(workspaceState.navigatorCollapsed);
31
32
  const currentProjectId = $derived(projectState.currentProject?.id);
32
33
  const navigatorWidth = $derived(
33
- workspaceState.navigatorCollapsed ? 48 : workspaceState.navigatorWidth
34
+ workspaceState.navigatorCollapsed ? 48 : Math.round(workspaceState.navigatorWidth * (settings.fontSize / 13))
34
35
  );
35
36
 
36
37
  const filteredProjects = $derived(() => {
@@ -110,18 +111,7 @@
110
111
  }
111
112
  }
112
113
 
113
- // Get status color from presence data (single source of truth from backend)
114
- // Shows real-time status for ALL projects, not just the active one.
115
- // Uses backend-computed isWaitingInput so background sessions are accurate
116
- // even when the frontend hasn't received their chat events.
117
- function getStatusColor(projectId: string): string {
118
- const status = presenceState.statuses.get(projectId);
119
- if (!status?.streams) return 'bg-slate-500/30';
120
- const activeStreams = status.streams.filter((s: any) => s.status === 'active');
121
- if (activeStreams.length === 0) return 'bg-slate-500/30';
122
- const hasWaitingInput = activeStreams.some((s: any) => s.isWaitingInput);
123
- return hasWaitingInput ? 'bg-amber-500' : 'bg-emerald-500';
124
- }
114
+ // Status color for project indicator uses shared helper from presence store
125
115
 
126
116
  // Close folder browser
127
117
  function closeFolderBrowser() {
@@ -244,7 +234,7 @@
244
234
  <div class="relative shrink-0">
245
235
  <Icon name="lucide:folder" class="w-4 h-4" />
246
236
  <span
247
- class="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-slate-50 dark:border-slate-900/95 {getStatusColor(project.id ?? '')}"
237
+ class="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-slate-50 dark:border-slate-900/95 {getProjectStatusColor(project.id ?? '')}"
248
238
  ></span>
249
239
  </div>
250
240
 
@@ -301,7 +291,7 @@
301
291
  </footer>
302
292
  {:else}
303
293
  <!-- Collapsed State: Icon Buttons -->
304
- <div class="flex-1 flex flex-col items-center gap-2 py-4 px-2">
294
+ <div class="flex flex-col items-center pt-4 px-2 shrink-0">
305
295
  <button
306
296
  type="button"
307
297
  class="flex items-center justify-center w-9 h-9 bg-transparent border-none rounded-lg text-slate-500 cursor-pointer transition-all duration-150 relative hover:bg-violet-500/10 hover:text-slate-900 dark:hover:text-slate-100"
@@ -312,13 +302,15 @@
312
302
  </button>
313
303
 
314
304
  <div class="w-6 h-px bg-violet-500/10 my-1"></div>
305
+ </div>
315
306
 
316
- {#each existingProjects.slice(0, 5) as project (project.id)}
307
+ <div class="flex-1 flex flex-col items-center gap-2 px-2 pb-4 min-h-0 overflow-y-auto">
308
+ {#each existingProjects as project (project.id)}
317
309
  {@const projectStatus = presenceState.statuses.get(project.id ?? '')}
318
310
  {@const activeUserCount = (projectStatus?.activeUsers || []).length}
319
311
  <button
320
312
  type="button"
321
- class="flex items-center justify-center w-9 h-9 border-none rounded-lg cursor-pointer transition-all duration-150 relative font-semibold text-sm
313
+ class="flex items-center justify-center w-9 h-9 shrink-0 border-none rounded-lg cursor-pointer transition-all duration-150 relative font-semibold text-sm
322
314
  {currentProjectId === project.id
323
315
  ? 'bg-violet-500/10 dark:bg-violet-500/20 text-violet-700 dark:text-violet-300'
324
316
  : 'bg-slate-200/50 dark:bg-slate-800/50 text-slate-600 dark:text-slate-400 hover:bg-violet-500/10 hover:text-slate-900 dark:hover:text-slate-100'}"
@@ -327,7 +319,7 @@
327
319
  >
328
320
  <span>{getProjectInitials(project.name)}</span>
329
321
  <span
330
- class="absolute bottom-1 right-1 w-2.5 h-2.5 rounded-full border-2 border-slate-50 dark:border-slate-900/95 {getStatusColor(project.id ?? '')}"
322
+ class="absolute bottom-1 right-1 w-2.5 h-2.5 rounded-full border-2 border-slate-50 dark:border-slate-900/95 {getProjectStatusColor(project.id ?? '')}"
331
323
  ></span>
332
324
  {#if activeUserCount > 0}
333
325
  <span