@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
|
@@ -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
|
/**
|
|
@@ -117,12 +117,12 @@ export const previewRouter = createRouter()
|
|
|
117
117
|
}))
|
|
118
118
|
// MCP control events
|
|
119
119
|
.emit('preview:browser-mcp-control-start', t.Object({
|
|
120
|
-
|
|
121
|
-
|
|
120
|
+
browserTabId: t.String(),
|
|
121
|
+
chatSessionId: t.Optional(t.String()),
|
|
122
122
|
timestamp: t.Number()
|
|
123
123
|
}))
|
|
124
124
|
.emit('preview:browser-mcp-control-end', t.Object({
|
|
125
|
-
|
|
125
|
+
browserTabId: t.String(),
|
|
126
126
|
timestamp: t.Number()
|
|
127
127
|
}))
|
|
128
128
|
.emit('preview:browser-mcp-cursor-position', t.Object({
|
|
@@ -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
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
|
|
53
53
|
// Auto-scroll reasoning/system content to bottom while receiving partial text
|
|
54
54
|
$effect(() => {
|
|
55
|
-
if (roleCategory !== 'reasoning' && roleCategory !== 'system') return;
|
|
55
|
+
if (roleCategory !== 'reasoning' && roleCategory !== 'system' && roleCategory !== 'compact') return;
|
|
56
56
|
if (!scrollContainer) return;
|
|
57
57
|
// Track message content changes (partialText for streaming, message for final)
|
|
58
58
|
const _track = message.type === 'stream_event' && 'partialText' in message
|
|
@@ -93,7 +93,7 @@
|
|
|
93
93
|
<!-- Message Content -->
|
|
94
94
|
<div
|
|
95
95
|
bind:this={scrollContainer}
|
|
96
|
-
class="p-3 md:p-4 {roleCategory === 'reasoning' || roleCategory === 'system' ? 'max-h-80 overflow-y-auto' : ''}"
|
|
96
|
+
class="p-3 md:p-4 {roleCategory === 'reasoning' || roleCategory === 'system' || roleCategory === 'compact' ? 'max-h-80 overflow-y-auto' : ''}"
|
|
97
97
|
>
|
|
98
98
|
<div class="max-w-none space-y-4">
|
|
99
99
|
<!-- Content rendering using MessageFormatter component -->
|
|
@@ -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}
|
|
@@ -473,7 +473,7 @@
|
|
|
473
473
|
bind:isConsoleOpen
|
|
474
474
|
{tabs}
|
|
475
475
|
{activeTabId}
|
|
476
|
-
|
|
476
|
+
mcpControlledTabIds={mcpHandler.getControlledTabIds()}
|
|
477
477
|
onGoClick={handleGoClick}
|
|
478
478
|
onRefresh={refreshPreview}
|
|
479
479
|
onOpenInExternalBrowser={() => {}}
|
|
@@ -406,7 +406,7 @@
|
|
|
406
406
|
// This matches the loading overlay background roughly
|
|
407
407
|
if (ctx) {
|
|
408
408
|
ctx.imageSmoothingEnabled = true;
|
|
409
|
-
ctx.imageSmoothingQuality = '
|
|
409
|
+
ctx.imageSmoothingQuality = 'medium';
|
|
410
410
|
ctx.fillStyle = '#f1f5f9'; // slate-100 - neutral light color
|
|
411
411
|
ctx.fillRect(0, 0, canvasElement.width, canvasElement.height);
|
|
412
412
|
}
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
// Tab state
|
|
26
26
|
tabs = $bindable<any[]>([]),
|
|
27
27
|
activeTabId = $bindable<string | null>(null),
|
|
28
|
-
|
|
28
|
+
mcpControlledTabIds = $bindable<Set<string>>(new Set()),
|
|
29
29
|
|
|
30
30
|
// Callbacks
|
|
31
31
|
onGoClick = $bindable<() => void>(() => {}),
|
|
@@ -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">
|
|
@@ -233,11 +233,11 @@
|
|
|
233
233
|
<span class="truncate max-w-28" title={tab.url}>
|
|
234
234
|
{tab.title || 'New Tab'}
|
|
235
235
|
</span>
|
|
236
|
-
{#if tab.id
|
|
236
|
+
{#if mcpControlledTabIds.has(tab.id)}
|
|
237
237
|
<span title="MCP Controlled" class="flex"><Icon name="lucide:lock" class="w-3 h-3 flex-shrink-0 text-amber-500" /></span>
|
|
238
238
|
{/if}
|
|
239
239
|
<!-- Close button -->
|
|
240
|
-
{#if tab.id
|
|
240
|
+
{#if !mcpControlledTabIds.has(tab.id)}
|
|
241
241
|
<span
|
|
242
242
|
role="button"
|
|
243
243
|
tabindex="0"
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Browser MCP Event Handlers
|
|
3
3
|
* Handles MCP (Model Context Protocol) control events for BrowserPreview
|
|
4
|
+
*
|
|
5
|
+
* Supports multi-tab control: each chat session can control multiple tabs.
|
|
6
|
+
* Tracks controlled tabs via a Set of backend tab IDs (session IDs).
|
|
4
7
|
*/
|
|
5
8
|
|
|
6
9
|
import { debug } from '$shared/utils/logger';
|
|
@@ -8,14 +11,6 @@ import { showInfo, showWarning } from '$frontend/stores/ui/notification.svelte';
|
|
|
8
11
|
import ws from '$frontend/utils/ws';
|
|
9
12
|
import type { TabManager } from './tab-manager.svelte';
|
|
10
13
|
|
|
11
|
-
// MCP Control State interface
|
|
12
|
-
export interface McpControlState {
|
|
13
|
-
isControlled: boolean;
|
|
14
|
-
controlledTabId: string | null;
|
|
15
|
-
browserSessionId: string | null;
|
|
16
|
-
startedAt: number | null;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
14
|
export interface McpHandlerConfig {
|
|
20
15
|
tabManager: TabManager;
|
|
21
16
|
transformBrowserToDisplayCoordinates?: (x: number, y: number) => { x: number, y: number } | null;
|
|
@@ -30,13 +25,8 @@ export interface McpHandlerConfig {
|
|
|
30
25
|
export function createMcpHandler(config: McpHandlerConfig) {
|
|
31
26
|
const { tabManager, transformBrowserToDisplayCoordinates, onCursorUpdate, onCursorHide, onLaunchRequest } = config;
|
|
32
27
|
|
|
33
|
-
//
|
|
34
|
-
let
|
|
35
|
-
isControlled: false,
|
|
36
|
-
controlledTabId: null,
|
|
37
|
-
browserSessionId: null,
|
|
38
|
-
startedAt: null
|
|
39
|
-
});
|
|
28
|
+
// Set of backend tab IDs (session IDs) currently controlled by MCP
|
|
29
|
+
let controlledSessionIds = $state(new Set<string>());
|
|
40
30
|
|
|
41
31
|
/**
|
|
42
32
|
* Setup WebSocket event listeners for MCP control events
|
|
@@ -44,7 +34,7 @@ export function createMcpHandler(config: McpHandlerConfig) {
|
|
|
44
34
|
function setupEventListeners() {
|
|
45
35
|
debug.log('preview', '🎧 Setting up MCP event listeners...');
|
|
46
36
|
|
|
47
|
-
// Listen for MCP control start/end events
|
|
37
|
+
// Listen for MCP control start/end events (per-tab)
|
|
48
38
|
ws.on('preview:browser-mcp-control-start', (data) => {
|
|
49
39
|
debug.log('preview', `📥 Received mcp-control-start:`, data);
|
|
50
40
|
handleControlStart(data);
|
|
@@ -93,60 +83,72 @@ export function createMcpHandler(config: McpHandlerConfig) {
|
|
|
93
83
|
}
|
|
94
84
|
|
|
95
85
|
/**
|
|
96
|
-
* Check if current tab is MCP controlled
|
|
86
|
+
* Check if current active tab is MCP controlled
|
|
97
87
|
*/
|
|
98
88
|
function isCurrentTabMcpControlled(): boolean {
|
|
99
|
-
|
|
89
|
+
const activeTab = tabManager.tabs.find(t => t.id === tabManager.activeTabId);
|
|
90
|
+
if (!activeTab?.sessionId) return false;
|
|
91
|
+
return controlledSessionIds.has(activeTab.sessionId);
|
|
100
92
|
}
|
|
101
93
|
|
|
102
94
|
/**
|
|
103
|
-
*
|
|
95
|
+
* Check if a specific frontend tab is MCP controlled (by sessionId)
|
|
104
96
|
*/
|
|
105
|
-
function
|
|
106
|
-
return
|
|
97
|
+
function isSessionControlled(sessionId: string): boolean {
|
|
98
|
+
return controlledSessionIds.has(sessionId);
|
|
107
99
|
}
|
|
108
100
|
|
|
109
|
-
|
|
101
|
+
/**
|
|
102
|
+
* Get set of frontend tab IDs that are MCP controlled
|
|
103
|
+
*/
|
|
104
|
+
function getControlledTabIds(): Set<string> {
|
|
105
|
+
const result = new Set<string>();
|
|
106
|
+
for (const tab of tabManager.tabs) {
|
|
107
|
+
if (tab.sessionId && controlledSessionIds.has(tab.sessionId)) {
|
|
108
|
+
result.add(tab.id);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return result;
|
|
112
|
+
}
|
|
110
113
|
|
|
111
|
-
|
|
112
|
-
debug.log('preview', `🎮 MCP control started for session: ${data.browserSessionId}`);
|
|
114
|
+
// Private handlers
|
|
113
115
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
+
function handleControlStart(data: { browserTabId: string; chatSessionId?: string; timestamp: number }) {
|
|
117
|
+
debug.log('preview', `🎮 MCP control started for tab: ${data.browserTabId}`);
|
|
116
118
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
controlledTabId: tab?.id || null,
|
|
120
|
-
browserSessionId: data.browserSessionId,
|
|
121
|
-
startedAt: data.timestamp
|
|
122
|
-
};
|
|
119
|
+
// Add to controlled set (reassign for Svelte reactivity)
|
|
120
|
+
controlledSessionIds = new Set([...controlledSessionIds, data.browserTabId]);
|
|
123
121
|
|
|
124
|
-
// Show toast
|
|
125
|
-
|
|
122
|
+
// Show toast only for the first controlled tab
|
|
123
|
+
if (controlledSessionIds.size === 1) {
|
|
124
|
+
showWarning('MCP Control Started', 'An MCP agent is now controlling the browser. User input is blocked.', 5000);
|
|
125
|
+
}
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
function handleControlEnd(data: {
|
|
129
|
-
debug.log('preview', `🎮 MCP control ended for
|
|
128
|
+
function handleControlEnd(data: { browserTabId: string; timestamp: number }) {
|
|
129
|
+
debug.log('preview', `🎮 MCP control ended for tab: ${data.browserTabId}`);
|
|
130
130
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
startedAt: null
|
|
136
|
-
};
|
|
131
|
+
// Remove from controlled set (reassign for Svelte reactivity)
|
|
132
|
+
const newSet = new Set(controlledSessionIds);
|
|
133
|
+
newSet.delete(data.browserTabId);
|
|
134
|
+
controlledSessionIds = newSet;
|
|
137
135
|
|
|
138
|
-
// Hide cursor
|
|
139
|
-
|
|
136
|
+
// Hide cursor if the released tab was the active one
|
|
137
|
+
const activeTab = tabManager.tabs.find(t => t.id === tabManager.activeTabId);
|
|
138
|
+
if (activeTab?.sessionId === data.browserTabId && onCursorHide) {
|
|
140
139
|
onCursorHide();
|
|
141
140
|
}
|
|
142
141
|
|
|
143
|
-
// Show toast
|
|
144
|
-
|
|
142
|
+
// Show toast when all tabs released
|
|
143
|
+
if (controlledSessionIds.size === 0) {
|
|
144
|
+
showInfo('MCP Control Ended', 'MCP agent released control. You can now interact with the browser.', 4000);
|
|
145
|
+
}
|
|
145
146
|
}
|
|
146
147
|
|
|
147
148
|
function handleCursorPosition(data: { sessionId: string; x: number; y: number; timestamp: number; source: 'mcp' }) {
|
|
148
|
-
// Only show cursor if
|
|
149
|
-
|
|
149
|
+
// Only show cursor if this tab is controlled AND user is viewing it
|
|
150
|
+
const activeTab = tabManager.tabs.find(t => t.id === tabManager.activeTabId);
|
|
151
|
+
if (activeTab?.sessionId === data.sessionId && controlledSessionIds.has(data.sessionId) && transformBrowserToDisplayCoordinates) {
|
|
150
152
|
const transformedPosition = transformBrowserToDisplayCoordinates(data.x, data.y);
|
|
151
153
|
if (transformedPosition && onCursorUpdate) {
|
|
152
154
|
onCursorUpdate(transformedPosition.x, transformedPosition.y, false);
|
|
@@ -155,8 +157,9 @@ export function createMcpHandler(config: McpHandlerConfig) {
|
|
|
155
157
|
}
|
|
156
158
|
|
|
157
159
|
function handleCursorClick(data: { sessionId: string; x: number; y: number; timestamp: number; source: 'mcp' }) {
|
|
158
|
-
// Only show cursor click if
|
|
159
|
-
|
|
160
|
+
// Only show cursor click if this tab is controlled AND user is viewing it
|
|
161
|
+
const activeTab = tabManager.tabs.find(t => t.id === tabManager.activeTabId);
|
|
162
|
+
if (activeTab?.sessionId === data.sessionId && controlledSessionIds.has(data.sessionId) && transformBrowserToDisplayCoordinates) {
|
|
160
163
|
const transformedPosition = transformBrowserToDisplayCoordinates(data.x, data.y);
|
|
161
164
|
if (transformedPosition && onCursorUpdate) {
|
|
162
165
|
onCursorUpdate(transformedPosition.x, transformedPosition.y, true);
|
|
@@ -170,12 +173,7 @@ export function createMcpHandler(config: McpHandlerConfig) {
|
|
|
170
173
|
*/
|
|
171
174
|
function restoreControlState(frontendTabId: string, browserSessionId: string): void {
|
|
172
175
|
debug.log('preview', `🔄 Restoring MCP control state for tab: ${frontendTabId} (session: ${browserSessionId})`);
|
|
173
|
-
|
|
174
|
-
isControlled: true,
|
|
175
|
-
controlledTabId: frontendTabId,
|
|
176
|
-
browserSessionId: browserSessionId,
|
|
177
|
-
startedAt: Date.now()
|
|
178
|
-
};
|
|
176
|
+
controlledSessionIds = new Set([...controlledSessionIds, browserSessionId]);
|
|
179
177
|
}
|
|
180
178
|
|
|
181
179
|
/**
|
|
@@ -183,12 +181,7 @@ export function createMcpHandler(config: McpHandlerConfig) {
|
|
|
183
181
|
*/
|
|
184
182
|
function resetControlState(): void {
|
|
185
183
|
debug.log('preview', `🔄 Resetting MCP control state`);
|
|
186
|
-
|
|
187
|
-
isControlled: false,
|
|
188
|
-
controlledTabId: null,
|
|
189
|
-
browserSessionId: null,
|
|
190
|
-
startedAt: null
|
|
191
|
-
};
|
|
184
|
+
controlledSessionIds = new Set();
|
|
192
185
|
if (onCursorHide) {
|
|
193
186
|
onCursorHide();
|
|
194
187
|
}
|
|
@@ -327,10 +320,11 @@ export function createMcpHandler(config: McpHandlerConfig) {
|
|
|
327
320
|
return {
|
|
328
321
|
setupEventListeners,
|
|
329
322
|
isCurrentTabMcpControlled,
|
|
330
|
-
|
|
323
|
+
isSessionControlled,
|
|
324
|
+
getControlledTabIds,
|
|
331
325
|
restoreControlState,
|
|
332
326
|
resetControlState,
|
|
333
|
-
get
|
|
327
|
+
get controlledSessionIds() { return controlledSessionIds; }
|
|
334
328
|
};
|
|
335
329
|
}
|
|
336
330
|
|
|
@@ -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 }}
|