@nextclaw/ui 0.24.3 → 0.25.0

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 (98) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/.vite/manifest.json +208 -195
  3. package/dist/assets/{account-panel-BRTSwNvA.js → account-panel-UvHFa8Kl.js} +1 -1
  4. package/dist/assets/{api-BnUzdltV.js → api-Dffeij78.js} +5 -5
  5. package/dist/assets/{app-data-Bjh_zM45.js → app-data-DzaIMy4D.js} +1 -1
  6. package/dist/assets/app-presenter-provider-O2tUKKGf.js +109 -0
  7. package/dist/assets/{appearance-settings-page-BrOYSgG0.js → appearance-settings-page-D8cWniw-.js} +1 -1
  8. package/dist/assets/{channels-list-page-BE0TxB3B.js → channels-list-page-B_fYP1Pn.js} +1 -1
  9. package/dist/assets/{config-split-page-BxDwwAGa.js → config-split-page-6hHiiapl.js} +1 -1
  10. package/dist/assets/{confirm-dialog-S5JxZcS1.js → confirm-dialog-ClT6iaj8.js} +1 -1
  11. package/dist/assets/{desktop-capabilities-page-BwHxJpbl.js → desktop-capabilities-page-CB5pniTx.js} +1 -1
  12. package/dist/assets/{desktop-update-config-Bfg7OdJ2.js → desktop-update-config-OuQV73gg.js} +1 -1
  13. package/dist/assets/doc-browser-BFkslEzY.js +1 -0
  14. package/dist/assets/{doc-browser-C8eqJLi_.js → doc-browser-Cu58uiEg.js} +1 -1
  15. package/dist/assets/{doc-browser-context-BPCDFcC-.js → doc-browser-context-CekJxJFm.js} +1 -1
  16. package/dist/assets/{extensions-DgKQRebe.js → extensions-B5r-0jCq.js} +1 -1
  17. package/dist/assets/{i18n-CKvm9zyk.js → i18n-CPjq_hQX.js} +2 -2
  18. package/dist/assets/{i18n-provider-CPV43pMX.js → i18n-provider-BYO9AhFy.js} +1 -1
  19. package/dist/assets/keyboard-shortcuts-settings-page-1efEkGwq.js +1 -0
  20. package/dist/assets/{login-page-DeDQjVxv.js → login-page-DBqkVTVD.js} +1 -1
  21. package/dist/assets/login-page-DjcRQLQe.js +1 -0
  22. package/dist/assets/main-CLwCjK9U.css +1 -0
  23. package/dist/assets/main-CQ6GUYdX.js +2 -0
  24. package/dist/assets/main-i18n-catalog.config-QB4DfJRJ.js +1 -0
  25. package/dist/assets/{mcp-marketplace-page-CWjJGp_D.js → mcp-marketplace-page-CWAxBNaS.js} +1 -1
  26. package/dist/assets/mcp-marketplace-page-Ct090ceA.js +1 -0
  27. package/dist/assets/{model-config-page-Dv_zE7HN.js → model-config-page-DBcuZyRC.js} +1 -1
  28. package/dist/assets/panel-app-i18n-catalog.config-CQFujXqt.js +1 -0
  29. package/dist/assets/panel-app-maumVhbl.js +1 -0
  30. package/dist/assets/{panelStandalone-CFRBHV94.js → panelStandalone-BGA1s7JG.js} +2 -2
  31. package/dist/assets/{privacy-settings-page-D6uc9f-A.js → privacy-settings-page-DVC-aq1v.js} +1 -1
  32. package/dist/assets/projects-Cq0E3c7N.js +1 -0
  33. package/dist/assets/{provider-scoped-model-input-DUa_9uwg.js → provider-scoped-model-input-BGdZMWWa.js} +1 -1
  34. package/dist/assets/{providers-config-page-DAiqxlF_.js → providers-config-page-B-IhHl6x.js} +1 -1
  35. package/dist/assets/{pwa-C_Zte1xe.js → pwa-C3kR9mdy.js} +1 -1
  36. package/dist/assets/remote-Cnawibbg.js +1 -0
  37. package/dist/assets/runtime-config-page-LmrB2q65.js +1 -0
  38. package/dist/assets/{search-config-page-3xbhRlW2.js → search-config-page-BIXmv5FY.js} +1 -1
  39. package/dist/assets/{secrets-config-page-Dn8_rdOj.js → secrets-config-page-DU3fadrq.js} +1 -1
  40. package/dist/assets/security-config-9s0i0I4N.js +1 -0
  41. package/dist/assets/{service-apps-panel-CGc04aMn.js → service-apps-panel-Bshf3Wmq.js} +1 -1
  42. package/dist/assets/{tag-chip-BDzpHEli.js → tag-chip-gAH1IOsZ.js} +1 -1
  43. package/dist/assets/{theme-provider-B6FinATQ.js → theme-provider-DE7m9jvf.js} +1 -1
  44. package/dist/assets/{use-config-BPUM2lbo.js → use-config-Cyiw06-j.js} +1 -1
  45. package/dist/assets/{use-desktop-capabilities-BDjArQP3.js → use-desktop-capabilities-OKG96v4y.js} +1 -1
  46. package/dist/index.html +18 -18
  47. package/dist/panel-standalone.html +5 -5
  48. package/package.json +6 -6
  49. package/src/app/configs/app-navigation.config.ts +10 -3
  50. package/src/app/index.tsx +7 -0
  51. package/src/features/chat/components/providers/chat-presenter.provider.tsx +2 -0
  52. package/src/features/chat/features/conversation/components/__tests__/session-conversation-area.test.tsx +12 -0
  53. package/src/features/chat/features/conversation/components/__tests__/session-conversation-input.streaming.test.tsx +13 -1
  54. package/src/features/chat/features/conversation/components/__tests__/session-voice-input.test-utils.ts +52 -0
  55. package/src/features/chat/features/conversation/components/chat-voice-input-panel.tsx +70 -0
  56. package/src/features/chat/features/conversation/components/chat-voice-shortcut-control.tsx +33 -0
  57. package/src/features/chat/features/conversation/components/session-conversation-area.tsx +1 -11
  58. package/src/features/chat/features/conversation/components/session-conversation-input.tsx +29 -5
  59. package/src/features/chat/features/conversation/hooks/__tests__/use-chat-voice-input.test.tsx +185 -0
  60. package/src/features/chat/features/conversation/hooks/use-chat-voice-input.ts +52 -0
  61. package/src/features/chat/features/input/utils/__tests__/chat-input-bar.utils.test.ts +13 -6
  62. package/src/features/chat/features/input/utils/chat-input-toolbar.utils.ts +28 -12
  63. package/src/features/chat/features/message/utils/__tests__/chat-message-process-summary.utils.test.ts +19 -1
  64. package/src/features/chat/features/message/utils/__tests__/chat-message.utils.test.ts +7 -6
  65. package/src/features/chat/features/message/utils/chat-message-part.utils.ts +4 -1
  66. package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-agent-runtime.test.tsx +20 -0
  67. package/src/features/chat/index.ts +1 -0
  68. package/src/features/chat/managers/chat-voice-input.manager.ts +174 -0
  69. package/src/features/chat/presenters/chat.presenter.ts +2 -0
  70. package/src/features/chat/stores/chat-voice-shortcut.store.ts +31 -0
  71. package/src/features/desktop-capabilities/index.ts +1 -0
  72. package/src/features/settings/pages/keyboard-shortcuts-settings-page.tsx +15 -0
  73. package/src/platforms/mobile/components/mobile-settings-shell.tsx +2 -1
  74. package/src/shared/lib/api/__tests__/ncp-session-query-cache.utils.test.ts +109 -1
  75. package/src/shared/lib/api/ncp-session-query-cache.utils.ts +71 -2
  76. package/src/shared/lib/i18n/locales/en-US/chat.json +32 -1
  77. package/src/shared/lib/i18n/locales/en-US/core.json +2 -0
  78. package/src/shared/lib/i18n/locales/zh-CN/chat.json +32 -1
  79. package/src/shared/lib/i18n/locales/zh-CN/core.json +2 -0
  80. package/src/shared/lib/transport/__tests__/browser-realtime-recovery.service.test.ts +42 -0
  81. package/src/shared/lib/transport/__tests__/local-transport.service.test.ts +85 -0
  82. package/src/shared/lib/transport/__tests__/remote.transport.test.ts +54 -1
  83. package/src/shared/lib/transport/browser-realtime-recovery.service.ts +35 -0
  84. package/src/shared/lib/transport/local-transport.service.ts +32 -0
  85. package/src/shared/lib/transport/remote-transport.service.ts +30 -0
  86. package/dist/assets/app-presenter-provider-CBt-lyr_.js +0 -109
  87. package/dist/assets/doc-browser-BgRP0Wni.js +0 -1
  88. package/dist/assets/login-page-ClcrvN04.js +0 -1
  89. package/dist/assets/main-DGqlaAaI.css +0 -1
  90. package/dist/assets/main-i18n-catalog.config-CpNF5aVc.js +0 -1
  91. package/dist/assets/main-nMVfHb5K.js +0 -2
  92. package/dist/assets/mcp-marketplace-page-fvu2NcXT.js +0 -1
  93. package/dist/assets/panel-app-BUUle0gP.js +0 -1
  94. package/dist/assets/panel-app-i18n-catalog.config-BzxE3tZb.js +0 -1
  95. package/dist/assets/projects-BD1rG75R.js +0 -1
  96. package/dist/assets/remote-BO0QP7ua.js +0 -1
  97. package/dist/assets/runtime-config-page-w1UAkGF_.js +0 -1
  98. package/dist/assets/security-config-Y1nEGM3A.js +0 -1
