@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,77 +1,105 @@
1
1
  import { fireEvent, render, screen } from "@testing-library/react";
2
2
  import { describe, expect, it, vi } from "vitest";
3
- import type { ProjectObservationSnapshot } from "@nextclaw/client-sdk";
4
3
  import { ProjectArtifacts } from "./project-artifacts";
5
4
 
6
- const observedAt = "2026-08-30T00:00:00.000Z";
7
- const designs = Array.from({ length: 11 }, (_, index) => ({
8
- id: `design-${index + 1}`,
9
- path: `docs/designs/${index + 1}.md`,
10
- categoryId: "designs",
11
- categoryLabel: "Designs",
12
- exists: true,
13
- fileUpdatedAt: `2026-08-${String(index + 1).padStart(2, "0")}T00:00:00.000Z`,
14
- references: [],
15
- }));
5
+ const useProjectArtifacts = vi.fn();
16
6
 
17
- const snapshot: ProjectObservationSnapshot = {
18
- asOf: observedAt,
19
- project: { name: "Research", rootPath: "/tmp/research", context: [] },
20
- sources: [],
21
- workflows: [],
22
- runs: [],
23
- workItems: [],
24
- artifactCategories: [
25
- { id: "plans", label: "Plans" },
26
- { id: "designs", label: "Designs" },
27
- ],
28
- artifacts: [
29
- ...designs,
30
- {
31
- id: "plan-1",
32
- path: "docs/plans/delivery.md",
33
- categoryId: "plans",
34
- categoryLabel: "Plans",
35
- exists: true,
36
- fileUpdatedAt: "2026-08-30T00:00:00.000Z",
37
- references: [],
38
- },
39
- ],
40
- signals: [],
41
- requests: [],
42
- activity: [],
43
- skills: [],
44
- diagnostics: [],
45
- dataQuality: "complete",
46
- };
7
+ vi.mock(
8
+ "@/features/projects/hooks/use-project-work",
9
+ async (importOriginal) => ({
10
+ ...(await importOriginal()),
11
+ useProjectArtifacts: (...args: unknown[]) => useProjectArtifacts(...args),
12
+ }),
13
+ );
47
14
 
