@myrialabs/clopen 0.2.12 → 0.2.14

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 (36) hide show
  1. package/backend/chat/stream-manager.ts +3 -0
  2. package/backend/engine/adapters/claude/stream.ts +2 -1
  3. package/backend/engine/types.ts +9 -0
  4. package/backend/mcp/config.ts +32 -6
  5. package/backend/snapshot/snapshot-service.ts +9 -7
  6. package/backend/terminal/stream-manager.ts +106 -155
  7. package/backend/ws/projects/crud.ts +3 -3
  8. package/backend/ws/snapshot/timeline.ts +6 -2
  9. package/backend/ws/terminal/persistence.ts +19 -33
  10. package/backend/ws/terminal/session.ts +37 -19
  11. package/bin/clopen.ts +376 -99
  12. package/bun.lock +6 -0
  13. package/frontend/components/chat/input/ChatInput.svelte +8 -0
  14. package/frontend/components/chat/input/components/LoadingIndicator.svelte +2 -2
  15. package/frontend/components/chat/input/composables/use-animations.svelte.ts +127 -111
  16. package/frontend/components/chat/input/composables/use-textarea-resize.svelte.ts +11 -1
  17. package/frontend/components/chat/widgets/FloatingTodoList.svelte +2 -2
  18. package/frontend/components/checkpoint/TimelineModal.svelte +3 -1
  19. package/frontend/components/common/overlay/Dialog.svelte +2 -2
  20. package/frontend/components/git/ChangesSection.svelte +104 -13
  21. package/frontend/components/preview/browser/BrowserPreview.svelte +7 -0
  22. package/frontend/components/preview/browser/components/Canvas.svelte +8 -0
  23. package/frontend/components/settings/engines/AIEnginesSettings.svelte +1 -1
  24. package/frontend/components/settings/general/AuthModeSettings.svelte +2 -2
  25. package/frontend/components/terminal/Terminal.svelte +5 -1
  26. package/frontend/components/tunnel/TunnelInactive.svelte +4 -4
  27. package/frontend/services/chat/chat.service.ts +52 -11
  28. package/frontend/services/terminal/project.service.ts +4 -60
  29. package/frontend/services/terminal/terminal.service.ts +18 -27
  30. package/frontend/stores/core/sessions.svelte.ts +6 -0
  31. package/frontend/stores/ui/settings-modal.svelte.ts +1 -1
  32. package/frontend/stores/ui/theme.svelte.ts +11 -11
  33. package/frontend/stores/ui/workspace.svelte.ts +1 -1
  34. package/index.html +2 -2
  35. package/package.json +4 -2
  36. package/shared/utils/anonymous-user.ts +4 -4
@@ -178,6 +178,14 @@
178
178
  }
179
179
  });
180
180
 
181
+ // Resize textarea when placeholder text changes (typewriter animation) while empty
182
+ $effect(() => {
183
+ chatPlaceholder; // track placeholder changes
184
+ if (!messageText || !messageText.trim()) {
185
+ adjustTextareaHeight();
186
+ }
187
+ });
188
+
181
189
  // Sync appState.isLoading from presence data (single source of truth for all users)
182
190
  // Also fetch partial text and reconnect to stream for late-joining users / refresh
183
191
  let lastCatchupProjectId: string | undefined;
@@ -13,12 +13,12 @@
13
13
 