@@ -638,5 +638,36 @@
638
638
  "chatWorkspaceExcerptCharacterCount": "{count} characters",
639
639
  "chatInputAttachmentUnsupported": "This file type is not supported in the current upload flow.",
640
640
  "chatInputAttachmentTooLarge": "Files must be {maxMb} MB or smaller.",
641
- "chatInputAttachmentReadFailed": "Failed to read the file. Please try again."
641
+ "chatInputAttachmentReadFailed": "Failed to read the file. Please try again.",
642
+ "chatInputVoice": "Voice input",
643
+ "chatInputVoicePermissionDenied": "Microphone permission was denied. Allow it in the browser or system settings and retry.",
644
+ "chatInputVoiceUnsupported": "Speech input is not supported in this browser.",
645
+ "chatInputVoiceNoSpeech": "Could not hear you. Please try again.",
646
+ "chatInputVoiceFailed": "Voice input failed. Please try again.",
647
+ "chatInputVoicePrivacy": "Click to dictate at the cursor. Underlined words are still being recognized. Your browser may process audio online. Nothing is sent automatically.",
648
+ "chatInputVoiceStart": "Start recording",
649
+ "chatInputVoiceFinish": "Finish recording",
650
+ "chatInputVoiceCancel": "Cancel",
651
+ "chatInputVoiceStarting": "Connecting to microphone…",
652
+ "chatInputVoiceRecording": "Listening",
653
+ "chatInputVoiceFinishing": "Finishing transcription…",
654
+ "chatInputVoiceReview": "Recording stopped",
655
+ "chatInputVoiceSpeak": "Speak now. Your transcript will appear here.",
656
+ "chatInputVoiceReviewHint": "Confirmed words remain in your draft; provisional words were removed. Continue editing or try again.",
657
+ "chatInputVoiceTranscript": "Recognized text",
658
+ "chatInputVoiceInsert": "Keep in draft",
659
+ "chatInputVoiceRetry": "Retry",
660
+ "chatInputVoiceNetwork": "Could not connect to the speech service. Check your network and try again.",
661
+ "chatInputVoiceDevice": "Microphone unavailable. Check your device and system permissions.",
662
+ "chatInputVoiceInterrupted": "Recording stopped because the page or window lost focus.",
663
+ "chatInputVoiceTimeout": "The speech service timed out. Recording has stopped. Try again.",
664
+ "chatInputVoiceShortcut": "Voice shortcut",
665
+ "chatInputVoiceCloseSettings": "Close settings",
666
+ "chatInputVoiceBind": "Bind a key",
667
+ "chatInputVoiceUnbind": "Disable",
668
+ "chatInputVoiceCapture": "Press your shortcut…",
669
+ "chatInputVoiceCaptureHelp": "Use a letter, F6–F10, or Ctrl/⌘ + Alt/⌥ + a letter. Esc cancels; Tab exits. Common shortcuts such as copy and paste are reserved.",
670
+ "chatInputVoiceShortcutHelp": "Hold to speak; release to finish. Single keys work outside text inputs; combinations also work inside. Only active in this chat page. System shortcuts may take priority.",
671
+ "chatInputVoiceBindInvalid": "This key may interfere with normal controls. Choose one of the suggested keys.",
672
+ "chatInputVoiceSaveFailed": "Could not save the shortcut. Check browser storage permissions."
642
673
  }
