@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
@@ -5,32 +5,21 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
5
5
  import { ProjectsPage } from "./project-home-page";
6
6
 
7
7
  const mocks = vi.hoisted(() => ({
8
- confirm: vi.fn(),
8
+ agreementProps: vi.fn(),
9
+ artifactsProps: vi.fn(),
9
10
  drawerProps: vi.fn(),
10
- removeProject: vi.fn(),
11
- useProjectObservation: vi.fn(),
11
+ skillsProps: vi.fn(),
12
+ useProjectAgreement: vi.fn(),
13
+ useProjectSkills: vi.fn(),
12
14
  useProjects: vi.fn(),
13
15
  }));
14
16
 
15
17
  vi.mock("@/shared/hooks/use-projects", () => ({
16
18
  useProjects: mocks.useProjects,
17
- useRemoveProject: () => ({
18
- mutateAsync: mocks.removeProject,
19
- isPending: false,
20
- }),
21
19
  }));
22
- vi.mock("@/shared/hooks/use-confirm-dialog", () => ({
23
- useConfirmDialog: () => ({
24
- confirm: mocks.confirm,
25
- ConfirmDialog: () => <div data-testid="confirm-dialog" />,
26
- }),
27
- }));
28
- vi.mock("@/features/projects/hooks/use-project-observation", () => ({
29
- projectObservationQueryKey: (projectId: string) => [
30
- "project-observation",
31
- projectId,
32
- ],
33
- useProjectObservation: mocks.useProjectObservation,
20
+ vi.mock("@/features/projects/hooks/use-project-materials", () => ({
21
+ useProjectAgreement: mocks.useProjectAgreement,
22
+ useProjectSkills: mocks.useProjectSkills,
34
23
  }));
35
24
  vi.mock("@/features/chat", () => ({
36
25
  ChatConversationWorkspaceSection: () => <div data-testid="workspace" />,
@@ -64,18 +53,23 @@ vi.mock("@/features/projects/components/work/project-work-item-drawer", () => ({
64
53
  },
65
54
  }));
66
55
  vi.mock("@/features/projects/components/project-artifacts", () => ({
67
- ProjectArtifacts: () => <div>Artifact content</div>,
56
+ ProjectArtifacts: (props: { projectId: string }) => {
57
+ mocks.artifactsProps(props);
58
+ return <div>Artifact content</div>;
59
+ },
68
60
  }));
69
61
  vi.mock("@/features/projects/components/project-skills", () => ({
70
- ProjectSkills: () => <div>Skills content</div>,
62
+ ProjectSkills: (props: { skills: unknown[] }) => {
63
+ mocks.skillsProps(props);
64
+ return <div>Skills content</div>;
65
+ },
71
66
  }));
72
67
  vi.mock("@/features/projects/components/project-agreement", () => ({
73
- ProjectAgreement: () => <div>Agreement content</div>,
74
- }));
75
- vi.mock("@/features/projects/components/project-requests", () => ({
76
- ProjectRequests: () => null,
68
+ ProjectAgreement: (props: { agreement?: unknown }) => {
69
+ mocks.agreementProps(props);
70
+ return <div>Agreement content</div>;
71
+ },
77
72
  }));
78
-
79
73
  function renderPage(path: string) {
80
74
  return render(
81
75
  <QueryClientProvider client={new QueryClient()}>
@@ -92,10 +86,9 @@ function renderPage(path: string) {
92
86
  describe("ProjectsPage", () => {
93
87
  beforeEach(() => {
94
88
  mocks.drawerProps.mockReset();
95
- mocks.confirm.mockReset();
96
- mocks.confirm.mockResolvedValue(true);
97
- mocks.removeProject.mockReset();
98
- mocks.removeProject.mockResolvedValue(undefined);
89
+ mocks.agreementProps.mockReset();
90
+ mocks.artifactsProps.mockReset();
91
+ mocks.skillsProps.mockReset();
99
92
  mocks.useProjects.mockReturnValue({
100
93
  data: {
101
94
  projects: [
@@ -105,38 +98,35 @@ describe("ProjectsPage", () => {
105
98
  isLoading: false,
106
99
  isError: false,
107
100
  });
108
- mocks.useProjectObservation.mockReturnValue({
101
+ mocks.useProjectAgreement.mockReturnValue({
102
+ data: undefined,
103
+ isLoading: false,
104
+ isError: false,
105
+ });
106
+ mocks.useProjectSkills.mockReturnValue({
109
107
  data: undefined,
110
108
  isLoading: false,
111
109
  isError: false,
112
110
  });
113
111
  });
114
112
 
115
- it("keeps overview scan-free and opens its work item in the unified drawer", () => {
113
+ it("keeps overview material-free and opens its work item in the unified drawer", () => {
116
114
  renderPage("/projects/project-1/overview");
117
115
 
118
- expect(mocks.useProjectObservation).toHaveBeenCalledWith(
119
- "project-1",
120
- "/tmp/research",
121
- false,
122
- );
116
+ expect(mocks.useProjectAgreement).toHaveBeenCalledWith(null);
117
+ expect(mocks.useProjectSkills).toHaveBeenCalledWith(null);
123
118
  expect(screen.getAllByRole("tab")).toHaveLength(5);
124
119
  fireEvent.click(screen.getByRole("button", { name: "Overview work" }));
125
120
  expect(screen.getByTestId("work-drawer").textContent).toBe("work-overview");
126
121
  });
127
122
 
128
- it("enables legacy observation only for preserved artifact, skill, and agreement surfaces", () => {
129
- mocks.useProjectObservation.mockReturnValue({
130
- data: { requests: [] },
131
- isLoading: false,
132
- isError: false,
133
- });
123
+ it("loads artifacts from project work without material observation", () => {
134
124
  renderPage("/projects/project-1/artifacts");
135
125
 
136
- expect(mocks.useProjectObservation).toHaveBeenCalledWith(
137
- "project-1",
138
- "/tmp/research",
139
- true,
126
+ expect(mocks.useProjectAgreement).toHaveBeenCalledWith(null);
127
+ expect(mocks.useProjectSkills).toHaveBeenCalledWith(null);
128
+ expect(mocks.artifactsProps).toHaveBeenCalledWith(
129
+ expect.objectContaining({ projectId: "project-1" }),
140
130
  );
141
131
  expect(screen.getByText("Artifact content")).toBeTruthy();
142
132
  expect(screen.getByRole("tab", { name: /Skills/ })).toBeTruthy();
@@ -147,47 +137,50 @@ describe("ProjectsPage", () => {
147
137
 
148
138
  it("asks the user to choose from the sidebar when no project route is selected", () => {
149
139
  renderPage("/projects");
150
- expect(mocks.useProjectObservation).toHaveBeenCalledWith(null, null, false);
140
+ expect(mocks.useProjectAgreement).toHaveBeenCalledWith(null);
141
+ expect(mocks.useProjectSkills).toHaveBeenCalledWith(null);
151
142
  expect(screen.getByText(/Choose a project|选择项目/)).toBeTruthy();
152
143
  });
153
144
 
154
- it("explains the impact before removing a project and returns to project selection", async () => {
155
- renderPage("/projects/project-1/overview");
156
-
157
- fireEvent.click(
158
- screen.getByRole("button", {
159
- name: /Remove from project list|从项目列表移除/,
145
+ it("loads each fixed project material only on its own tab", () => {
146
+ mocks.useProjectAgreement.mockReturnValue({
147
+ data: { path: "AGENTS.md", available: true },
148
+ isLoading: false,
149
+ isError: false,
150
+ });
151
+ const { unmount } = renderPage("/projects/project-1/agreement");
152
+ expect(mocks.useProjectAgreement).toHaveBeenCalledWith("project-1");
153
+ expect(mocks.useProjectSkills).toHaveBeenCalledWith(null);
154
+ expect(mocks.agreementProps).toHaveBeenCalledWith(
155
+ expect.objectContaining({
156
+ agreement: { path: "AGENTS.md", available: true },
160
157
  }),
161
158
  );
159
+ unmount();
162
160
 
163
- await vi.waitFor(() =>
164
- expect(mocks.confirm).toHaveBeenCalledWith(
165
- expect.objectContaining({
166
- description: expect.stringMatching(/local folder|本地目录/),
167
- variant: "destructive",
168
- }),
169
- ),
170
- );
171
- await vi.waitFor(() =>
172
- expect(mocks.removeProject).toHaveBeenCalledWith("project-1"),
173
- );
174
- await vi.waitFor(() =>
175
- expect(screen.getByText(/Choose a project|选择项目/)).toBeTruthy(),
161
+ mocks.useProjectSkills.mockReturnValue({
162
+ data: [{ ref: "project:alpha", name: "alpha" }],
163
+ isLoading: false,
164
+ isError: false,
165
+ });
166
+ renderPage("/projects/project-1/skills");
167
+ expect(mocks.useProjectAgreement).toHaveBeenLastCalledWith(null);
168
+ expect(mocks.useProjectSkills).toHaveBeenLastCalledWith("project-1");
169
+ expect(mocks.skillsProps).toHaveBeenCalledWith(
170
+ expect.objectContaining({
171
+ skills: [{ ref: "project:alpha", name: "alpha" }],
172
+ }),
176
173
  );
177
174
  });
178
175
 
179
- it("keeps the project when removal is cancelled", async () => {
180
- mocks.confirm.mockResolvedValue(false);
176
+ it("does not expose project removal as a persistent detail-page action", () => {
181
177
  renderPage("/projects/project-1/overview");
182
178
 
183
- fireEvent.click(
184
- screen.getByRole("button", {
179
+ expect(
180
+ screen.queryByRole("button", {
185
181
  name: /Remove from project list|从项目列表移除/,
186
182
  }),
187
- );
188
-
189
- await vi.waitFor(() => expect(mocks.confirm).toHaveBeenCalledOnce());
190
- expect(mocks.removeProject).not.toHaveBeenCalled();
183
+ ).toBeNull();
191
184
  expect(screen.getByText("Research")).toBeTruthy();
192
185
  });
193
186
  });
@@ -1,17 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import type { ObservedRequest, ObservedWorkItem, ObservedWorkflow } from "@nextclaw/client-sdk";
3
- import {
4
- canReplyToProjectRequest,
5
- createProjectBoardColumns,
6
- getScheduledWorkItems,
7
- isProjectHomeTab,
8
- } from "./project-home.presenter";
9
-
10
- const reference = {
11
- kind: "ai-report" as const,
12
- label: "AI report",
13
- observedAt: "2026-08-30T00:00:00.000Z",
14
- };
2
+ import { isProjectHomeTab } from "./project-home.presenter";
15
3
 
16
4
  describe("project home presenter", () => {
17
5
  it("accepts only the project home tabs represented in the route", () => {
@@ -20,55 +8,4 @@ describe("project home presenter", () => {
20
8
  expect(isProjectHomeTab("missing")).toBe(false);
21
9
  expect(isProjectHomeTab(undefined)).toBe(false);
22
10
  });
23
-
24
- it("projects the same work items into board and scheduled views without inventing facts", () => {
25
- const workflows: ObservedWorkflow[] = [{
26
- id: "research",
27
- label: "Research",
28
- stages: [{ id: "draft", label: "Draft" }],
29
- reference,
30
- }];
31
- const workItems: ObservedWorkItem[] = [
32
- {
33
- id: "with-schedule",
34
- name: "Scheduled",
35
- status: "active",
36
- workflowId: "research",
37
- stageId: "draft",
38
- schedule: { start: "2026-08-30", end: "2026-09-02", milestone: false, dependsOn: [] },
39
- updatedAt: reference.observedAt,
40
- reference,
41
- },
42
- {
43
- id: "without-schedule",
44
- name: "Unscheduled",
45
- status: "blocked",
46
- updatedAt: reference.observedAt,
47
- reference,
48
- },
49
- ];
50
-
51
- expect(createProjectBoardColumns(workflows, workItems)[0]?.items.map((item) => item.id))
52
- .toEqual(["with-schedule"]);
53
- expect(getScheduledWorkItems(workItems).map((item) => item.id))
54
- .toEqual(["with-schedule"]);
55
- });
56
-
57
- it("offers direct replies only for open confirm-reject requests with a source session", () => {
58
- const request: ObservedRequest = {
59
- id: "approval",
60
- status: "open",
61
- response: "confirm-reject",
62
- prompt: "Ship?",
63
- updatedAt: reference.observedAt,
64
- reference: { ...reference, sessionId: "session-1" },
65
- };
66
- expect(canReplyToProjectRequest(request)).toBe(true);
67
- expect(canReplyToProjectRequest({ ...request, reply: {
68
- decision: "confirmed",
69
- sentAt: reference.observedAt,
70
- messageId: "response-1",
71
- } })).toBe(false);
72
- expect(canReplyToProjectRequest({ ...request, reference })).toBe(false);
73
- });
74
11
  });
@@ -1,55 +1,18 @@
1
- import type {
2
- ObservedRequest,
3
- ObservedWorkItem,
4
- ObservedWorkflow,
5
- ProjectObservationSnapshot,
6
- } from "@nextclaw/client-sdk";
7
-
8
- export type ProjectHomeTab = "overview" | "work" | "artifacts" | "skills" | "agreement";
9
- export type ProjectWorkView = "list" | "board" | "gantt";
10
-
11
- export type ProjectBoardColumn = {
12
- id: string;
13
- label: string;
14
- items: ObservedWorkItem[];
15
- };
16
-
17
- export function isProjectHomeTab(value: string | undefined): value is ProjectHomeTab {
18
- return value === "overview" || value === "work" || value === "artifacts" || value === "skills" || value === "agreement";
19
- }
20
-
21
- export function createProjectBoardColumns(
22
- workflows: readonly ObservedWorkflow[],
23
- workItems: readonly ObservedWorkItem[],
24
- ): ProjectBoardColumn[] {
25
- return workflows.flatMap((workflow) => workflow.stages.map((stage) => ({
26
- id: `${workflow.id}:${stage.id}`,
27
- label: stage.label,
28
- items: workItems.filter((item) => (
29
- item.workflowId === workflow.id && item.stageId === stage.id
30
- )),
31
- })));
32
- }
33
-
34
- export function getUnstagedWorkItems(
35
- workItems: readonly ObservedWorkItem[],
36
- ): ObservedWorkItem[] {
37
- return workItems.filter((item) => !item.workflowId || !item.stageId);
38
- }
39
-
40
- export function getScheduledWorkItems(
41
- workItems: readonly ObservedWorkItem[],
42
- ): ObservedWorkItem[] {
43
- return workItems.filter((item) => item.schedule?.start || item.schedule?.end);
44
- }
45
-
46
- export function canReplyToProjectRequest(request: ObservedRequest): boolean {
47
- return request.status === "open"
48
- && request.response === "confirm-reject"
49
- && Boolean(request.reference.sessionId)
50
- && !request.reply;
51
- }
52
-
53
- export function getOpenProjectRequests(snapshot: ProjectObservationSnapshot): ObservedRequest[] {
54
- return snapshot.requests.filter((request) => request.status === "open");
1
+ export type ProjectHomeTab =
2
+ | "overview"
3
+ | "work"
4
+ | "artifacts"
5
+ | "skills"
6
+ | "agreement";
7
+
8
+ export function isProjectHomeTab(
9
+ value: string | undefined,
10
+ ): value is ProjectHomeTab {
11
+ return (
12
+ value === "overview" ||
13
+ value === "work" ||
14
+ value === "artifacts" ||
15
+ value === "skills" ||
16
+ value === "agreement"
17
+ );
55
18
  }
@@ -2,55 +2,9 @@ import { describe, expect, it } from "vitest";
2
2
  import {
3
3
  formatProjectRelativeTime,
4
4
  joinProjectPath,
5
- sortProjectArtifacts,
6
5
  } from "./project-artifact-view.utils";
7
6
 
8
- const artifacts = [
9
- {
10
- id: "older-update",
11
- path: "docs/designs/older.md",
12
- categoryId: "designs",
13
- categoryLabel: "Designs",
14
- exists: true,
15
- fileCreatedAt: "2026-08-01T00:00:00.000Z",
16
- fileUpdatedAt: "2026-08-28T00:00:00.000Z",
17
- references: [],
18
- },
19
- {
20
- id: "newer-update",
21
- path: "docs/designs/newer.md",
22
- categoryId: "designs",
23
- categoryLabel: "Designs",
24
- exists: true,
25
- fileCreatedAt: "2026-08-29T00:00:00.000Z",
26
- fileUpdatedAt: "2026-08-30T00:00:00.000Z",
27
- references: [],
28
- },
29
- {
30
- id: "untimed",
31
- path: "docs/designs/alpha.md",
32
- categoryId: "designs",
33
- categoryLabel: "Designs",
34
- exists: false,
35
- references: [],
36
- },
37
- ];
38
-
39
7
  describe("project artifact view", () => {
40
- it("prioritizes recent updates by default and supports alternate sort orders", () => {
41
- expect(
42
- sortProjectArtifacts(artifacts).map((artifact) => artifact.id),
43
- ).toEqual(["newer-update", "older-update", "untimed"]);
44
- expect(
45
- sortProjectArtifacts(artifacts, "created-desc").map(
46
- (artifact) => artifact.id,
47
- ),
48
- ).toEqual(["newer-update", "older-update", "untimed"]);
49
- expect(
50
- sortProjectArtifacts(artifacts, "name").map((artifact) => artifact.id),
51
- ).toEqual(["untimed", "newer-update", "older-update"]);
52
- });
53
-
54
8
  it("renders recent timestamps in people-friendly language", () => {
55
9
  const now = new Date("2026-08-30T12:00:00.000Z");
56
10
  expect(formatProjectRelativeTime("2026-08-30T11:57:00.000Z", now)).toBe(
@@ -1,15 +1,5 @@
1
- import type { ProjectObservationSnapshot } from "@nextclaw/client-sdk";
2
1
  import { formatDateShort, t } from "@/shared/lib/i18n";
3
2
 
4
- export type ProjectArtifactSort =
5
- | "updated-desc"
6
- | "updated-asc"
7
- | "created-desc"
8
- | "name";
9
-
10
- export const DEFAULT_PROJECT_ARTIFACT_SORT: ProjectArtifactSort =
11
- "updated-desc";
12
-
13
3
  export function joinProjectPath(
14
4
  rootPath: string,
15
5
  relativePath: string,
@@ -19,43 +9,6 @@ export function joinProjectPath(
19
9
  return `${rootPath}${separator}${relativePath}`;
20
10
  }
21
11
 
22
- function timestamp(value?: string): number {
23
- if (!value) return Number.NEGATIVE_INFINITY;
24
- const parsed = new Date(value).getTime();
25
- return Number.isNaN(parsed) ? Number.NEGATIVE_INFINITY : parsed;
26
- }
27
-
28
- function byName(
29
- left: ProjectObservationSnapshot["artifacts"][number],
30
- right: ProjectObservationSnapshot["artifacts"][number],
31
- ): number {
32
- return left.path.localeCompare(right.path);
33
- }
34
-
35
- export function sortProjectArtifacts(
36
- artifacts: ProjectObservationSnapshot["artifacts"],
37
- sort: ProjectArtifactSort = DEFAULT_PROJECT_ARTIFACT_SORT,
38
- ): ProjectObservationSnapshot["artifacts"] {
39
- return [...artifacts].sort((left, right) => {
40
- if (sort === "name") return byName(left, right);
41
- const leftTimestamp = timestamp(
42
- sort === "created-desc"
43
- ? (left.fileCreatedAt ?? left.fileUpdatedAt)
44
- : (left.fileUpdatedAt ?? left.fileCreatedAt),
45
- );
46
- const rightTimestamp = timestamp(
47
- sort === "created-desc"
48
- ? (right.fileCreatedAt ?? right.fileUpdatedAt)
49
- : (right.fileUpdatedAt ?? right.fileCreatedAt),
50
- );
51
- const order =
52
- sort === "updated-asc"
53
- ? leftTimestamp - rightTimestamp
54
- : rightTimestamp - leftTimestamp;
55
- return order || byName(left, right);
56
- });
57
- }
58
-
59
12
  export function formatProjectRelativeTime(
60
13
  value?: string,
61
14
  now: Date = new Date(),
@@ -0,0 +1,15 @@
1
+ import { useViewportLayout } from '@/app/hooks/use-viewport-layout';
2
+ import { ChatVoiceShortcutControl } from '@/features/chat';
3
+ import { SettingsPage } from '@/shared/components/settings/settings-page';
4
+ import { SettingRow, SettingsGroup, SettingsSection } from '@/shared/components/settings/setting-row';
5
+ import { t } from '@/shared/lib/i18n';
6
+
7
+ export function KeyboardShortcutsSettingsPage() {
8
+ const { isMobile } = useViewportLayout();
9
+ return <SettingsPage title={t('keyboardShortcuts')}>
10
+ {isMobile ? <p className='text-sm text-muted-foreground'>{t('keyboardShortcutsDesktopOnly')}</p> :
11
+ <SettingsSection><SettingsGroup>
12
+ <SettingRow title={t('chatInputVoice')} layout='stacked'><ChatVoiceShortcutControl /></SettingRow>
13
+ </SettingsGroup></SettingsSection>}
14
+ </SettingsPage>;
15
+ }
@@ -2,11 +2,12 @@ import { NavLink } from "react-router-dom";
2
2
  import { getSettingsNavItems } from "@/app/configs/app-navigation.config";
3
3
  import { t } from "@/shared/lib/i18n";
4
4
  import { cn } from "@/shared/lib/utils";
5
- import { useDesktopCapabilityAvailability } from "@/features/desktop-capabilities/hooks/use-desktop-capabilities";
5
+ import { useDesktopCapabilityAvailability } from "@/features/desktop-capabilities";
6
6
 
7
7
  export function MobileSettingsShell() {
8
8
  const desktopCapabilityAvailable = useDesktopCapabilityAvailability();
9
9
  const settingsNavItems = getSettingsNavItems(t, {
10
+ includeKeyboardShortcuts: false,
10
11
  includeDesktopCapabilities: desktopCapabilityAvailable,
11
12
  });
12
13
 
@@ -1,4 +1,4 @@
1
- import { QueryClient } from '@tanstack/react-query';
1
+ import { QueryClient, type InfiniteData } from '@tanstack/react-query';
2
2
  import { describe, expect, it } from 'vitest';
3
3
  import {
4
4
  applyNcpSessionRealtimeEvent,
@@ -262,3 +262,111 @@ describe('ncp-session-query-cache', () => {
262
262
  expect(queryClient.getQueryData<NcpSessionsListView>(['ncp-sessions', 200, 'peer-b'])?.sessions).toEqual([]);
263
263
  });
264
264
  });
265
+
266
+ describe('paginated ncp session realtime cache', () => {
267
+ it('updates existing paginated summaries without invalidating the session pages', () => {
268
+ const queryClient = new QueryClient();
269
+ const queryKey = ['ncp-session-pages', 1, null] as const;
270
+ const current = createSessionsList();
271
+ queryClient.setQueryData<InfiniteData<NcpSessionsListView>>(queryKey, {
272
+ pages: [
273
+ { ...current, sessions: [current.sessions[0]!], page: 1, pageSize: 1, hasMore: true },
274
+ { ...current, sessions: [current.sessions[1]!], page: 2, pageSize: 1, hasMore: false }
275
+ ],
276
+ pageParams: [1, 2]
277
+ });
278
+
279
+ applyNcpSessionRealtimeEvent(queryClient, {
280
+ type: 'session.summary.upsert',
281
+ payload: {
282
+ summary: {
283
+ ...current.sessions[1]!,
284
+ updatedAt: '2026-03-29T11:00:00.000Z',
285
+ lastMessageAt: '2026-03-29T11:00:00.000Z',
286
+ messageCount: 3
287
+ }
288
+ }
289
+ });
290
+
291
+ const updated = queryClient.getQueryData<InfiniteData<NcpSessionsListView>>(queryKey);
292
+ expect(updated?.pages.map((page) => page.sessions[0]?.sessionId)).toEqual(['session-2', 'session-1']);
293
+ expect(queryClient.getQueryState(queryKey)?.isInvalidated).toBe(false);
294
+ });
295
+
296
+ it('invalidates paginated session queries when an upsert changes their membership', () => {
297
+ const queryClient = new QueryClient();
298
+ const queryKey = ['ncp-session-pages', 100, null] as const;
299
+ queryClient.setQueryData<InfiniteData<NcpSessionsListView>>(queryKey, {
300
+ pages: [{ ...createSessionsList(), page: 1, pageSize: 100, hasMore: false }],
301
+ pageParams: [1]
302
+ });
303
+
304
+ applyNcpSessionRealtimeEvent(queryClient, {
305
+ type: 'session.summary.upsert',
306
+ payload: {
307
+ summary: {
308
+ sessionId: 'session-3',
309
+ messageCount: 1,
310
+ updatedAt: '2026-03-29T12:00:00.000Z',
311
+ status: 'idle'
312
+ }
313
+ }
314
+ });
315
+
316
+ expect(queryClient.getQueryState(queryKey)?.isInvalidated).toBe(true);
317
+ });
318
+
319
+ it('patches paginated run status without invalidating the session pages', () => {
320
+ const queryClient = new QueryClient();
321
+ const queryKey = ['ncp-session-pages', 100, null] as const;
322
+ queryClient.setQueryData<InfiniteData<NcpSessionsListView>>(queryKey, {
323
+ pages: [{ ...createSessionsList(), page: 1, pageSize: 100, hasMore: false }],
324
+ pageParams: [1]
325
+ });
326
+
327
+ applyNcpSessionRealtimeEvent(queryClient, {
328
+ type: 'session.run-status',
329
+ payload: { sessionKey: 'session-2', status: 'running' }
330
+ });
331
+
332
+ const updated = queryClient.getQueryData<InfiniteData<NcpSessionsListView>>(queryKey);
333
+ expect(updated?.pages[0]?.sessions[1]).toMatchObject({
334
+ sessionId: 'session-2',
335
+ messageCount: 2,
336
+ status: 'running'
337
+ });
338
+ expect(queryClient.getQueryState(queryKey)?.isInvalidated).toBe(false);
339
+ });
340
+
341
+ it('invalidates filtered paginated queries when an upsert may change search membership', () => {
342
+ const queryClient = new QueryClient();
343
+ const queryKey = ['ncp-session-pages', 100, 'journal'] as const;
344
+ queryClient.setQueryData<InfiniteData<NcpSessionsListView>>(queryKey, {
345
+ pages: [{ ...createSessionsList(), page: 1, pageSize: 100, hasMore: false }],
346
+ pageParams: [1]
347
+ });
348
+
349
+ applyNcpSessionRealtimeEvent(queryClient, {
350
+ type: 'session.summary.upsert',
351
+ payload: { summary: { ...createSessionsList().sessions[0]!, metadata: { label: 'Journal' } } }
352
+ });
353
+
354
+ expect(queryClient.getQueryState(queryKey)?.isInvalidated).toBe(true);
355
+ });
356
+
357
+ it('invalidates paginated queries after deletion changes their membership', () => {
358
+ const queryClient = new QueryClient();
359
+ const queryKey = ['ncp-session-pages', 100, null] as const;
360
+ queryClient.setQueryData<InfiniteData<NcpSessionsListView>>(queryKey, {
361
+ pages: [{ ...createSessionsList(), page: 1, pageSize: 100, hasMore: false }],
362
+ pageParams: [1]
363
+ });
364
+
365
+ applyNcpSessionRealtimeEvent(queryClient, {
366
+ type: 'session.summary.delete',
367
+ payload: { sessionKey: 'session-1' }
368
+ });
369
+
370
+ expect(queryClient.getQueryState(queryKey)?.isInvalidated).toBe(true);
371
+ });
372
+ });