@myrialabs/clopen 0.2.5 → 0.2.7

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 (43) hide show
  1. package/backend/chat/stream-manager.ts +136 -10
  2. package/backend/database/queries/session-queries.ts +9 -0
  3. package/backend/engine/adapters/claude/error-handler.ts +7 -2
  4. package/backend/engine/adapters/claude/stream.ts +16 -7
  5. package/backend/index.ts +25 -3
  6. package/backend/mcp/servers/browser-automation/browser.ts +23 -6
  7. package/backend/preview/browser/browser-mcp-control.ts +32 -16
  8. package/backend/preview/browser/browser-pool.ts +3 -1
  9. package/backend/preview/browser/browser-preview-service.ts +16 -17
  10. package/backend/preview/browser/browser-tab-manager.ts +1 -1
  11. package/backend/preview/browser/browser-video-capture.ts +199 -156
  12. package/backend/preview/browser/scripts/audio-stream.ts +11 -0
  13. package/backend/preview/browser/scripts/video-stream.ts +3 -5
  14. package/backend/snapshot/helpers.ts +15 -2
  15. package/backend/ws/chat/stream.ts +1 -1
  16. package/backend/ws/preview/browser/tab-info.ts +5 -2
  17. package/backend/ws/snapshot/restore.ts +43 -2
  18. package/frontend/components/chat/input/ChatInput.svelte +6 -4
  19. package/frontend/components/chat/input/components/ChatInputActions.svelte +10 -0
  20. package/frontend/components/chat/input/composables/use-chat-actions.svelte.ts +6 -2
  21. package/frontend/components/chat/message/MessageBubble.svelte +22 -1
  22. package/frontend/components/chat/tools/components/TerminalCommand.svelte +2 -2
  23. package/frontend/components/files/FileViewer.svelte +13 -2
  24. package/frontend/components/history/HistoryModal.svelte +1 -1
  25. package/frontend/components/preview/browser/BrowserPreview.svelte +15 -0
  26. package/frontend/components/preview/browser/components/Canvas.svelte +432 -69
  27. package/frontend/components/preview/browser/components/Container.svelte +23 -1
  28. package/frontend/components/preview/browser/components/Toolbar.svelte +1 -8
  29. package/frontend/components/preview/browser/core/coordinator.svelte.ts +27 -4
  30. package/frontend/components/preview/browser/core/mcp-handlers.svelte.ts +36 -3
  31. package/frontend/components/preview/browser/core/tab-operations.svelte.ts +1 -0
  32. package/frontend/components/terminal/TerminalTabs.svelte +1 -2
  33. package/frontend/components/workspace/PanelHeader.svelte +15 -0
  34. package/frontend/components/workspace/panels/FilesPanel.svelte +1 -0
  35. package/frontend/components/workspace/panels/GitPanel.svelte +27 -13
  36. package/frontend/components/workspace/panels/PreviewPanel.svelte +2 -0
  37. package/frontend/services/chat/chat.service.ts +9 -8
  38. package/frontend/services/preview/browser/browser-webcodecs.service.ts +43 -138
  39. package/frontend/stores/core/app.svelte.ts +4 -3
  40. package/frontend/stores/core/presence.svelte.ts +3 -2
  41. package/frontend/stores/core/sessions.svelte.ts +2 -0
  42. package/frontend/stores/ui/notification.svelte.ts +4 -1
  43. package/package.json +1 -1
@@ -44,18 +44,28 @@
44
44
  // Preview dimensions (bindable to parent)
45
45
  previewDimensions = $bindable<any>({ scale: 1 }),
46
46
 
47
+ // Touch interaction mode
48
+ touchMode = $bindable<'scroll' | 'cursor'>('scroll'),
49
+
47
50
  // Callbacks
48
51
  onInteraction = $bindable<(action: any) => void>(() => {}),
49
52
  onRetry = $bindable<() => void>(() => {})
50
53
  } = $props();
51
54
 
52
55
  let previewContainer = $state<HTMLDivElement | undefined>();