@@ -15,6 +15,8 @@
15
15
  "settingsGroupBasic": "Basic Configuration",
16
16
  "settingsGroupAdvanced": "Advanced Configuration",
17
17
  "appearance": "Appearance",
18
+ "keyboardShortcuts": "Keyboard shortcuts",
19
+ "keyboardShortcutsDesktopOnly": "Keyboard shortcuts can be configured on desktop. Use the microphone button on mobile.",
18
20
  "chatMessageLayoutTitle": "Message layout",
19
21
  "chatMessageLayoutDescription": "Choose how chat messages are displayed.",
20
22
  "chatMessageLayoutCard": "Cards",
@@ -638,5 +638,36 @@
638
638
  "chatWorkspaceExcerptCharacterCount": "{count} 个字符",
639
639
  "chatInputAttachmentUnsupported": "当前上传流程不支持该文件类型。",
640
640
  "chatInputAttachmentTooLarge": "文件不能超过 {maxMb} MB。",
641
- "chatInputAttachmentReadFailed": "读取文件失败,请重试。"
641
+ "chatInputAttachmentReadFailed": "读取文件失败,请重试。",
642
+ "chatInputVoice": "语音输入",
643
+ "chatInputVoicePermissionDenied": "麦克风权限被拒绝,请在浏览器或系统中允许后重试。",
644
+ "chatInputVoiceUnsupported": "当前浏览器不支持语音输入。",
645
+ "chatInputVoiceNoSpeech": "没有听清,请再试一次。",
646
+ "chatInputVoiceFailed": "语音输入失败,请重试。",
647
+ "chatInputVoicePrivacy": "点击录音,文字实时显示在光标处,下划线表示尚未确定的内容。浏览器语音服务可能联网处理声音,不会自动发送。",
648
+ "chatInputVoiceStart": "开始录音",
649
+ "chatInputVoiceFinish": "结束录音",
650
+ "chatInputVoiceCancel": "取消",
651
+ "chatInputVoiceStarting": "正在连接麦克风…",
652
+ "chatInputVoiceRecording": "正在聆听",
653
+ "chatInputVoiceFinishing": "正在完成转写…",
654
+ "chatInputVoiceReview": "录音已停止",
655
+ "chatInputVoiceSpeak": "请说话,转写会显示在这里",
656
+ "chatInputVoiceReviewHint": "已确认的文字保留在草稿中,未确定的片段已撤回。你可以继续编辑或重试。",
657
+ "chatInputVoiceTranscript": "已识别的文字",
658
+ "chatInputVoiceInsert": "保留到输入框",
659
+ "chatInputVoiceRetry": "重试",
660
+ "chatInputVoiceNetwork": "语音服务连接失败,请检查网络后重试。",
661
+ "chatInputVoiceDevice": "无法访问麦克风,请检查设备连接和系统权限。",
662
+ "chatInputVoiceInterrupted": "录音已因离开页面或窗口失焦而停止。",
663
+ "chatInputVoiceTimeout": "语音服务响应超时,录音已停止,请重试。",
664
+ "chatInputVoiceShortcut": "语音快捷键",
665
+ "chatInputVoiceCloseSettings": "关闭设置",
666
+ "chatInputVoiceBind": "绑定按键",
667
+ "chatInputVoiceUnbind": "停用",
668
+ "chatInputVoiceCapture": "请按下快捷键…",
669
+ "chatInputVoiceCaptureHelp": "支持单字母、F6–F10,或 Ctrl/⌘ + Alt/⌥ + 字母。Esc 取消,Tab 离开。复制、粘贴等常用组合键不接受。",
670
+ "chatInputVoiceShortcutHelp": "按住说话,松开结束。单键仅在输入框外生效;输入框内可用组合键。仅当前聊天页生效,系统已占用的键可能无法使用。",
671
+ "chatInputVoiceBindInvalid": "这个按键可能影响正常操作,请选择提示中的按键。",
672
+ "chatInputVoiceSaveFailed": "无法保存快捷键,请检查浏览器存储权限。"
642
673
  }
