@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,11 +1,13 @@
1
1
  import { useMemo, useState, type ReactNode } from "react";
2
- import { Link, useLocation } from "react-router-dom";
2
+ import { Link, useLocation, useNavigate } from "react-router-dom";
3
3
  import {
4
4
  AlarmClock,
5
5
  ChevronDown,
6
6
  ChevronRight,
7
7
  Folder,
8
+ FolderMinus,
8
9
  MessageSquareText,
10
+ MoreVertical,
9
11
  Pin,
10
12
  Plus,
11
13
  } from "lucide-react";
@@ -22,6 +24,9 @@ import { t } from "@/shared/lib/i18n";
22
24
  import { ChatSidebarContextCard } from "@/features/chat/features/session/components/chat-sidebar-context-card";
23
25
  import { SessionRunBadge } from "@/features/chat/features/session/components/session-run-badge";
24
26
  import { cn } from "@/shared/lib/utils";
27
+ import { useRemoveProject } from "@/shared/hooks/use-projects";
28
+ import { useConfirmDialog } from "@/shared/hooks/use-confirm-dialog";
29
+ import { ChatSessionHeaderMenuItem } from "@/features/chat/features/session/components/session-header/chat-session-header-menu-item";
25
30
 
26
31
  export type { ChatSidebarProjectGroup };
27
32
 
@@ -57,10 +62,16 @@ export function ChatSidebarProjectGroups(props: ChatSidebarProjectGroupsProps) {
57
62
  } = props;
58
63
  const presenter = usePresenter();
59
64
  const location = useLocation();
65
+ const navigate = useNavigate();
66
+ const projectRemoval = useRemoveProject();
67
+ const { confirm, ConfirmDialog } = useConfirmDialog();
60
68
  const collapsedProjectRoots = useChatSessionListStore(
61
69
  (state) => state.snapshot.collapsedProjectRoots,
62
70
  );
63
71
  const [openProjectRoot, setOpenProjectRoot] = useState<string | null>(null);
72
+ const [openProjectMenuRoot, setOpenProjectMenuRoot] = useState<string | null>(
73
+ null,
74
+ );
64
75
  const preferredSessionType = useMemo(
65
76
  () =>
66
77
  resolveProjectGroupDefaultSessionType(
@@ -70,6 +81,25 @@ export function ChatSidebarProjectGroups(props: ChatSidebarProjectGroupsProps) {
70
81
  [defaultSessionType, sessionTypeOptions],
71
82
  );
72
83
  const supportsSessionTypeChoice = sessionTypeOptions.length > 1;
84
+ const removeProject = async (group: ChatSidebarProjectGroup) => {
85
+ if (!group.projectId) return;
86
+ setOpenProjectMenuRoot(null);
87
+ const confirmed = await confirm({
88
+ title: `${t("projectsRemoveConfirmTitle")} “${group.projectName}”`,
89
+ description: t("projectsRemoveConfirmDescription"),
90
+ confirmLabel: t("projectsRemoveConfirmAction"),
91
+ variant: "default",
92
+ });
93
+ if (!confirmed) return;
94
+ try {
95
+ await projectRemoval.mutateAsync(group.projectId);
96
+ if (location.pathname.startsWith(`/projects/${group.projectId}/`)) {
97
+ navigate("/projects");
98
+ }
99
+ } catch {
100
+ // The mutation owns the visible error and the project stays available.
101
+ }
102
+ };
73
103
 
74
104
  return (
75
105
  <div className="space-y-0.5">
@@ -116,7 +146,7 @@ export function ChatSidebarProjectGroups(props: ChatSidebarProjectGroupsProps) {
116
146
  >
117
147
  <div
118
148
  className={cn(
119
- "flex h-full w-full min-w-0 items-center group-hover/project:pr-14 group-has-[[data-project-actions]:focus-within]/project:pr-14",
149
+ "flex h-full w-full min-w-0 items-center group-hover/project:pr-20 group-has-[[data-project-actions]:focus-within]/project:pr-20",
120
150
  hasRunningSession && "pr-7",
121
151
  )}
122
152
  >
@@ -243,6 +273,37 @@ export function ChatSidebarProjectGroups(props: ChatSidebarProjectGroupsProps) {
243
273
  )
244
274
  }
245
275
  />
276
+ {group.projectId ? (
277
+ <Popover
278
+ open={openProjectMenuRoot === group.projectRoot}
279
+ onOpenChange={(nextOpen) =>
280
+ setOpenProjectMenuRoot(
281
+ nextOpen ? group.projectRoot : null,
282
+ )
283
+ }
284
+ >
285
+ <PopoverTrigger asChild>
286
+ <IconActionButton
287
+ icon={<MoreVertical className="h-3.5 w-3.5" />}
288
+ label={t("projectsMoreActions").replace(
289
+ "{name}",
290
+ group.projectName,
291
+ )}
292
+ tooltip={false}
293
+ disabled={projectRemoval.isPending}
294
+ onClick={(event) => event.stopPropagation()}
295
+ />
296
+ </PopoverTrigger>
297
+ <ChatPopoverContent align="end" className="w-56 p-2">
298
+ <ChatSessionHeaderMenuItem
299
+ icon={FolderMinus}
300
+ label={t("projectsRemoveFromList")}
301
+ disabled={projectRemoval.isPending}
302
+ onClick={() => void removeProject(group)}
303
+ />
304
+ </ChatPopoverContent>
305
+ </Popover>
306
+ ) : null}
246
307
  </div>
247
308
  </div>
248
309
  {isCollapsed ? null : (
@@ -253,6 +314,7 @@ export function ChatSidebarProjectGroups(props: ChatSidebarProjectGroupsProps) {
253
314
  </div>
254
315
  );
255
316
  })}
317
+ <ConfirmDialog key="project-remove-confirm-dialog" />
256
318
  </div>
257
319
  );
258
320
  }
