@myrialabs/clopen 0.2.8 → 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/terminal/stream-manager.ts +40 -26
- package/backend/ws/system/operations.ts +23 -0
- 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/components/Toolbar.svelte +1 -1
- 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/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
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type { IPty } from 'bun-pty';
|
|
7
|
-
import { existsSync, mkdirSync,
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync } from 'fs';
|
|
8
8
|
import { join } from 'path';
|
|
9
9
|
|
|
10
10
|
interface TerminalStream {
|
|
@@ -129,36 +129,50 @@ class TerminalStreamManager {
|
|
|
129
129
|
}
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
/** Pending write flag to coalesce rapid writes */
|
|
133
|
+
private pendingWrites = new Set<string>();
|
|
134
|
+
|
|
132
135
|
/**
|
|
133
|
-
* Persist output to disk for cross-project persistence
|
|
136
|
+
* Persist output to disk for cross-project persistence (async, coalesced)
|
|
134
137
|
*/
|
|
135
138
|
private persistOutputToDisk(stream: TerminalStream): void {
|
|
136
|
-
|
|
137
|
-
|
|
139
|
+
// Coalesce rapid writes - only schedule one write per session per microtask
|
|
140
|
+
if (this.pendingWrites.has(stream.sessionId)) return;
|
|
141
|
+
this.pendingWrites.add(stream.sessionId);
|
|
138
142
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const newOutput = stream.outputStartIndex !== undefined
|
|
142
|
-
? stream.output.slice(stream.outputStartIndex)
|
|
143
|
-
: stream.output;
|
|
143
|
+
queueMicrotask(() => {
|
|
144
|
+
this.pendingWrites.delete(stream.sessionId);
|
|
144
145
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
146
|
+
try {
|
|
147
|
+
const cacheFile = join(this.tempDir, `${stream.sessionId}.json`);
|
|
148
|
+
|
|
149
|
+
// Only save new output (from outputStartIndex onwards)
|
|
150
|
+
const newOutput = stream.outputStartIndex !== undefined
|
|
151
|
+
? stream.output.slice(stream.outputStartIndex)
|
|
152
|
+
: stream.output;
|
|
153
|
+
|
|
154
|
+
const cacheData = {
|
|
155
|
+
streamId: stream.streamId,
|
|
156
|
+
sessionId: stream.sessionId,
|
|
157
|
+
command: stream.command,
|
|
158
|
+
projectId: stream.projectId,
|
|
159
|
+
projectPath: stream.projectPath,
|
|
160
|
+
workingDirectory: stream.workingDirectory,
|
|
161
|
+
startedAt: stream.startedAt,
|
|
162
|
+
status: stream.status,
|
|
163
|
+
output: newOutput,
|
|
164
|
+
outputStartIndex: stream.outputStartIndex || 0,
|
|
165
|
+
lastUpdated: new Date().toISOString()
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// Use Bun.write for non-blocking async disk write
|
|
169
|
+
Bun.write(cacheFile, JSON.stringify(cacheData)).catch(() => {
|
|
170
|
+
// Silently handle write errors
|
|
171
|
+
});
|
|
172
|
+
} catch {
|
|
173
|
+
// Silently handle errors
|
|
174
|
+
}
|
|
175
|
+
});
|
|
162
176
|
}
|
|
163
177
|
|
|
164
178
|
/**
|
|
@@ -10,10 +10,13 @@
|
|
|
10
10
|
import { t } from 'elysia';
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
import { readFileSync } from 'node:fs';
|
|
13
|
+
import fs from 'node:fs/promises';
|
|
13
14
|
import { createRouter } from '$shared/utils/ws-server';
|
|
14
15
|
import { initializeDatabase, getDatabase } from '../../database';
|
|
15
16
|
import { debug } from '$shared/utils/logger';
|
|
16
17
|
import { ws } from '$backend/utils/ws';
|
|
18
|
+
import { getClopenDir } from '$backend/utils/index';
|
|
19
|
+
import { resetEnvironment } from '$backend/engine/adapters/claude/environment';
|
|
17
20
|
|
|
18
21
|
/** In-memory flag: set after successful update, cleared on server restart */
|
|
19
22
|
let pendingUpdate: { fromVersion: string; toVersion: string } | null = null;
|
|
@@ -163,6 +166,26 @@ export const operationsHandler = createRouter()
|
|
|
163
166
|
|
|
164
167
|
debug.log('server', 'Database cleared successfully');
|
|
165
168
|
|
|
169
|
+
// Delete snapshots directory
|
|
170
|
+
const clopenDir = getClopenDir();
|
|
171
|
+
const snapshotsDir = join(clopenDir, 'snapshots');
|
|
172
|
+
try {
|
|
173
|
+
await fs.rm(snapshotsDir, { recursive: true, force: true });
|
|
174
|
+
debug.log('server', 'Snapshots directory cleared');
|
|
175
|
+
} catch (err) {
|
|
176
|
+
debug.warn('server', 'Failed to clear snapshots directory:', err);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Delete Claude config directory and reset environment state
|
|
180
|
+
const claudeDir = join(clopenDir, 'claude');
|
|
181
|
+
try {
|
|
182
|
+
await fs.rm(claudeDir, { recursive: true, force: true });
|
|
183
|
+
resetEnvironment();
|
|
184
|
+
debug.log('server', 'Claude config directory cleared');
|
|
185
|
+
} catch (err) {
|
|
186
|
+
debug.warn('server', 'Failed to clear Claude config directory:', err);
|
|
187
|
+
}
|
|
188
|
+
|
|
166
189
|
return {
|
|
167
190
|
cleared: true,
|
|
168
191
|
tablesCount: tables.length
|
|
@@ -16,6 +16,13 @@
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
const parsedCommand = $derived(parseCommandParts(command));
|
|
19
|
+
|
|
20
|
+
function formatTimeout(ms: number): string {
|
|
21
|
+
if (ms < 1000) return `${ms}ms`;
|
|
22
|
+
if (ms < 60_000) return `${ms / 1000}s`;
|
|
23
|
+
if (ms < 3_600_000) return `${ms / 60_000}m`;
|
|
24
|
+
return `${ms / 3_600_000}h`;
|
|
25
|
+
}
|
|
19
26
|
</script>
|
|
20
27
|
|
|
21
28
|
<!-- Description (if provided) -->
|
|
@@ -34,7 +41,7 @@
|
|
|
34
41
|
</div>
|
|
35
42
|
{#if timeout}
|
|
36
43
|
<div class="inline-block ml-auto text-3xs bg-orange-100 dark:bg-orange-900 text-orange-700 dark:text-orange-300 px-2 py-0.5 rounded">
|
|
37
|
-
Timeout: {timeout}
|
|
44
|
+
Timeout: {formatTimeout(timeout)}
|
|
38
45
|
</div>
|
|
39
46
|
{/if}
|
|
40
47
|
</div>
|
|
@@ -184,7 +184,7 @@
|
|
|
184
184
|
out:fade={{ duration: 150, easing: cubicOut }}
|
|
185
185
|
>
|
|
186
186
|
<div
|
|
187
|
-
class="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-xl max-w-md w-full p-6 space-y-4 shadow-xl"
|
|
187
|
+
class="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-xl max-w-md w-full p-6 space-y-4 shadow-xl max-h-[calc(100dvh-2rem)] overflow-y-auto"
|
|
188
188
|
role="document"
|
|
189
189
|
onclick={(e) => e.stopPropagation()}
|
|
190
190
|
onkeydown={(e) => e.stopPropagation()}
|
|
@@ -171,7 +171,7 @@
|
|
|
171
171
|
|
|
172
172
|
<!-- Content container -->
|
|
173
173
|
<div
|
|
174
|
-
class="relative max-w-[95vw] max-h-[
|
|
174
|
+
class="relative max-w-[95vw] max-h-[95dvh] flex items-center justify-center"
|
|
175
175
|
onclick={(e) => e.stopPropagation()}
|
|
176
176
|
onkeydown={(e) => e.stopPropagation()}
|
|
177
177
|
role="document"
|
|
@@ -184,7 +184,7 @@
|
|
|
184
184
|
<img
|
|
185
185
|
src="data:{mediaType};base64,{data}"
|
|
186
186
|
alt="Full size view"
|
|
187
|
-
class="max-w-full max-h-[
|
|
187
|
+
class="max-w-full max-h-[90dvh] object-contain rounded-lg shadow-2xl"
|
|
188
188
|
loading="eager"
|
|
189
189
|
/>
|
|
190
190
|
{:else if type === 'document'}
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
md: 'max-w-[95vw] md:max-w-lg',
|
|
52
52
|
lg: 'max-w-[95vw] md:max-w-2xl',
|
|
53
53
|
xl: 'max-w-[95vw] md:max-w-4xl',
|
|
54
|
-
full: 'max-w-[95vw] md:max-w-[90vw]
|
|
54
|
+
full: 'max-w-[95vw] md:max-w-[90vw]'
|
|
55
55
|
};
|
|
56
56
|
|
|
57
57
|
// Auto-focus management
|
|
@@ -106,7 +106,7 @@
|
|
|
106
106
|
<div
|
|
107
107
|
class="bg-white dark:bg-slate-900 rounded-lg md:rounded-xl border border-slate-200 dark:border-slate-800 shadow-2xl w-full {sizeClasses[
|
|
108
108
|
size
|
|
109
|
-
]} max-h-[
|
|
109
|
+
]} max-h-[calc(100dvh-1rem)] md:max-h-[calc(100dvh-2rem)] overflow-hidden flex flex-col {className}"
|
|
110
110
|
role="document"
|
|
111
111
|
onclick={(e) => e.stopPropagation()}
|
|
112
112
|
onkeydown={(e) => e.stopPropagation()}
|
|
@@ -635,7 +635,7 @@
|
|
|
635
635
|
<!-- Pure xterm.js terminal container -->
|
|
636
636
|
<div
|
|
637
637
|
bind:this={terminalContainer}
|
|
638
|
-
class="w-full h-full overflow-hidden bg-
|
|
638
|
+
class="w-full h-full overflow-hidden bg-white dark:bg-slate-900/70 {className} select-none"
|
|
639
639
|
style="transition: opacity 0.2s ease-in-out; user-select: text;"
|
|
640
640
|
role="textbox"
|
|
641
641
|
tabindex="0"
|
|
@@ -677,6 +677,11 @@
|
|
|
677
677
|
height: 100% !important;
|
|
678
678
|
}
|
|
679
679
|
|
|
680
|
+
:global(.xterm .xterm-scrollable-element) {
|
|
681
|
+
background: transparent !important;
|
|
682
|
+
height: 100% !important;
|
|
683
|
+
}
|
|
684
|
+
|
|
680
685
|
:global(.xterm .xterm-helper-textarea) {
|
|
681
686
|
height: 100% !important;
|
|
682
687
|
}
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
{#if isOpen}
|
|
27
27
|
<div class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center" onclick={onClose}>
|
|
28
28
|
<div
|
|
29
|
-
class="bg-white dark:bg-slate-800 rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[
|
|
29
|
+
class="bg-white dark:bg-slate-800 rounded-lg shadow-xl max-w-4xl w-full mx-4 my-4 max-h-[calc(100dvh-2rem)] flex flex-col"
|
|
30
30
|
onclick={(e) => e.stopPropagation()}
|
|
31
31
|
>
|
|
32
32
|
<!-- Header -->
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
let gitPanelRef: any = $state();
|
|
14
14
|
</script>
|
|
15
15
|
|
|
16
|
-
<Modal {isOpen} {onClose} size="full" className="!max-h-[
|
|
16
|
+
<Modal {isOpen} {onClose} size="full" className="!max-h-[85dvh] !max-w-[95vw] md:!max-w-5xl">
|
|
17
17
|
{#snippet header()}
|
|
18
18
|
<div class="flex items-center justify-between px-4 py-3 md:px-6 md:py-4">
|
|
19
19
|
<div class="flex items-center gap-2.5">
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
{/snippet}
|
|
74
74
|
|
|
75
75
|
{#snippet children()}
|
|
76
|
-
<div class="h-[
|
|
76
|
+
<div class="h-[65dvh] -mx-4 -my-6 md:-mx-6">
|
|
77
77
|
<GitPanel bind:this={gitPanelRef} />
|
|
78
78
|
</div>
|
|
79
79
|
{/snippet}
|
|
@@ -214,7 +214,7 @@
|
|
|
214
214
|
</script>
|
|
215
215
|
|
|
216
216
|
<!-- Preview Toolbar -->
|
|
217
|
-
<div class="relative bg-
|
|
217
|
+
<div class="relative bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700">
|
|
218
218
|
<!-- Tabs bar (Git-style underline tabs) — separated with its own border-bottom -->
|
|
219
219
|
{#if tabs.length > 0}
|
|
220
220
|
<div class="relative flex items-center overflow-x-auto border-b border-slate-200 dark:border-slate-700">
|
|
@@ -106,7 +106,7 @@
|
|
|
106
106
|
role="dialog"
|
|
107
107
|
aria-labelledby="settings-title"
|
|
108
108
|
tabindex="-1"
|
|
109
|
-
class="flex flex-col w-full max-w-225 h-[85dvh] max-h-175 bg-slate-50 dark:bg-slate-950 border border-
|
|
109
|
+
class="flex flex-col w-full max-w-225 h-[85dvh] max-h-175 bg-slate-50 dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-2xl overflow-hidden shadow-[0_25px_50px_-12px_rgba(0,0,0,0.25)] dark:shadow-[0_25px_50px_-12px_rgba(0,0,0,0.5)] max-md:max-w-full max-md:h-dvh max-md:max-h-dvh max-md:rounded-none"
|
|
110
110
|
onclick={(e) => e.stopPropagation()}
|
|
111
111
|
onkeydown={(e) => e.stopPropagation()}
|
|
112
112
|
in:scale={{ duration: 250, easing: cubicOut, start: 0.95 }}
|
|
@@ -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>
|
|
@@ -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",
|
|
@@ -56,6 +56,8 @@ export interface WSClientOptions {
|
|
|
56
56
|
maxReconnectDelay?: number;
|
|
57
57
|
/** Callback when connection status changes */
|
|
58
58
|
onStatusChange?: (status: WSConnectionStatus, reconnectAttempts: number) => void;
|
|
59
|
+
/** Callback when WebSocket reconnects (not on initial connection) */
|
|
60
|
+
onReconnect?: () => void;
|
|
59
61
|
}
|
|
60
62
|
|
|
61
63
|
// ============================================================================
|
|
@@ -204,13 +206,14 @@ function decodeBinaryMessage(buffer: ArrayBuffer): { action: string; payload: an
|
|
|
204
206
|
export class WSClient<TAPI extends { client: any; server: any }> {
|
|
205
207
|
private ws: WebSocket | null = null;
|
|
206
208
|
private url: string;
|
|
207
|
-
private options: Required<Omit<WSClientOptions, 'onStatusChange'>> & Pick<WSClientOptions, 'onStatusChange'>;
|
|
209
|
+
private options: Required<Omit<WSClientOptions, 'onStatusChange' | 'onReconnect'>> & Pick<WSClientOptions, 'onStatusChange' | 'onReconnect'>;
|
|
208
210
|
private reconnectAttempts = 0;
|
|
209
211
|
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
210
212
|
private listeners = new Map<string, Set<(payload: any) => void>>();
|
|
211
213
|
private messageQueue: Array<{ action: string; payload: any }> = [];
|
|
212
214
|
private isConnected = false;
|
|
213
215
|
private shouldReconnect = true;
|
|
216
|
+
private hasConnectedBefore = false;
|
|
214
217
|
|
|
215
218
|
/** Current context (synced with server) */
|
|
216
219
|
private context: {
|
|
@@ -237,7 +240,8 @@ export class WSClient<TAPI extends { client: any; server: any }> {
|
|
|
237
240
|
maxReconnectAttempts: options.maxReconnectAttempts ?? 5,
|
|
238
241
|
reconnectDelay: options.reconnectDelay ?? 1000,
|
|
239
242
|
maxReconnectDelay: options.maxReconnectDelay ?? 30000,
|
|
240
|
-
onStatusChange: options.onStatusChange ?? undefined
|
|
243
|
+
onStatusChange: options.onStatusChange ?? undefined,
|
|
244
|
+
onReconnect: options.onReconnect ?? undefined
|
|
241
245
|
};
|
|
242
246
|
|
|
243
247
|
this.connect();
|
|
@@ -295,7 +299,20 @@ export class WSClient<TAPI extends { client: any; server: any }> {
|
|
|
295
299
|
}
|
|
296
300
|
}
|
|
297
301
|
|
|
298
|
-
//
|
|
302
|
+
// Fire reconnect handlers BEFORE queue flush so room
|
|
303
|
+
// subscriptions (chat:join-session, projects:join) are
|
|
304
|
+
// restored before any queued messages are sent.
|
|
305
|
+
const isReconnect = this.hasConnectedBefore;
|
|
306
|
+
this.hasConnectedBefore = true;
|
|
307
|
+
if (isReconnect) {
|
|
308
|
+
try {
|
|
309
|
+
this.options.onReconnect?.();
|
|
310
|
+
} catch (err) {
|
|
311
|
+
debug.error('websocket', 'onReconnect callback error:', err);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Flush queued messages AFTER context + room re-joins are synced
|
|
299
316
|
while (this.messageQueue.length > 0) {
|
|
300
317
|
const msg = this.messageQueue.shift();
|
|
301
318
|
if (msg) {
|
|
@@ -303,7 +320,7 @@ export class WSClient<TAPI extends { client: any; server: any }> {
|
|
|
303
320
|
}
|
|
304
321
|
}
|
|
305
322
|
|
|
306
|
-
// Resolve waitUntilConnected() callers AFTER
|
|
323
|
+
// Resolve waitUntilConnected() callers AFTER everything is ready
|
|
307
324
|
for (const resolve of this.connectResolvers) {
|
|
308
325
|
resolve();
|
|
309
326
|
}
|
package/static/manifest.json
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
"description": "All-in-one web workspace for Claude Code & OpenCode — chat, terminal, git, browser preview, checkpoints, and real-time collaboration",
|
|
5
5
|
"start_url": "/",
|
|
6
6
|
"display": "standalone",
|
|
7
|
-
"background_color": "#
|
|
8
|
-
"theme_color": "#
|
|
7
|
+
"background_color": "#0e172b",
|
|
8
|
+
"theme_color": "#0e172b",
|
|
9
9
|
"icons": [
|
|
10
10
|
{
|
|
11
11
|
"src": "/favicon.svg",
|