@myrialabs/clopen 0.2.7 → 0.2.9
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 +23 -12
- package/backend/mcp/project-context.ts +20 -0
- package/backend/mcp/servers/browser-automation/actions.ts +0 -2
- package/backend/mcp/servers/browser-automation/browser.ts +80 -143
- package/backend/mcp/servers/browser-automation/inspection.ts +5 -11
- package/backend/preview/browser/browser-mcp-control.ts +174 -195
- package/backend/preview/browser/browser-preview-service.ts +3 -3
- package/backend/preview/browser/browser-video-capture.ts +12 -14
- package/backend/preview/browser/scripts/video-stream.ts +14 -14
- package/backend/preview/browser/types.ts +7 -7
- package/backend/preview/index.ts +1 -1
- package/backend/terminal/stream-manager.ts +40 -26
- package/backend/ws/preview/index.ts +3 -3
- package/backend/ws/system/operations.ts +23 -0
- package/frontend/components/chat/message/MessageBubble.svelte +2 -2
- package/frontend/components/chat/tools/components/FileHeader.svelte +1 -1
- package/frontend/components/chat/tools/components/TerminalCommand.svelte +8 -1
- package/frontend/components/common/overlay/Dialog.svelte +1 -1
- package/frontend/components/common/overlay/Lightbox.svelte +2 -2
- package/frontend/components/common/overlay/Modal.svelte +2 -2
- package/frontend/components/common/xterm/XTerm.svelte +6 -1
- package/frontend/components/git/ConflictResolver.svelte +1 -1
- package/frontend/components/git/GitModal.svelte +2 -2
- package/frontend/components/preview/browser/BrowserPreview.svelte +1 -1
- package/frontend/components/preview/browser/components/Canvas.svelte +1 -1
- package/frontend/components/preview/browser/components/Toolbar.svelte +4 -4
- package/frontend/components/preview/browser/core/mcp-handlers.svelte.ts +58 -64
- package/frontend/components/settings/SettingsModal.svelte +1 -1
- package/frontend/components/settings/general/DataManagementSettings.svelte +5 -66
- package/frontend/components/terminal/Terminal.svelte +1 -29
- package/frontend/components/tunnel/TunnelInactive.svelte +7 -5
- package/frontend/components/workspace/DesktopNavigator.svelte +1 -1
- package/frontend/components/workspace/PanelHeader.svelte +22 -16
- package/frontend/components/workspace/panels/GitPanel.svelte +1 -6
- package/frontend/services/preview/browser/browser-webcodecs.service.ts +2 -2
- package/frontend/services/project/status.service.ts +11 -1
- package/frontend/stores/core/sessions.svelte.ts +11 -1
- package/frontend/stores/features/terminal.svelte.ts +56 -26
- package/frontend/stores/ui/theme.svelte.ts +1 -1
- package/frontend/utils/ws.ts +42 -0
- package/index.html +2 -2
- package/package.json +1 -1
- package/shared/utils/ws-client.ts +21 -4
- package/static/manifest.json +2 -2
|
@@ -1,16 +1,11 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { initializeProjects, projectState } from '$frontend/stores/core/projects.svelte';
|
|
3
|
-
import { initializeStore } from '$frontend/stores/core/app.svelte';
|
|
4
|
-
import { sessionState } from '$frontend/stores/core/sessions.svelte';
|
|
5
2
|
import { addNotification } from '$frontend/stores/ui/notification.svelte';
|
|
6
|
-
import {
|
|
3
|
+
import { resetToDefaults } from '$frontend/stores/features/settings.svelte';
|
|
7
4
|
import { showConfirm } from '$frontend/stores/ui/dialog.svelte';
|
|
8
|
-
import { terminalStore } from '$frontend/stores/features/terminal.svelte';
|
|
9
5
|
import Icon from '../../common/display/Icon.svelte';
|
|
10
6
|
import { debug } from '$shared/utils/logger';
|
|
11
7
|
import ws from '$frontend/utils/ws';
|
|
12
8
|
|
|
13
|
-
let isExporting = $state(false);
|
|
14
9
|
let isClearing = $state(false);
|
|
15
10
|
|
|
16
11
|
async function clearData() {
|
|
@@ -26,82 +21,26 @@
|
|
|
26
21
|
if (confirmed) {
|
|
27
22
|
isClearing = true;
|
|
28
23
|
try {
|
|
29
|
-
localStorage.clear();
|
|
30
|
-
sessionStorage.clear();
|
|
31
|
-
|
|
32
24
|
const response = await ws.http('system:clear-data', {});
|
|
33
25
|
|
|
34
26
|
if (response.cleared) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
await initializeStore();
|
|
39
|
-
resetToDefaults();
|
|
40
|
-
|
|
41
|
-
addNotification({
|
|
42
|
-
type: 'success',
|
|
43
|
-
title: 'Data Cleared',
|
|
44
|
-
message: 'All data has been cleared successfully'
|
|
45
|
-
});
|
|
27
|
+
localStorage.clear();
|
|
28
|
+
sessionStorage.clear();
|
|
29
|
+
window.location.reload();
|
|
46
30
|
}
|
|
47
31
|
} catch (error) {
|
|
48
32
|
debug.error('settings', 'Error clearing data:', error);
|
|
33
|
+
isClearing = false;
|
|
49
34
|
addNotification({
|
|
50
35
|
type: 'error',
|
|
51
36
|
title: 'Clear Data Error',
|
|
52
37
|
message: 'Failed to clear all data',
|
|
53
38
|
duration: 4000
|
|
54
39
|
});
|
|
55
|
-
} finally {
|
|
56
|
-
isClearing = false;
|
|
57
40
|
}
|
|
58
41
|
}
|
|
59
42
|
}
|
|
60
43
|
|
|
61
|
-
async function exportData() {
|
|
62
|
-
isExporting = true;
|
|
63
|
-
try {
|
|
64
|
-
const [projects, sessions, messages] = await Promise.all([
|
|
65
|
-
ws.http('projects:list', {}),
|
|
66
|
-
ws.http('sessions:list', {}),
|
|
67
|
-
ws.http('messages:list', { session_id: '', include_all: true })
|
|
68
|
-
]);
|
|
69
|
-
|
|
70
|
-
const data = {
|
|
71
|
-
projects: projects || projectState.projects,
|
|
72
|
-
sessions: sessions || sessionState.sessions,
|
|
73
|
-
messages: messages || sessionState.messages,
|
|
74
|
-
settings: settings,
|
|
75
|
-
exportedAt: new Date().toISOString(),
|
|
76
|
-
version: '1.0'
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
|
80
|
-
const url = URL.createObjectURL(blob);
|
|
81
|
-
const a = document.createElement('a');
|
|
82
|
-
a.href = url;
|
|
83
|
-
a.download = `clopen-data-${new Date().toISOString().split('T')[0]}.json`;
|
|
84
|
-
a.click();
|
|
85
|
-
URL.revokeObjectURL(url);
|
|
86
|
-
|
|
87
|
-
addNotification({
|
|
88
|
-
type: 'success',
|
|
89
|
-
title: 'Export Complete',
|
|
90
|
-
message: 'Your data has been exported successfully'
|
|
91
|
-
});
|
|
92
|
-
} catch (error) {
|
|
93
|
-
debug.error('settings', 'Export error:', error);
|
|
94
|
-
addNotification({
|
|
95
|
-
type: 'error',
|
|
96
|
-
title: 'Export Error',
|
|
97
|
-
message: 'Failed to export data',
|
|
98
|
-
duration: 4000
|
|
99
|
-
});
|
|
100
|
-
} finally {
|
|
101
|
-
isExporting = false;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
44
|
async function resetSettings() {
|
|
106
45
|
const confirmed = await showConfirm({
|
|
107
46
|
title: 'Reset Settings',
|
|
@@ -5,9 +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 { getShortcutLabels } from '$frontend/utils/platform';
|
|
9
8
|
import TerminalTabs from './TerminalTabs.svelte';
|
|
10
|
-
import LoadingSpinner from '../common/feedback/LoadingSpinner.svelte';
|
|
11
9
|
import Icon from '$frontend/components/common/display/Icon.svelte';
|
|
12
10
|
import XTerm from '$frontend/components/common/xterm/XTerm.svelte';
|
|
13
11
|
|
|
@@ -26,9 +24,6 @@
|
|
|
26
24
|
let isCancelling = $state(false);
|
|
27
25
|
let terminalContainer: HTMLDivElement | undefined = $state();
|
|
28
26
|
|
|
29
|
-
// Get platform-specific shortcut labels
|
|
30
|
-
const shortcuts = $derived(getShortcutLabels());
|
|
31
|
-
|
|
32
27
|
// Initialize terminal only once when component mounts
|
|
33
28
|
let isInitialized = false;
|
|
34
29
|
$effect(() => {
|
|
@@ -258,7 +253,7 @@
|
|
|
258
253
|
aria-label="Terminal application">
|
|
259
254
|
|
|
260
255
|
<!-- Terminal Header with Tabs -->
|
|
261
|
-
<div class="flex-shrink-0 bg-
|
|
256
|
+
<div class="flex-shrink-0 bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700">
|
|
262
257
|
<!-- Terminal Tabs -->
|
|
263
258
|
<TerminalTabs
|
|
264
259
|
sessions={terminalStore.sessions}
|
|
@@ -292,29 +287,6 @@
|
|
|
292
287
|
</div>
|
|
293
288
|
{/if}
|
|
294
289
|
|
|
295
|
-
<!-- Terminal status bar -->
|
|
296
|
-
<div class="flex-shrink-0 px-2 py-0.5 bg-slate-100 dark:bg-slate-900 border-t border-slate-200 dark:border-slate-800 text-3xs text-slate-500 dark:text-slate-500 font-mono">
|
|
297
|
-
<div class="flex items-center justify-between">
|
|
298
|
-
<div class="flex items-center space-x-3">
|
|
299
|
-
<span class="hidden sm:inline"><kbd class="px-1 py-0.5 bg-slate-200 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded text-3xs text-slate-700 dark:text-slate-300">↑↓</kbd> History</span>
|
|
300
|
-
<span class="hidden md:inline"><kbd class="px-1 py-0.5 bg-slate-200 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded text-3xs text-slate-700 dark:text-slate-300">Ctrl+L</kbd> Clear</span>
|
|
301
|
-
<span class="hidden sm:inline"><kbd class="px-1 py-0.5 bg-slate-200 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded text-3xs text-slate-700 dark:text-slate-300">{shortcuts.cancel}</kbd> Interrupt
|
|
302
|
-
{#if isCancelling}
|
|
303
|
-
<span class="animate-pulse ml-1">(cancelling...)</span>
|
|
304
|
-
{/if}
|
|
305
|
-
</span>
|
|
306
|
-
</div>
|
|
307
|
-
<div class="flex items-center space-x-1.5">
|
|
308
|
-
{#if hasActiveProject}
|
|
309
|
-
<span class="text-emerald-500 text-xs">●</span>
|
|
310
|
-
<span class="hidden sm:inline">Ready</span>
|
|
311
|
-
{:else}
|
|
312
|
-
<span class="text-amber-500 text-xs">●</span>
|
|
313
|
-
<span class="hidden sm:inline">No Project</span>
|
|
314
|
-
{/if}
|
|
315
|
-
</div>
|
|
316
|
-
</div>
|
|
317
|
-
</div>
|
|
318
290
|
</div>
|
|
319
291
|
|
|
320
292
|
<style>
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import Modal from '$frontend/components/common/overlay/Modal.svelte';
|
|
5
5
|
import Checkbox from '$frontend/components/common/form/Checkbox.svelte';
|
|
6
6
|
|
|
7
|
-
let port = $state(
|
|
7
|
+
let port = $state<number | null>(null);
|
|
8
8
|
let autoStopMinutes = $state(60);
|
|
9
9
|
let showWarning = $state(false);
|
|
10
10
|
let dontShowWarningAgain = $state(false);
|
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
);
|
|
21
21
|
|
|
22
22
|
async function handleStartTunnel() {
|
|
23
|
+
if (!port) return;
|
|
24
|
+
|
|
23
25
|
// Check if tunnel already exists for this port
|
|
24
26
|
if (tunnelStore.getTunnel(port)) {
|
|
25
27
|
warningMessage = `Tunnel already active on port ${port}`;
|
|
@@ -44,9 +46,9 @@
|
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
// Get loading and progress state for current port
|
|
47
|
-
const isLoading = $derived(tunnelStore.isLoading(port));
|
|
48
|
-
const progress = $derived(tunnelStore.getProgress(port));
|
|
49
|
-
const error = $derived(tunnelStore.getError(port));
|
|
49
|
+
const isLoading = $derived(tunnelStore.isLoading(port ?? 0));
|
|
50
|
+
const progress = $derived(tunnelStore.getProgress(port ?? 0));
|
|
51
|
+
const error = $derived(tunnelStore.getError(port ?? 0));
|
|
50
52
|
|
|
51
53
|
function openWarningModal() {
|
|
52
54
|
// Clear any previous warning messages
|
|
@@ -151,7 +153,7 @@
|
|
|
151
153
|
<!-- Start Button -->
|
|
152
154
|
<button
|
|
153
155
|
onclick={openWarningModal}
|
|
154
|
-
disabled={isLoading}
|
|
156
|
+
disabled={isLoading || !port}
|
|
155
157
|
class="inline-flex items-center justify-center font-semibold transition-colors duration-200 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed w-full px-3 md:px-4 py-2.5 text-sm rounded-lg bg-violet-600 hover:bg-violet-700 text-white gap-2"
|
|
156
158
|
>
|
|
157
159
|
{#if isLoading}
|
|
@@ -168,7 +168,7 @@
|
|
|
168
168
|
aria-label="Project Navigator"
|
|
169
169
|
>
|
|
170
170
|
<nav
|
|
171
|
-
class="flex flex-col h-full bg-
|
|
171
|
+
class="flex flex-col h-full bg-white dark:bg-slate-900/95 transition-all duration-200 {isCollapsed
|
|
172
172
|
? 'items-center'
|
|
173
173
|
: ''}"
|
|
174
174
|
>
|
|
@@ -47,6 +47,9 @@
|
|
|
47
47
|
// Mobile detection
|
|
48
48
|
let isMobile = $state(false);
|
|
49
49
|
|
|
50
|
+
// Touchscreen detection
|
|
51
|
+
let isTouchDevice = $state(false);
|
|
52
|
+
|
|
50
53
|
// Chat session users (other users in the same chat session, excluding self)
|
|
51
54
|
const chatSessionUsers = $derived.by(() => {
|
|
52
55
|
if (panelId !== 'chat') return [];
|
|
@@ -142,6 +145,7 @@
|
|
|
142
145
|
onMount(() => {
|
|
143
146
|
handleResize();
|
|
144
147
|
if (browser) {
|
|
148
|
+
isTouchDevice = navigator.maxTouchPoints > 0 || 'ontouchstart' in window;
|
|
145
149
|
window.addEventListener('resize', handleResize);
|
|
146
150
|
}
|
|
147
151
|
});
|
|
@@ -383,7 +387,7 @@
|
|
|
383
387
|
{/if} -->
|
|
384
388
|
|
|
385
389
|
<!-- Device size dropdown -->
|
|
386
|
-
<div class="relative
|
|
390
|
+
<div class="relative">
|
|
387
391
|
<button
|
|
388
392
|
type="button"
|
|
389
393
|
class="flex items-center justify-center gap-1.5 {isMobile ? 'px-2 h-9' : 'px-1 h-6'} bg-transparent border-none rounded-md text-slate-500 cursor-pointer transition-all duration-150 hover:bg-violet-500/10 hover:text-slate-900 dark:hover:text-slate-100"
|
|
@@ -481,20 +485,22 @@
|
|
|
481
485
|
{/if}
|
|
482
486
|
</div>
|
|
483
487
|
|
|
484
|
-
<!-- Touch mode toggle (scroll ↔ trackpad cursor) -->
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
488
|
+
<!-- Touch mode toggle (scroll ↔ trackpad cursor) — only shown on touchscreen devices -->
|
|
489
|
+
{#if isTouchDevice}
|
|
490
|
+
<button
|
|
491
|
+
type="button"
|
|
492
|
+
class="flex items-center justify-center gap-1.5 {isMobile ? 'px-2 h-9' : 'px-1 h-6'} bg-transparent border-none rounded-md cursor-pointer transition-all duration-150 hover:bg-violet-500/10
|
|
493
|
+
{previewPanelRef?.panelActions?.getTouchMode() === 'cursor' ? 'text-violet-600 dark:text-violet-400' : 'text-slate-500 hover:text-slate-900 dark:hover:text-slate-100'}"
|
|
494
|
+
onclick={() => {
|
|
495
|
+
const current = previewPanelRef?.panelActions?.getTouchMode() || 'scroll';
|
|
496
|
+
previewPanelRef?.panelActions?.setTouchMode(current === 'scroll' ? 'cursor' : 'scroll');
|
|
497
|
+
}}
|
|
498
|
+
title={previewPanelRef?.panelActions?.getTouchMode() === 'cursor' ? 'Trackpad mode: 1-finger moves cursor, tap=click, 2-finger scroll/right-click' : 'Scroll mode: touch scrolls the page (tap to click)'}
|
|
499
|
+
>
|
|
500
|
+
<Icon name={previewPanelRef?.panelActions?.getTouchMode() === 'cursor' ? 'lucide:mouse-pointer-2' : 'lucide:pointer'} class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
|
|
501
|
+
<span class="text-xs font-medium">{previewPanelRef?.panelActions?.getTouchMode() === 'cursor' ? 'Cursor' : 'Touch'}</span>
|
|
502
|
+
</button>
|
|
503
|
+
{/if}
|
|
498
504
|
|
|
499
505
|
<!-- Rotation toggle -->
|
|
500
506
|
<button
|
|
@@ -510,7 +516,7 @@
|
|
|
510
516
|
</button>
|
|
511
517
|
|
|
512
518
|
<!-- Scale info badge -->
|
|
513
|
-
<div class="flex items-center gap-1.5 {isMobile ? 'px-
|
|
519
|
+
<div class="flex items-center gap-1.5 {isMobile ? 'px-1 h-9 bg-transparent' : 'px-1 h-6 bg-slate-100/60 dark:bg-slate-800/40'} rounded-md text-xs font-medium text-slate-500">
|
|
514
520
|
<Icon name="lucide:move-diagonal" class={isMobile ? 'w-4 h-4' : 'w-3.5 h-3.5'} />
|
|
515
521
|
<span>{Math.round((previewPanelRef?.panelActions?.getScale() || 1) * 100)}%</span>
|
|
516
522
|
</div>
|
|
@@ -1426,12 +1426,7 @@
|
|
|
1426
1426
|
<div class="space-y-1 px-1">
|
|
1427
1427
|
{#each tags as tag (tag.name)}
|
|
1428
1428
|
<div class="group flex items-center gap-2 px-2.5 py-2 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800/60 transition-colors">
|
|
1429
|
-
<
|
|
1430
|
-
<Icon
|
|
1431
|
-
name={tag.isAnnotated ? 'lucide:bookmark' : 'lucide:tag'}
|
|
1432
|
-
class="w-4 h-4 {tag.isAnnotated ? 'text-amber-500' : 'text-slate-400'}"
|
|
1433
|
-
/>
|
|
1434
|
-
</span>
|
|
1429
|
+
<Icon name="lucide:tag" class="w-4 h-4 text-slate-400 shrink-0" />
|
|
1435
1430
|
<div class="flex-1 min-w-0">
|
|
1436
1431
|
<p class="text-sm font-medium text-slate-900 dark:text-slate-100 truncate">{tag.name}</p>
|
|
1437
1432
|
<div class="flex items-center gap-1.5">
|
|
@@ -208,7 +208,7 @@ export class BrowserWebCodecsService {
|
|
|
208
208
|
|
|
209
209
|
if (this.ctx) {
|
|
210
210
|
this.ctx.imageSmoothingEnabled = true;
|
|
211
|
-
this.ctx.imageSmoothingQuality = '
|
|
211
|
+
this.ctx.imageSmoothingQuality = 'medium';
|
|
212
212
|
}
|
|
213
213
|
|
|
214
214
|
this.clearCanvas();
|
|
@@ -1003,7 +1003,7 @@ export class BrowserWebCodecsService {
|
|
|
1003
1003
|
|
|
1004
1004
|
if (this.ctx) {
|
|
1005
1005
|
this.ctx.imageSmoothingEnabled = true;
|
|
1006
|
-
this.ctx.imageSmoothingQuality = '
|
|
1006
|
+
this.ctx.imageSmoothingQuality = 'medium';
|
|
1007
1007
|
}
|
|
1008
1008
|
|
|
1009
1009
|
try {
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { getOrCreateAnonymousUser, type AnonymousUser } from '$shared/utils/anonymous-user';
|
|
10
|
-
import ws from '$frontend/utils/ws';
|
|
10
|
+
import ws, { onWsReconnect } from '$frontend/utils/ws';
|
|
11
11
|
import { debug } from '$shared/utils/logger';
|
|
12
12
|
|
|
13
13
|
export interface ProjectStatus {
|
|
@@ -44,6 +44,16 @@ class ProjectStatusService {
|
|
|
44
44
|
this.currentUser = await getOrCreateAnonymousUser();
|
|
45
45
|
debug.log('project', 'Initialized with user:', this.currentUser?.name);
|
|
46
46
|
|
|
47
|
+
// Re-join project presence after WebSocket reconnection.
|
|
48
|
+
// Without this, the new connection loses presence tracking and
|
|
49
|
+
// panels (Git, Terminal, Preview, etc.) miss status updates.
|
|
50
|
+
onWsReconnect(() => {
|
|
51
|
+
if (this.currentProjectId && this.currentUser) {
|
|
52
|
+
ws.emit('projects:join', { userName: this.currentUser.name });
|
|
53
|
+
debug.log('project', 'Re-joined project presence after reconnection');
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
47
57
|
this.unsubscribe = ws.on('projects:presence-updated', (data) => {
|
|
48
58
|
try {
|
|
49
59
|
if (data.type === 'presence-updated' && data.data) {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import type { ChatSession, SDKMessageFormatter } from '$shared/types/database/schema';
|
|
11
11
|
import type { SDKMessage } from '$shared/types/messaging';
|
|
12
12
|
import { buildMetadataFromTransport } from '$shared/utils/message-formatter';
|
|
13
|
-
import ws from '$frontend/utils/ws';
|
|
13
|
+
import ws, { onWsReconnect } from '$frontend/utils/ws';
|
|
14
14
|
import { projectState } from './projects.svelte';
|
|
15
15
|
import { setupEditModeListener, restoreEditMode } from '$frontend/stores/ui/edit-mode.svelte';
|
|
16
16
|
import { markSessionUnread, markSessionRead, appState } from '$frontend/stores/core/app.svelte';
|
|
@@ -376,6 +376,16 @@ export async function reloadSessionsForProject(): Promise<string | null> {
|
|
|
376
376
|
* automatically switch to the new shared session.
|
|
377
377
|
*/
|
|
378
378
|
function setupCollaborativeListeners() {
|
|
379
|
+
// Re-join chat session room after WebSocket reconnection.
|
|
380
|
+
// Without this, the new connection is not in the session room and
|
|
381
|
+
// misses all chat events (stream, partial, complete, input sync, etc.).
|
|
382
|
+
onWsReconnect(() => {
|
|
383
|
+
if (sessionState.currentSession?.id) {
|
|
384
|
+
ws.emit('chat:join-session', { chatSessionId: sessionState.currentSession.id });
|
|
385
|
+
debug.log('session', 'Re-joined chat session room after reconnection:', sessionState.currentSession.id);
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
|
|
379
389
|
// Listen for new session available notifications from other users.
|
|
380
390
|
// Does NOT auto-switch — adds session to list and shows notification.
|
|
381
391
|
ws.on('sessions:session-available', async (data: { session: ChatSession }) => {
|
|
@@ -17,6 +17,7 @@ interface TerminalState {
|
|
|
17
17
|
executingSessionIds: Set<string>; // Track multiple executing sessions
|
|
18
18
|
sessionExecutionStates: Map<string, boolean>; // Track execution state per session
|
|
19
19
|
lineBuffers: Map<string, string>; // Line buffering for chunked PTY output
|
|
20
|
+
flushTimers: Map<string, ReturnType<typeof setTimeout>>; // Auto-flush timers for buffered output
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
// Terminal store state
|
|
@@ -28,7 +29,8 @@ const terminalState = $state<TerminalState>({
|
|
|
28
29
|
lastCommandWasCancelled: false,
|
|
29
30
|
executingSessionIds: new Set(),
|
|
30
31
|
sessionExecutionStates: new Map(),
|
|
31
|
-
lineBuffers: new Map()
|
|
32
|
+
lineBuffers: new Map(),
|
|
33
|
+
flushTimers: new Map()
|
|
32
34
|
});
|
|
33
35
|
|
|
34
36
|
// Computed properties
|
|
@@ -214,8 +216,13 @@ export const terminalStore = {
|
|
|
214
216
|
debug.error('terminal', `🔴 [closeSession] Failed to remove from project context:`, error);
|
|
215
217
|
}
|
|
216
218
|
|
|
217
|
-
// Clear any buffered content for this session
|
|
219
|
+
// Clear any buffered content and flush timers for this session
|
|
218
220
|
terminalState.lineBuffers.delete(sessionId);
|
|
221
|
+
const closeFlushTimer = terminalState.flushTimers.get(sessionId);
|
|
222
|
+
if (closeFlushTimer) {
|
|
223
|
+
clearTimeout(closeFlushTimer);
|
|
224
|
+
terminalState.flushTimers.delete(sessionId);
|
|
225
|
+
}
|
|
219
226
|
|
|
220
227
|
// Remove execution states for this session
|
|
221
228
|
terminalState.sessionExecutionStates.delete(sessionId);
|
|
@@ -285,6 +292,13 @@ export const terminalStore = {
|
|
|
285
292
|
|
|
286
293
|
// Flush any buffered content before cancel (if was executing)
|
|
287
294
|
if (wasExecuting) {
|
|
295
|
+
// Clear flush timer first
|
|
296
|
+
const cancelFlushTimer = terminalState.flushTimers.get(activeSession.id);
|
|
297
|
+
if (cancelFlushTimer) {
|
|
298
|
+
clearTimeout(cancelFlushTimer);
|
|
299
|
+
terminalState.flushTimers.delete(activeSession.id);
|
|
300
|
+
}
|
|
301
|
+
|
|
288
302
|
const remainingBuffer = terminalState.lineBuffers.get(activeSession.id);
|
|
289
303
|
if (remainingBuffer && remainingBuffer.length > 0) {
|
|
290
304
|
this.addLineToSession(activeSession.id, {
|
|
@@ -353,31 +367,41 @@ export const terminalStore = {
|
|
|
353
367
|
|
|
354
368
|
// Process buffered output to handle chunked PTY data properly
|
|
355
369
|
processBufferedOutput(sessionId: string, content: string, type: 'output' | 'error'): void {
|
|
356
|
-
//
|
|
370
|
+
// Clear any pending flush timer for this session
|
|
371
|
+
const existingTimer = terminalState.flushTimers.get(sessionId);
|
|
372
|
+
if (existingTimer) {
|
|
373
|
+
clearTimeout(existingTimer);
|
|
374
|
+
terminalState.flushTimers.delete(sessionId);
|
|
375
|
+
}
|
|
376
|
+
|
|
357
377
|
let buffer = terminalState.lineBuffers.get(sessionId) || '';
|
|
358
378
|
buffer += content;
|
|
359
|
-
|
|
360
|
-
// Only send complete chunks to avoid word splitting
|
|
361
|
-
// If buffer ends with a partial ANSI sequence or in middle of a word, wait for more
|
|
362
|
-
if (buffer.length < 2) {
|
|
363
|
-
// Very short buffer, likely incomplete - wait for more
|
|
364
|
-
terminalState.lineBuffers.set(sessionId, buffer);
|
|
365
|
-
return;
|
|
366
|
-
}
|
|
367
|
-
|
|
379
|
+
|
|
368
380
|
// Check if we're in the middle of an ANSI escape sequence
|
|
369
381
|
const lastEscIndex = buffer.lastIndexOf('\x1b');
|
|
370
382
|
if (lastEscIndex >= 0 && lastEscIndex > buffer.length - 10) {
|
|
371
|
-
// Might be in middle of escape sequence, check if it's complete
|
|
372
383
|
const remaining = buffer.substring(lastEscIndex);
|
|
373
384
|
if (!/^(\x1b\[[0-9;]*[a-zA-Z]|\x1b\[\?[0-9]+[lh])/.test(remaining)) {
|
|
374
|
-
// Incomplete escape sequence,
|
|
385
|
+
// Incomplete escape sequence - hold briefly, auto-flush after 8ms
|
|
375
386
|
terminalState.lineBuffers.set(sessionId, buffer);
|
|
387
|
+
const flushTimer = setTimeout(() => {
|
|
388
|
+
terminalState.flushTimers.delete(sessionId);
|
|
389
|
+
const pending = terminalState.lineBuffers.get(sessionId);
|
|
390
|
+
if (pending && pending.length > 0) {
|
|
391
|
+
this.addLineToSession(sessionId, {
|
|
392
|
+
content: pending,
|
|
393
|
+
type: type,
|
|
394
|
+
timestamp: new Date()
|
|
395
|
+
});
|
|
396
|
+
terminalState.lineBuffers.set(sessionId, '');
|
|
397
|
+
}
|
|
398
|
+
}, 8);
|
|
399
|
+
terminalState.flushTimers.set(sessionId, flushTimer);
|
|
376
400
|
return;
|
|
377
401
|
}
|
|
378
402
|
}
|
|
379
|
-
|
|
380
|
-
//
|
|
403
|
+
|
|
404
|
+
// Flush immediately - no artificial delay for complete data
|
|
381
405
|
if (buffer.length > 0) {
|
|
382
406
|
this.addLineToSession(sessionId, {
|
|
383
407
|
content: buffer,
|
|
@@ -390,15 +414,11 @@ export const terminalStore = {
|
|
|
390
414
|
|
|
391
415
|
// Session Content Management
|
|
392
416
|
addLineToSession(sessionId: string, line: TerminalLine): void {
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
lastUsedAt: new Date()
|
|
399
|
-
}
|
|
400
|
-
: session
|
|
401
|
-
);
|
|
417
|
+
const session = terminalState.sessions.find(s => s.id === sessionId);
|
|
418
|
+
if (session) {
|
|
419
|
+
session.lines.push(line);
|
|
420
|
+
session.lastUsedAt = new Date();
|
|
421
|
+
}
|
|
402
422
|
},
|
|
403
423
|
|
|
404
424
|
updateSessionHistory(sessionId: string, history: string[]): void {
|
|
@@ -411,8 +431,13 @@ export const terminalStore = {
|
|
|
411
431
|
|
|
412
432
|
|
|
413
433
|
clearSession(sessionId: string): void {
|
|
414
|
-
// Clear any buffered content for this session
|
|
434
|
+
// Clear any buffered content and flush timers for this session
|
|
415
435
|
terminalState.lineBuffers.delete(sessionId);
|
|
436
|
+
const clearFlushTimer = terminalState.flushTimers.get(sessionId);
|
|
437
|
+
if (clearFlushTimer) {
|
|
438
|
+
clearTimeout(clearFlushTimer);
|
|
439
|
+
terminalState.flushTimers.delete(sessionId);
|
|
440
|
+
}
|
|
416
441
|
|
|
417
442
|
// CRITICAL FIX: Actually clear the session lines history
|
|
418
443
|
// This ensures when switching tabs, the cleared terminal stays clear
|
|
@@ -600,6 +625,11 @@ export const terminalStore = {
|
|
|
600
625
|
*/
|
|
601
626
|
removeSessionFromStore(sessionId: string): void {
|
|
602
627
|
terminalState.lineBuffers.delete(sessionId);
|
|
628
|
+
const removeFlushTimer = terminalState.flushTimers.get(sessionId);
|
|
629
|
+
if (removeFlushTimer) {
|
|
630
|
+
clearTimeout(removeFlushTimer);
|
|
631
|
+
terminalState.flushTimers.delete(sessionId);
|
|
632
|
+
}
|
|
603
633
|
terminalState.sessionExecutionStates.delete(sessionId);
|
|
604
634
|
terminalState.executingSessionIds.delete(sessionId);
|
|
605
635
|
terminalState.sessions = terminalState.sessions.filter(s => s.id !== sessionId);
|
|
@@ -96,7 +96,7 @@ function updateThemeColor(mode: 'light' | 'dark') {
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
// Set appropriate theme color
|
|
99
|
-
const themeColor = mode === 'dark' ? '#
|
|
99
|
+
const themeColor = mode === 'dark' ? '#0e172b' : '#ffffff';
|
|
100
100
|
metaThemeColor.setAttribute('content', themeColor);
|
|
101
101
|
}
|
|
102
102
|
|
package/frontend/utils/ws.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { WSClient } from '$shared/utils/ws-client';
|
|
8
8
|
import type { WSAPI } from '$backend/ws';
|
|
9
9
|
import { setConnectionStatus } from '$frontend/stores/ui/connection.svelte';
|
|
10
|
+
import { debug } from '$shared/utils/logger';
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Get WebSocket URL based on environment
|
|
@@ -18,6 +19,28 @@ function getWebSocketUrl(): string {
|
|
|
18
19
|
return `${protocol}//${host}/ws`;
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
// ============================================================================
|
|
23
|
+
// Reconnect Handler Registry
|
|
24
|
+
// ============================================================================
|
|
25
|
+
|
|
26
|
+
/** Handlers to run after WebSocket reconnection (re-join rooms, restore subscriptions) */
|
|
27
|
+
const reconnectHandlers = new Set<() => void>();
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Register a handler to run after WebSocket reconnection.
|
|
31
|
+
* Use this to re-join rooms (chat:join-session, projects:join) and
|
|
32
|
+
* restore subscriptions that are lost when the connection drops.
|
|
33
|
+
* Returns an unsubscribe function.
|
|
34
|
+
*/
|
|
35
|
+
export function onWsReconnect(handler: () => void): () => void {
|
|
36
|
+
reconnectHandlers.add(handler);
|
|
37
|
+
return () => { reconnectHandlers.delete(handler); };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ============================================================================
|
|
41
|
+
// WebSocket Client
|
|
42
|
+
// ============================================================================
|
|
43
|
+
|
|
21
44
|
const ws = new WSClient<WSAPI>(getWebSocketUrl(), {
|
|
22
45
|
autoReconnect: true,
|
|
23
46
|
maxReconnectAttempts: 0, // Infinite reconnect
|
|
@@ -25,6 +48,16 @@ const ws = new WSClient<WSAPI>(getWebSocketUrl(), {
|
|
|
25
48
|
maxReconnectDelay: 30000,
|
|
26
49
|
onStatusChange: (status, reconnectAttempts) => {
|
|
27
50
|
setConnectionStatus(status, reconnectAttempts);
|
|
51
|
+
},
|
|
52
|
+
onReconnect: () => {
|
|
53
|
+
debug.log('websocket', `Running ${reconnectHandlers.size} reconnect handler(s)`);
|
|
54
|
+
for (const handler of reconnectHandlers) {
|
|
55
|
+
try {
|
|
56
|
+
handler();
|
|
57
|
+
} catch (err) {
|
|
58
|
+
debug.error('websocket', 'Reconnect handler error:', err);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
28
61
|
}
|
|
29
62
|
});
|
|
30
63
|
|
|
@@ -45,4 +78,13 @@ window.addEventListener('beforeunload', () => {
|
|
|
45
78
|
ws.disconnect();
|
|
46
79
|
});
|
|
47
80
|
|
|
81
|
+
// Force reload when page is restored from bfcache (back-forward cache).
|
|
82
|
+
// After beforeunload, all WS listeners are cleared and the connection is dead.
|
|
83
|
+
// A full reload ensures all state (handlers, room subscriptions) is re-initialized.
|
|
84
|
+
window.addEventListener('pageshow', (event) => {
|
|
85
|
+
if (event.persisted) {
|
|
86
|
+
window.location.reload();
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
48
90
|
export default ws;
|
package/index.html
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
name="description"
|
|
10
10
|
content="Clopen - Modern web UI for Claude Code & OpenCode with real browser preview, git management, multi-account support, file management, checkpoints, collaboration, and integrated terminal. Built with Bun and Svelte 5."
|
|
11
11
|
/>
|
|
12
|
-
<meta name="theme-color" content="#
|
|
12
|
+
<meta name="theme-color" content="#0e172b" />
|
|
13
13
|
<title>Clopen</title>
|
|
14
14
|
|
|
15
15
|
<!-- DM Sans - Local self-hosted font -->
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
// Update meta theme-color for mobile browsers
|
|
48
48
|
const metaThemeColor = document.querySelector('meta[name="theme-color"]');
|
|
49
49
|
if (metaThemeColor) {
|
|
50
|
-
metaThemeColor.setAttribute('content', isDark ? '#
|
|
50
|
+
metaThemeColor.setAttribute('content', isDark ? '#0e172b' : '#ffffff');
|
|
51
51
|
}
|
|
52
52
|
} catch (e) {
|
|
53
53
|
// Fallback to system preference if anything fails
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@myrialabs/clopen",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.9",
|
|
4
4
|
"description": "All-in-one web workspace for Claude Code & OpenCode — chat, terminal, git, browser preview, checkpoints, and real-time collaboration",
|
|
5
5
|
"author": "Myria Labs",
|
|
6
6
|
"license": "MIT",
|