@@ -22,3 +22,4 @@ export {
22
22
  resolveSessionTypeLabel,
23
23
  type ChatSessionTypeOption,
24
24
  } from "./features/session-type/utils/chat-session-type.utils";
25
+ export { ChatVoiceShortcutControl } from './features/conversation/components/chat-voice-shortcut-control';
@@ -0,0 +1,174 @@
1
+ import type { ChatComposerDictationSession } from '@nextclaw/agent-chat-ui';
2
+
3
+ export type VoicePhase = 'idle' | 'ready' | 'starting' | 'recording' | 'stopping' | 'error';
4
+ export type VoiceError = 'unsupported' | 'permission' | 'network' | 'audio-capture' | 'no-speech' | 'interrupted' | 'timeout' | 'failed';
5
+ export type VoiceSnapshot = { phase: VoicePhase; text: string; interim: string; seconds: number; error: VoiceError | null };
6
+ export type SpeechRecognitionLike = {
7
+ lang: string;
8
+ continuous: boolean;
9
+ interimResults: boolean;
10
+ start: () => void;
11
+ stop: () => void;
12
+ abort: () => void;
13
+ onstart: (() => void) | null;
14
+ onresult: ((event: { results: ArrayLike<{ isFinal: boolean; 0: { transcript: string } }> }) => void) | null;
15
+ onend: (() => void) | null;
16
+ onerror: ((event: { error: string }) => void) | null;
17
+ };
18
+ type RecognitionConstructor = new () => SpeechRecognitionLike;
19
+ const createBrowserRecognition = (): SpeechRecognitionLike | null => {
20
+ const host = window as Window & { SpeechRecognition?: RecognitionConstructor; webkitSpeechRecognition?: RecognitionConstructor };
21
+ const Constructor = host.SpeechRecognition ?? host.webkitSpeechRecognition;
22
+ return Constructor ? new Constructor() : null;
23
+ };
24
+ const initialSnapshot = (): VoiceSnapshot => ({ phase: 'idle', text: '', interim: '', seconds: 0, error: null });
25
+ const isActive = (phase: VoicePhase) => ['starting', 'recording', 'stopping'].includes(phase);
26
+
27
+ /** Owns one explicit dictation session; never writes to the chat draft or sends messages. */
28
+ export class ChatVoiceInputManager {
29
+ private snapshot = initialSnapshot();
30
+ private listeners = new Set<() => void>();
31
+ private recognition: SpeechRecognitionLike | null = null;
32
+ private deadline: ReturnType<typeof setTimeout> | undefined;
33
+ private ticker: ReturnType<typeof setInterval> | undefined;
34
+ private context = '';
35
+ private settingsRequested = false;
36
+ private draft: ChatComposerDictationSession | null = null;
37
+
38
+ constructor(private readonly createRecognition = createBrowserRecognition) {}
39
+
40
+ getSnapshot = (): VoiceSnapshot => this.snapshot;
41
+ subscribe = (listener: () => void): (() => void) => {
42
+ this.listeners.add(listener);
43
+ return () => { this.listeners.delete(listener); };
44
+ };
45
+ private update = (patch: Partial<VoiceSnapshot>): void => {
46
+ this.snapshot = { ...this.snapshot, ...patch };
47
+ this.listeners.forEach((listener) => listener());
48
+ };
49
+ open = (context: string): void => {
50
+ if (this.snapshot.phase !== 'idle') return;
51
+ this.context = context;
52
+ this.update({ ...initialSnapshot(), phase: 'ready' });
53
+ };
54
+ openSettings = (): void => {
55
+ this.settingsRequested = true;
56
+ if (this.snapshot.phase === 'recording') { this.finish(); return; }
57
+ if (this.snapshot.phase === 'stopping') return;
58
+ const { context } = this;
59
+ this.acceptDraft();
60
+ this.open(context);
61
+ };
62
+ start = (language: string, context: string, draft: ChatComposerDictationSession | null): void => {
63
+ if (isActive(this.snapshot.phase)) return;
64
+ if (!draft) return;
65
+ this.release();
66
+ this.context = context;
67
+ this.draft = draft;
68
+ this.update({ ...initialSnapshot(), phase: 'starting' });
69
+ try {
70
+ const recognition = this.createRecognition();
71
+ if (!recognition) { this.fail('unsupported'); return; }
72
+ this.recognition = recognition;
73
+ recognition.lang = language === 'zh' ? 'zh-CN' : 'en-US';
74
+ recognition.continuous = true;
75
+ recognition.interimResults = true;
76
+ this.connect(recognition);
77
+ this.setDeadline(15000);
78
+ recognition.start();
79
+ } catch { this.fail('failed'); }
80
+ };
81
+ private connect = (recognition: SpeechRecognitionLike): void => {
82
+ recognition.onstart = () => {
83
+ if (this.recognition !== recognition || this.snapshot.phase !== 'starting') return;
84
+ clearTimeout(this.deadline);
85
+ this.update({ phase: 'recording' });
86
+ const started = Date.now();
87
+ this.ticker = setInterval(() => {
88
+ const seconds = Math.floor((Date.now() - started) / 1000);
89
+ this.update({ seconds });
90
+ if (seconds >= 60) this.finish();
91
+ }, 1000);
92
+ };
93
+ recognition.onresult = (event) => {
94
+ if (this.recognition !== recognition) return;
95
+ const finals: string[] = [];
96
+ const interim: string[] = [];
97
+ for (let i = 0; i < event.results.length; i += 1) {
98
+ const result = event.results[i];
99
+ const text = result[0]?.transcript?.trim();
100
+ if (text) (result.isFinal ? finals : interim).push(text);
101
+ }
102
+ this.update({ text: finals.join(' '), interim: interim.join(' ') });
103
+ this.draft?.update(this.snapshot.text, this.snapshot.interim);
104
+ };
105
+ recognition.onerror = ({ error }) => {
106
+ if (this.recognition !== recognition) return;
107
+ const reason: VoiceError = error === 'not-allowed' || error === 'service-not-allowed'
108
+ ? 'permission'
109
+ : error === 'network' || error === 'audio-capture' || error === 'no-speech' ? error : 'failed';
110
+ this.fail(reason);
111
+ };
112
+ recognition.onend = () => {
113
+ if (this.recognition !== recognition) return;
114
+ const text = this.snapshot.text.trim();
115
+ this.release(false);
116
+ if (!text) { this.fail('no-speech'); return; }
117
+ this.draft?.commit(text);
118
+ this.draft = null;
119
+ const { settingsRequested, context } = this;
120
+ this.cancel();
121
+ if (settingsRequested) this.open(context);
122
+ };
123
+ };
124
+ finish = (): void => {
125
+ if (this.snapshot.phase === 'starting') { this.cancel(); return; }
126
+ if (this.snapshot.phase !== 'recording' || !this.recognition) return;
127
+ clearInterval(this.ticker);
128
+ this.update({ phase: 'stopping' });
129
+ this.setDeadline(8000);
130
+ try { this.recognition.stop(); } catch { this.fail('failed'); }
131
+ };
132
+ interrupt = (): void => {
133
+ if (isActive(this.snapshot.phase)) this.fail('interrupted');
134
+ };
135
+ acceptDraft = (): void => {
136
+ this.draft?.commit();
137
+ this.draft = null;
138
+ this.cancel();
139
+ };
140
+ cancel = (): void => {
141
+ this.settingsRequested = false;
142
+ this.release();
143
+ this.context = '';
144
+ this.draft?.cancel();
145
+ this.draft = null;
146
+ this.update(initialSnapshot());
147
+ };
148
+ private fail = (error: VoiceError): void => {
149
+ this.release();
150
+ if (this.snapshot.text) this.draft?.commit(this.snapshot.text);
151
+ else this.draft?.cancel();
152
+ this.draft = null;
153
+ this.update(this.settingsRequested ? { ...initialSnapshot(), phase: 'ready' } : { phase: 'error', error, interim: '' });
154
+ this.settingsRequested = false;
155
+ };
156
+ private setDeadline = (milliseconds: number): void => {
157
+ clearTimeout(this.deadline);
158
+ this.deadline = setTimeout(() => this.fail('timeout'), milliseconds);
159
+ };
160
+ private release = (abort = true): void => {
161
+ clearTimeout(this.deadline);
162
+ clearInterval(this.ticker);
163
+ const { recognition } = this;
164
+ this.recognition = null;
165
+ if (!recognition) return;
166
+ recognition.onstart = null;
167
+ recognition.onend = null;
168
+ recognition.onerror = null;
169
+ recognition.onresult = null;
170
+ if (abort) {
171
+ try { recognition.abort(); } catch { /* Already released by the browser. */ }
172
+ }
173
+ };
174
+ }
@@ -4,8 +4,10 @@ import { ChatQueryManager } from '@/features/chat/managers/chat-query.manager';
4
4
  import { ChatThreadManager } from '@/features/chat/managers/chat-thread.manager';