@@ -15,6 +15,8 @@
15
15
  "settingsGroupBasic": "基础配置",
16
16
  "settingsGroupAdvanced": "高级配置",
17
17
  "appearance": "外观",
18
+ "keyboardShortcuts": "快捷键",
19
+ "keyboardShortcutsDesktopOnly": "请在桌面端配置键盘快捷键。手机可使用麦克风按钮输入。",
18
20
  "chatMessageLayoutTitle": "消息布局",
19
21
  "chatMessageLayoutDescription": "选择聊天消息的展示方式。",
20
22
  "chatMessageLayoutCard": "卡片式",
@@ -0,0 +1,42 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { BrowserRealtimeRecoveryService } from '@/shared/lib/transport/browser-realtime-recovery.service';
3
+
4
+ describe('BrowserRealtimeRecoveryService', () => {
5
+ afterEach(() => {
6
+ vi.restoreAllMocks();
7
+ });
8
+
9
+ it('requests recovery only when the page can actively reconnect', () => {
10
+ let visibilityState: DocumentVisibilityState = 'hidden';
11
+ vi.spyOn(document, 'visibilityState', 'get').mockImplementation(() => visibilityState);
12
+ const requestRecovery = vi.fn();
13
+ const recovery = new BrowserRealtimeRecoveryService(requestRecovery);
14
+
15
+ recovery.start();
16
+ window.dispatchEvent(new Event('online'));
17
+ expect(requestRecovery).not.toHaveBeenCalled();
18
+
19
+ visibilityState = 'visible';
20
+ document.dispatchEvent(new Event('visibilitychange'));
21
+ window.dispatchEvent(new Event('online'));
22
+ expect(requestRecovery).toHaveBeenCalledTimes(2);
23
+
24
+ recovery.stop();
25
+ document.dispatchEvent(new Event('visibilitychange'));
26
+ expect(requestRecovery).toHaveBeenCalledTimes(2);
27
+ });
28
+
29
+ it('keeps start and stop idempotent', () => {
30
+ vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible');
31
+ const requestRecovery = vi.fn();
32
+ const recovery = new BrowserRealtimeRecoveryService(requestRecovery);
33
+
34
+ recovery.start();
35
+ recovery.start();
36
+ document.dispatchEvent(new Event('visibilitychange'));
37
+ expect(requestRecovery).toHaveBeenCalledTimes(1);
38
+
39
+ recovery.stop();
40
+ recovery.stop();
41
+ });
42
+ });
@@ -0,0 +1,85 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { LocalAppTransport } from '@/shared/lib/transport/local-transport.service';
3
+
4
+ class MockWebSocket {
5
+ static readonly CONNECTING = 0;
6
+ static readonly OPEN = 1;
7
+ static readonly CLOSING = 2;
8
+ static readonly CLOSED = 3;
9
+ static instances: MockWebSocket[] = [];
10
+
11
+ readyState = MockWebSocket.CONNECTING;
12
+ onopen: ((event: Event) => void) | null = null;
13
+ onmessage: ((event: MessageEvent) => void) | null = null;
14
+ onerror: ((event: Event) => void) | null = null;
15
+ onclose: ((event: CloseEvent) => void) | null = null;
16
+
17
+ constructor(public readonly url: string) {
18
+ MockWebSocket.instances.push(this);
19
+ }
20
+
21
+ send = vi.fn();
22
+
23
+ close = vi.fn(() => {
24
+ this.readyState = MockWebSocket.CLOSED;
25
+ });
26
+
27
+ open = (): void => {
28
+ this.readyState = MockWebSocket.OPEN;
29
+ this.onopen?.({} as Event);
30
+ };
31
+
32
+ finishClose = (): void => {
33
+ this.readyState = MockWebSocket.CLOSED;
34
+ this.onclose?.({} as CloseEvent);
35
+ };
36
+ }
37
+
38
+ describe('LocalAppTransport browser connection recovery', () => {
39
+ beforeEach(() => {
40
+ MockWebSocket.instances = [];
41
+ vi.stubGlobal('WebSocket', MockWebSocket as unknown as typeof WebSocket);
42
+ });
43
+
44
+ afterEach(() => {
45
+ vi.restoreAllMocks();
46
+ vi.unstubAllGlobals();
47
+ });
48
+
49
+ it('replaces a possibly stale open socket when a mobile page becomes visible again', () => {
50
+ let visibilityState: DocumentVisibilityState = 'hidden';
51
+ vi.spyOn(document, 'visibilityState', 'get').mockImplementation(() => visibilityState);
52
+
53
+ const transport = new LocalAppTransport({ apiBase: 'https://vps.example.com' });
54
+ const handler = vi.fn();
55
+ const unsubscribe = transport.subscribe(handler);
56
+ const staleSocket = MockWebSocket.instances[0];
57
+ staleSocket?.open();
58
+
59
+ visibilityState = 'visible';
60
+ document.dispatchEvent(new Event('visibilitychange'));
61
+ window.dispatchEvent(new Event('online'));
62
+
63
+ expect(staleSocket?.close).toHaveBeenCalledTimes(1);
64
+ expect(MockWebSocket.instances).toHaveLength(2);
65
+ expect(handler).toHaveBeenCalledWith({ type: 'connection.close', payload: {} });
66
+
67
+ const replacementSocket = MockWebSocket.instances[1];
68
+ replacementSocket?.open();
69
+ staleSocket?.finishClose();
70
+
71
+ expect(MockWebSocket.instances).toHaveLength(2);
72
+ expect(handler).toHaveBeenLastCalledWith({ type: 'connection.open', payload: {} });
73
+ window.dispatchEvent(new Event('online'));
74
+ const secondReplacement = MockWebSocket.instances[2];
75
+ secondReplacement?.open();
76
+ replacementSocket?.finishClose();
77
+ const event = { type: 'session.updated', payload: { sessionKey: 'after-second-recovery' } };
78
+ secondReplacement?.onmessage?.({ data: JSON.stringify(event) } as MessageEvent);
79
+ expect(handler).toHaveBeenLastCalledWith(event);
80
+ expect(MockWebSocket.instances).toHaveLength(3);
81
+ unsubscribe();
82
+ window.dispatchEvent(new Event('online'));
83
+ expect(MockWebSocket.instances).toHaveLength(3);
84
+ });
85
+ });
@@ -25,7 +25,6 @@ class MockWebSocket {
25
25
 
26
26
  close = (): void => {
27
27
  this.readyState = MockWebSocket.CLOSED;
28
- this.onclose?.({} as CloseEvent);
29
28
  }
30
29
 
31
30
  open = (): void => {
@@ -39,6 +38,11 @@ class MockWebSocket {
39
38
  } as MessageEvent);
40
39
  }
