@nextclaw/ui 0.24.2 → 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 (172) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/.vite/manifest.json +538 -525
  3. package/dist/assets/{account-panel-BxMlLXYH.js → account-panel-UvHFa8Kl.js} +1 -1
  4. package/dist/assets/{api-Utt-xzrT.js → api-Dffeij78.js} +5 -5
  5. package/dist/assets/{app-data-DYXDL6X2.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-BfYznAK5.js → appearance-settings-page-D8cWniw-.js} +1 -1
  8. package/dist/assets/{book-open-DDW_3XpM.js → book-open-xqEj1OLE.js} +1 -1
  9. package/dist/assets/{boxes-DwJeuK_h.js → boxes-D6uPnkGo.js} +1 -1
  10. package/dist/assets/{card-IevY2nm-.js → card-CS8su2Zh.js} +1 -1
  11. package/dist/assets/{channels-list-page-DitnqbMR.js → channels-list-page-B_fYP1Pn.js} +1 -1
  12. package/dist/assets/{chevron-down-An7Km-t4.js → chevron-down-BL-yMYcX.js} +1 -1
  13. package/dist/assets/{circle-alert-CDXqELuo.js → circle-alert-lSa3LRvS.js} +1 -1
  14. package/dist/assets/{circle-dashed-CapXEpiV.js → circle-dashed-CU3yApvP.js} +1 -1
  15. package/dist/assets/{client-B0krol7A.js → client-BtGBwLTs.js} +1 -1
  16. package/dist/assets/{config-split-page-BtnkqWVp.js → config-split-page-6hHiiapl.js} +1 -1
  17. package/dist/assets/{confirm-dialog-DFt4jpN7.js → confirm-dialog-ClT6iaj8.js} +1 -1
  18. package/dist/assets/{createLucideIcon-BTHK0Ot9.js → createLucideIcon-BNKf0_kc.js} +1 -1
  19. package/dist/assets/{desktop-capabilities-page-CCf14WId.js → desktop-capabilities-page-CB5pniTx.js} +1 -1
  20. package/dist/assets/{desktop-update-config-DVoVgfFi.js → desktop-update-config-OuQV73gg.js} +1 -1
  21. package/dist/assets/{dialog-BFN_1DBH.js → dialog-Xe73hpQz.js} +1 -1
  22. package/dist/assets/{dist-Bc9hOVNN.js → dist-BiIcnf9j.js} +1 -1
  23. package/dist/assets/{dist-CEhIDAnr.js → dist-C5I0v8Qn.js} +1 -1
  24. package/dist/assets/{dist-hkVPP1th.js → dist-czPa8xFO.js} +1 -1
  25. package/dist/assets/doc-browser-BFkslEzY.js +1 -0
  26. package/dist/assets/{doc-browser-BNUxmwD6.js → doc-browser-Cu58uiEg.js} +1 -1
  27. package/dist/assets/{doc-browser-context-C4yQrKrH.js → doc-browser-context-CekJxJFm.js} +1 -1
  28. package/dist/assets/{ellipsis-BRI6ROeu.js → ellipsis-C5YXdOUx.js} +1 -1
  29. package/dist/assets/{es2015-MOCbM0Eu.js → es2015-cuwbYI-l.js} +1 -1
  30. package/dist/assets/{extensions-DxUBMtHW.js → extensions-B5r-0jCq.js} +1 -1
  31. package/dist/assets/{eye-cnynUDGt.js → eye-C84inPcV.js} +1 -1
  32. package/dist/assets/{form-actions-gQ0JEUwB.js → form-actions-AIdXOoCL.js} +1 -1
  33. package/dist/assets/{i18n-B9yXA8KO.js → i18n-CPjq_hQX.js} +2 -2
  34. package/dist/assets/{i18n-provider-BYBJua91.js → i18n-provider-BYO9AhFy.js} +1 -1
  35. package/dist/assets/keyboard-shortcuts-settings-page-1efEkGwq.js +1 -0
  36. package/dist/assets/loader-circle-K1BDdHR5.js +1 -0
  37. package/dist/assets/{login-page-CqrHhzaN.js → login-page-DBqkVTVD.js} +1 -1
  38. package/dist/assets/login-page-DjcRQLQe.js +1 -0
  39. package/dist/assets/{logo-badge-Cr03YceJ.js → logo-badge-C-5-zim6.js} +1 -1
  40. package/dist/assets/main-CLwCjK9U.css +1 -0
  41. package/dist/assets/main-CQ6GUYdX.js +2 -0
  42. package/dist/assets/main-i18n-catalog.config-QB4DfJRJ.js +1 -0
  43. package/dist/assets/{mcp-marketplace-page-CifiVgFD.js → mcp-marketplace-page-CWAxBNaS.js} +1 -1
  44. package/dist/assets/mcp-marketplace-page-Ct090ceA.js +1 -0
  45. package/dist/assets/{model-config-page-C6jVNbSj.js → model-config-page-DBcuZyRC.js} +1 -1
  46. package/dist/assets/{navigation-link-kbCLRSr_.js → navigation-link-BAQjgx2F.js} +1 -1
  47. package/dist/assets/panel-app-i18n-catalog.config-CQFujXqt.js +1 -0
  48. package/dist/assets/panel-app-maumVhbl.js +1 -0
  49. package/dist/assets/{panelStandalone-DMBnYyTw.js → panelStandalone-BGA1s7JG.js} +2 -2
  50. package/dist/assets/plus-LA8-jpxP.js +1 -0
  51. package/dist/assets/{popover-XVDgjxcl.js → popover-NkrFlWHp.js} +1 -1
  52. package/dist/assets/{privacy-settings-page-BN27MBVA.js → privacy-settings-page-DVC-aq1v.js} +1 -1
  53. package/dist/assets/projects-Cq0E3c7N.js +1 -0
  54. package/dist/assets/{provider-scoped-model-input-BGZMCThn.js → provider-scoped-model-input-BGdZMWWa.js} +1 -1
  55. package/dist/assets/{providers-config-page-D5aBwbOD.js → providers-config-page-B-IhHl6x.js} +1 -1
  56. package/dist/assets/{puzzle-D5Dhacmb.js → puzzle-D6gulz7u.js} +1 -1
  57. package/dist/assets/{pwa-ClQK56Qe.js → pwa-C3kR9mdy.js} +1 -1
  58. package/dist/assets/{refresh-cw-Bw5BY7vp.js → refresh-cw-h9Xq3LY3.js} +1 -1
  59. package/dist/assets/remote-Cnawibbg.js +1 -0
  60. package/dist/assets/{rotate-cw-BksCLAeE.js → rotate-cw-B6xm41Wn.js} +1 -1
  61. package/dist/assets/runtime-config-page-LmrB2q65.js +1 -0
  62. package/dist/assets/{save-CVi8VWzb.js → save-fff_WpY2.js} +1 -1
  63. package/dist/assets/{search-DN-f7IIC.js → search-CUe41wjm.js} +1 -1
  64. package/dist/assets/{search-config-page-BmuZTVxs.js → search-config-page-BIXmv5FY.js} +1 -1
  65. package/dist/assets/{secrets-config-page-NPHpg9sb.js → secrets-config-page-DU3fadrq.js} +1 -1
  66. package/dist/assets/security-config-9s0i0I4N.js +1 -0
  67. package/dist/assets/{select-CmDTqk2C.js → select-CG5YWnwe.js} +1 -1
  68. package/dist/assets/{service-apps-panel-BYGJ9_pO.js → service-apps-panel-Bshf3Wmq.js} +1 -1
  69. package/dist/assets/{settings-2-B62YtZon.js → settings-2-CE3ELQyk.js} +1 -1
  70. package/dist/assets/skeleton-BL9h32W7.js +1 -0
  71. package/dist/assets/{tabs-Bk_mG7ql.js → tabs-BuE0qKMh.js} +1 -1
  72. package/dist/assets/{tag-chip-DgqKlI5D.js → tag-chip-gAH1IOsZ.js} +1 -1
  73. package/dist/assets/{theme-provider-CeBY2pyu.js → theme-provider-DE7m9jvf.js} +1 -1
  74. package/dist/assets/{tooltip-CgGbBdYf.js → tooltip-DYcuWhuP.js} +1 -1
  75. package/dist/assets/{trash-2-CVOev8yc.js → trash-2-BPPYt2NV.js} +1 -1
  76. package/dist/assets/{use-config-b2DN67gs.js → use-config-Cyiw06-j.js} +1 -1
  77. package/dist/assets/{use-desktop-capabilities-B3tLU1yl.js → use-desktop-capabilities-OKG96v4y.js} +1 -1
  78. package/dist/assets/{use-panel-app-scroll-restoration-BekzoX6z.js → use-panel-app-scroll-restoration-DsV1IJx-.js} +1 -1
  79. package/dist/assets/{use-viewport-layout-CUA3jQsA.js → use-viewport-layout-BS18drMT.js} +1 -1
  80. package/dist/assets/{utils-UBHTdG5Q.js → utils-CBZPzC_b.js} +1 -1
  81. package/dist/assets/x-DdXOqLnX.js +1 -0
  82. package/dist/index.html +50 -50
  83. package/dist/panel-standalone.html +15 -15
  84. package/package.json +7 -7
  85. package/src/app/configs/app-navigation.config.ts +10 -3
  86. package/src/app/index.tsx +7 -0
  87. package/src/features/chat/components/providers/chat-presenter.provider.tsx +2 -0
  88. package/src/features/chat/features/conversation/components/__tests__/session-conversation-area.test.tsx +12 -0
  89. package/src/features/chat/features/conversation/components/__tests__/session-conversation-input.streaming.test.tsx +13 -1
  90. package/src/features/chat/features/conversation/components/__tests__/session-voice-input.test-utils.ts +52 -0
  91. package/src/features/chat/features/conversation/components/chat-voice-input-panel.tsx +70 -0
  92. package/src/features/chat/features/conversation/components/chat-voice-shortcut-control.tsx +33 -0
  93. package/src/features/chat/features/conversation/components/session-conversation-area.tsx +1 -11
  94. package/src/features/chat/features/conversation/components/session-conversation-input.tsx +29 -5
  95. package/src/features/chat/features/conversation/hooks/__tests__/use-chat-voice-input.test.tsx +185 -0
  96. package/src/features/chat/features/conversation/hooks/use-chat-voice-input.ts +52 -0
  97. package/src/features/chat/features/input/utils/__tests__/chat-input-bar.utils.test.ts +13 -6
  98. package/src/features/chat/features/input/utils/chat-input-toolbar.utils.ts +28 -12
  99. package/src/features/chat/features/message/utils/__tests__/chat-message-process-summary.utils.test.ts +19 -1
  100. package/src/features/chat/features/message/utils/__tests__/chat-message-timeline.utils.test.ts +10 -0
  101. package/src/features/chat/features/message/utils/__tests__/chat-message.utils.test.ts +7 -6
  102. package/src/features/chat/features/message/utils/chat-message-part.utils.ts +4 -1
  103. package/src/features/chat/features/ncp/hooks/__tests__/use-ncp-agent-runtime.test.tsx +20 -0
  104. package/src/features/chat/features/session/components/__tests__/chat-sidebar-project-groups.test.tsx +90 -6
  105. package/src/features/chat/features/session/components/chat-sidebar-project-groups.tsx +64 -2
  106. package/src/features/chat/index.ts +1 -0
  107. package/src/features/chat/managers/chat-voice-input.manager.ts +174 -0
  108. package/src/features/chat/presenters/chat.presenter.ts +2 -0
  109. package/src/features/chat/stores/chat-voice-shortcut.store.ts +31 -0
  110. package/src/features/desktop-capabilities/index.ts +1 -0
  111. package/src/features/panel-apps/components/__tests__/panel-app-open-standalone-menu-item.test.tsx +3 -2
  112. package/src/features/panel-apps/components/panel-app-open-standalone-menu-item.tsx +4 -1
  113. package/src/features/projects/AGENTS.md +1 -2
  114. package/src/features/projects/components/project-agreement.tsx +36 -77
  115. package/src/features/projects/components/project-artifacts.test.tsx +90 -62
  116. package/src/features/projects/components/project-artifacts.tsx +81 -160
  117. package/src/features/projects/components/project-materials.test.tsx +80 -0
  118. package/src/features/projects/components/project-overview.test.tsx +50 -40
  119. package/src/features/projects/components/project-overview.tsx +5 -4
  120. package/src/features/projects/components/project-skills.tsx +24 -14
  121. package/src/features/projects/components/work/project-work-item-drawer.test.tsx +3 -2
  122. package/src/features/projects/components/work/project-work-items.test.tsx +13 -1
  123. package/src/features/projects/components/work/project-work-items.tsx +7 -7
  124. package/src/features/projects/hooks/use-project-materials.test.tsx +65 -0
  125. package/src/features/projects/hooks/use-project-materials.ts +24 -0
  126. package/src/features/projects/hooks/use-project-work.test.tsx +57 -1
  127. package/src/features/projects/hooks/use-project-work.ts +11 -5
  128. package/src/features/projects/pages/project-home-page.tsx +51 -127
  129. package/src/features/projects/pages/projects-page.test.tsx +68 -75
  130. package/src/features/projects/presenters/project-home.presenter.test.ts +1 -64
  131. package/src/features/projects/presenters/project-home.presenter.ts +17 -54
  132. package/src/features/projects/utils/project-artifact-view.utils.test.ts +0 -46
  133. package/src/features/projects/utils/project-artifact-view.utils.ts +0 -47
  134. package/src/features/settings/pages/keyboard-shortcuts-settings-page.tsx +15 -0
  135. package/src/platforms/mobile/components/mobile-settings-shell.tsx +2 -1
  136. package/src/shared/lib/api/__tests__/ncp-session-query-cache.utils.test.ts +109 -1
  137. package/src/shared/lib/api/ncp-session-query-cache.utils.ts +71 -2
  138. package/src/shared/lib/i18n/locales/en-US/chat.json +32 -1
  139. package/src/shared/lib/i18n/locales/en-US/core.json +2 -0
  140. package/src/shared/lib/i18n/locales/en-US/projects.json +8 -59
  141. package/src/shared/lib/i18n/locales/zh-CN/chat.json +32 -1
  142. package/src/shared/lib/i18n/locales/zh-CN/core.json +2 -0
  143. package/src/shared/lib/i18n/locales/zh-CN/projects.json +8 -59
  144. package/src/shared/lib/transport/__tests__/browser-realtime-recovery.service.test.ts +42 -0
  145. package/src/shared/lib/transport/__tests__/local-transport.service.test.ts +85 -0
  146. package/src/shared/lib/transport/__tests__/remote.transport.test.ts +54 -1
  147. package/src/shared/lib/transport/browser-realtime-recovery.service.ts +35 -0
  148. package/src/shared/lib/transport/local-transport.service.ts +32 -0
  149. package/src/shared/lib/transport/remote-transport.service.ts +30 -0
  150. package/dist/assets/app-presenter-provider-CLsFiXvV.js +0 -109
  151. package/dist/assets/doc-browser-w4eGZXLu.js +0 -1
  152. package/dist/assets/loader-circle-rWr5Yo0E.js +0 -1
  153. package/dist/assets/login-page-CfMSkOsg.js +0 -1
  154. package/dist/assets/main-8cVkMfgE.css +0 -1
  155. package/dist/assets/main-Bys58GxY.js +0 -2
  156. package/dist/assets/main-i18n-catalog.config-BSq9yiUQ.js +0 -1
  157. package/dist/assets/mcp-marketplace-page-DX5VE3mU.js +0 -1
  158. package/dist/assets/panel-app-BUUle0gP.js +0 -1
  159. package/dist/assets/panel-app-i18n-catalog.config-C2St67SP.js +0 -1
  160. package/dist/assets/plus-BHnKwuPq.js +0 -1
  161. package/dist/assets/projects-DV3UuJMS.js +0 -1
  162. package/dist/assets/remote-Bl005fzu.js +0 -1
  163. package/dist/assets/runtime-config-page-CmktQcMW.js +0 -1
  164. package/dist/assets/security-config-Djptuyf2.js +0 -1
  165. package/dist/assets/skeleton-Eh64l4T2.js +0 -1
  166. package/dist/assets/x-C0iaOsRW.js +0 -1
  167. package/src/features/projects/components/project-requests.tsx +0 -57
  168. package/src/features/projects/components/project-runs.tsx +0 -60
  169. package/src/features/projects/hooks/use-project-observation.test.tsx +0 -93
  170. package/src/features/projects/hooks/use-project-observation.ts +0 -115
  171. package/src/features/projects/utils/project-request-response.utils.test.ts +0 -44
  172. package/src/features/projects/utils/project-request-response.utils.ts +0 -39
