@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,27 +1,42 @@
1
- import type { ProjectObservationSnapshot } from "@nextclaw/client-sdk";
1
+ import type { ProjectSkillMaterial } from "@nextclaw/client-sdk";
2
2
  import { formatNumber, t } from "@/shared/lib/i18n";
3
3
  import { ProjectEmptyState } from "./project-section";
4
4
 
5
5
  export function ProjectSkills({
6
- snapshot,
6
+ skills,
7
+ isLoading,
8
+ isError,
7
9
  onOpen,
8
10
  }: {
9
- snapshot: ProjectObservationSnapshot;
10
- onOpen: (skill: ProjectObservationSnapshot["skills"][number]) => void;
11
+ skills: ProjectSkillMaterial[];
12
+ isLoading: boolean;
13
+ isError: boolean;
14
+ onOpen: (skill: ProjectSkillMaterial) => void;
11
15
  }) {
16
+ if (isLoading) {
17
+ return (
18
+ <p className="py-5 text-sm text-muted-foreground">
19
+ {t("projectsLoading")}
20
+ </p>
21
+ );
22
+ }
23
+ if (isError) {
24
+ return (
25
+ <p className="py-5 text-sm text-destructive">{t("projectsLoadFailed")}</p>
26
+ );
27
+ }
12
28
  return (
13
29
  <section className="min-w-0" aria-label={t("projectsSkills")}>
14
30
  <p className="mb-3 text-sm text-muted-foreground">
15
- {formatNumber(snapshot.skills.length)} {t("projectsSkillsCountSuffix")}
31
+ {formatNumber(skills.length)} {t("projectsSkillsCountSuffix")}
16
32
  </p>
17
- {snapshot.skills.length ? (
33
+ {skills.length ? (
18
34
  <div className="grid min-w-0 gap-3 md:grid-cols-2">
19
- {snapshot.skills.map((skill) => (
35
+ {skills.map((skill) => (
20
36
  <button
21
37
  type="button"
22
38
  key={skill.ref}
23
- className="min-w-0 rounded-xl border border-border/60 p-3 text-left transition-colors enabled:hover:border-border enabled:hover:bg-muted/30 disabled:cursor-not-allowed"
24
- disabled={!skill.readable}
39
+ className="min-w-0 rounded-xl border border-border/60 p-3 text-left transition-colors hover:border-border hover:bg-muted/30 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border"
25
40
  onClick={() => onOpen(skill)}
26
41
  >
27
42
  <h3 className="text-sm font-medium">{skill.name}</h3>
@@ -37,11 +52,6 @@ export function ProjectSkills({
37
52
  >
38
53
  {skill.description ?? skill.ref}
39
54
  </p>
40
- {!skill.readable ? (
41
- <p className="mt-2 text-xs text-destructive">
42
- {t("projectsUnavailable")}
43
- </p>
44
- ) : null}
45
55
  </button>
46
56
  ))}
47
57
  </div>
@@ -8,8 +8,9 @@ const mocks = vi.hoisted(() => ({
8
8
  }));
9
9
 
10
10
  vi.mock("@/features/projects/hooks/use-project-work", () => ({
11
- useProjectWork: () => ({
12
- data: { states: [{ id: "planned", name: "Planned" }] },
11
+ useProjectWorkEvents: vi.fn(),
12
+ useProjectWorkStates: () => ({
13
+ data: [{ id: "planned", name: "Planned" }],
13
14
  }),
14
15
  useProjectWorkItem: mocks.useProjectWorkItem,
15
16
  useProjectWorkActivity: () => ({ data: { activities: [] } }),
@@ -129,7 +129,17 @@ describe("ProjectWorkItems", () => {
129
129
 
130
130
  const group = screen.getByRole("button", { name: /Planned.*21/ });
131
131
  const count = screen.getByText("21");
132
- expect(count.className).toContain("rounded-full");
132
+ const groupSection = group.closest("section");
133
+ const workItem = screen.getByRole("button", {
134
+ name: /Clickable work item/,
135
+ });
136
+ expect(groupSection?.className).not.toContain("border");
137
+ expect(groupSection?.className).not.toContain("rounded-xl");
138
+ expect(groupSection?.className).not.toContain("bg-muted");
139
+ expect(workItem.className).toContain("border");
140
+ expect(workItem.className).toContain("rounded-xl");
141
+ expect(count.className).not.toContain("rounded-full");
142
+ expect(count.className).toContain("text-muted-foreground");
133
143
  expect(count.className).toContain("tabular-nums");
134
144
  fireEvent.click(group);
135
145
  expect(group.textContent).toContain("21");
@@ -162,6 +172,8 @@ describe("ProjectWorkItems", () => {
162
172
 
163
173
  const group = screen.getByRole("region", { name: /Planned|计划中/ });
164
174
  expect(group.className).toContain("h-full");
175
+ expect(group.className).not.toContain("rounded-xl");
176
+ expect(group.className).not.toContain("bg-muted");
165
177
  const cards = screen.getByRole("button", {
166
178
  name: /Clickable work item/,
167
179
  }).parentElement;
@@ -227,14 +227,14 @@ function ProjectWorkStateGroup({
227
227
  <section
228
228
  className={
229
229
  view === "board"
230
- ? "flex h-full min-h-0 min-w-0 flex-col overflow-hidden rounded-xl bg-muted/25"
231
- : "min-w-0 overflow-hidden rounded-xl border border-border/60"
230
+ ? "flex h-full min-h-0 min-w-0 flex-col overflow-hidden"
231
+ : "min-w-0"
232
232
  }
233
233
  aria-label={getProjectWorkStateLabel(state.name)}
234
234
  >
235
235
  <button
236
236
  type="button"
237
- className="flex w-full shrink-0 items-center gap-2 bg-muted/30 px-3 py-2.5 text-left transition-colors hover:bg-[var(--interaction-hover)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border"
237
+ className="flex w-full shrink-0 items-center gap-2 rounded-lg px-1 py-2 text-left transition-colors hover:bg-[var(--interaction-hover)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border"
238
238
  aria-expanded={!collapsed}
239
239
  onClick={() => setCollapsed((value) => !value)}
240
240
  >
@@ -247,7 +247,7 @@ function ProjectWorkStateGroup({
247
247
  <span className="min-w-0 truncate text-sm font-semibold">
248
248
  {getProjectWorkStateLabel(state.name)}
249
249
  </span>
250
- <span className="inline-flex min-w-6 shrink-0 items-center justify-center rounded-full bg-background/85 px-1.5 py-0.5 text-xs font-semibold tabular-nums text-foreground ring-1 ring-border/60">
250
+ <span className="ml-0.5 shrink-0 text-xs font-medium tabular-nums text-muted-foreground">
251
251
  {total ?? "…"}
252
252
  </span>
253
253
  </button>
@@ -255,8 +255,8 @@ function ProjectWorkStateGroup({
255
255
  <div
256
256
  className={
257
257
  view === "board"
258
- ? "min-h-0 flex-1 space-y-2 overflow-y-auto overscroll-contain p-3"
259
- : "space-y-2 p-3"
258
+ ? "min-h-0 flex-1 space-y-2 overflow-y-auto overscroll-contain px-1 pb-3"
259
+ : "space-y-2 px-1 pb-3"
260
260
  }
261
261
  >
262
262
  <ProjectWorkStateGroupBody
@@ -389,7 +389,7 @@ export function ProjectWorkItems({
389
389
  {t("projectsWorkShowDeleted")}
390
390
  </label>
391
391
  {view === "list" ? (
392
- <div className="space-y-2">
392
+ <div className="space-y-3">
393
393
  {listStates.map((state) => (
394
394
  <ProjectWorkStateGroup
395
395
  key={state.id}
@@ -0,0 +1,65 @@
1
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2
+ import { renderHook, waitFor } from "@testing-library/react";
3
+ import type { PropsWithChildren } from "react";
4
+ import { beforeEach, describe, expect, it, vi } from "vitest";
5
+ import {
6
+ useProjectAgreement,
7
+ useProjectSkills,
8
+ } from "@/features/projects/hooks/use-project-materials";
9
+ import { nextclawClient } from "@/shared/lib/api";
10
+
11
+ function createWrapper() {
12
+ const client = new QueryClient({
13
+ defaultOptions: { queries: { retry: false } },
14
+ });
15
+ return function Wrapper({ children }: PropsWithChildren) {
16
+ return (
17
+ <QueryClientProvider client={client}>{children}</QueryClientProvider>
18
+ );
19
+ };
20
+ }
21
+
22
+ describe("project material queries", () => {
23
+ beforeEach(() => vi.restoreAllMocks());
24
+
25
+ it("loads agreement and skills independently", async () => {
26
+ const getAgreement = vi
27
+ .spyOn(nextclawClient.projects, "getAgreement")
28
+ .mockResolvedValue({ path: "AGENTS.md", available: true });
29
+ const listProjectSkills = vi
30
+ .spyOn(nextclawClient.projects, "listProjectSkills")
31
+ .mockResolvedValue([
32
+ {
33
+ ref: "project:alpha",
34
+ name: "alpha",
35
+ path: ".agents/skills/alpha/SKILL.md",
36
+ },
37
+ ]);
38
+
39
+ const agreement = renderHook(() => useProjectAgreement("project-1"), {
40
+ wrapper: createWrapper(),
41
+ });
42
+ const skills = renderHook(() => useProjectSkills("project-1"), {
43
+ wrapper: createWrapper(),
44
+ });
45
+
46
+ await waitFor(() => expect(agreement.result.current.isSuccess).toBe(true));
47
+ await waitFor(() => expect(skills.result.current.isSuccess).toBe(true));
48
+ expect(getAgreement).toHaveBeenCalledWith("project-1");
49
+ expect(listProjectSkills).toHaveBeenCalledWith("project-1");
50
+ });
51
+
52
+ it("does not query without a selected project", () => {
53
+ const getAgreement = vi.spyOn(nextclawClient.projects, "getAgreement");
54
+ const listProjectSkills = vi.spyOn(
55
+ nextclawClient.projects,
56
+ "listProjectSkills",
57
+ );
58
+
59
+ renderHook(() => useProjectAgreement(null), { wrapper: createWrapper() });
60
+ renderHook(() => useProjectSkills(null), { wrapper: createWrapper() });
61
+
62
+ expect(getAgreement).not.toHaveBeenCalled();
63
+ expect(listProjectSkills).not.toHaveBeenCalled();
64
+ });
65
+ });
@@ -0,0 +1,24 @@
1
+ import { useQuery } from "@tanstack/react-query";
2
+ import { nextclawClient } from "@/shared/lib/api";
3
+
4
+ export const projectAgreementQueryKey = (projectId: string) =>
5
+ ["project-agreement", projectId] as const;
6
+ export const projectSkillsQueryKey = (projectId: string) =>
7
+ ["project-skills", projectId] as const;
8
+
9
+ export function useProjectAgreement(projectId: string | null) {
10
+ return useQuery({
11
+ queryKey: projectAgreementQueryKey(projectId ?? ""),
12
+ enabled: Boolean(projectId),
13
+ queryFn: async () => await nextclawClient.projects.getAgreement(projectId!),
14
+ });
15
+ }
16
+
17
+ export function useProjectSkills(projectId: string | null) {
18
+ return useQuery({
19
+ queryKey: projectSkillsQueryKey(projectId ?? ""),
20
+ enabled: Boolean(projectId),
21
+ queryFn: async () =>
22
+ await nextclawClient.projects.listProjectSkills(projectId!),
23
+ });
24
+ }
@@ -6,6 +6,7 @@ import type { ProjectWorkState } from "@nextclaw/client-sdk";
6
6
  import {
7
7
  sortProjectWorkStates,
8
8
  sortProjectWorkStatesForList,
9
+ useProjectArtifacts,
9
10
  useProjectWork,
10
11
  useProjectWorkEvents,
11
12
  } from "./use-project-work";
@@ -15,11 +16,15 @@ const mocks = vi.hoisted(() => ({
15
16
  | null
16
17
  | ((event: { projectId: string; workItemId?: string }) => void),
17
18
  listWork: vi.fn(),
19
+ listRecentWorkArtifacts: vi.fn(),
18
20
  }));
19
21
 
20
22
  vi.mock("@/shared/lib/api", () => ({
21
23
  nextclawClient: {
22
- projects: { listWork: mocks.listWork },
24
+ projects: {
25
+ listWork: mocks.listWork,
26
+ listRecentWorkArtifacts: mocks.listRecentWorkArtifacts,
27
+ },
23
28
  eventBus: {
24
29
  on: (
25
30
  _key: unknown,
@@ -38,6 +43,12 @@ describe("useProjectWork", () => {
38
43
  beforeEach(() => {
39
44
  mocks.listWork.mockReset();
40
45
  mocks.listWork.mockResolvedValue({ items: [], nextCursor: null, total: 0 });
46
+ mocks.listRecentWorkArtifacts.mockReset();
47
+ mocks.listRecentWorkArtifacts.mockResolvedValue({
48
+ artifacts: [],
49
+ nextCursor: null,
50
+ total: 0,
51
+ });
41
52
  });
42
53
 
43
54
  it("refreshes the project query from committed work events only for the matching project", async () => {
@@ -105,6 +116,51 @@ describe("useProjectWork", () => {
105
116
  });
106
117
  await waitFor(() => expect(result.current.data?.pages).toHaveLength(2));
107
118
  });
119
+
120
+ it("loads filtered artifacts through the paginated project-work owner", async () => {
121
+ mocks.listRecentWorkArtifacts
122
+ .mockResolvedValueOnce({
123
+ artifacts: [{ id: "one", path: "docs/report.md" }],
124
+ nextCursor: "artifact-next",
125
+ total: 2,
126
+ })
127
+ .mockResolvedValueOnce({
128
+ artifacts: [{ id: "two", path: "docs/report-final.md" }],
129
+ nextCursor: null,
130
+ total: 2,
131
+ });
132
+ const client = new QueryClient({
133
+ defaultOptions: { queries: { retry: false } },
134
+ });
135
+ const wrapper = ({ children }: { children: ReactNode }) => (
136
+ <QueryClientProvider client={client}>{children}</QueryClientProvider>
137
+ );
138
+ const { result } = renderHook(
139
+ () => useProjectArtifacts("project-1", { limit: 20, query: "report" }),
140
+ { wrapper },
141
+ );
142
+ await waitFor(() => expect(result.current.hasNextPage).toBe(true));
143
+
144
+ await act(async () => await result.current.fetchNextPage());
145
+
146
+ expect(mocks.listRecentWorkArtifacts).toHaveBeenNthCalledWith(
147
+ 1,
148
+ "project-1",
149
+ {
150
+ limit: 20,
151
+ query: "report",
152
+ },
153
+ );
154
+ expect(mocks.listRecentWorkArtifacts).toHaveBeenNthCalledWith(
155
+ 2,
156
+ "project-1",
157
+ {
158
+ limit: 20,
159
+ query: "report",
160
+ cursor: "artifact-next",
161
+ },
162
+ );
163
+ });
108
164
  });
109
165
 
110
166
  describe("project work state ordering", () => {
@@ -68,14 +68,20 @@ export function useProjectWorkStates(projectId: string | null) {
68
68
  });
69
69
  }
70
70
 
71
- export function useProjectRecentArtifacts(projectId: string | null, limit = 5) {
72
- return useQuery({
73
- queryKey: [...projectWorkArtifactsQueryKey(projectId ?? ""), limit],
71
+ export function useProjectArtifacts(
72
+ projectId: string | null,
73
+ input: { limit?: number; query?: string } = {},
74
+ ) {
75
+ return useInfiniteQuery({
76
+ queryKey: [...projectWorkArtifactsQueryKey(projectId ?? ""), input],
74
77
  enabled: Boolean(projectId),
75
- queryFn: async () =>
78
+ initialPageParam: null as string | null,
79
+ queryFn: async ({ pageParam }) =>
76
80
  await nextclawClient.projects.listRecentWorkArtifacts(projectId!, {
77
- limit,
81
+ ...input,
82
+ ...(pageParam ? { cursor: pageParam } : {}),
78
83
  }),
84
+ getNextPageParam: (lastPage) => lastPage.nextCursor,
79
85
  });
80
86
  }
81
87
 
@@ -1,13 +1,8 @@
1
1
  import { useState } from "react";
2
2
  import { useNavigate, useParams } from "react-router-dom";
3
- import { useMutation, useQueryClient } from "@tanstack/react-query";
4
- import type { ObservedRequest } from "@nextclaw/client-sdk";
5
- import { toast } from "sonner";
6
- import { useProjects, useRemoveProject } from "@/shared/hooks/use-projects";
3
+ import { useProjects } from "@/shared/hooks/use-projects";
7
4
  import { useViewportLayout } from "@/app/hooks/use-viewport-layout";
8
5
  import { t } from "@/shared/lib/i18n";
9
- import { Button } from "@/shared/components/ui/button";
10
- import { useConfirmDialog } from "@/shared/hooks/use-confirm-dialog";
11
6
  import {
12
7
  Tabs,
13
8
  TabsContent,
@@ -17,7 +12,6 @@ import {
17
12
  import { ProjectAgreement } from "@/features/projects/components/project-agreement";
18
13
  import { ProjectArtifacts } from "@/features/projects/components/project-artifacts";
19
14
  import { ProjectOverview } from "@/features/projects/components/project-overview";
20
- import { ProjectRequests } from "@/features/projects/components/project-requests";
21
15
  import { ProjectSkills } from "@/features/projects/components/project-skills";
22
16
  import { ProjectWorkItemDrawer } from "@/features/projects/components/work/project-work-item-drawer";
23
17
  import { ProjectWorkItems } from "@/features/projects/components/work/project-work-items";
@@ -26,18 +20,13 @@ import {
26
20
  usePresenter,
27
21
  } from "@/features/chat";
28
22
  import {
29
- projectObservationQueryKey,
30
- useProjectObservation,
31
- } from "@/features/projects/hooks/use-project-observation";
23
+ useProjectAgreement,
24
+ useProjectSkills,
25
+ } from "@/features/projects/hooks/use-project-materials";
32
26
  import {
33
- getOpenProjectRequests,
34
27
  isProjectHomeTab,
35
28
  type ProjectHomeTab,
36
29
  } from "@/features/projects/presenters/project-home.presenter";
37
- import {
38
- sendProjectRequestResponse,
39
- type ProjectRequestDecision,
40
- } from "@/features/projects/utils/project-request-response.utils";
41
30
  import { joinProjectPath } from "@/features/projects/utils/project-artifact-view.utils";
42
31
 
43
32
  const PROJECT_TABS: ProjectHomeTab[] = [
@@ -55,73 +44,23 @@ export function ProjectsPage() {
55
44
  }>();
56
45
  const navigate = useNavigate();
57
46
  const presenter = usePresenter();
58
- const queryClient = useQueryClient();
59
47
  const { isMobile } = useViewportLayout();
60
48
  const projects = useProjects();
61
- const projectRemoval = useRemoveProject();
62
- const { confirm, ConfirmDialog } = useConfirmDialog();
63
49
  const [selectedWorkItemId, setSelectedWorkItemId] = useState<string | null>(
64
50
  null,
65
51
  );
66
- const [pendingRequestId, setPendingRequestId] = useState<string | null>(null);
67
52
  const registered = projects.data?.projects ?? [];
68
53
  const selectedProject =
69
54
  registered.find((project) => project.id === projectId) ?? null;
70
55
  const tab: ProjectHomeTab | null = isProjectHomeTab(tabParam)
71
56
  ? tabParam
72
57
  : null;
73
- const needsObservation =
74
- tab === "artifacts" || tab === "skills" || tab === "agreement";
75
- const observation = useProjectObservation(
76
- selectedProject?.id ?? null,
77
- selectedProject?.rootPath ?? null,
78
- needsObservation,
58
+ const agreement = useProjectAgreement(
59
+ tab === "agreement" ? (selectedProject?.id ?? null) : null,
60
+ );
61
+ const skills = useProjectSkills(
62
+ tab === "skills" ? (selectedProject?.id ?? null) : null,
79
63
  );
80
- const response = useMutation({
81
- mutationFn: sendProjectRequestResponse,
82
- onMutate: (input) => setPendingRequestId(input.requestId),
83
- onSuccess: async () => {
84
- if (selectedProject)
85
- await queryClient.invalidateQueries({
86
- queryKey: projectObservationQueryKey(selectedProject.id),
87
- });
88
- toast.success(t("projectsResponseSent"));
89
- },
90
- onError: (error) =>
91
- toast.error(
92
- `${t("projectsResponseFailed")}: ${error instanceof Error ? error.message : String(error)}`,
93
- ),
94
- onSettled: () => setPendingRequestId(null),
95
- });
96
- const reply = (
97
- request: ObservedRequest,
98
- decision: ProjectRequestDecision,
99
- ) => {
100
- if (!request.reference.sessionId) return;
101
- response.mutate({
102
- requestId: request.id,
103
- sessionId: request.reference.sessionId,
104
- decision,
105
- prompt: request.prompt,
106
- });
107
- };
108
- const removeSelectedProject = async () => {
109
- if (!selectedProject) return;
110
- const confirmed = await confirm({
111
- title: `${t("projectsRemoveConfirmTitle")} “${selectedProject.name}”`,
112
- description: t("projectsRemoveConfirmDescription"),
113
- confirmLabel: t("projectsRemoveConfirmAction"),
114
- variant: "destructive",
115
- });
116
- if (!confirmed) return;
117
- try {
118
- await projectRemoval.mutateAsync(selectedProject.id);
119
- navigate("/projects");
120
- } catch {
121
- // The mutation owns the visible error and the current project stays open.
122
- }
123
- };
124
-
125
64
  if (projects.isLoading)
126
65
  return (
127
66
  <main className="flex h-full items-center justify-center p-6 text-sm text-muted-foreground">
@@ -154,35 +93,16 @@ export function ProjectsPage() {
154
93
  viewMode: "preview",
155
94
  previewViewer: "rendered",
156
95
  });
157
- const snapshot = observation.data;
158
96
  return (
159
97
  <>
160
98
  <main className="h-full min-w-0 flex-1 overflow-y-auto p-3 sm:p-4 md:p-6">
161
99
  <div className="mx-auto max-w-6xl space-y-4">
162
- <header className="flex flex-col gap-3 rounded-2xl border border-border/60 bg-card p-3 sm:flex-row sm:items-start sm:justify-between sm:p-4">
163
- <div className="min-w-0">
164
- <h1 className="text-lg font-semibold">{selectedProject.name}</h1>
165
- <p className="mt-1 break-all text-xs text-muted-foreground">
166
- {selectedProject.rootPath}
167
- </p>
168
- </div>
169
- <Button
170
- type="button"
171
- variant="destructive"
172
- size="sm"
173
- disabled={projectRemoval.isPending}
174
- onClick={() => void removeSelectedProject()}
175
- >
176
- {t("projectsRemoveFromList")}
177
- </Button>
100
+ <header className="rounded-2xl border border-border/60 bg-card p-3 sm:p-4">
101
+ <h1 className="text-lg font-semibold">{selectedProject.name}</h1>
102
+ <p className="mt-1 break-all text-xs text-muted-foreground">
103
+ {selectedProject.rootPath}
104
+ </p>
178
105
  </header>
179
- {snapshot ? (
180
- <ProjectRequests
181
- requests={getOpenProjectRequests(snapshot)}
182
- pendingRequestId={pendingRequestId}
183
- onReply={reply}
184
- />
185
- ) : null}
186
106
  <Tabs
187
107
  value={tab}
188
108
  onValueChange={(value) =>
@@ -218,38 +138,43 @@ export function ProjectsPage() {
218
138
  onOpenWorkItem={setSelectedWorkItemId}
219
139
  />
220
140
  </TabsContent>
221
- {needsObservation && observation.isLoading ? (
222
- <div className="mt-4 rounded-xl border border-border/60 p-5 text-sm text-muted-foreground">
223
- {t("projectsLoading")}
224
- </div>
225
- ) : null}
226
- {needsObservation && observation.isError ? (
227
- <div className="mt-4 rounded-xl border border-destructive/40 p-5 text-sm text-destructive">
228
- {t("projectsLoadFailed")}: {observation.error.message}
229
- </div>
230
- ) : null}
231
- {snapshot ? (
232
- <>
233
- <TabsContent value="artifacts" className="mt-4">
234
- <ProjectArtifacts
235
- snapshot={snapshot}
236
- onOpenFile={openProjectFile}
237
- />
238
- </TabsContent>
239
- <TabsContent value="skills" className="mt-4">
240
- <ProjectSkills
241
- snapshot={snapshot}
242
- onOpen={(skill) => openProjectFile(skill.path, skill.name)}
243
- />
244
- </TabsContent>
245
- <TabsContent value="agreement" className="mt-4">
246
- <ProjectAgreement
247
- snapshot={snapshot}
248
- onOpenFile={openProjectFile}
249
- />
250
- </TabsContent>
251
- </>
252
- ) : null}
141
+ <TabsContent value="artifacts" className="mt-4">
142
+ <ProjectArtifacts
143
+ projectId={selectedProject.id}
144
+ onOpenFile={(path, label) =>
145
+ openProjectFile(
146
+ joinProjectPath(selectedProject.rootPath, path),
147
+ label,
148
+ )
149
+ }
150
+ />
151
+ </TabsContent>
152
+ <TabsContent value="skills" className="mt-4">
153
+ <ProjectSkills
154
+ skills={skills.data ?? []}
155
+ isLoading={skills.isLoading}
156
+ isError={skills.isError}
157
+ onOpen={(skill) =>
158
+ openProjectFile(
159
+ joinProjectPath(selectedProject.rootPath, skill.path),
160
+ skill.name,
161
+ )
162
+ }
163
+ />
164
+ </TabsContent>
165
+ <TabsContent value="agreement" className="mt-4">
166
+ <ProjectAgreement
167
+ agreement={agreement.data}
168
+ isLoading={agreement.isLoading}
169
+ isError={agreement.isError}
170
+ onOpenFile={(path, label) =>
171
+ openProjectFile(
172
+ joinProjectPath(selectedProject.rootPath, path),
173
+ label,
174
+ )
175
+ }
176
+ />
177
+ </TabsContent>
253
178
  </Tabs>
254
179
  </div>
255
180
  </main>
@@ -267,7 +192,6 @@ export function ProjectsPage() {
267
192
  sessionKey={null}
268
193
  projectRoot={selectedProject.rootPath}
269
194
  />
270
- <ConfirmDialog />
271
195
  </>
272
196
  );
273
197
  }