41
40
 
41
+ finishClose = (): void => {
42
+ this.readyState = MockWebSocket.CLOSED;
43
+ this.onclose?.({} as CloseEvent);
44
+ }
45
+
42
46
  static reset(): void {
43
47
  MockWebSocket.instances = [];
44
48
  }
@@ -144,4 +148,53 @@ describe('RemoteSessionMultiplexTransport request path', () => {
144
148
 
145
149
  await timeoutExpectation;
146
150
  });
151
+
152
+ it('replaces a possibly stale open socket when a mobile page becomes visible again', async () => {
153
+ let visibilityState: DocumentVisibilityState = 'hidden';
154
+ vi.spyOn(document, 'visibilityState', 'get').mockImplementation(() => visibilityState);
155
+ const transport = new RemoteSessionMultiplexTransport({
156
+ mode: 'remote',
157
+ protocolVersion: 1,
158
+ wsPath: '/_remote/ws'
159
+ }, 'https://remote.claw.cool');
160
+
161
+ const handler = vi.fn();
162
+ const unsubscribe = transport.subscribe(handler);
163
+ const staleSocket = MockWebSocket.instances[0];
164
+ staleSocket?.open();
165
+ await Promise.resolve();
166
+
167
+ const pending = transport.request({ method: 'POST', path: '/api/ncp/agent/messages', body: { text: 'once' } });
168
+ const pendingFailure = expect(pending).rejects.toThrow('connection replaced');
169
+ await Promise.resolve();
170
+ expect(staleSocket?.sent).toHaveLength(1);
171
+
172
+ visibilityState = 'visible';
173
+ document.dispatchEvent(new Event('visibilitychange'));
174
+
175
+ expect(staleSocket?.readyState).toBe(MockWebSocket.CLOSED);
176
+ expect(MockWebSocket.instances).toHaveLength(2);
177
+ expect(handler).toHaveBeenCalledWith({ type: 'connection.close', payload: {} });
178
+
179
+ const replacementSocket = MockWebSocket.instances[1];
180
+ replacementSocket?.open();
181
+ staleSocket?.finishClose();
182
+
183
+ expect(MockWebSocket.instances).toHaveLength(2);
184
+ expect(handler).toHaveBeenLastCalledWith({ type: 'connection.open', payload: {} });
185
+ await pendingFailure;
186
+ await Promise.resolve();
187
+ expect(replacementSocket?.sent).toHaveLength(0);
188
+ window.dispatchEvent(new Event('online'));
189
+ const secondReplacement = MockWebSocket.instances[2];
190
+ secondReplacement?.open();
191
+ await Promise.resolve();
192
+ const recovered = transport.request({ method: 'GET', path: '/api/sessions' });
193
+ await Promise.resolve();
194
+ const frame = lastSentRequestFrame(secondReplacement!);
195
+ secondReplacement?.receive({ type: 'response', id: frame.id, status: 200, body: { ok: true, data: { total: 1 } } });
196
+ await expect(recovered).resolves.toEqual({ total: 1 });
197
+ expect(MockWebSocket.instances).toHaveLength(3);
198
+ unsubscribe();
199
+ });
147
200
  });
