@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.
Files changed (44) hide show
  1. package/backend/chat/stream-manager.ts +23 -12
  2. package/backend/mcp/project-context.ts +20 -0
  3. package/backend/mcp/servers/browser-automation/actions.ts +0 -2
  4. package/backend/mcp/servers/browser-automation/browser.ts +80 -143
  5. package/backend/mcp/servers/browser-automation/inspection.ts +5 -11
  6. package/backend/preview/browser/browser-mcp-control.ts +174 -195
  7. package/backend/preview/browser/browser-preview-service.ts +3 -3
  8. package/backend/preview/browser/browser-video-capture.ts +12 -14
  9. package/backend/preview/browser/scripts/video-stream.ts +14 -14
  10. package/backend/preview/browser/types.ts +7 -7
  11. package/backend/preview/index.ts +1 -1
  12. package/backend/terminal/stream-manager.ts +40 -26
  13. package/backend/ws/preview/index.ts +3 -3
  14. package/backend/ws/system/operations.ts +23 -0
  15. package/frontend/components/chat/message/MessageBubble.svelte +2 -2
  16. package/frontend/components/chat/tools/components/FileHeader.svelte +1 -1
  17. package/frontend/components/chat/tools/components/TerminalCommand.svelte +8 -1
  18. package/frontend/components/common/overlay/Dialog.svelte +1 -1
  19. package/frontend/components/common/overlay/Lightbox.svelte +2 -2
  20. package/frontend/components/common/overlay/Modal.svelte +2 -2
  21. package/frontend/components/common/xterm/XTerm.svelte +6 -1
  22. package/frontend/components/git/ConflictResolver.svelte +1 -1
  23. package/frontend/components/git/GitModal.svelte +2 -2
  24. package/frontend/components/preview/browser/BrowserPreview.svelte +1 -1
  25. package/frontend/components/preview/browser/components/Canvas.svelte +1 -1
  26. package/frontend/components/preview/browser/components/Toolbar.svelte +4 -4
  27. package/frontend/components/preview/browser/core/mcp-handlers.svelte.ts +58 -64
  28. package/frontend/components/settings/SettingsModal.svelte +1 -1
  29. package/frontend/components/settings/general/DataManagementSettings.svelte +5 -66
  30. package/frontend/components/terminal/Terminal.svelte +1 -29
  31. package/frontend/components/tunnel/TunnelInactive.svelte +7 -5
  32. package/frontend/components/workspace/DesktopNavigator.svelte +1 -1
  33. package/frontend/components/workspace/PanelHeader.svelte +22 -16
  34. package/frontend/components/workspace/panels/GitPanel.svelte +1 -6
  35. package/frontend/services/preview/browser/browser-webcodecs.service.ts +2 -2
  36. package/frontend/services/project/status.service.ts +11 -1
  37. package/frontend/stores/core/sessions.svelte.ts +11 -1
  38. package/frontend/stores/features/terminal.svelte.ts +56 -26
  39. package/frontend/stores/ui/theme.svelte.ts +1 -1
  40. package/frontend/utils/ws.ts +42 -0
  41. package/index.html +2 -2
  42. package/package.json +1 -1
  43. package/shared/utils/ws-client.ts +21 -4
  44. 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, writeFileSync, readFileSync, unlinkSync } from 'fs';
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
- try {
137
- const cacheFile = join(this.tempDir, `${stream.sessionId}.json`);
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
- // Only save new output (from outputStartIndex onwards)
140
- // This prevents duplicating old output that was already displayed
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
- const cacheData = {
146
- streamId: stream.streamId,
147
- sessionId: stream.sessionId,
148
- command: stream.command,
149
- projectId: stream.projectId,
150
- projectPath: stream.projectPath,
151
- workingDirectory: stream.workingDirectory,
152
- startedAt: stream.startedAt,
153
- status: stream.status,
154
- output: newOutput, // Only save new output
155
- outputStartIndex: stream.outputStartIndex || 0,
156
- lastUpdated: new Date().toISOString()
157
- };
158
- writeFileSync(cacheFile, JSON.stringify(cacheData));
159
- } catch (error) {
160
- // Silently handle write errors
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
- browserSessionId: t.String(),
121
- mcpSessionId: t.Optional(t.String()),
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
- browserSessionId: t.String(),
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 -->
@@ -50,7 +50,7 @@
50
50
  {#if badges.length > 0}
51
51
  <div class="flex gap-2 mt-3">
52
52
  {#each badges as badge}
53
- <div class="text-xs px-2 py-1 rounded {badge.color}">
53
+ <div class="text-3xs px-2 py-0.5 rounded {badge.color}">
54
54
  {badge.text}
55
55
  </div>
56
56
  {/each}
@@ -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}ms
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-[95vh] flex items-center justify-center"
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-[90vh] object-contain rounded-lg shadow-2xl"
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] max-h-[90vh]'
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-[95vh] md:max-h-[90vh] overflow-hidden flex flex-col {className}"
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-slate-50 dark:bg-slate-900/70 {className} select-none"
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-[85vh] flex flex-col"
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-[85vh] !max-w-[95vw] md:!max-w-5xl">
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-[65vh] -mx-4 -my-6 md:-mx-6">
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
- mcpControlledTabId={mcpHandler.mcpControlState.controlledTabId}
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 = 'low'; // Faster rendering
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
- mcpControlledTabId = $bindable<string | null>(null),
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-slate-100 dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700">
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 === mcpControlledTabId}
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 !== mcpControlledTabId}
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
- // MCP control state
34
- let mcpControlState = $state<McpControlState>({
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
- return mcpControlState.isControlled && mcpControlState.controlledTabId === tabManager.activeTabId;
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
- * Get MCP control state
95
+ * Check if a specific frontend tab is MCP controlled (by sessionId)
104
96
  */
105
- function getControlState(): McpControlState {
106
- return mcpControlState;
97
+ function isSessionControlled(sessionId: string): boolean {
98
+ return controlledSessionIds.has(sessionId);
107
99
  }
108
100
 
109
- // Private handlers
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
- function handleControlStart(data: { browserSessionId: string; mcpSessionId?: string; timestamp: number }) {
112
- debug.log('preview', `🎮 MCP control started for session: ${data.browserSessionId}`);
114
+ // Private handlers
113
115
 
114
- // Find which tab has this session
115
- const tab = tabManager.tabs.find(t => t.sessionId === data.browserSessionId);
116
+ function handleControlStart(data: { browserTabId: string; chatSessionId?: string; timestamp: number }) {
117
+ debug.log('preview', `🎮 MCP control started for tab: ${data.browserTabId}`);
116
118
 
117
- mcpControlState = {
118
- isControlled: true,
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 notification
125
- showWarning('MCP Control Started', 'An MCP agent is now controlling the browser. User input is blocked.', 5000);
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: { browserSessionId: string; timestamp: number }) {
129
- debug.log('preview', `🎮 MCP control ended for session: ${data.browserSessionId}`);
128
+ function handleControlEnd(data: { browserTabId: string; timestamp: number }) {
129
+ debug.log('preview', `🎮 MCP control ended for tab: ${data.browserTabId}`);
130
130
 
131
- mcpControlState = {
132
- isControlled: false,
133
- controlledTabId: null,
134
- browserSessionId: null,
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
- if (onCursorHide) {
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 notification
144
- showInfo('MCP Control Ended', 'MCP agent released control. You can now interact with the browser.', 4000);
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 MCP is controlling AND user is currently viewing that tab
149
- if (mcpControlState.isControlled && mcpControlState.browserSessionId === data.sessionId && isCurrentTabMcpControlled() && transformBrowserToDisplayCoordinates) {
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 MCP is controlling AND user is currently viewing that tab
159
- if (mcpControlState.isControlled && mcpControlState.browserSessionId === data.sessionId && isCurrentTabMcpControlled() && transformBrowserToDisplayCoordinates) {
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
- mcpControlState = {
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
- mcpControlState = {
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
- getControlState,
323
+ isSessionControlled,
324
+ getControlledTabIds,
331
325
  restoreControlState,
332
326
  resetControlState,
333
- get mcpControlState() { return mcpControlState; }
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-violet-500/20 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"
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 }}