@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.
- package/backend/chat/stream-manager.ts +3 -0
- package/backend/engine/adapters/claude/stream.ts +2 -1
- package/backend/engine/types.ts +9 -0
- package/backend/mcp/config.ts +32 -6
- package/backend/terminal/stream-manager.ts +106 -155
- package/backend/ws/projects/crud.ts +3 -3
- package/backend/ws/terminal/persistence.ts +19 -33
- package/backend/ws/terminal/session.ts +37 -19
- package/bun.lock +6 -0
- package/frontend/components/chat/input/ChatInput.svelte +8 -0
- package/frontend/components/chat/input/composables/use-animations.svelte.ts +127 -111
- package/frontend/components/chat/input/composables/use-textarea-resize.svelte.ts +11 -1
- package/frontend/components/chat/widgets/FloatingTodoList.svelte +2 -2
- package/frontend/components/git/ChangesSection.svelte +104 -13
- package/frontend/components/terminal/Terminal.svelte +5 -1
- package/frontend/services/chat/chat.service.ts +8 -10
- package/frontend/services/terminal/project.service.ts +4 -60
- package/frontend/services/terminal/terminal.service.ts +18 -27
- package/frontend/stores/core/sessions.svelte.ts +6 -0
- package/package.json +4 -2
|
@@ -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
|
-
|
|
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
|
|
183
|
-
// The
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
16
|
-
|
|
17
|
-
|
|
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 (
|
|
22
|
-
|
|
23
|
-
}
|
|
27
|
+
if (typewriterInterval) clearInterval(typewriterInterval);
|
|
28
|
+
typewriterInterval = null;
|
|
24
29
|
|
|
25
|
-
let
|
|
30
|
+
let idx = 0;
|
|
26
31
|
placeholderText = '';
|
|
27
32
|
|
|
28
|
-
|
|
29
|
-
if (
|
|
30
|
-
|
|
31
|
-
|
|
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(
|
|
34
|
-
|
|
39
|
+
clearInterval(typewriterInterval!);
|
|
40
|
+
typewriterInterval = null;
|
|
35
41
|
}
|
|
36
|
-
}, 20);
|
|
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
|
-
|
|
44
|
-
if (
|
|
45
|
-
|
|
46
|
-
|
|
48
|
+
if (deleteTimeout) clearTimeout(deleteTimeout);
|
|
49
|
+
if (deleteInterval) clearInterval(deleteInterval);
|
|
50
|
+
deleteTimeout = null;
|
|
51
|
+
deleteInterval = null;
|
|
47
52
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
62
|
+
clearInterval(deleteInterval!);
|
|
63
|
+
deleteInterval = null;
|
|
57
64
|
typewritePlaceholder(fullText);
|
|
58
65
|
}
|
|
59
|
-
}, 15);
|
|
60
|
-
}, 2000);
|
|
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
|
-
|
|
69
|
-
const initialText = placeholderTexts[currentPlaceholderIndex];
|
|
70
|
-
typewritePlaceholder(initialText);
|
|
75
|
+
typewritePlaceholder(placeholderTexts[currentPlaceholderIndex]);
|
|
71
76
|
|
|
72
|
-
|
|
73
|
-
|
|
77
|
+
rotationInterval = window.setInterval(() => {
|
|
78
|
+
if (destroyed) { clearInterval(rotationInterval!); rotationInterval = null; return; }
|
|
74
79
|
currentPlaceholderIndex = (currentPlaceholderIndex + 1) % placeholderTexts.length;
|
|
75
80
|
updatePlaceholder();
|
|
76
|
-
}, 7000);
|
|
81
|
+
}, 7000);
|
|
77
82
|
}
|
|
78
83
|
|
|
79
84
|
function stopPlaceholderAnimation() {
|
|
80
|
-
if (
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
120
|
-
let
|
|
121
|
-
let
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
},
|
|
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
|
-
//
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
179
|
-
if (
|
|
180
|
-
|
|
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
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
{
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|