@@ -0,0 +1,35 @@
1
+ export class BrowserRealtimeRecoveryService {
2
+ private started = false;
3
+
4
+ constructor(private readonly requestRecovery: () => void) {}
5
+
6
+ start = (): void => {
7
+ if (this.started) {
8
+ return;
9
+ }
10
+ this.started = true;
11
+ document.addEventListener('visibilitychange', this.handleVisibilityChange);
12
+ window.addEventListener('online', this.handleOnline);
13
+ };
14
+
15
+ stop = (): void => {
16
+ if (!this.started) {
17
+ return;
18
+ }
19
+ this.started = false;
20
+ document.removeEventListener('visibilitychange', this.handleVisibilityChange);
21
+ window.removeEventListener('online', this.handleOnline);
22
+ };
23
+
24
+ private readonly handleVisibilityChange = (): void => {
25
+ if (document.visibilityState === 'visible') {
26
+ this.requestRecovery();
27
+ }
28
+ };
29
+
30
+ private readonly handleOnline = (): void => {
31
+ if (document.visibilityState === 'visible') {
32
+ this.requestRecovery();
33
+ }
34
+ };
35
+ }
@@ -1,5 +1,6 @@
1
1
  import { systemStatusManager } from '@/features/system-status';
2
2
  import type { AppEvent, AppTransport, RequestInput, StreamInput, StreamSession } from './transport.types';
