@myrialabs/clopen 0.2.12 → 0.2.13

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.
@@ -30,8 +30,7 @@ export const sessionHandler = createRouter()
30
30
  workingDirectory: t.Optional(t.String()),
31
31
  projectPath: t.Optional(t.String()),
32
32
  cols: t.Optional(t.Number()),
33
- rows: t.Optional(t.Number()),
34
- outputStartIndex: t.Optional(t.Number())
33
+ rows: t.Optional(t.Number())
35
34
  }),
36
35
  response: t.Object({
37
36
  sessionId: t.String(),
@@ -48,8 +47,7 @@ export const sessionHandler = createRouter()
48
47
  workingDirectory,
49
48
  projectPath,
50
49
  cols = 80,
51
- rows = 24,
52
- outputStartIndex = 0
50
+ rows = 24
53
51
  } = data;
54
52
 
55
53
  const projectId = ws.getProjectId(conn);
@@ -120,7 +118,7 @@ export const sessionHandler = createRouter()
120
118
  projectPath || '',
121
119
  projectId || '',
122
120
  streamId,
123
- outputStartIndex
121
+ { cols, rows }
124
122
  );
125
123
 
126
124
  // Broadcast initial ready event (frontend filters by sessionId)
@@ -179,20 +177,17 @@ export const sessionHandler = createRouter()
179
177
 
180
178
  debug.log('terminal', `✅ Added fresh listeners to PTY session ${sessionId}`);
181
179
 
182
- // Replay historical output for reconnection (e.g., after browser refresh)
183
- // The stream preserves output from the old stream when reconnecting to the same PTY.
184
- // Replay from outputStartIndex so frontend receives all output it doesn't have yet.
185
- const historicalOutput = terminalStreamManager.getOutput(registeredStreamId, outputStartIndex);
186
- if (historicalOutput.length > 0) {
187
- debug.log('terminal', `📜 Replaying ${historicalOutput.length} historical output entries for session ${sessionId}`);
188
- for (const output of historicalOutput) {
189
- ws.emit.project(projectId, 'terminal:output', {
190
- sessionId,
191
- content: output,
192
- projectId,
193
- timestamp: new Date().toISOString()
194
- });
195
- }
180
+ // Replay serialized terminal state for reconnection (e.g., after browser refresh)
181
+ // The headless xterm preserves full terminal state including clear/scrollback
182
+ const serializedOutput = terminalStreamManager.getSerializedOutput(registeredStreamId);
183
+ if (serializedOutput) {
184
+ debug.log('terminal', `📜 Replaying serialized terminal state for session ${sessionId}`);
185
+ ws.emit.project(projectId, 'terminal:output', {
186
+ sessionId,
187
+ content: serializedOutput,
188
+ projectId,
189
+ timestamp: new Date().toISOString()
190
+ });
196
191
  }
197
192
 
198
193
  // Broadcast terminal tab created to all project users
@@ -216,6 +211,20 @@ export const sessionHandler = createRouter()
216
211
  };
217
212
  })
218
213
 
214
+ // Clear headless terminal buffer (sync with frontend clear)
215
+ .http('terminal:clear', {
216
+ data: t.Object({
217
+ sessionId: t.String()
218
+ }),
219
+ response: t.Object({
220
+ sessionId: t.String()
221
+ })
222
+ }, async ({ data }) => {
223
+ const { sessionId } = data;
224
+ terminalStreamManager.clearHeadlessTerminal(sessionId);
225
+ return { sessionId };
226
+ })
227
+
219
228
  // Resize terminal viewport
220
229
  .http('terminal:resize', {
221
230
  data: t.Object({
@@ -239,6 +248,9 @@ export const sessionHandler = createRouter()
239
248
  throw new Error('No active PTY session found');
240
249
  }
241
250
 
251
+ // Keep headless terminal in sync with PTY dimensions
252
+ terminalStreamManager.resizeHeadlessTerminal(sessionId, cols, rows);
253
+
242
254
  return { sessionId, cols, rows };
243
255
  })
244
256
 
@@ -309,6 +321,12 @@ export const sessionHandler = createRouter()
309
321
 
310
322
  debug.log('terminal', `💀 [kill-session] Successfully killed PTY session: ${sessionId} (PID: ${pid})`);
311
323
 
324
+ // Clean up stream and headless terminal
325
+ const stream = terminalStreamManager.getStreamBySession(sessionId);
326
+ if (stream) {
327
+ terminalStreamManager.removeStream(stream.streamId);
328
+ }
329
+
312
330
  // Broadcast terminal tab closed to all project users
313
331
  const projectId = ws.getProjectId(conn);
314
332
  ws.emit.project(projectId, 'terminal:tab-closed', {
package/bun.lock CHANGED
@@ -16,8 +16,10 @@
16
16
  "@xterm/addon-clipboard": "^0.2.0",
17
17
  "@xterm/addon-fit": "^0.11.0",
18
18
  "@xterm/addon-ligatures": "^0.10.0",
19
+ "@xterm/addon-serialize": "^0.14.0",
19
20
  "@xterm/addon-unicode11": "^0.9.0",
20
21
  "@xterm/addon-web-links": "^0.12.0",
22
+ "@xterm/headless": "^6.0.0",
21
23
  "@xterm/xterm": "^6.0.0",
22
24
  "bun-pty": "^0.4.2",
23
25
  "cloudflared": "^0.7.1",
@@ -348,10 +350,14 @@
348
350
 
349
351
  "@xterm/addon-ligatures": ["@xterm/addon-ligatures@0.10.0", "", { "dependencies": { "font-finder": "^1.1.0", "font-ligatures": "^1.4.1" } }, "sha512-/Few8ZSHMib7sGjRJoc5l7bCtEB9XJfkNofvPpOcWADxKaUl8og8P172j67OoACSNJAXqeCLIuvj8WFCBkcTxg=="],
350
352
 
353
+ "@xterm/addon-serialize": ["@xterm/addon-serialize@0.14.0", "", {}, "sha512-uteyTU1EkrQa2Ux6P/uFl2fzmXI46jy5uoQMKEOM0fKTyiW7cSn0WrFenHm5vO5uEXX/GpwW/FgILvv3r0WbkA=="],
354
+
351
355
  "@xterm/addon-unicode11": ["@xterm/addon-unicode11@0.9.0", "", {}, "sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw=="],
352
356
 
353
357
  "@xterm/addon-web-links": ["@xterm/addon-web-links@0.12.0", "", {}, "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw=="],
354
358
 
359
+ "@xterm/headless": ["@xterm/headless@6.0.0", "", {}, "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw=="],
360
+
355
361
  "@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="],
356
362
 
357
363
  "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
@@ -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;
@@ -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" />
@@ -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}
@@ -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