5
5
  import type { ChatComposerIntentManager } from '@/features/chat/managers/chat-composer-intent.manager';
6
6
  import type { AppPresenter } from '@/app/presenters/app.presenter';
7
+ import { ChatVoiceInputManager } from '@/features/chat/managers/chat-voice-input.manager';
7
8
 
8
9
  export class ChatPresenter {
10
+ readonly chatVoiceInputManager = new ChatVoiceInputManager();
9
11
  readonly chatUiManager: ChatUiManager;
10
12
  readonly chatSessionListManager: ChatSessionListManager;
11
13
  readonly chatQueryManager: ChatQueryManager;
@@ -0,0 +1,31 @@
1
+ import { create } from 'zustand';
2
+ import { createJSONStorage, persist } from 'zustand/middleware';
3
+
4
+ export type VoiceShortcut = { code: string; ctrl: boolean; meta: boolean; alt: boolean; shift: boolean };
5
+ export function isVoiceShortcut(value: unknown): value is VoiceShortcut {
6
+ if (!value || typeof value !== 'object') return false;
7
+ const key = value as VoiceShortcut;
8
+ const unmodified = key.ctrl === false && key.meta === false && key.alt === false && key.shift === false;
9
+ return typeof key.code === 'string' && (/^Key[A-Z]$/.test(key.code) || /^F(?:[6-9]|10)$/.test(key.code)) &&
10
+ typeof key.ctrl === 'boolean' && typeof key.meta === 'boolean' &&
11
+ typeof key.alt === 'boolean' && typeof key.shift === 'boolean' &&
12
+ (unmodified || (key.alt && key.ctrl !== key.meta));
13
+ }
14
+
15
+ type ShortcutState = { shortcut: VoiceShortcut | null; setShortcut: (shortcut: VoiceShortcut | null) => void };
16
+ export const useChatVoiceShortcutStore = create<ShortcutState>()(persist(
17
+ (set) => ({ shortcut: null, setShortcut: (shortcut) => set({ shortcut }) }),
18
+ {
19
+ name: 'nextclaw.chat.voice-shortcut', version: 1,
20
+ storage: createJSONStorage(() => window.localStorage),
21
+ partialize: (state) => ({ shortcut: state.shortcut }),
22
+ merge: (persisted, current) => {
23
+ const value = (persisted as { shortcut?: unknown } | null)?.shortcut;
24
+ return { ...current, shortcut: isVoiceShortcut(value) ? value : null };
25
+ },
26
+ },
27
+ ));
28
+
29
+ export function voiceShortcutLabel(shortcut: VoiceShortcut): string {
30
+ return [shortcut.ctrl && 'Ctrl', shortcut.meta && '⌘', shortcut.alt && 'Alt/⌥', shortcut.shift && 'Shift', shortcut.code.replace(/^Key/, '')].filter(Boolean).join(' + ');
31
+ }
@@ -1 +1,2 @@
1
1
  export { DesktopAuthorizationDialog } from "./components/desktop-authorization-dialog";
2
+ export { useDesktopCapabilityAvailability } from './hooks/use-desktop-capabilities';
@@ -32,7 +32,7 @@ describe("PanelAppOpenStandaloneMenuItem", () => {
32
32
  setDesktopHost(undefined);
33
33
  });
34
34
 
35
- it("uses a real new-tab link on the web", () => {
35
+ it("uses an auxiliary new-tab link that installed PWA navigation cannot capture", () => {
36
36
  const onSelect = vi.fn();
37
37
  setDesktopHost(undefined);
38
38
  render(
@@ -44,7 +44,8 @@ describe("PanelAppOpenStandaloneMenuItem", () => {
44
44
  "/apps/panel/publisher.todo%20board/standalone",
45
45
  );
46
46
  expect(link.getAttribute("target")).toBe("_blank");
47
- expect(link.getAttribute("rel")).toBe("noopener noreferrer");
47
+ expect(link.getAttribute("rel")).toBe("opener");
48
+ expect(link.getAttribute("referrerpolicy")).toBe("no-referrer");
48
49
  });
49
50
 
50
51
  it("describes the same link as opening in the browser inside Desktop", () => {
@@ -18,7 +18,10 @@ export function PanelAppOpenStandaloneMenuItem({
18
18
  <a
19
19
  href={href}
20
20
  target="_blank"
21
- rel="noopener noreferrer"
21
+ // Chrome captures non-auxiliary in-scope links into an installed PWA.
22
+ // `opener` keeps this trusted same-origin target auxiliary, so the current browser owns the new tab.
23
+ rel="opener"
24
+ referrerPolicy="no-referrer"
22
25
  className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm text-muted-foreground transition-colors hover:bg-[var(--interaction-hover)] hover:text-accent-foreground"
23
26
  onClick={onSelect}
24
27
  >
@@ -1,7 +1,6 @@
1
1
  # Projects UI boundary
2
2
 
3
- - 本目录拥有 Projects 页面、presenter、查询状态和用户回复编排;项目观测事实来自 `client.projects` 公共快照,工作项来自 Project Work API,组件不得自行扫描文件或解析 Marker。
4
- - 页面默认只读;确认/拒绝只复用现有 agent-run 消息入口,并携带可追踪、可幂等的响应元数据,不创建第二套 response bridge。
3
+ - 本目录拥有 Projects 页面、presenter 和查询状态;工作项与产物来自 Project Work API,Skills 与工作约定来自独立的有界材料 API,组件不得读取项目配置、扫描文件或从会话正文重建项目事实。
5
4
  - Overview、列表和看板消费同一份工作项查询状态;所有工作项入口统一打开右侧详情抽屉,不得在页面内追加平铺详情。
6
5
  - `project.work.changed` 只用于失效查询;UI 不重放事件历史,也不以轮询或目录扫描代替业务存储。
7
6
  - 组件负责连接与展示,跨组件状态归 hook/presenter;用户文案必须走 Projects i18n 资源。
@@ -1,4 +1,4 @@
1
- import type { ProjectObservationSnapshot } from "@nextclaw/client-sdk";
1
+ import type { ProjectAgreementMaterial } from "@nextclaw/client-sdk";
2
2
  import { t } from "@/shared/lib/i18n";
3
3
  import {
4
4
  ProjectEmptyState,
@@ -7,86 +7,45 @@ import {
7
7
  } from "./project-section";
8
8
 
9
9
  export function ProjectAgreement({
10
- snapshot,
10
+ agreement,
11
+ isLoading,
12
+ isError,
11
13
  onOpenFile,
12
14
  }: {
13
- snapshot: ProjectObservationSnapshot;
15
+ agreement?: ProjectAgreementMaterial;
16
+ isLoading: boolean;
17
+ isError: boolean;
14
18
  onOpenFile: (path: string, label: string) => void;
15
19
  }) {
16
20
  return (
17
- <div className="grid min-w-0 gap-4 lg:grid-cols-2">
18
- <ProjectSection title={t("projectsVisionAndContext")}>
19
- {snapshot.project.summary ? (
20
- <p className="mb-4 text-sm">{snapshot.project.summary}</p>
21
- ) : null}
22
- {snapshot.project.context.length ? (
23
- <div className="divide-y divide-border/60">
24
- {snapshot.project.context.map((context) => (
25
- <ProjectContextReference
26
- key={context.id}
27
- context={context}
28
- onOpenFile={onOpenFile}
29
- />
30
- ))}
31
- </div>
32
- ) : (
33
- <ProjectEmptyState>{t("projectsNoContext")}</ProjectEmptyState>
34
- )}
35
- </ProjectSection>
36
- <ProjectSection title={t("projectsSourceHealth")}>
37
- <div className="space-y-2">
38
- {snapshot.sources.map((source) => (
39
- <div
40
- key={source.id}
41
- className="flex items-center justify-between gap-3 rounded-xl bg-muted/45 p-3 text-sm"
42
- >
43
- <span>{source.label}</span>
44
- <span className="text-muted-foreground">
45
- {t(`projectsSource_${source.status}`)} · {source.itemCount}
46
- </span>
47
- </div>
48
- ))}
49
- </div>
50
- {snapshot.diagnostics.length ? (
51
- <div className="mt-4 space-y-2 border-t border-border/60 pt-4">
52
- {snapshot.diagnostics.map((diagnostic) => (
53
- <div
54
- key={diagnostic.id}
55
- className="rounded-xl bg-muted/35 p-3 text-sm"
56
- >
57
- <p className="font-medium">{diagnostic.code}</p>
58
- <p className="mt-1 text-xs text-muted-foreground">
59
- {diagnostic.message}
60
- </p>
61
- </div>
62
- ))}
63
- </div>
64
- ) : null}
65
- </ProjectSection>
66
- </div>
67
- );
68
- }
69
-
70
- function ProjectContextReference({
71
- context,
72
- onOpenFile,
73
- }: {
74
- context: ProjectObservationSnapshot["project"]["context"][number];
75
- onOpenFile: (path: string, label: string) => void;
76
- }) {
77
- const { accessible, role, source } = context;
78
- return (
79
- <ProjectFilePreviewButton
80
- available={accessible}
81
- path={source}
82
- label={role}
83
- onOpen={onOpenFile}
84
- className="block w-full py-3 text-left first:pt-0 enabled:hover:text-foreground enabled:focus-visible:outline-none enabled:focus-visible:ring-1 enabled:focus-visible:ring-border"
85
- >
86
- <span className="block text-sm font-medium">{role}</span>
87
- <span className="mt-1 block truncate text-xs text-muted-foreground">
88
- {source}{!accessible ? ` · ${t("projectsUnavailable")}` : ""}
89
- </span>
90
- </ProjectFilePreviewButton>
21
+ <ProjectSection title={t("projectsAgreementFile")}>
22
+ <p className="mb-4 text-sm text-muted-foreground">
23
+ {t("projectsAgreementDescription")}
24
+ </p>
25
+ {isLoading ? (
26
+ <p className="py-5 text-sm text-muted-foreground">
27
+ {t("projectsLoading")}
28
+ </p>
29
+ ) : isError ? (
30
+ <p className="py-5 text-sm text-destructive">
31
+ {t("projectsLoadFailed")}
32
+ </p>
33
+ ) : agreement?.available ? (
34
+ <ProjectFilePreviewButton
35
+ available
36
+ path={agreement.path}
37
+ label={agreement.path}
38
+ onOpen={onOpenFile}
39
+ className="block w-full rounded-xl bg-muted/45 p-3 text-left transition-colors enabled:hover:bg-[var(--interaction-hover)] enabled:focus-visible:outline-none enabled:focus-visible:ring-1 enabled:focus-visible:ring-border"
40
+ >
41
+ <span className="block text-sm font-medium">{agreement.path}</span>
42
+ <span className="mt-1 block text-xs text-muted-foreground">
43
+ {t("projectsAgreementOpen")}
44
+ </span>
45
+ </ProjectFilePreviewButton>
46
+ ) : (
47
+ <ProjectEmptyState>{t("projectsNoAgreement")}</ProjectEmptyState>
48
+ )}
49
+ </ProjectSection>
91
50
  );
92
51
  }