56
+ let touchCursorPos = $state<{ x: number; y: number; visible: boolean; clicking?: boolean }>({ x: 0, y: 0, visible: false });
53
57
 
54
58
  // Solid loading overlay: shown during initial load states
59
+ // Skip when lastFrameData exists (tab was previously loaded - snapshot handles display)
55
60
  const showSolidOverlay = $derived(
56
- isLaunchingBrowser || !sessionInfo || (!isStreamReady && !isNavigating && !isReconnecting)
61
+ isLaunchingBrowser || !sessionInfo || (!isStreamReady && !isNavigating && !isReconnecting && !lastFrameData)
57
62
  );
58
63
 
64
+ // Diagnostic: log whenever showSolidOverlay changes
65
+ $effect(() => {
66
+ debug.log('preview', `[DIAG] showSolidOverlay=${showSolidOverlay} (isLaunchingBrowser=${isLaunchingBrowser}, sessionInfo=${!!sessionInfo}, isStreamReady=${isStreamReady}, isNavigating=${isNavigating}, isReconnecting=${isReconnecting}, lastFrameData=${!!lastFrameData})`);
67
+ });
68
+
59
69
  // Navigation overlay state with debounce to prevent flickering during state transitions
60
70
  let showNavigationOverlay = $state(false);
61
71
  let overlayHideTimeout: ReturnType<typeof setTimeout> | null = null;
@@ -88,6 +98,10 @@
88
98
  }
89
99
  });
90
100
 