14
14
  {#if appState.isLoading}
15
15
  <div
16
- class="absolute z-20 {isWelcomeState ? '-top-16' : '-top-14'} left-0 right-0 flex justify-center pointer-events-none"
16
+ class="absolute z-20 h-9 {isWelcomeState ? '-top-16' : '-top-14'} left-0 right-0 flex justify-center pointer-events-none"
17
17
  transition:fly={{ y: 100, duration: 300 }}
18
18
  >
19
19
  {#if appState.isWaitingInput}
20
20
  <!-- Waiting for user input state -->
21
- <div class="flex items-center gap-2.5 px-4 py-2 bg-amber-50 dark:bg-amber-900/30 rounded-full border border-amber-300 dark:border-amber-700 shadow-sm">
21
+ <div class="flex items-center gap-2.5 px-4 py-2 bg-amber-50 dark:bg-amber-950 rounded-full border border-amber-200 dark:border-amber-900 shadow-sm">
22
22
  <Icon name="lucide:message-circle-question-mark" class="w-4 h-4 text-amber-600 dark:text-amber-400" />
23
23
  <span class="text-sm font-medium text-amber-700 dark:text-amber-300">
24
24
  Waiting for your input...
@@ -3,6 +3,9 @@ import { onDestroy } from 'svelte';
3
3
  /**
4
4
  * Composable for managing placeholder and loading text animations
5
5
  * Combines placeholder typewriter effect and loading text rotation
6
+ *
7
+ * Both animations use a `destroyed` flag to prevent interval callbacks
8
+ * from mutating state after the owning component is torn down (HMR / navigation).
6
9
  */
7
10
 
8
11
  // ============================================================================
@@ -12,83 +15,77 @@ import { onDestroy } from 'svelte';
12
15
  export function usePlaceholderAnimation(placeholderTexts: string[]) {
13
16
  let currentPlaceholderIndex = $state(0);
14
17
  let placeholderText = $state('');
15
- let placeholderTypewriterInterval: number | null = null;
16
- let placeholderRotationInterval: number | null = null;
17
- let placeholderDeleteTimeout: number | null = null;
18
+ let destroyed = false;
19
+
20
+ // Track every active timer so stopPlaceholderAnimation can clear them all
21
+ let typewriterInterval: number | null = null;
22
+ let rotationInterval: number | null = null;
23
+ let deleteTimeout: number | null = null;
24
+ let deleteInterval: number | null = null;
18
25
 
19
- // Typewriter effect for placeholder
20
26
  function typewritePlaceholder(text: string) {
21
- if (placeholderTypewriterInterval) {
22
- clearInterval(placeholderTypewriterInterval);
23
- }
27
+ if (typewriterInterval) clearInterval(typewriterInterval);
28
+ typewriterInterval = null;
24
29
 
25
- let currentIndex = 0;
30
+ let idx = 0;
26
31
  placeholderText = '';
27
32
 
28
- placeholderTypewriterInterval = window.setInterval(() => {
29
- if (currentIndex < text.length) {
30
- placeholderText = text.substring(0, currentIndex + 1);
31
- currentIndex++;
33
+ typewriterInterval = window.setInterval(() => {
34
+ if (destroyed) { clearInterval(typewriterInterval!); typewriterInterval = null; return; }
35
+ if (idx < text.length) {
36
+ placeholderText = text.substring(0, idx + 1);
37
+ idx++;
32
38
  } else {
33
- clearInterval(placeholderTypewriterInterval!);
34
- placeholderTypewriterInterval = null;
39
+ clearInterval(typewriterInterval!);
40
+ typewriterInterval = null;
35
41
  }
36
- }, 20); // Typing speed
42
+ }, 20);
37
43
  }
38
44
 
39
- // Update placeholder with typewriter effect
40
45
  function updatePlaceholder() {
41
46
  const fullText = placeholderTexts[currentPlaceholderIndex];
42
47
 
43
- // Clear any existing delete timeout
44
- if (placeholderDeleteTimeout) {
45
- clearTimeout(placeholderDeleteTimeout);
46
- }
48
+ if (deleteTimeout) clearTimeout(deleteTimeout);
49
+ if (deleteInterval) clearInterval(deleteInterval);
50
+ deleteTimeout = null;
51
+ deleteInterval = null;
47
52
 
48
- // Use a tracked timeout that can be cleared properly
49
- placeholderDeleteTimeout = window.setTimeout(() => {
50
- // Clear current text with backspace effect
51
- let deleteInterval = window.setInterval(() => {
53
+ deleteTimeout = window.setTimeout(() => {
54
+ if (destroyed) return;
55
+ deleteTimeout = null;
56
+
57
+ deleteInterval = window.setInterval(() => {
58
+ if (destroyed) { clearInterval(deleteInterval!); deleteInterval = null; return; }
52
59
  if (placeholderText.length > 0) {
53
60
  placeholderText = placeholderText.substring(0, placeholderText.length - 1);
54
61
  } else {
55
- clearInterval(deleteInterval);
56
- // Start typing new text
62
+ clearInterval(deleteInterval!);
63
+ deleteInterval = null;
57
64
  typewritePlaceholder(fullText);
58
65
  }
59
- }, 15); // Delete speed
60
- }, 2000); // Wait 2 seconds before deleting
66
+ }, 15);
67
+ }, 2000);
61
68
  }
62
69
 
63
70
  function startPlaceholderAnimation() {
64
- // Clear any existing intervals first
65
71
  stopPlaceholderAnimation();
72
+ destroyed = false;
66
73
 
67
74
  currentPlaceholderIndex = Math.floor(Math.random() * placeholderTexts.length);
68
- // Initial placeholder
69
- const initialText = placeholderTexts[currentPlaceholderIndex];
70
- typewritePlaceholder(initialText);
75
+ typewritePlaceholder(placeholderTexts[currentPlaceholderIndex]);
71
76
 
72
- // Rotate placeholders
73
- placeholderRotationInterval = window.setInterval(() => {
77
+ rotationInterval = window.setInterval(() => {
78
+ if (destroyed) { clearInterval(rotationInterval!); rotationInterval = null; return; }
74
79
  currentPlaceholderIndex = (currentPlaceholderIndex + 1) % placeholderTexts.length;
75
80
  updatePlaceholder();
76
- }, 7000); // Change every 7 seconds
81
+ }, 7000);
77
82
  }
78
83
 
79
84
  function stopPlaceholderAnimation() {
80
- if (placeholderTypewriterInterval) {
81
- clearInterval(placeholderTypewriterInterval);
82
- placeholderTypewriterInterval = null;
83
- }
84
- if (placeholderRotationInterval) {
85
- clearInterval(placeholderRotationInterval);
86
- placeholderRotationInterval = null;
87
- }
88
- if (placeholderDeleteTimeout) {
89
- clearTimeout(placeholderDeleteTimeout);
90
- placeholderDeleteTimeout = null;
91
- }
85
+ if (typewriterInterval) { clearInterval(typewriterInterval); typewriterInterval = null; }
86
+ if (rotationInterval) { clearInterval(rotationInterval); rotationInterval = null; }
87
+ if (deleteTimeout) { clearTimeout(deleteTimeout); deleteTimeout = null; }
88
+ if (deleteInterval) { clearInterval(deleteInterval); deleteInterval = null; }
92
89
  }
93
90
 
94
91
  function setStaticPlaceholder(text: string) {
@@ -96,15 +93,13 @@ export function usePlaceholderAnimation(placeholderTexts: string[]) {
96
93
  placeholderText = text;
97
94
  }
98
95
 
99
- // Cleanup on destroy
100
96
  onDestroy(() => {
97
+ destroyed = true;
101
98
  stopPlaceholderAnimation();
102
99
  });
103
100
 
104
101
  return {
105
- get placeholderText() {
106
- return placeholderText;
107
- },
102
+ get placeholderText() { return placeholderText; },
108
103
  startAnimation: startPlaceholderAnimation,
109
104
  stopAnimation: stopPlaceholderAnimation,
110
105
  setStaticPlaceholder
@@ -116,85 +111,106 @@ export function usePlaceholderAnimation(placeholderTexts: string[]) {
116
111
  // ============================================================================
117
112
 
118
113
  export function useLoadingTextAnimation(loadingTexts: string[]) {
119
- let currentLoadingText = $state('thinking');
120
- let visibleLoadingText = $state('thinking');
121
- let loadingTextIntervalId: number | null = null;
122
- let typewriterIntervalId: number | null = null;
123
-
124
- // Typewriter effect for smooth text transition
125
- function animateTextTransition(newText: string) {
126
- if (typewriterIntervalId) {
127
- clearInterval(typewriterIntervalId);
128
- }
114
+ let visibleLoadingText = $state('');
115
+ let currentFullText = '';
116
+ let destroyed = false;
117
+
118
+ let typewriterInterval: number | null = null;
119
+ let rotationInterval: number | null = null;
120
+
121
+ /**
122
+ * Typewriter: type characters one-by-one.
123
+ * Calls `onDone` when the full text has been typed.
124
+ */
125
+ function typeText(text: string, onDone?: () => void) {
126
+ if (typewriterInterval) clearInterval(typewriterInterval);
127
+ typewriterInterval = null;
128
+
129
+ let idx = 0;
130
+ visibleLoadingText = '';
131
+
132
+ typewriterInterval = window.setInterval(() => {
133
+ if (destroyed) { clearInterval(typewriterInterval!); typewriterInterval = null; return; }
134
+ if (idx < text.length) {
135
+ visibleLoadingText = text.substring(0, idx + 1);
136
+ idx++;
137
+ } else {
138
+ clearInterval(typewriterInterval!);
139
+ typewriterInterval = null;
140
+ onDone?.();
141
+ }
142
+ }, 40);
143
+ }
129
144
 
130
- const oldText = visibleLoadingText;
131
- let deleteIndex = oldText.length;
132
- let typeIndex = 0;
133
- let isDeleting = true;
134
-
135
- typewriterIntervalId = window.setInterval(() => {
136
- if (isDeleting) {
137
- // Delete characters
138
- if (deleteIndex > 0) {
139
- visibleLoadingText = oldText.substring(0, deleteIndex - 1);
140
- deleteIndex--;
141
- } else {
142
- // Finished deleting, start typing
143
- isDeleting = false;
144
- }
145
+ /**
146
+ * Backspace: delete characters one-by-one from the current visible text.
147
+ * Calls `onDone` when the text is fully erased.
148
+ */
149
+ function deleteText(onDone?: () => void) {
150
+ if (typewriterInterval) clearInterval(typewriterInterval);
151
+ typewriterInterval = null;
152
+
153
+ const snapshot = visibleLoadingText;
154
+ let len = snapshot.length;
155
+
156
+ typewriterInterval = window.setInterval(() => {
157
+ if (destroyed) { clearInterval(typewriterInterval!); typewriterInterval = null; return; }
158
+ if (len > 0) {
159
+ len--;
160
+ visibleLoadingText = snapshot.substring(0, len);
145
161
  } else {
146
- // Type new characters
147
- if (typeIndex < newText.length) {
148
- visibleLoadingText = newText.substring(0, typeIndex + 1);
149
- typeIndex++;
150
- } else {
151
- // Finished typing
152
- clearInterval(typewriterIntervalId!);
153
- typewriterIntervalId = null;
154
- }
162
+ clearInterval(typewriterInterval!);
163
+ typewriterInterval = null;
164
+ onDone?.();
155
165
  }
156
- }, 50); // Adjust speed here (lower = faster)
166
+ }, 40);
167
+ }
168
+
169
+ function pickNextText(): string {
170
+ let next = currentFullText;
171
+ while (next === currentFullText && loadingTexts.length > 1) {
172
+ next = loadingTexts[Math.floor(Math.random() * loadingTexts.length)];
173
+ }
174
+ return next;
175
+ }
176
+
177
+ /** Delete current text, then type new text */
178
+ function transitionTo(newText: string) {
179
+ currentFullText = newText;
180
+ deleteText(() => {
181
+ if (destroyed) return;
182
+ typeText(newText);
183
+ });
157
184
  }
158
185
 
159
186
  function startLoadingAnimation() {
160
- // Clear any existing intervals first to prevent duplication
161
187
  stopLoadingAnimation();
188
+ destroyed = false;
162
189
 
163
- // Start loading text rotation with typewriter effect
164
- currentLoadingText = loadingTexts[Math.floor(Math.random() * loadingTexts.length)];
165
- visibleLoadingText = currentLoadingText;
166
- loadingTextIntervalId = window.setInterval(() => {
167
- // Get a different text than the current one
168
- let newText = currentLoadingText;
169
- while (newText === currentLoadingText && loadingTexts.length > 1) {
170
- newText = loadingTexts[Math.floor(Math.random() * loadingTexts.length)];
171
- }
172
- currentLoadingText = newText;
173
- animateTextTransition(newText);
190
+ // Type the initial text character-by-character
191
+ currentFullText = loadingTexts[Math.floor(Math.random() * loadingTexts.length)];
192
+ typeText(currentFullText);
193
+
194
+ // Rotate to a new random text periodically
195
+ rotationInterval = window.setInterval(() => {
196
+ if (destroyed) { clearInterval(rotationInterval!); rotationInterval = null; return; }
197
+ transitionTo(pickNextText());
174
198
  }, 15000);
175
199
  }
176
200
 
177
201
  function stopLoadingAnimation() {
178
- // Clear loading text interval
179
- if (loadingTextIntervalId) {
180
- window.clearInterval(loadingTextIntervalId);
181
- loadingTextIntervalId = null;
182
- }
183
- if (typewriterIntervalId) {
184
- window.clearInterval(typewriterIntervalId);
185
- typewriterIntervalId = null;
186
- }
202
+ if (typewriterInterval) { clearInterval(typewriterInterval); typewriterInterval = null; }
203
+ if (rotationInterval) { clearInterval(rotationInterval); rotationInterval = null; }
204
+ visibleLoadingText = '';
187
205
  }
188
206
 
189
- // Cleanup on destroy
190
207
  onDestroy(() => {
208
+ destroyed = true;
191
209
  stopLoadingAnimation();
192
210
  });
193
211
 
194
212
  return {
195
- get visibleLoadingText() {
196
- return visibleLoadingText;
197
- },
213
+ get visibleLoadingText() { return visibleLoadingText; },
198
214
  startAnimation: startLoadingAnimation,
199
215
  stopAnimation: stopLoadingAnimation
200
216
  };
@@ -12,8 +12,18 @@ export function useTextareaResize() {
12
12
  // Reset height to auto to get accurate scrollHeight
13
13
  textareaElement.style.height = 'auto';
14
14
 
15
- // If content is empty or only whitespace, keep at minimum height
15
+ // If content is empty, measure placeholder height instead
16
16
  if (!messageText || !messageText.trim()) {
17
+ const placeholder = textareaElement.placeholder;
18
+ if (placeholder) {
19
+ // Temporarily set value to placeholder to measure wrapped height
20
+ // (native placeholder doesn't affect scrollHeight)
21
+ textareaElement.value = placeholder;
22
+ const scrollHeight = textareaElement.scrollHeight;
23
+ textareaElement.value = '';
24
+ const newHeight = Math.min(scrollHeight, MAX_HEIGHT_PX);
25
+ textareaElement.style.height = newHeight + 'px';
26
+ }
17
27
  return;
18
28
  }
19
29
 
@@ -259,7 +259,7 @@
259
259
  <div class="flex items-center gap-1">
260
260
  <button
261
261
  onclick={toggleExpand}
262
- class="p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg transition-colors"
262
+ class="flex p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg transition-colors"
263
263
  title={todoPanelState.isExpanded ? 'Collapse' : 'Expand'}
264
264
  >
265
265
  <Icon
@@ -269,7 +269,7 @@
269
269
  </button>
270
270
  <button
271
271
  onclick={minimize}
272
- class="p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg transition-colors"
272
+ class="flex p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg transition-colors"
273
273
  title="Minimize"
274
274
  >
275
275
  <Icon name="lucide:minus" class="w-4 h-4 text-slate-600 dark:text-slate-400" />
@@ -247,9 +247,11 @@
247
247
  const previousCurrentId = timelineData?.currentHeadId;
248
248
  if (timelineData) {
249
249
  timelineData.currentHeadId = node.id;
250
+ const isInitialRestore = !!node.checkpoint.isInitial;
250
251
  graphNodes = graphNodes.map(n => ({
251
252
  ...n,
252
- isCurrent: n.id === node.id
253
+ isCurrent: n.id === node.id,
254
+ isOrphaned: isInitialRestore ? n.id !== node.id : n.isOrphaned
253
255
  }));
254
256
  }
255
257
 
@@ -201,7 +201,7 @@
201
201
  </div>
202
202
  {/if}
203
203
 
204
- <div class="flex-1 space-y-2">
204
+ <div class="flex-1 space-y-1">
205
205
  <h3 id="dialog-title" class="text-lg font-semibold {colors.text}">
206
206
  {title}
207
207
  </h3>
@@ -227,7 +227,7 @@
227
227
  </div>
228
228
  {/if}
229
229
 
230
- {#if !children}
230
+ {#if !children || onConfirm}
231
231
  <div class="flex justify-end gap-3 pt-2">
232
232
  {#if showCancel}
233
233
  <button
@@ -27,12 +27,78 @@
27
27
  onStageAll, onUnstageAll, onDiscardAll,
28
28
  onViewDiff, onResolve
29
29
  }: Props = $props();
30
+
31
+ // Virtual scroll — only render visible items when list is large
32
+ const ITEM_HEIGHT = 32;
33
+ const BUFFER = 10;
34
+ const VIRTUALIZE_THRESHOLD = 200;
35
+
36
+ let scrollEl = $state<HTMLDivElement>();
37
+ let headerEl = $state<HTMLDivElement>();
38
+ let scrollTop = $state(0);
39
+ let containerHeight = $state(384);
40
+ let panelHeight = $state(0);
41
+
42
+ const shouldVirtualize = $derived(files.length > VIRTUALIZE_THRESHOLD);
43
+ const visibleStart = $derived(
44
+ shouldVirtualize ? Math.max(0, Math.floor(scrollTop / ITEM_HEIGHT) - BUFFER) : 0
45
+ );
46
+ const visibleEnd = $derived(
47
+ shouldVirtualize
48
+ ? Math.min(files.length, Math.ceil((scrollTop + containerHeight) / ITEM_HEIGHT) + BUFFER)
49
+ : files.length
50
+ );
51
+ const visibleFiles = $derived(files.slice(visibleStart, visibleEnd));
52
+ const topPad = $derived(visibleStart * ITEM_HEIGHT);
53
+ const bottomPad = $derived(Math.max(0, (files.length - visibleEnd) * ITEM_HEIGHT));
54
+
55
+ // Dynamic max-height: fill panel minus section header — no gap
56
+ const headerH = $derived(headerEl?.offsetHeight ?? 48);
57
+ const scrollMaxH = $derived(panelHeight > 0 ? Math.max(128, panelHeight - headerH) : 384);
58
+
59
+ // Track nearest scrollable ancestor size via ResizeObserver
60
+ $effect(() => {
61
+ const el = scrollEl;
62
+ if (!el) return;
63
+
64
+ let parent = el.parentElement;
65
+ while (parent && parent !== document.body) {
66
+ const ov = getComputedStyle(parent).overflowY;
67
+ if (ov === 'auto' || ov === 'scroll') break;
68
+ parent = parent.parentElement;
69
+ }
70
+ if (!parent || parent === document.body) return;
71
+
72
+ const obs = new ResizeObserver(() => {
73
+ panelHeight = parent!.clientHeight;
74
+ containerHeight = el.clientHeight || 384;
75
+ });
76
+ obs.observe(parent);
77
+ panelHeight = parent.clientHeight;
78
+ containerHeight = el.clientHeight || 384;
79
+
80
+ return () => obs.disconnect();
81
+ });
82
+
83
+ // Reset scroll position when section is expanded (container remounts)
84
+ $effect(() => {
85
+ if (!isCollapsed) {
86
+ scrollTop = 0;
87
+ }
88
+ });
89
+
90
+ function onScroll(e: Event) {
91
+ const el = e.currentTarget as HTMLDivElement;
92
+ scrollTop = el.scrollTop;
93
+ containerHeight = el.clientHeight;
94
+ }
30
95
  </script>
31
96
 
32
97
  {#if files.length > 0}
33
98
  <div class="mb-1">
34
99
  <!-- Section header -->
35
100
  <div
101
+ bind:this={headerEl}
36
102
  onclick={() => isCollapsed = !isCollapsed}
37
103
  class="group flex items-center gap-2 py-3 px-2 cursor-pointer select-none hover:bg-slate-100 dark:hover:bg-slate-800/40 rounded-md transition-colors">
38
104
  <div
@@ -87,19 +153,44 @@
87
153
 
88
154
  <!-- Files list -->
89
155
  {#if !isCollapsed}
90
- <div class="ml-2">
91
- {#each files as file (file.path)}
92
- <FileChangeItem
93
- {file}
94
- {section}
95
- {onStage}
96
- {onUnstage}
97
- {onDiscard}
98
- {onViewDiff}
99
- {onResolve}
100
- />
101
- {/each}
102
- </div>
156
+ {#if shouldVirtualize}
157
+ <div
158
+ class="ml-2 overflow-y-auto"
159
+ style="max-height: {scrollMaxH}px"
160
+ bind:this={scrollEl}
161
+ onscroll={onScroll}
162
+ >
163
+ <div style="padding-top: {topPad}px; padding-bottom: {bottomPad}px;">
164
+ {#each visibleFiles as file (file.path)}
165
+ <div style="height: {ITEM_HEIGHT}px" class="overflow-hidden">
166
+ <FileChangeItem
167
+ {file}
168
+ {section}
169
+ {onStage}
170
+ {onUnstage}
171
+ {onDiscard}
172
+ {onViewDiff}
173
+ {onResolve}
174
+ />
175
+ </div>
176
+ {/each}
177
+ </div>
178
+ </div>
179
+ {:else}
180
+ <div class="ml-2">
181
+ {#each files as file (file.path)}
182
+ <FileChangeItem
183
+ {file}
184
+ {section}
185
+ {onStage}
186
+ {onUnstage}
187
+ {onDiscard}
188
+ {onViewDiff}
189
+ {onResolve}
190
+ />
191
+ {/each}
192
+ </div>
193
+ {/if}
103
194
  {/if}
104
195
  </div>
105
196
  {/if}
@@ -299,6 +299,13 @@
299
299
  }
300
300
  });
301
301
 
302
+ // Sync isNavigating back to active tab (Canvas resets this on first frame after navigation)
303
+ $effect(() => {
304
+ if (activeTabId && activeTab && activeTab.isNavigating !== isNavigating) {
305
+ tabManager.updateTab(activeTabId, { isNavigating });
306
+ }
307
+ });
308
+
302
309
  // Watch scale changes and send to backend
303
310
  let lastSentScale = 1;
304
311
  $effect(() => {
@@ -573,6 +573,14 @@
573
573
  isReconnecting = false;
574
574
  }, 300);
575
575
  }
576
+
577
+ // Reset navigation state when first frame arrives after navigation.
578
+ // The preview:browser-navigation event that normally resets this can be
579
+ // missed during stream reconnect (listeners are removed/re-registered),
580
+ // so use the first rendered frame as definitive signal that navigation completed.
581
+ if (isNavigating) {
582
+ isNavigating = false;
583
+ }
576
584
  });
577
585
 
578
586
  // Setup cursor change handler
@@ -73,7 +73,7 @@
73
73
  let openCodeCommandCopiedTimer: ReturnType<typeof setTimeout> | null = null;
74
74
 
75
75
  // Debug PTY (xterm.js)
76
- const showDebug = $state(true);
76
+ const showDebug = $state(false);
77
77
  let debugTermContainer = $state<HTMLDivElement>();
78
78
  let debugTerminal: Terminal | null = null;
79
79
  let debugFitAddon: FitAddon | null = null;
@@ -188,7 +188,7 @@
188
188
  <Icon name="lucide:triangle-alert" class="w-6 h-6 text-amber-600 dark:text-amber-400" />
189
189
  </div>
190
190
 
191
- <div class="flex-1 space-y-3">
191
+ <div class="flex-1 space-y-1">
192
192
  <h3 class="text-lg font-semibold text-slate-900 dark:text-slate-100">
193
193
  Change Authentication Mode
194
194
  </h3>
@@ -197,7 +197,7 @@
197
197
  </p>
198
198
 
199
199
  <!-- PAT Display -->
200
- <div class="p-3.5 rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800">
200
+ <div class="mt-3 p-3.5 rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800">
201
201
  <div class="flex items-center gap-2 text-sm font-semibold text-amber-800 dark:text-amber-200 mb-2">
202
202
  <Icon name="lucide:key-round" class="w-4 h-4" />
203
203
  <span>Your Personal Access Token</span>
@@ -5,6 +5,7 @@
5
5
  <script lang="ts">
6
6
  import { terminalStore } from '$frontend/stores/features/terminal.svelte';
7
7
  import { projectState } from '$frontend/stores/core/projects.svelte';
8
+ import { terminalService } from '$frontend/services/terminal';
8
9
  import TerminalTabs from './TerminalTabs.svelte';
9
10
  import Icon from '$frontend/components/common/display/Icon.svelte';
10
11
  import XTerm from '$frontend/components/common/xterm/XTerm.svelte';
@@ -160,11 +161,14 @@
160
161
  if (activeSession) {
161
162
  // Clear the terminal store session
162
163
  terminalStore.clearSession(activeSession.id);
163
-
164
+
164
165
  // Also immediately clear the XTerm display
165
166
  if (xterminalRef) {
166
167
  xterminalRef.clear();
167
168
  }
169
+
170
+ // Sync clear with backend headless terminal
171
+ terminalService.clearHeadlessTerminal(activeSession.id);
168
172
  }
169
173
  }
170
174