@myrialabs/clopen 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/backend/lib/chat/stream-manager.ts +8 -0
  2. package/backend/lib/database/migrations/022_add_snapshot_changes_column.ts +35 -0
  3. package/backend/lib/database/migrations/index.ts +7 -0
  4. package/backend/lib/database/queries/snapshot-queries.ts +7 -4
  5. package/backend/lib/files/file-watcher.ts +34 -0
  6. package/backend/lib/project/status-manager.ts +6 -4
  7. package/backend/lib/snapshot/snapshot-service.ts +471 -316
  8. package/backend/lib/terminal/pty-session-manager.ts +1 -32
  9. package/backend/ws/chat/stream.ts +45 -2
  10. package/backend/ws/snapshot/restore.ts +77 -67
  11. package/backend/ws/system/operations.ts +95 -0
  12. package/frontend/App.svelte +24 -7
  13. package/frontend/lib/components/chat/ChatInterface.svelte +14 -14
  14. package/frontend/lib/components/chat/input/ChatInput.svelte +2 -2
  15. package/frontend/lib/components/chat/input/components/ChatInputActions.svelte +1 -1
  16. package/frontend/lib/components/chat/input/components/EngineModelPicker.svelte +8 -3
  17. package/frontend/lib/components/chat/input/composables/use-textarea-resize.svelte.ts +12 -2
  18. package/frontend/lib/components/chat/tools/AskUserQuestionTool.svelte +3 -8
  19. package/frontend/lib/components/checkpoint/TimelineModal.svelte +222 -30
  20. package/frontend/lib/components/common/ConnectionBanner.svelte +55 -0
  21. package/frontend/lib/components/common/MonacoEditor.svelte +14 -0
  22. package/frontend/lib/components/common/UpdateBanner.svelte +88 -0
  23. package/frontend/lib/components/common/xterm/XTerm.svelte +9 -0
  24. package/frontend/lib/components/common/xterm/xterm-service.ts +9 -0
  25. package/frontend/lib/components/git/DiffViewer.svelte +16 -2
  26. package/frontend/lib/components/history/HistoryModal.svelte +8 -4
  27. package/frontend/lib/components/settings/SettingsModal.svelte +2 -2
  28. package/frontend/lib/components/settings/appearance/AppearanceSettings.svelte +59 -0
  29. package/frontend/lib/components/settings/general/GeneralSettings.svelte +6 -1
  30. package/frontend/lib/components/settings/general/UpdateSettings.svelte +123 -0
  31. package/frontend/lib/components/terminal/Terminal.svelte +1 -7
  32. package/frontend/lib/components/workspace/DesktopNavigator.svelte +11 -19
  33. package/frontend/lib/components/workspace/MobileNavigator.svelte +4 -15
  34. package/frontend/lib/components/workspace/WorkspaceLayout.svelte +1 -1
  35. package/frontend/lib/components/workspace/panels/FilesPanel.svelte +3 -2
  36. package/frontend/lib/components/workspace/panels/GitPanel.svelte +3 -2
  37. package/frontend/lib/services/notification/global-stream-monitor.ts +56 -16
  38. package/frontend/lib/services/snapshot/snapshot.service.ts +71 -32
  39. package/frontend/lib/stores/core/app.svelte.ts +47 -0
  40. package/frontend/lib/stores/core/presence.svelte.ts +80 -1
  41. package/frontend/lib/stores/core/projects.svelte.ts +10 -2
  42. package/frontend/lib/stores/core/sessions.svelte.ts +15 -2
  43. package/frontend/lib/stores/features/settings.svelte.ts +10 -1
  44. package/frontend/lib/stores/features/terminal.svelte.ts +6 -0
  45. package/frontend/lib/stores/ui/connection.svelte.ts +40 -0
  46. package/frontend/lib/stores/ui/update.svelte.ts +124 -0
  47. package/frontend/lib/stores/ui/workspace.svelte.ts +4 -3
  48. package/frontend/lib/utils/ws.ts +5 -1
  49. package/index.html +1 -1
  50. package/package.json +1 -1
  51. package/shared/types/database/schema.ts +18 -0
  52. package/shared/types/stores/settings.ts +4 -0
  53. package/shared/utils/ws-client.ts +16 -2
  54. package/vite.config.ts +1 -0
@@ -2,7 +2,6 @@
2
2
  import type { AskUserQuestionToolInput } from '$shared/types/messaging';
3
3
  import Icon from '$frontend/lib/components/common/Icon.svelte';
4
4
  import ws from '$frontend/lib/utils/ws';