101
+ function handleTouchCursorUpdate(pos: { x: number; y: number; visible: boolean; clicking?: boolean }) {
102
+ touchCursorPos = { x: pos.x, y: pos.y, visible: pos.visible, clicking: pos.clicking };
103
+ }
104
+
91
105
  onDestroy(() => {
92
106
  if (overlayHideTimeout) {
93
107
  clearTimeout(overlayHideTimeout);
@@ -370,10 +384,12 @@
370
384
  bind:isStreamReady
371
385
  bind:isNavigating
372
386
  bind:isReconnecting
387
+ bind:touchMode
373
388
  onInteraction={handleCanvasInteraction}
374
389
  onCursorUpdate={handleCursorUpdate}
375
390
  onFrameUpdate={handleFrameUpdate}
376
391
  onRequestScreencastRefresh={handleScreencastRefresh}
392
+ onTouchCursorUpdate={handleTouchCursorUpdate}
377
393
  />
378
394
  </div>
379
395
  {/if}
@@ -426,6 +442,7 @@
426
442
  </div>
427
443
  {/if}
428
444
 
445
+
429
446
  </div>
430
447
  {:else}
431
448
  <div
@@ -444,6 +461,11 @@
444
461
  <VirtualCursor cursor={virtualCursor} />
445
462
  {/if}
446
463
 
464
+ <!-- Touch Cursor - shown in cursor simulation mode -->
465
+ {#if touchMode === 'cursor' && touchCursorPos.visible}
466
+ <VirtualCursor cursor={touchCursorPos} />
467
+ {/if}
468
+
447
469
  <!-- MCP Virtual Cursor -->
448
470
  {#if mcpVirtualCursor.visible}
449
471
  <VirtualCursor cursor={mcpVirtualCursor} />
@@ -222,7 +222,7 @@
222
222
  {@const isActive = tab.id === activeTabId}
223
223
  <button
224
224
  type="button"
225
- class="group relative flex items-center justify-center gap-1 px-2 py-2 text-xs font-medium transition-colors min-w-0 max-w-xs cursor-pointer
225
+ class="group relative flex items-center justify-center gap-1 pr-2 pl-3 py-2 text-xs font-medium transition-colors min-w-0 max-w-xs cursor-pointer
226
226
  {isActive
227
227
  ? 'text-violet-600 dark:text-violet-400'
228
228
  : 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300'}"
@@ -230,13 +230,6 @@
230
230
  role="tab"
231
231
  tabindex="0"
232
232
  >
233
- {#if tab.id === mcpControlledTabId}
234
- <Icon name="lucide:bot" class="w-3 h-3 flex-shrink-0 text-amber-500" />
235
- {:else if tab.isLoading}
236
- <Icon name="lucide:loader-circle" class="w-3 h-3 animate-spin flex-shrink-0" />
237
- {:else}
238
- <Icon name="lucide:globe" class="w-3 h-3 flex-shrink-0" />
239
- {/if}
240
233
  <span class="truncate max-w-28" title={tab.url}>
241
234
  {tab.title || 'New Tab'}
242
235
  </span>
@@ -486,7 +486,10 @@ export function createBrowserCoordinator(config: BrowserCoordinatorConfig) {
486
486
 
487
487
  if (!existingTabs || existingTabs.count === 0) {
488
488
  debug.log('preview', '📭 No existing sessions to recover');
489
- isRestoring = false;
489
+ // Notify parent about 0-tab recovery (enables empty tab creation)
490
+ if (onSessionsRecovered) {
491
+ onSessionsRecovered(0);
492
+ }
490
493
  return;
491
494
  }
492
495
 
@@ -507,6 +510,13 @@ export function createBrowserCoordinator(config: BrowserCoordinatorConfig) {
507
510
  if (backendTab.isActive && frontendId) {
508
511
  activeTabFrontendId = frontendId;
509
512
  }
513
+
514
+ // Restore MCP control state if this tab was being controlled
515
+ if (backendTab.isMcpControlled && frontendId) {
516
+ debug.log('preview', `🎮 Restoring MCP control state for recovered tab: ${frontendId} (session: ${backendTab.tabId})`);
517
+ mcpHandler.restoreControlState(frontendId, backendTab.tabId);
518
+ }
519
+
510
520
  totalRestored++;
511
521
  }
512
522
 
@@ -533,6 +543,11 @@ export function createBrowserCoordinator(config: BrowserCoordinatorConfig) {
533
543
 
534
544
  debug.log('preview', `✅ Session recovery complete - restored ${totalRestored} tabs`);
535
545
 
546
+ // Diagnostic: dump state of all restored tabs
547
+ for (const tab of tabManager.getAllTabs()) {
548
+ debug.log('preview', `[DIAG] Restored tab: id=${tab.id}, sessionId=${tab.sessionId}, url=${tab.url}, isConnected=${tab.isConnected}, isStreamReady=${tab.isStreamReady}, isLaunchingBrowser=${tab.isLaunchingBrowser}, sessionInfo=${!!tab.sessionInfo}`);
549
+ }
550
+
536
551
  // Notify parent that sessions were recovered
537
552
  if (onSessionsRecovered) {
538
553
  onSessionsRecovered(totalRestored);
@@ -781,7 +796,7 @@ export function createBrowserCoordinator(config: BrowserCoordinatorConfig) {
781
796
  const newProjectId = getProjectId();
782
797
  debug.log('preview', `🔄 Switching to project: ${newProjectId}`);
783
798
 
784
- // Set restore lock during project switch
799
+ // Set restore lock during entire project switch (prevents race conditions)
785
800
  isRestoring = true;
786
801
 
787
802
  try {
@@ -794,14 +809,22 @@ export function createBrowserCoordinator(config: BrowserCoordinatorConfig) {
794
809
  // Clear frontend tracking
795
810
  browserCleanup.clearAll();
796
811
 
812
+ // Reset MCP control state to avoid stale tab IDs after tab recreation
813
+ mcpHandler.resetControlState();
814
+
797
815
  // Recover sessions from new project
798
- // Note: recoverExistingSessions will handle its own lock
799
- isRestoring = false;
800
816
  await recoverExistingSessions();
801
817
  } catch (error) {
802
818
  debug.error('preview', '❌ Error switching project:', error);
819
+ } finally {
803
820
  isRestoring = false;
804
821
  }
822
+
823
+ // After recovery and lock release, create empty tab if no tabs exist
824
+ if (tabManager.getAllTabs().length === 0) {
825
+ debug.log('preview', '📭 No tabs after project switch, creating empty tab');
826
+ createNewTab('');
827
+ }
805
828
  }
806
829
 
807
830
  return {
@@ -145,8 +145,8 @@ export function createMcpHandler(config: McpHandlerConfig) {
145
145
  }
146
146
 
147
147
  function handleCursorPosition(data: { sessionId: string; x: number; y: number; timestamp: number; source: 'mcp' }) {
148
- // Only update cursor if this is for the active session and MCP is controlling
149
- if (mcpControlState.isControlled && mcpControlState.browserSessionId === data.sessionId && transformBrowserToDisplayCoordinates) {
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) {
150
150
  const transformedPosition = transformBrowserToDisplayCoordinates(data.x, data.y);
151
151
  if (transformedPosition && onCursorUpdate) {
152
152
  onCursorUpdate(transformedPosition.x, transformedPosition.y, false);
@@ -155,7 +155,8 @@ export function createMcpHandler(config: McpHandlerConfig) {
155
155
  }
156
156
 
157
157
  function handleCursorClick(data: { sessionId: string; x: number; y: number; timestamp: number; source: 'mcp' }) {
158
- if (mcpControlState.isControlled && mcpControlState.browserSessionId === data.sessionId && transformBrowserToDisplayCoordinates) {
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) {
159
160
  const transformedPosition = transformBrowserToDisplayCoordinates(data.x, data.y);
160
161
  if (transformedPosition && onCursorUpdate) {
161
162
  onCursorUpdate(transformedPosition.x, transformedPosition.y, true);
@@ -163,6 +164,36 @@ export function createMcpHandler(config: McpHandlerConfig) {
163
164
  }
164
165
  }
165
166
 
167
+ /**
168
+ * Restore MCP control state after session recovery (browser refresh or project switch)
169
+ * Called when recovered backend tab was previously MCP-controlled
170
+ */
171
+ function restoreControlState(frontendTabId: string, browserSessionId: string): void {
172
+ 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
+ };
179
+ }
180
+
181
+ /**
182
+ * Reset MCP control state (called before project switch recovery)
183
+ */
184
+ function resetControlState(): void {
185
+ debug.log('preview', `🔄 Resetting MCP control state`);
186
+ mcpControlState = {
187
+ isControlled: false,
188
+ controlledTabId: null,
189
+ browserSessionId: null,
190
+ startedAt: null
191
+ };
192
+ if (onCursorHide) {
193
+ onCursorHide();
194
+ }
195
+ }
196
+
166
197
  function handleTestCompleted(_data: { sessionId: string; timestamp: number; source: 'mcp' }) {
167
198
  // Cursor is hidden via chat:complete / chat:cancelled listeners instead,
168
199
  // because test-completed fires per-tool-call, not at end of full request.
@@ -297,6 +328,8 @@ export function createMcpHandler(config: McpHandlerConfig) {
297
328
  setupEventListeners,
298
329
  isCurrentTabMcpControlled,
299
330
  getControlState,
331
+ restoreControlState,
332
+ resetControlState,
300
333
  get mcpControlState() { return mcpControlState; }
301
334
  };
302
335
  }
@@ -44,6 +44,7 @@ export interface ExistingTabInfo {
44
44
  deviceSize: string;
45
45
  rotation: string;
46
46
  isActive: boolean;
47
+ isMcpControlled?: boolean;
47
48
  }
48
49
 
49
50
  export interface ExistingTabsResult {
@@ -37,7 +37,7 @@
37
37
  {@const isActive = session.isActive}
38
38
  <button
39
39
  type="button"
40
- class="group relative flex items-center justify-center gap-1 px-2 py-2 text-xs font-medium transition-colors min-w-0 max-w-xs cursor-pointer
40
+ class="group relative flex items-center justify-center gap-1 pr-2 pl-3 py-2 text-xs font-medium transition-colors min-w-0 max-w-xs cursor-pointer
41
41
  {isActive
42
42
  ? 'text-violet-600 dark:text-violet-400'
43
43
  : 'text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300'}"
@@ -45,7 +45,6 @@
45
45
  role="tab"
46
46
  tabindex="0"
47
47
  >
48
- <Icon name="lucide:terminal" class="w-3 h-3 flex-shrink-0" />
49
48
  <span class="truncate max-w-28">{session.name}</span>
50
49
  <!-- Close button -->
51
50
  <span
@@ -481,6 +481,21 @@
481
481
  {/if}
482
482
  </div>
483
483
 
484
+ <!-- Touch mode toggle (scroll ↔ trackpad cursor) -->
485
+ <button
486
+ type="button"
487
+ 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
488
+ {previewPanelRef?.panelActions?.getTouchMode() === 'cursor' ? 'text-violet-600 dark:text-violet-400' : 'text-slate-500 hover:text-slate-900 dark:hover:text-slate-100'}"
489
+ onclick={() => {
490
+ const current = previewPanelRef?.panelActions?.getTouchMode() || 'scroll';
491
+ previewPanelRef?.panelActions?.setTouchMode(current === 'scroll' ? 'cursor' : 'scroll');
492
+ }}
493
+ 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)'}
494
+ >
495
+ <Icon name={previewPanelRef?.panelActions?.getTouchMode() === 'cursor' ? 'lucide:mouse-pointer-2' : 'lucide:pointer'} class={isMobile ? 'w-4.5 h-4.5' : 'w-4 h-4'} />
496
+ <span class="text-xs font-medium">{previewPanelRef?.panelActions?.getTouchMode() === 'cursor' ? 'Cursor' : 'Touch'}</span>
497
+ </button>
498
+
484
499
  <!-- Rotation toggle -->
485
500
  <button
486
501
  type="button"
@@ -1344,6 +1344,7 @@
1344
1344
  externallyChanged={displayExternallyChanged}
1345
1345
  onForceReload={forceReloadTab}
1346
1346
  isBinary={displayIsBinary}
1347
+ projectPath={projectPath}
1347
1348
  />
1348
1349
  </div>
1349
1350
  </div>
@@ -942,6 +942,16 @@
942
942
  }
943
943
  }
944
944
 
945
+ async function copyTagHash(hash: string, e: MouseEvent) {
946
+ e.stopPropagation();
947
+ try {
948
+ await navigator.clipboard.writeText(hash);
949
+ showInfo('Copied', `Hash ${hash.substring(0, 7)} copied to clipboard`);
950
+ } catch {
951
+ showError('Copy Failed', 'Could not copy to clipboard');
952
+ }
953
+ }
954
+
945
955
  // ============================
946
956
  // Lifecycle
947
957
  // ============================
@@ -1323,8 +1333,8 @@
1323
1333
  <div class="group flex items-center gap-2 px-2.5 py-2 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800/60 transition-colors">
1324
1334
  <Icon name="lucide:archive" class="w-4 h-4 text-slate-400 shrink-0" />
1325
1335
  <div class="flex-1 min-w-0">
1326
- <p class="text-xs font-medium text-slate-900 dark:text-slate-100 truncate">{entry.message}</p>
1327
- <p class="text-3xs text-slate-400 dark:text-slate-500">stash@&#123;{entry.index}&#125;</p>
1336
+ <p class="text-sm font-medium text-slate-900 dark:text-slate-100 truncate">{entry.message}</p>
1337
+ <p class="text-xs text-slate-400 dark:text-slate-500">stash@&#123;{entry.index}&#125;</p>
1328
1338
  </div>
1329
1339
  <div class="flex items-center gap-0.5 shrink-0">
1330
1340
  <button
@@ -1416,21 +1426,25 @@
1416
1426
  <div class="space-y-1 px-1">
1417
1427
  {#each tags as tag (tag.name)}
1418
1428
  <div class="group flex items-center gap-2 px-2.5 py-2 rounded-md hover:bg-slate-100 dark:hover:bg-slate-800/60 transition-colors">
1419
- <Icon
1420
- name={tag.isAnnotated ? 'lucide:bookmark' : 'lucide:tag'}
1421
- class="w-4 h-4 shrink-0 {tag.isAnnotated ? 'text-amber-500' : 'text-slate-400'}"
1422
- />
1429
+ <span title={tag.isAnnotated ? 'Annotated tag' : 'Lightweight tag'} class="shrink-0">
1430
+ <Icon
1431
+ name={tag.isAnnotated ? 'lucide:bookmark' : 'lucide:tag'}
1432
+ class="w-4 h-4 {tag.isAnnotated ? 'text-amber-500' : 'text-slate-400'}"
1433
+ />
1434
+ </span>
1423
1435
  <div class="flex-1 min-w-0">
1436
+ <p class="text-sm font-medium text-slate-900 dark:text-slate-100 truncate">{tag.name}</p>
1424
1437
  <div class="flex items-center gap-1.5">
1425
- <p class="text-xs font-medium text-slate-900 dark:text-slate-100 truncate">{tag.name}</p>
1426
- {#if tag.isAnnotated}
1427
- <span class="text-3xs px-1 py-0.5 rounded bg-amber-500/10 text-amber-600 dark:text-amber-400 shrink-0">annotated</span>
1438
+ <button
1439
+ type="button"
1440
+ class="text-xs font-mono text-slate-400 dark:text-slate-500 hover:text-violet-600 dark:hover:text-violet-400 bg-transparent border-none cursor-pointer p-0 shrink-0 transition-colors"
1441
+ onclick={(e) => copyTagHash(tag.hash, e)}
1442
+ title="Copy tag hash"
1443
+ >{tag.hash.slice(0, 7)}</button>
1444
+ {#if tag.message}
1445
+ <span class="text-xs text-slate-400 dark:text-slate-500 truncate">{tag.message}</span>
1428
1446
  {/if}
1429
1447
  </div>
1430
- {#if tag.message}
1431
- <p class="text-3xs text-slate-500 dark:text-slate-400 truncate">{tag.message}</p>
1432
- {/if}
1433
- <p class="text-3xs text-slate-400 dark:text-slate-500 font-mono">{tag.hash}</p>
1434
1448
  </div>
1435
1449
  <div class="flex items-center gap-0.5 shrink-0">
1436
1450
  <button
@@ -104,6 +104,8 @@
104
104
 
105
105
  // Export actions for DesktopPanel header
106
106
  export const panelActions = {
107
+ getTouchMode: () => browserPreviewRef?.browserActions?.getTouchMode() || 'scroll',
108
+ setTouchMode: (mode: 'scroll' | 'cursor') => { browserPreviewRef?.browserActions?.setTouchMode(mode); },
107
109
  getDeviceSize: () => deviceSize,
108
110
  getRotation: () => rotation,
109
111
  getScale: () => previewDimensions?.scale || 1,
@@ -196,7 +196,12 @@ class ChatService {
196
196
  this.streamCompleted = true;
197
197
  this.reconnected = false;
198
198
  this.activeProcessId = null;
199
- this.setProcessState({ isLoading: false, isWaitingInput: false, isCancelling: false });
199
+ // Don't clear isCancelling here it causes a race with presence.
200
+ // The chat:cancelled WS event arrives before broadcastPresence() updates,
201
+ // so clearing isCancelling lets the presence $effect re-enable isLoading
202
+ // (because hasActiveForSession is still true). The presence $effect will
203
+ // clear isCancelling once the stream is actually gone from presence.
204
+ this.setProcessState({ isLoading: false, isWaitingInput: false });
200
205
 
201
206
  // Mark any tool_use blocks that never got a tool_result
202
207
  this.markInterruptedTools();
@@ -480,13 +485,9 @@ class ChatService {
480
485
  // before a stale non-reasoning stream_event instead of at the end).
481
486
  this.cleanupStreamEvents();
482
487
 
483
- // Safety timeout: clear isCancelling after 10s if WS confirmation never arrives
484
- // (e.g., network issues, dropped connection)
485
- setTimeout(() => {
486
- if (appState.isCancelling) {
487
- appState.isCancelling = false;
488
- }
489
- }, 10000);
488
+ // No safety timeout needed cancel completion is confirmed via WS events:
489
+ // chat:cancelled clears isLoading, then presence update clears isCancelling.
490
+ // If WS disconnects, reconnection logic re-fetches presence and clears state.
490
491
  }
491
492
 
492
493
  /**