48
15
  describe("ProjectArtifacts", () => {
49
- it("groups in configuration order, expands incrementally, filters, and opens the selected file", () => {
16
+ it("loads linked artifacts, searches through the owner, and opens available files", () => {
17
+ useProjectArtifacts.mockReturnValue({
18
+ data: {
19
+ pages: [
20
+ {
21
+ artifacts: [
22
+ {
23
+ id: "artifact-1",
24
+ path: "docs/design.md",
25
+ label: "Design",
26
+ workItemTitle: "Current work",
27
+ createdAt: "2026-09-03T00:00:00.000Z",
28
+ exists: true,
29
+ },
30
+ ],
31
+ total: 1,
32
+ nextCursor: null,
33
+ },
34
+ ],
35
+ },
36
+ isLoading: false,
37
+ isError: false,
38
+ hasNextPage: false,
39
+ });
50
40
  const onOpenFile = vi.fn();
51
- render(<ProjectArtifacts snapshot={snapshot} onOpenFile={onOpenFile} />);
41
+ render(<ProjectArtifacts projectId="project-1" onOpenFile={onOpenFile} />);
42
+
43
+ fireEvent.click(screen.getByRole("button", { name: /Design/ }));
44
+ expect(onOpenFile).toHaveBeenCalledWith("docs/design.md", "Design");
45
+
46
+ fireEvent.change(screen.getByPlaceholderText(/Search artifacts|搜索产物/), {
47
+ target: { value: "report" },
48
+ });
49
+ expect(useProjectArtifacts).toHaveBeenLastCalledWith("project-1", {
50
+ limit: 20,
51
+ query: "report",
52
+ });
53
+ });
52
54
 
53
- const groupHeaders = screen.getAllByRole("button", { name: /Plans 1|Designs 11/ });
54
- expect(groupHeaders.map((header) => header.getAttribute("aria-label"))).toEqual([
55
- "Plans 1",
56
- "Designs 11",
57
- ]);
58
- expect(screen.getByTitle("docs/designs/11.md")).toBeTruthy();
59
- expect(screen.queryByTitle("docs/designs/1.md")).toBeNull();
55
+ it("explains that artifacts come from work-item links", () => {
56
+ useProjectArtifacts.mockReturnValue({
57
+ data: { pages: [{ artifacts: [], total: 0, nextCursor: null }] },
58
+ isLoading: false,
59
+ isError: false,
60
+ hasNextPage: false,
61
+ });
60
62
 
61
- fireEvent.click(screen.getByRole("button", { name: /Show more \(1\)/ }));
62
- expect(screen.getByTitle("docs/designs/1.md")).toBeTruthy();
63
+ render(<ProjectArtifacts projectId="project-1" onOpenFile={vi.fn()} />);
63
64
 
64
- fireEvent.change(screen.getByPlaceholderText("Search artifacts"), {
65
- target: { value: "delivery" },
65
+ expect(
66
+ screen.getByText(/Link a file from a work item|从工作项关联文件/),
67
+ ).toBeTruthy();
68
+ });
69
+
70
+ it("keeps missing links visible and loads the next page explicitly", () => {
71
+ const fetchNextPage = vi.fn();
72
+ useProjectArtifacts.mockReturnValue({
73
+ data: {
74
+ pages: [
75
+ {
76
+ artifacts: [
77
+ {
78
+ id: "artifact-missing",
79
+ path: "docs/missing.md",
80
+ label: null,
81
+ workItemTitle: "Archived work",
82
+ createdAt: "2026-09-03T00:00:00.000Z",
83
+ exists: false,
84
+ },
85
+ ],
86
+ total: 21,
87
+ nextCursor: "next",
88
+ },
89
+ ],
90
+ },
91
+ isLoading: false,
92
+ isError: false,
93
+ hasNextPage: true,
94
+ isFetchingNextPage: false,
95
+ fetchNextPage,
66
96
  });
67
- expect(screen.queryByRole("button", { name: /Designs 11/ })).toBeNull();
68
- expect(screen.getByTitle("docs/plans/delivery.md")).toBeTruthy();
69
- fireEvent.click(screen.getByTitle("docs/plans/delivery.md"));
70
- expect(onOpenFile).toHaveBeenCalledWith("docs/plans/delivery.md", "docs/plans/delivery.md");
71
97
 
72
- const plansHeader = screen.getByRole("button", { name: /Plans 1/ });
73
- fireEvent.click(plansHeader);
74
- expect(plansHeader.getAttribute("aria-expanded")).toBe("false");
75
- expect(screen.queryByTitle("docs/plans/delivery.md")).toBeNull();
98
+ render(<ProjectArtifacts projectId="project-1" onOpenFile={vi.fn()} />);
99
+
100
+ expect(screen.getByText(/Currently unavailable|暂不可用/)).toBeTruthy();
101
+ expect(screen.queryByRole("button", { name: /missing\.md/ })).toBeNull();
102
+ fireEvent.click(screen.getByRole("button", { name: /Show more|显示更多/ }));
103
+ expect(fetchNextPage).toHaveBeenCalledTimes(1);
76
104
  });
77
105
  });
@@ -1,187 +1,108 @@
1
- import { useMemo, useState } from "react";
2
- import type { ProjectObservationSnapshot } from "@nextclaw/client-sdk";
1
+ import { useState } from "react";
2
+ import { useProjectArtifacts } from "@/features/projects/hooks/use-project-work";
3
+ import { formatProjectRelativeTime } from "@/features/projects/utils/project-artifact-view.utils";
3
4
  import { formatDateTime, formatNumber, t } from "@/shared/lib/i18n";
4
- import {
5
- Select,
6
- SelectContent,
7
- SelectItem,
8
- SelectTrigger,
9
- SelectValue,
10
- } from "@/shared/components/ui/select";
11
5
  import { ProjectEmptyState, ProjectFilePreviewButton } from "./project-section";
12
- import {
13
- DEFAULT_PROJECT_ARTIFACT_SORT,
14
- formatProjectRelativeTime,
15
- sortProjectArtifacts,
16
- type ProjectArtifactSort,
17
- } from "../utils/project-artifact-view.utils";
18
6
 
19
- const INITIAL_VISIBLE_COUNT = 10;
20
-
21
- type ArtifactGroup = {
22
- id: string;
23
- label: string;
24
- artifacts: ProjectObservationSnapshot["artifacts"];
25
- };
26
-
27
- function createArtifactGroups(
28
- snapshot: ProjectObservationSnapshot,
29
- query: string,
30
- sort: ProjectArtifactSort,
31
- ): ArtifactGroup[] {
32
- const normalizedQuery = query.trim().toLocaleLowerCase();
33
- const filteredArtifacts = normalizedQuery
34
- ? snapshot.artifacts.filter((artifact) => artifact.path.toLocaleLowerCase().includes(normalizedQuery))
35
- : snapshot.artifacts;
36
- const artifacts = sortProjectArtifacts(filteredArtifacts, sort);
37
- const artifactsByCategory = new Map<string, ProjectObservationSnapshot["artifacts"]>();
38
- for (const artifact of artifacts) {
39
- const group = artifactsByCategory.get(artifact.categoryId) ?? [];
40
- group.push(artifact);
41
- artifactsByCategory.set(artifact.categoryId, group);
42
- }
43
- const configuredCategories = snapshot.artifactCategories ?? [];
44
- const configuredIds = new Set(configuredCategories.map((category) => category.id));
45
- const groups = configuredCategories.flatMap((category) => {
46
- const groupArtifacts = artifactsByCategory.get(category.id) ?? [];
47
- return groupArtifacts.length ? [{ ...category, artifacts: groupArtifacts }] : [];
48
- });
49
- const unconfiguredGroups = [...artifactsByCategory.entries()]
50
- .filter(([categoryId]) => !configuredIds.has(categoryId))
51
- .map(([id, groupArtifacts]) => ({
52
- id,
53
- label: groupArtifacts[0]?.categoryLabel ?? id,
54
- artifacts: groupArtifacts,
55
- }))
56
- .sort((left, right) => left.label.localeCompare(right.label));
57
- return [...groups, ...unconfiguredGroups];
58
- }
7
+ const PAGE_SIZE = 20;
59
8
 
60
9
  export function ProjectArtifacts({
61
- snapshot,
10
+ projectId,
62
11
  onOpenFile,
63
12
  }: {
64
- snapshot: ProjectObservationSnapshot;
13
+ projectId: string;
65
14
  onOpenFile: (path: string, label: string) => void;
66
15
  }) {
67
16
  const [query, setQuery] = useState("");
68
- const [sort, setSort] = useState<ProjectArtifactSort>(DEFAULT_PROJECT_ARTIFACT_SORT);
69
- const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(() => new Set());
70
- const [visibleCounts, setVisibleCounts] = useState<Record<string, number>>({});
71
- const groups = useMemo(() => createArtifactGroups(snapshot, query, sort), [query, snapshot, sort]);
72
- const canSortByCreated = snapshot.artifacts.some((artifact) => artifact.fileCreatedAt);
17
+ const artifacts = useProjectArtifacts(projectId, {
18
+ limit: PAGE_SIZE,
19
+ ...(query.trim() ? { query: query.trim() } : {}),
20
+ });
21
+ const pages = artifacts.data?.pages ?? [];
22
+ const items = pages.flatMap((page) => page.artifacts);
23
+ const total = pages[0]?.total ?? 0;
73
24
 
74
- if (!snapshot.artifacts.length) {
75
- return <ProjectEmptyState>{t("projectsNoArtifacts")}</ProjectEmptyState>;
25
+ if (artifacts.isLoading) {
26
+ return (
27
+ <p className="py-5 text-sm text-muted-foreground">
28
+ {t("projectsLoading")}
29
+ </p>
30
+ );
31
+ }
32
+ if (artifacts.isError) {
33
+ return (
34
+ <p className="py-5 text-sm text-destructive">{t("projectsLoadFailed")}</p>
35
+ );
76
36
  }
77
37
 
78
38
  return (
79
39
  <section className="min-w-0" aria-label={t("projectsArtifacts")}>
80
40
  <div className="mb-4 flex flex-wrap items-center justify-between gap-3">
81
41
  <p className="text-sm text-muted-foreground">
82
- {formatNumber(snapshot.artifacts.length)} {t("projectsArtifactsCountSuffix")}
42
+ {formatNumber(total)} {t("projectsArtifactsCountSuffix")}
83
43
  </p>
84
- <div className="flex w-full flex-wrap items-center gap-2 sm:w-auto">
85
- <Select value={sort} onValueChange={(value) => setSort(value as ProjectArtifactSort)}>
86
- <SelectTrigger aria-label={t("projectsArtifactsSort")} className="h-8 w-32 rounded-lg">
87
- <SelectValue />
88
- </SelectTrigger>
89
- <SelectContent>
90
- <SelectItem value="updated-desc">{t("projectsArtifactsSortUpdatedDesc")}</SelectItem>
91
- <SelectItem value="updated-asc">{t("projectsArtifactsSortUpdatedAsc")}</SelectItem>
92
- {canSortByCreated ? (
93
- <SelectItem value="created-desc">{t("projectsArtifactsSortCreatedDesc")}</SelectItem>
94
- ) : null}
95
- <SelectItem value="name">{t("projectsArtifactsSortName")}</SelectItem>
96
- </SelectContent>
97
- </Select>
98
- <input
99
- type="search"
100
- value={query}
101
- className="h-8 min-w-0 flex-1 rounded-lg border border-border/60 bg-transparent px-3 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-border sm:w-60"
102
- placeholder={t("projectsArtifactsSearchPlaceholder")}
103
- onChange={(event) => {
104
- setQuery(event.target.value);
105
- setVisibleCounts({});
106
- }}
107
- />
108
- </div>
44
+ <input
45
+ type="search"
46
+ value={query}
47
+ className="h-8 w-full min-w-0 rounded-lg border border-border/60 bg-transparent px-3 text-sm outline-none placeholder:text-muted-foreground sm:w-60"
48
+ placeholder={t("projectsArtifactsSearchPlaceholder")}
49
+ onChange={(event) => setQuery(event.target.value)}
50
+ />
109
51
  </div>
110
- {groups.length ? (
111
- <div className="space-y-2">
112
- {groups.map((group) => {
113
- const collapsed = collapsedGroups.has(group.id);
114
- const visibleCount = visibleCounts[group.id] ?? INITIAL_VISIBLE_COUNT;
115
- const visibleArtifacts = group.artifacts.slice(0, visibleCount);
116
- return (
117
- <section key={group.id} className="overflow-hidden rounded-xl border border-border/60">
118
- <button
119
- type="button"
120
- className="flex w-full items-center justify-between gap-3 bg-muted/30 px-3 py-2.5 text-left text-sm transition-colors hover:bg-[var(--interaction-hover)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border"
121
- aria-label={`${group.label} ${formatNumber(group.artifacts.length)}`}
122
- aria-expanded={!collapsed}
123
- onClick={() => setCollapsedGroups((current) => {
124
- const next = new Set(current);
125
- if (next.has(group.id)) next.delete(group.id);
126
- else next.add(group.id);
127
- return next;
128
- })}
52
+ {items.length ? (
53
+ <div className="overflow-hidden rounded-xl border border-border/60">
54
+ <div className="divide-y divide-border/60">
55
+ {items.map((artifact) => (
56
+ <ProjectFilePreviewButton
57
+ key={artifact.id}
58
+ available={artifact.exists}
59
+ path={artifact.path}
60
+ label={artifact.label ?? artifact.path}
61
+ onOpen={onOpenFile}
62
+ className="flex w-full min-w-0 items-center gap-3 px-3 py-3 text-left text-sm transition-colors enabled:hover:bg-[var(--interaction-hover)] enabled:focus-visible:outline-none enabled:focus-visible:ring-1 enabled:focus-visible:ring-border"
63
+ >
64
+ <span className="min-w-0 flex-1">
65
+ <span className="block truncate font-medium">
66
+ {artifact.label ?? artifact.path}
67
+ </span>
68
+ <span className="mt-1 block truncate text-xs text-muted-foreground">
69
+ {artifact.path} · {artifact.workItemTitle}
70
+ </span>
71
+ </span>
72
+ <time
73
+ className="shrink-0 text-xs text-muted-foreground"
74
+ dateTime={artifact.createdAt}
75
+ title={formatDateTime(artifact.createdAt)}
129
76
  >
130
- <span className="min-w-0 truncate font-medium">{group.label}</span>
131
- <span className="shrink-0 text-xs text-muted-foreground">
132
- {formatNumber(group.artifacts.length)} {collapsed ? "▸" : "▾"}
77
+ {formatProjectRelativeTime(artifact.createdAt)}
78
+ </time>
79
+ {!artifact.exists ? (
80
+ <span className="shrink-0 text-xs text-destructive">
81
+ {t("projectsUnavailable")}
133
82
  </span>
134
- </button>
135
- {!collapsed ? (
136
- <div className="divide-y divide-border/60">
137
- {visibleArtifacts.map((artifact) => (
138
- <ProjectFilePreviewButton
139
- key={artifact.id}
140
- available={artifact.exists}
141
- path={artifact.path}
142
- label={artifact.path}
143
- onOpen={onOpenFile}
144
- className="flex w-full min-w-0 items-center gap-3 px-3 py-2.5 text-left text-sm transition-colors enabled:hover:bg-[var(--interaction-hover)] enabled:focus-visible:outline-none enabled:focus-visible:ring-1 enabled:focus-visible:ring-border"
145
- >
146
- <span className="min-w-0 flex-1 truncate">{artifact.path}</span>
147
- {artifact.fileUpdatedAt ? (
148
- <time
149
- className="shrink-0 text-xs text-muted-foreground"
150
- dateTime={artifact.fileUpdatedAt}
151
- title={formatDateTime(artifact.fileUpdatedAt)}
152
- >
153
- {formatProjectRelativeTime(artifact.fileUpdatedAt)}
154
- </time>
155
- ) : null}
156
- {!artifact.exists ? (
157
- <span className="shrink-0 text-xs text-destructive">
158
- {t("projectsUnavailable")}
159
- </span>
160
- ) : null}
161
- </ProjectFilePreviewButton>
162
- ))}
163
- {group.artifacts.length > visibleArtifacts.length ? (
164
- <button
165
- type="button"
166
- className="w-full px-3 py-2.5 text-left text-sm text-muted-foreground transition-colors hover:bg-[var(--interaction-hover)] hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border"
167
- onClick={() => setVisibleCounts((current) => ({
168
- ...current,
169
- [group.id]: visibleCount + INITIAL_VISIBLE_COUNT,
170
- }))}
171
- >
172
- {t("projectsArtifactsShowMore")} ({formatNumber(
173
- Math.min(INITIAL_VISIBLE_COUNT, group.artifacts.length - visibleArtifacts.length),
174
- )})
175
- </button>
176
- ) : null}
177
- </div>
178
83
  ) : null}
179
- </section>
180
- );
181
- })}
84
+ </ProjectFilePreviewButton>
85
+ ))}
86
+ </div>
87
+ {artifacts.hasNextPage ? (
88
+ <button
89
+ type="button"
90
+ className="w-full border-t border-border/60 px-3 py-2.5 text-left text-sm text-muted-foreground transition-colors hover:bg-[var(--interaction-hover)] hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border"
91
+ disabled={artifacts.isFetchingNextPage}
92
+ onClick={() => void artifacts.fetchNextPage()}
93
+ >
94
+ {artifacts.isFetchingNextPage
95
+ ? t("projectsLoading")
96
+ : t("projectsArtifactsShowMore")}
97
+ </button>
98
+ ) : null}
182
99
  </div>
183
100
  ) : (
184
- <ProjectEmptyState>{t("projectsArtifactsNoMatches")}</ProjectEmptyState>
101
+ <ProjectEmptyState>
102
+ {query.trim()
103
+ ? t("projectsArtifactsNoMatches")
104
+ : t("projectsNoArtifacts")}
105
+ </ProjectEmptyState>
185
106
  )}
186
107
  </section>
187
108
  );
@@ -0,0 +1,80 @@
1
+ import { fireEvent, render, screen } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { ProjectAgreement } from "./project-agreement";
4
+ import { ProjectSkills } from "./project-skills";
5
+
6
+ describe("project materials", () => {
7
+ it("opens the root agreement and renders a missing-file empty state", () => {
8
+ const onOpenFile = vi.fn();
9
+ const { rerender } = render(
10
+ <ProjectAgreement
11
+ agreement={{ path: "AGENTS.md", available: true }}
12
+ isLoading={false}
13
+ isError={false}
14
+ onOpenFile={onOpenFile}
15
+ />,
16
+ );
17
+
18
+ fireEvent.click(screen.getByRole("button", { name: /AGENTS\.md/ }));
19
+ expect(onOpenFile).toHaveBeenCalledWith("AGENTS.md", "AGENTS.md");
20
+
21
+ rerender(
22
+ <ProjectAgreement
23
+ agreement={{ path: "AGENTS.md", available: false }}
24
+ isLoading={false}
25
+ isError={false}
26
+ onOpenFile={onOpenFile}
27
+ />,
28
+ );
29
+ expect(
30
+ screen.getByText(/No AGENTS\.md exists|还没有 AGENTS\.md/),
31
+ ).toBeTruthy();
32
+ });
33
+
34
+ it("opens project skills from the fixed source and renders an empty state", () => {
35
+ const onOpen = vi.fn();
36
+ const skill = {
37
+ ref: "project:alpha",
38
+ name: "alpha",
39
+ description: "Alpha project skill",
40
+ path: ".agents/skills/alpha/SKILL.md",
41
+ };
42
+ const { rerender } = render(
43
+ <ProjectSkills
44
+ skills={[skill]}
45
+ isLoading={false}
46
+ isError={false}
47
+ onOpen={onOpen}
48
+ />,
49
+ );
50
+
51
+ fireEvent.click(screen.getByRole("button", { name: /alpha/ }));
52
+ expect(onOpen).toHaveBeenCalledWith(skill);
53
+
54
+ rerender(
55
+ <ProjectSkills
56
+ skills={[]}
57
+ isLoading={false}
58
+ isError={false}
59
+ onOpen={onOpen}
60
+ />,
61
+ );
62
+ expect(
63
+ screen.getByText(/No project Skills found|暂无项目 Skills/),
64
+ ).toBeTruthy();
65
+ });
66
+
67
+ it("shows bounded loading and error states", () => {
68
+ const { rerender } = render(
69
+ <ProjectSkills skills={[]} isLoading isError={false} onOpen={vi.fn()} />,
70
+ );
71
+ expect(screen.getByText(/Loading project|正在读取项目/)).toBeTruthy();
72
+
73
+ rerender(
74
+ <ProjectSkills skills={[]} isLoading={false} isError onOpen={vi.fn()} />,
75
+ );
76
+ expect(
77
+ screen.getByText(/Failed to load project|项目读取失败/),
78
+ ).toBeTruthy();
79
+ });
80
+ });
@@ -2,47 +2,57 @@ import { fireEvent, render, screen } from "@testing-library/react";
2
2
  import { describe, expect, it, vi } from "vitest";
3
3
  import { ProjectOverview } from "./project-overview";
4
4
 
5
- vi.mock("@/features/projects/hooks/use-project-work", () => ({
6
- useProjectWorkEvents: vi.fn(),
7
- useProjectWorkSummary: () => ({
8
- data: { total: 4, active: 2, attention: 1, completed: 1 },
9
- isLoading: false,
10
- isError: false,
5
+ vi.mock(
6
+ "@/features/projects/hooks/use-project-work",
7
+ async (importOriginal) => ({
8
+ ...(await importOriginal()),
9
+ useProjectWorkEvents: vi.fn(),
10
+ useProjectWorkSummary: () => ({
11
+ data: { total: 4, active: 2, attention: 1, completed: 1 },
12
+ isLoading: false,
13
+ isError: false,
14
+ }),
15
+ useProjectWork: () => ({
16
+ data: {
17
+ pages: [
18
+ {
19
+ items: [
20
+ {
21
+ id: "work-1",
22
+ title: "Current work",
23
+ state: { name: "In Progress" },
24
+ },
25
+ ],
26
+ },
27
+ ],
28
+ },
29
+ isLoading: false,
30
+ isError: false,
31
+ }),
32
+ useProjectArtifacts: () => ({
33
+ data: {
34
+ pages: [
35
+ {
36
+ artifacts: [
37
+ {
38
+ id: "artifact-1",
39
+ path: "docs/design.md",
40
+ label: "Design",
41
+ workItemTitle: "Current work",
42
+ createdAt: "2026-09-03T00:00:00.000Z",
43
+ exists: true,
44
+ },
45
+ ],
46
+ nextCursor: null,
47
+ total: 1,
48
+ },
49
+ ],
50
+ },
51
+ isLoading: false,
52
+ isError: false,
53
+ }),
11
54
  }),
12
- useProjectWork: () => ({
13
- data: {
14
- pages: [
15
- {
16
- items: [
17
- {
18
- id: "work-1",
19
- title: "Current work",
20
- state: { name: "In Progress" },
21
- },
22
- ],
23
- },
24
- ],
25
- },
26
- isLoading: false,
27
- isError: false,
28
- }),
29
- useProjectRecentArtifacts: () => ({
30
- data: {
31
- artifacts: [
32
- {
33
- id: "artifact-1",
34
- path: "docs/design.md",
35
- label: "Design",
36
- workItemTitle: "Current work",
37
- createdAt: "2026-09-03T00:00:00.000Z",
38
- exists: true,
39
- },
40
- ],
41
- },
42
- isLoading: false,
43
- isError: false,
44
- }),
45
- }));
55
+ );
46
56
 
47
57
  describe("ProjectOverview", () => {
48
58
  it("renders current work and recent artifacts as equal wide-layout columns", () => {
@@ -1,6 +1,6 @@
1
1
  import { formatDateTime, t } from "@/shared/lib/i18n";
2
2
  import {
3
- useProjectRecentArtifacts,
3
+ useProjectArtifacts,
4
4
  useProjectWork,
5
5
  useProjectWorkEvents,
6
6
  useProjectWorkSummary,
@@ -25,7 +25,8 @@ export function ProjectOverview({
25
25
  useProjectWorkEvents(projectId);
26
26
  const summary = useProjectWorkSummary(projectId);
27
27
  const work = useProjectWork(projectId, { limit: 5 });
28
- const artifacts = useProjectRecentArtifacts(projectId, 5);
28
+ const artifacts = useProjectArtifacts(projectId, { limit: 5 });
29
+ const recentArtifacts = artifacts.data?.pages[0]?.artifacts ?? [];
29
30
  const recent =
30
31
  work.data?.pages.flatMap((page) => page.items).slice(0, 5) ?? [];
31
32
  return (
@@ -100,9 +101,9 @@ export function ProjectOverview({
100
101
  <p className="py-5 text-sm text-destructive">
101
102
  {t("projectsLoadFailed")}
102
103
  </p>
103
- ) : artifacts.data?.artifacts.length ? (
104
+ ) : recentArtifacts.length ? (
104
105
  <div className="space-y-2">
105
- {artifacts.data.artifacts.map((artifact) => (
106
+ {recentArtifacts.map((artifact) => (
106
107
  <ProjectFilePreviewButton
107
108
  key={artifact.id}
108
109
  available={artifact.exists}