5
- import { soundNotification, pushNotification } from '$frontend/lib/services/notification';
6
5
  import { currentSessionId } from '$frontend/lib/stores/core/sessions.svelte';
7
6
  import { appState, updateSessionProcessState } from '$frontend/lib/stores/core/app.svelte';
8
7
  import { debug } from '$shared/utils/logger';
@@ -99,13 +98,9 @@
99
98
  otherActive = initialOther;
100
99
  });
101
100
 
102
- // Play notification sound when this tool appears and is not yet answered/errored/interrupted
103
- $effect(() => {
104
- if (!hasResult && !hasSubmitted && !isInterrupted) {
105
- soundNotification.play().catch(() => {});
106
- pushNotification.sendChatComplete('Claude is asking you a question. Please respond.').catch(() => {});
107
- }
108
- });
101
+ // Sound/push notifications for AskUserQuestion are handled globally by
102
+ // GlobalStreamMonitor (via chat:waiting-input event) works cross-session,
103
+ // plays once per tool_use, and does not replay when returning to session.
109
104
 
110
105
  function toggleSelection(questionIdx: number, label: string, isMultiSelect: boolean) {
111
106
  const current = selections[questionIdx] || new Set();
@@ -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-3xs 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>
@@ -0,0 +1,55 @@
1
+ <script lang="ts">
2
+ import { connectionState } from '$frontend/lib/stores/ui/connection.svelte';
3
+ import Icon from '$frontend/lib/components/common/Icon.svelte';
4
+ import ws from '$frontend/lib/utils/ws';
5
+ import { slide } from 'svelte/transition';
6
+
7
+ const showBanner = $derived(
8
+ connectionState.status !== 'connected' || connectionState.justReconnected
9
+ );
10
+
11
+ const isReconnecting = $derived(connectionState.status === 'reconnecting');
12
+ const isDisconnected = $derived(connectionState.status === 'disconnected');
13
+ const isReconnected = $derived(connectionState.justReconnected && connectionState.status === 'connected');
14
+
15
+ function handleReconnect() {
16
+ ws.reconnect();
17
+ }
18
+ </script>
19
+
20
+ {#if showBanner}
21
+ <div
22
+ transition:slide={{ duration: 300 }}
23
+ class="flex items-center justify-center gap-2 px-4 py-1.5 text-sm font-medium
24
+ {isReconnected
25
+ ? 'bg-emerald-600 text-white'
26
+ : isDisconnected
27
+ ? 'bg-red-600 text-white'
28
+ : 'bg-amber-600 text-white'}"
29
+ role="status"
30
+ aria-live="polite"
31
+ >
32
+ {#if isReconnected}
33
+ <Icon name="lucide:wifi" class="w-4 h-4" />
34
+ <span>Reconnected</span>
35
+ {:else if isReconnecting}
36
+ <Icon name="lucide:loader-circle" class="w-4 h-4 animate-spin" />
37
+ <span>Reconnecting{#if connectionState.reconnectAttempts > 1}&nbsp;(attempt {connectionState.reconnectAttempts}){/if}...</span>
38
+ <button
39
+ onclick={handleReconnect}
40
+ class="ml-1 px-2 py-0.5 text-xs font-semibold rounded bg-white/20 hover:bg-white/30 transition-colors"
41
+ >
42
+ Reconnect now
43
+ </button>
44
+ {:else}
45
+ <Icon name="lucide:wifi-off" class="w-4 h-4" />
46
+ <span>Connection lost</span>
47
+ <button
48
+ onclick={handleReconnect}
49
+ class="ml-1 px-2 py-0.5 text-xs font-semibold rounded bg-white/20 hover:bg-white/30 transition-colors"
50
+ >
51
+ Reconnect now
52
+ </button>
53
+ {/if}
54
+ </div>
55
+ {/if}
@@ -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) {
@@ -0,0 +1,88 @@
1
+ <script lang="ts">
2
+ import { updateState, runUpdate, dismissUpdate, checkForUpdate } from '$frontend/lib/stores/ui/update.svelte';
3
+ import { settings, updateSettings } from '$frontend/lib/stores/features/settings.svelte';
4
+ import Icon from '$frontend/lib/components/common/Icon.svelte';
5
+ import { slide } from 'svelte/transition';
6
+
7
+ const showBanner = $derived(
8
+ !updateState.dismissed && (
9
+ updateState.updateAvailable ||
10
+ updateState.updating ||
11
+ updateState.updateSuccess ||
12
+ updateState.error
13
+ )
14
+ );
15
+
16
+ function handleUpdate() {
17
+ runUpdate();
18
+ }
19
+
20
+ function handleDismiss() {
21
+ dismissUpdate();
22
+ }
23
+
24
+ function toggleAutoUpdate() {
25
+ updateSettings({ autoUpdate: !settings.autoUpdate });
26
+ }
27
+
28
+ function handleRetry() {
29
+ checkForUpdate();
30
+ }
31
+ </script>
32
+
33
+ {#if showBanner}
34
+ <div
35
+ transition:slide={{ duration: 300 }}
36
+ class="flex items-center justify-center gap-2 px-4 py-1.5 text-sm font-medium
37
+ {updateState.updateSuccess
38
+ ? 'bg-emerald-600 text-white'
39
+ : updateState.error
40
+ ? 'bg-red-600 text-white'
41
+ : updateState.updating
42
+ ? 'bg-amber-600 text-white'
43
+ : 'bg-violet-600 text-white'}"
44
+ role="status"
45
+ aria-live="polite"
46
+ >
47
+ {#if updateState.updateSuccess}
48
+ <Icon name="lucide:package-check" class="w-4 h-4" />
49
+ <span>Updated to v{updateState.latestVersion} — restart clopen to apply</span>
50
+ {:else if updateState.error}
51
+ <Icon name="lucide:package-x" class="w-4 h-4" />
52
+ <span>Update failed</span>
53
+ <button
54
+ onclick={handleRetry}
55
+ class="ml-1 px-2 py-0.5 text-xs font-semibold rounded bg-white/20 hover:bg-white/30 transition-colors"
56
+ >
57
+ Retry
58
+ </button>
59
+ <button
60
+ onclick={handleDismiss}
61
+ class="ml-1 px-1.5 py-0.5 text-xs rounded bg-white/10 hover:bg-white/20 transition-colors"
62
+ >
63
+ <Icon name="lucide:x" class="w-3 h-3" />
64
+ </button>
65
+ {:else if updateState.updating}
66
+ <Icon name="lucide:loader-circle" class="w-4 h-4 animate-spin" />
67
+ <span>Updating to v{updateState.latestVersion}...</span>
68
+ {:else}
69
+ <Icon name="lucide:package" class="w-4 h-4" />
70
+ <span>
71
+ v{updateState.latestVersion} available
72
+ <span class="opacity-70">(current: v{updateState.currentVersion})</span>
73
+ </span>
74
+ <button
75
+ onclick={handleUpdate}
76
+ class="ml-1 px-2 py-0.5 text-xs font-semibold rounded bg-white/20 hover:bg-white/30 transition-colors"
77
+ >
78
+ Update now
79
+ </button>
80
+ <button
81
+ onclick={handleDismiss}
82
+ class="ml-1 px-1.5 py-0.5 text-xs rounded bg-white/10 hover:bg-white/20 transition-colors"
83
+ >
84
+ <Icon name="lucide:x" class="w-3 h-3" />
85
+ </button>
86
+ {/if}
87
+ </div>
88
+ {/if}
@@ -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,9 @@
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
+ import { isSessionUnread } from '$frontend/lib/stores/core/app.svelte';
14
15
  import { userStore } from '$frontend/lib/stores/features/user.svelte';
15
- import { getSessionProcessState } from '$frontend/lib/stores/core/app.svelte';
16
16
  import { debug } from '$shared/utils/logger';
17
17
 
18
18
  interface Props {
@@ -474,7 +474,11 @@
474
474
  <Icon name="lucide:message-square" class="text-violet-600 dark:text-violet-400 w-4 h-4" />
475
475
  {#if streaming}
476
476
  <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'}"
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 {isSessionWaitingInput(session.id, projectState.currentProject?.id) ? 'bg-amber-500' : 'bg-emerald-500'}"
478
+ ></span>
479
+ {:else if isSessionUnread(session.id)}
480
+ <span
481
+ class="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-white dark:border-slate-900 bg-blue-500"
478
482
  ></span>
479
483
  {:else}
480
484
  <span
@@ -512,7 +516,7 @@
512
516
  {/if}
513
517
  </div>
514
518
  {#if streaming}
515
- {#if getSessionProcessState(session.id).isWaitingInput}
519
+ {#if isSessionWaitingInput(session.id, projectState.currentProject?.id)}
516
520
  <p class="text-xs text-amber-500 dark:text-amber-400 mt-0.5 flex items-center gap-1.5">
517
521
  <Icon name="lucide:message-circle-question-mark" class="w-3 h-3 shrink-0" />
518
522
  Waiting for input...