@@ -1,4 +1,4 @@
1
- import type { QueryClient } from '@tanstack/react-query';
1
+ import type { InfiniteData, QueryClient } from '@tanstack/react-query';
2
2
  import type { NcpSessionSummaryView, NcpSessionsListView, WsEvent } from '@/shared/lib/api';
3
3
 
4
4
  function readSessionActivityAt(summary: NcpSessionSummaryView): string {
@@ -26,6 +26,73 @@ function queryKeyAcceptsSessionSummary(queryKey: readonly unknown[], summary: Nc
26
26
  return !peerId || summary.peerId === peerId;
27
27
  }
28
28
 
29
+ function updateSessionPages(
30
+ current: InfiniteData<NcpSessionsListView> | undefined,
31
+ update: (sessions: NcpSessionsListView) => NcpSessionsListView | undefined
32
+ ): InfiniteData<NcpSessionsListView> | undefined {
33
+ if (!current) return current;
34
+ const pageLengths = current.pages.map((page) => page.sessions.length);
35
+ const sessions = current.pages.flatMap((page) => page.sessions);
36
+ const updated = update({
37
+ sessions,
38
+ total: current.pages[0]?.total ?? sessions.length
39
+ });
40
+ if (!updated) return current;
41
+ let offset = 0;
42
+ return {
43
+ ...current,
44
+ pages: current.pages.map((page, index) => {
45
+ const nextOffset = offset + (pageLengths[index] ?? 0);
46
+ const nextPage = {
47
+ ...page,
48
+ sessions: updated.sessions.slice(offset, nextOffset)
49
+ };
50
+ offset = nextOffset;
51
+ return nextPage;
52
+ })
53
+ };
54
+ }
55
+
56
+ function updateExistingNcpSessionSummaryPages(
57
+ queryClient: QueryClient | undefined,
58
+ summary: NcpSessionSummaryView
59
+ ): void {
60
+ if (!queryClient) return;
61
+ const entries = queryClient.getQueriesData<InfiniteData<NcpSessionsListView>>({
62
+ queryKey: ['ncp-session-pages']
63
+ });
64
+ for (const [queryKey, current] of entries) {
65
+ const query = typeof queryKey[2] === 'string' ? queryKey[2].trim() : '';
66
+ const containsSession = current?.pages.some((page) =>
67
+ page.sessions.some((session) => session.sessionId === summary.sessionId)
68
+ );
69
+ if (query || !containsSession) {
70
+ void queryClient.invalidateQueries({ queryKey, exact: true });
71
+ continue;
72
+ }
73
+ queryClient.setQueryData<InfiniteData<NcpSessionsListView>>(
74
+ queryKey,
75
+ (value) => updateSessionPages(
76
+ value,
77
+ (sessions) => upsertNcpSessionSummaryList(sessions, summary)
78
+ )
79
+ );
80
+ }
81
+ }
82
+
83
+ function updateNcpSessionRunStatusPages(
84
+ queryClient: QueryClient | undefined,
85
+ payload: { sessionKey: string; status: 'running' | 'idle' }
86
+ ): void {
87
+ queryClient?.setQueriesData<InfiniteData<NcpSessionsListView>>(
88
+ { queryKey: ['ncp-session-pages'] },
89
+ (current) => updateSessionPages(
90
+ current,
91
+ (sessions) => updateNcpSessionRunStatusList(sessions, payload)
92
+ )
93
+ );
94
+ }
95
+
29
96
  export function upsertNcpSessionSummaryList(
30
97
  current: NcpSessionsListView | undefined,
31
98
  summary: NcpSessionSummaryView
@@ -149,13 +216,14 @@ export function applyNcpSessionRealtimeEvent(
149
216
  queryClient: QueryClient | undefined,
150
217
  event: Extract<WsEvent, { type: 'session.run-status' | 'session.summary.upsert' | 'session.summary.delete' }>
151
218
  ): void {
152
- void queryClient?.invalidateQueries({ queryKey: ['ncp-session-pages'] });
153
219
  if (event.type === 'session.run-status') {
154
220
  updateNcpSessionRunStatusInQueryClient(queryClient, event.payload);
221
+ updateNcpSessionRunStatusPages(queryClient, event.payload);
155
222
  return;
156
223
  }
157
224
  if (event.type === 'session.summary.upsert') {
158
225
  upsertNcpSessionSummaryInQueryClient(queryClient, event.payload.summary);
226
+ updateExistingNcpSessionSummaryPages(queryClient, event.payload.summary);
159
227
  void queryClient?.invalidateQueries({
160
228
  queryKey: ['ncp-session-token-usage', event.payload.summary.sessionId],
161
229
  exact: true,
@@ -164,4 +232,5 @@ export function applyNcpSessionRealtimeEvent(
164
232
  }
165
233
 
166
234
  deleteNcpSessionSummaryInQueryClient(queryClient, event.payload.sessionKey);
235
+ void queryClient?.invalidateQueries({ queryKey: ['ncp-session-pages'] });
167
236
  }
@@ -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",
@@ -6,28 +6,22 @@
6
6
  "projectsSkills": "Skills",
7
7
  "projectsSkillsCountSuffix": "skills",
8
8
  "projectsAgreement": "Working rules",
9
- "projectsVisionAndContext": "Vision and context",
9
+ "projectsAgreementFile": "AGENTS.md",
10
+ "projectsAgreementDescription": "Project working rules come from AGENTS.md at the project root.",
11
+ "projectsAgreementOpen": "Open working rules",
12
+ "projectsNoAgreement": "No AGENTS.md exists at the project root",
10
13
  "projectsLoading": "Loading project…",
11
14
  "projectsLoadFailed": "Failed to load project",
12
15
  "projectsEmpty": "No registered projects",
13
16
  "projectsChoose": "Choose a project",
17
+ "projectsMoreActions": "More actions for {name}",
14
18
  "projectsRemoveFromList": "Remove from project list",
15
19
  "projectsRemoveConfirmTitle": "Remove from project list",
16
20
  "projectsRemoveConfirmDescription": "The local folder, previous sessions, and project work records will not be deleted. Existing sessions remain available in the session list, and adding the same folder again restores the original project.",
17
21
  "projectsRemoveConfirmAction": "Remove project",
18
22
  "projectsRemoveSuccess": "Project removed from the list",
19
23
  "projectsRemoveFailed": "Failed to remove project",
20
- "projectsAsOf": "Observed at",
21
- "projectsQuality": "Data quality",
22
- "projectsQuality_complete": "Complete",
23
- "projectsQuality_partial": "Partially available",
24
- "projectsQuality_unavailable": "Unavailable",
25
- "projectsNoSummary": "This project has no configured summary. Existing sources remain available under Working rules.",
26
- "projectsObservationSetupTitle": "Set up project observation",
27
- "projectsObservationSetupDescription": "This project has no observation convention yet. Ask AI to inspect existing materials, recommend a work-item lifecycle and artifact scope, and prepare the project configuration, always-on rule, and project tracking Skill. Nothing is written until you confirm.",
28
- "projectsObservationSetupAction": "Let AI set up project observation",
29
- "projectsObservationSetupPrompt": "Follow the built-in project observation setup guidance to establish a durable work-tracking convention for this project. Read the existing materials, then directly propose one editable recommendation containing all three assets: .nextclaw/project.yaml, the minimal always-on section in the root AGENTS.md, and the project-specific .agents/skills/project-work-tracking/SKILL.md. A Workflow describes how one work item progresses; it is not the macro lifecycle of the whole project. Markers must be emitted before a stage starts, never batched at the end. Do not turn this into a long interview; recommend directly when the evidence is sufficient. Write nothing until I confirm, then write and verify all three files together.",
30
- "projectsNoData": "No observable data",
24
+ "projectsNoData": "No work items in this view",
31
25
  "projectsUnavailable": "Currently unavailable",
32
26
  "projectsWorkList": "List",
33
27
  "projectsWorkBoard": "Board",
@@ -35,64 +29,19 @@
35
29
  "projectsWorkViews": "Work item views",
36
30
  "projectsWorkDetails": "Work item details",
37
31
  "projectsCloseDetails": "Close details",
38
- "projectsNoWork": "No work items explicitly reported by AI",
39
- "projectsWorkNeedsSetup": "Set up project observation first so AI can report real progress using this project's workflow.",
40
- "projectsWorkNoSessions": "No conversation has started in this project yet. Once substantive project work begins, AI-reported progress will appear here.",
41
- "projectsWorkNoReports": "AI has not explicitly reported any work items from the existing project conversations yet. Real progress will appear here as the work continues.",
42
- "projectsAiActivity": "AI runs",
43
- "projectsAiRun": "Project session",
44
- "projectsRunUnreported": "Work item and stage not reported yet",
45
- "projectsRunState_running": "Running",
46
- "projectsRunState_completed": "Finished",
47
- "projectsRunState_failed": "Failed",
48
- "projectsRunState_cancelled": "Cancelled",
49
- "projectsRunState_idle": "Idle",
50
- "projectsStartProjectWork": "Start project work",
51
- "projectsStartProjectWorkPrompt": "Start working on this project. First read the project configuration and the materials relevant to this goal; I will add or revise the specific goal before sending.",
52
- "projectsBoardNeedsWorkflow": "No workflow stages are available. The board does not invent stages.",
53
- "projectsGanttNeedsSchedule": "No schedule markers are available. The Gantt view does not infer dates from files.",
54
- "projectsUnstaged": "Stage not reported",
55
- "projectsStage": "Stage",
56
- "projectsSchedule": "Schedule",
57
- "projectsNoArtifacts": "No artifacts matched by configuration or reported by AI",
58
- "projectsNoSkills": "No project Skills are available",
59
- "projectsNoContext": "No vision or working-rule sources are configured",
60
- "projectsRequests": "Waiting for your reply",
61
- "projectsConfirm": "Confirm",
62
- "projectsReject": "Reject",
63
- "projectsResponseSent": "Reply sent; waiting for AI to update",
64
- "projectsResponseWaiting": "Replied; waiting for AI to update",
65
- "projectsResponseFailed": "Failed to send reply",
32
+ "projectsNoArtifacts": "No linked artifacts yet. Link a file from a work item to show it here.",
33
+ "projectsNoSkills": "No project Skills found in .agents/skills",
66
34
  "projectsActivity": "Recent activity",
67
- "projectsNoActivity": "No AI-reported activity",
68
- "projectsOpenSession": "Open source session",
69
- "projectsAttention": "Needs attention",
70
- "projectsNoAttention": "No open attention signals",
71
- "projectsSourceHealth": "Sources and diagnostics",
72
35
  "projectsArtifactsCountSuffix": "artifacts",
73
36
  "projectsArtifactsSearchPlaceholder": "Search artifacts",
74
37
  "projectsArtifactsShowMore": "Show more",
75
38
  "projectsArtifactsNoMatches": "No matching artifacts",
76
- "projectsArtifactsSort": "Sort",
77
- "projectsArtifactsSortUpdatedDesc": "Recently updated",
78
- "projectsArtifactsSortUpdatedAsc": "Oldest updated",
79
- "projectsArtifactsSortCreatedDesc": "Recently created",
80
- "projectsArtifactsSortName": "File name",
81
39
  "projectsTimeJustNow": "Just now",
82
40
  "projectsTimeMinutesAgo": "{count} minutes ago",
83
41
  "projectsTimeHoursAgo": "{count} hours ago",
84
42
  "projectsTimeYesterday": "Yesterday",
85
43
  "projectsTimeDaysAgo": "{count} days ago",
86
- "projectsReadable": "Readable",
87
44
  "projectsStatus": "Status",
88
- "projectsUpdated": "Updated",
89
- "projectsStatus_active": "Active",
90
- "projectsStatus_blocked": "Blocked",
91
- "projectsStatus_completed": "Completed",
92
- "projectsStatus_cancelled": "Cancelled",
93
- "projectsSource_available": "Available",
94
- "projectsSource_empty": "No data",
95
- "projectsSource_error": "Failed",
96
45
  "projectsWorkNewItem": "Add a work item",
97
46
  "projectsWorkEmpty": "No work items yet. Add one here or ask AI from a project conversation.",
98
47
  "projectsWorkShowDeleted": "Show deleted work items",
@@ -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": "卡片式",
@@ -6,28 +6,22 @@
6
6
  "projectsSkills": "Skills",
7
7
  "projectsSkillsCountSuffix": "个 Skills",
8
8
  "projectsAgreement": "工作约定",
9
- "projectsVisionAndContext": "愿景与上下文",
9
+ "projectsAgreementFile": "AGENTS.md",
10
+ "projectsAgreementDescription": "项目工作约定统一来自项目根目录的 AGENTS.md。",
11
+ "projectsAgreementOpen": "打开工作约定",
12
+ "projectsNoAgreement": "项目根目录还没有 AGENTS.md",
10
13
  "projectsLoading": "正在读取项目…",
11
14
  "projectsLoadFailed": "项目读取失败",
12
15
  "projectsEmpty": "还没有注册项目",
13
16
  "projectsChoose": "选择项目",
17
+ "projectsMoreActions": "{name} 的更多操作",
14
18
  "projectsRemoveFromList": "从项目列表移除",
15
19
  "projectsRemoveConfirmTitle": "从项目列表移除",
16
20
  "projectsRemoveConfirmDescription": "本地目录、历史会话和项目工作记录不会删除。已有会话仍可在会话列表中使用;以后重新添加同一目录时,会恢复原项目。",
17
21
  "projectsRemoveConfirmAction": "移除项目",
18
22
  "projectsRemoveSuccess": "项目已从列表移除",
19
23
  "projectsRemoveFailed": "项目移除失败",
20
- "projectsAsOf": "观测时间",
21
- "projectsQuality": "数据质量",
22
- "projectsQuality_complete": "完整",
23
- "projectsQuality_partial": "部分可用",
24
- "projectsQuality_unavailable": "暂不可用",
25
- "projectsNoSummary": "项目没有配置摘要,可在工作约定中查看已有来源。",
26
- "projectsObservationSetupTitle": "建立项目观察",
27
- "projectsObservationSetupDescription": "项目还没有建立观察约定。让 AI 阅读已有材料,推荐工作项流程和产物范围,并同时准备项目配置、常驻规则与项目追踪 Skill;确认后再统一写入。",
28
- "projectsObservationSetupAction": "让 AI 帮我建立项目观察",
29
- "projectsObservationSetupPrompt": "请按内置的项目观察 setup 规范,为当前项目建立可持续生效的工作追踪约定。先读取已有材料,根据项目实际情况直接给出一套可修改的推荐,完整包含:.nextclaw/project.yaml、根 AGENTS.md 的最小常驻规则,以及 .agents/skills/project-work-tracking/SKILL.md 的项目日常追踪方法。Workflow 描述单个工作项如何推进,不是整个项目的宏观阶段。Marker 必须在节点开始前报告,不能在结束时批量补写。不要反复提问;信息足够时直接推荐。未经我确认不要写入,确认后一次写入并复核三份文件。",
30
- "projectsNoData": "暂无可观测数据",
24
+ "projectsNoData": "当前没有工作项",
31
25
  "projectsUnavailable": "暂不可用",
32
26
  "projectsWorkList": "列表",
33
27
  "projectsWorkBoard": "看板",
@@ -35,64 +29,19 @@
35
29
  "projectsWorkViews": "工作项视图",
36
30
  "projectsWorkDetails": "工作项详情",
37
31
  "projectsCloseDetails": "关闭详情",
38
- "projectsNoWork": "暂无 AI 明确报告的工作项",
39
- "projectsWorkNeedsSetup": "先建立项目观察配置,AI 才能按这个项目的流程报告真实进展。",
40
- "projectsWorkNoSessions": "还没有在这个项目中开始会话。开始实际项目工作后,AI 报告的进展会显示在这里。",
41
- "projectsWorkNoReports": "项目会话中还没有 AI 明确报告的工作项。继续实际工作后,真实进展会显示在这里。",
42
- "projectsAiActivity": "AI 运行",
43
- "projectsAiRun": "项目会话",
44
- "projectsRunUnreported": "尚未报告工作项与阶段",
45
- "projectsRunState_running": "运行中",
46
- "projectsRunState_completed": "已结束",
47
- "projectsRunState_failed": "运行失败",
48
- "projectsRunState_cancelled": "已中止",
49
- "projectsRunState_idle": "空闲",
50
- "projectsStartProjectWork": "开始项目工作",
51
- "projectsStartProjectWorkPrompt": "请开始处理这个项目。先读取项目配置和与本次目标相关的材料;我会在发送前补充或修改具体目标。",
52
- "projectsBoardNeedsWorkflow": "没有可用的 Workflow 阶段;看板不会自动生成阶段。",
53
- "projectsGanttNeedsSchedule": "没有 schedule Marker;甘特图不会从文件时间推断日期。",
54
- "projectsUnstaged": "未声明阶段",
55
- "projectsStage": "阶段",
56
- "projectsSchedule": "计划",
57
- "projectsNoArtifacts": "暂无配置匹配或 AI 标记的产物",
58
- "projectsNoSkills": "项目目录中暂无可用 Skills",
59
- "projectsNoContext": "项目没有配置愿景或工作约定来源",
60
- "projectsRequests": "等待你回复",
61
- "projectsConfirm": "确认",
62
- "projectsReject": "拒绝",
63
- "projectsResponseSent": "回复已发送,等待 AI 更新",
64
- "projectsResponseWaiting": "已回复,等待 AI 更新",
65
- "projectsResponseFailed": "回复发送失败",
32
+ "projectsNoArtifacts": "还没有关联产物。从工作项关联文件后会显示在这里。",
33
+ "projectsNoSkills": ".agents/skills 中暂无项目 Skills",
66
34
  "projectsActivity": "最近动态",
67
- "projectsNoActivity": "暂无 AI 报告的动态",
68
- "projectsOpenSession": "打开原会话",
69
- "projectsAttention": "待关注",
70
- "projectsNoAttention": "暂无待关注信号",
71
- "projectsSourceHealth": "来源与诊断",
72
35
  "projectsArtifactsCountSuffix": "个产物",
73
36
  "projectsArtifactsSearchPlaceholder": "搜索产物",
74
37
  "projectsArtifactsShowMore": "显示更多",
75
38
  "projectsArtifactsNoMatches": "没有匹配的产物",
76
- "projectsArtifactsSort": "排序",
77
- "projectsArtifactsSortUpdatedDesc": "最近更新",
78
- "projectsArtifactsSortUpdatedAsc": "最早更新",
79
- "projectsArtifactsSortCreatedDesc": "最近创建",
80
- "projectsArtifactsSortName": "文件名",
81
39
  "projectsTimeJustNow": "刚刚",
82
40
  "projectsTimeMinutesAgo": "{count} 分钟前",
83
41
  "projectsTimeHoursAgo": "{count} 小时前",
84
42
  "projectsTimeYesterday": "昨天",
85
43
  "projectsTimeDaysAgo": "{count} 天前",
86
- "projectsReadable": "可读取",
87
44
  "projectsStatus": "状态",
88
- "projectsUpdated": "更新时间",
89
- "projectsStatus_active": "进行中",
90
- "projectsStatus_blocked": "受阻",
91
- "projectsStatus_completed": "已完成",
92
- "projectsStatus_cancelled": "已取消",
93
- "projectsSource_available": "可用",
94
- "projectsSource_empty": "暂无数据",
95
- "projectsSource_error": "读取失败",
96
45
  "projectsWorkNewItem": "添加工作项",
97
46
  "projectsWorkEmpty": "还没有工作项。你可以在这里添加,也可以在项目会话中让 AI 创建。",
98
47
  "projectsWorkShowDeleted": "显示已删除的工作项",
@@ -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
+ }