3
+ import { BrowserRealtimeRecoveryService } from './browser-realtime-recovery.service';
3
4
  import { requestRawApiResponse } from './request-raw-api-response.utils';
4
5
  import { readSseStreamResult } from './sse-stream.utils';
5
6
  import { resolveTransportWebSocketUrl } from './transport-websocket-url.utils';
@@ -53,13 +54,18 @@ class LocalRealtimeGateway {
53
54
  private socket: WebSocket | null = null;
54
55
  private reconnectTimer: number | null = null;
55
56
  private manualClose = false;
57
+ private replacingAfterBrowserRecovery = false;
56
58
  private subscribers = new Set<EventHandler>();
59
+ private readonly browserRecovery = new BrowserRealtimeRecoveryService(() => {
60
+ this.replaceSocket();
61
+ });
57
62
 
58
63
  constructor(private readonly wsUrl: string) {}
59
64
 
60
65
  subscribe = (handler: EventHandler): () => void => {
61
66
  this.subscribers.add(handler);
62
67
  if (this.subscribers.size === 1) {
68
+ this.browserRecovery.start();
63
69
  this.connect();
64
70
  } else if (this.socket?.readyState === WebSocket.OPEN) {
65
71
  handler({ type: 'connection.open', payload: {} });
@@ -68,6 +74,7 @@ class LocalRealtimeGateway {
68
74
  return () => {
69
75
  this.subscribers.delete(handler);
70
76
  if (this.subscribers.size === 0) {
77
+ this.browserRecovery.stop();
71
78
  this.disconnect();
72
79
  }
73
80
  };
@@ -88,6 +95,7 @@ class LocalRealtimeGateway {
88
95
  this.socket = socket;
89
96
 
90
97
  socket.onopen = () => {
98
+ this.replacingAfterBrowserRecovery = false;
91
99
  this.emit({ type: 'connection.open', payload: {} });
92
100
  };
93
101
 
@@ -105,6 +113,7 @@ class LocalRealtimeGateway {
105
113
  };
106
114
 
107
115
  socket.onclose = () => {
116
+ this.replacingAfterBrowserRecovery = false;
108
117
  this.emit({ type: 'connection.close', payload: {} });
109
118
  this.socket = null;
110
119
  if (!this.manualClose && this.subscribers.size > 0) {
@@ -123,8 +132,31 @@ class LocalRealtimeGateway {
123
132
  }, 3_000);
124
133
  };
125
134
 
135
+ private replaceSocket = (): void => {
136
+ if (this.subscribers.size === 0 || this.replacingAfterBrowserRecovery) {
137
+ return;
138
+ }
139
+ this.replacingAfterBrowserRecovery = true;
140
+ if (this.reconnectTimer !== null) {
141
+ window.clearTimeout(this.reconnectTimer);
142
+ this.reconnectTimer = null;
143
+ }
144
+ const staleSocket = this.socket;
145
+ this.socket = null;
146
+ if (staleSocket) {
147
+ staleSocket.onopen = null;
148
+ staleSocket.onmessage = null;
149
+ staleSocket.onerror = null;
150
+ staleSocket.onclose = null;
151
+ staleSocket.close();
152
+ this.emit({ type: 'connection.close', payload: {} });
153
+ }
154
+ this.connect();
155
+ };
156
+
126
157
  private disconnect = (): void => {
127
158
  this.manualClose = true;
159
+ this.replacingAfterBrowserRecovery = false;
128
160
  if (this.reconnectTimer !== null) {
129
161
  window.clearTimeout(this.reconnectTimer);
130
162
  this.reconnectTimer = null;
@@ -1,5 +1,6 @@
1
1
  import type { ApiError } from '@nextclaw/client-sdk';
2
2
  import type { AppEvent, AppTransport, RemoteRuntimeInfo, RequestInput, StreamInput, StreamSession } from './transport.types';
3
+ import { BrowserRealtimeRecoveryService } from './browser-realtime-recovery.service';
3
4
  import { resolveTransportWebSocketUrl } from './transport-websocket-url.utils';
4
5
 
5
6
  type RemoteTarget = {
@@ -95,6 +96,9 @@ export class RemoteSessionMultiplexTransport implements AppTransport {
95
96
  private subscribers = new Set<(event: AppEvent) => void>();
96
97
  private pendingRequests = new Map<string, PendingRequest>();
97
98
  private pendingStreams = new Map<string, PendingStream>();
99
+ private readonly browserRecovery = new BrowserRealtimeRecoveryService(() => {
100
+ this.replaceSocket();
101
+ });
98
102
 
99
103
  constructor(
100
104
  private readonly runtime: RemoteRuntimeInfo,
@@ -215,6 +219,7 @@ export class RemoteSessionMultiplexTransport implements AppTransport {
215
219
 
216
220
  subscribe = (handler: (event: AppEvent) => void): () => void => {
217
221
  this.subscribers.add(handler);
222
+ this.browserRecovery.start();
218
223
  void this.ensureSocket().catch((error) => {
219
224
  handler({
220
225
  type: 'connection.error',
@@ -223,6 +228,9 @@ export class RemoteSessionMultiplexTransport implements AppTransport {
223
228
  });
224
229
  return () => {
225
230
  this.subscribers.delete(handler);
231
+ if (this.subscribers.size === 0) {
232
+ this.browserRecovery.stop();
233
+ }
226
234
  };
227
235
  }
228
236
 
@@ -321,6 +329,28 @@ export class RemoteSessionMultiplexTransport implements AppTransport {
321
329
  }, 3_000);
322
330
  }
323
331
 
332
+ private replaceSocket = (): void => {
333
+ if (this.subscribers.size === 0 || this.connectPromise !== null) {
334
+ return;
335
+ }
336
+ if (this.reconnectTimer !== null) {
337
+ window.clearTimeout(this.reconnectTimer);
338
+ this.reconnectTimer = null;
339
+ }
340
+ const staleSocket = this.socket;
341
+ this.socket = null;
342
+ if (staleSocket) {
343
+ staleSocket.onopen = null;
344
+ staleSocket.onmessage = null;
345
+ staleSocket.onerror = null;
346
+ staleSocket.onclose = null;
347
+ staleSocket.close();
348
+ this.failPendingWork(new Error('Remote transport connection replaced after browser recovery.'));
349
+ this.emit({ type: 'connection.close', payload: {} });
350
+ }
351
+ void this.ensureSocket().catch(() => undefined);
352
+ }
353
+
324
354
  private failPendingWork = (error: Error): void => {
325
355
  for (const pending of this.pendingRequests.values()) {
326
356
  window.clearTimeout(pending.timeoutId);