@nextclaw/ui 0.22.5 → 0.24.0-beta.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 (111) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/assets/{api-aEu9cEI8.js → api-CoqPRKmF.js} +5 -5
  3. package/dist/assets/{appearance-settings-page-z4DaHc_E.js → appearance-settings-page-CPoFZLw8.js} +1 -1
  4. package/dist/assets/{book-open-DYRapW_h.js → book-open-jcUt0rr9.js} +1 -1
  5. package/dist/assets/{channels-list-page-x07sGqsT.js → channels-list-page-anMJAkfK.js} +2 -2
  6. package/dist/assets/{config-split-page-DJDp86Ne.js → config-split-page-AcuFnbIC.js} +1 -1
  7. package/dist/assets/{confirm-dialog-BALTnP33.js → confirm-dialog-B-atCfC5.js} +1 -1
  8. package/dist/assets/{createLucideIcon-CgM0rnsa.js → createLucideIcon-Blz0FzrV.js} +1 -1
  9. package/dist/assets/{desktop-capabilities-page-4-v3wKLV.js → desktop-capabilities-page-8lKF_Ob-.js} +1 -1
  10. package/dist/assets/desktop-update-config-DABXDpbj.js +1 -0
  11. package/dist/assets/{dist-BjYvUhPL.js → dist-D8kcEpTf.js} +1 -1
  12. package/dist/assets/{dist-N9zc5bkM.js → dist-eH-_j8yU.js} +1 -1
  13. package/dist/assets/doc-browser-DJEMr7Zk.js +1 -0
  14. package/dist/assets/{doc-browser-DwRLokCI.js → doc-browser-DzXRehlW.js} +1 -1
  15. package/dist/assets/{doc-browser-context-BDuypObS.js → doc-browser-context-BlbUcg2J.js} +1 -1
  16. package/dist/assets/{extensions-DFQXmSwG.js → extensions-mquwAJ2Z.js} +1 -1
  17. package/dist/assets/{form-actions-CzGmrPAv.js → form-actions-CaZDGEmB.js} +1 -1
  18. package/dist/assets/index-DyR5iR0O.css +1 -0
  19. package/dist/assets/{index-zuqzAvNx.js → index-XxXj8WDS.js} +10 -10
  20. package/dist/assets/loader-circle-0qPOOYQ5.js +1 -0
  21. package/dist/assets/{mcp-marketplace-page-COUopIOc.js → mcp-marketplace-page-gFtgyueL.js} +1 -1
  22. package/dist/assets/mcp-marketplace-page-uanamJ6L.js +1 -0
  23. package/dist/assets/{model-config-page-g9_-hZqZ.js → model-config-page-hFBpeRAO.js} +1 -1
  24. package/dist/assets/{navigation-link-Ci4Z7Phi.js → navigation-link-Hb5Z0UxZ.js} +1 -1
  25. package/dist/assets/plus-CYOvNvcV.js +1 -0
  26. package/dist/assets/privacy-settings-page-C18zo3ox.js +1 -0
  27. package/dist/assets/projects-C8Gm9RS8.js +1 -0
  28. package/dist/assets/providers-config-page-YU0-AOXJ.js +1 -0
  29. package/dist/assets/react-DF-kP7iN.js +8 -0
  30. package/dist/assets/{refresh-cw-C-3_thgr.js → refresh-cw-ZHKfE3yx.js} +1 -1
  31. package/dist/assets/remote-B_utkBra.js +1 -0
  32. package/dist/assets/{rotate-cw-DSFTq_yu.js → rotate-cw-CLhOXGwE.js} +1 -1
  33. package/dist/assets/runtime-config-page-Dmy1A_0Y.js +1 -0
  34. package/dist/assets/{save-CpPr78_S.js → save-CWuuZs77.js} +1 -1
  35. package/dist/assets/{search-msqk2-rd.js → search-DcmsKAn2.js} +1 -1
  36. package/dist/assets/{search-config-page-BqoDbKpM.js → search-config-page-CxBacx6W.js} +1 -1
  37. package/dist/assets/{secrets-config-page-GsahsEVw.js → secrets-config-page-B4py8-KR.js} +2 -2
  38. package/dist/assets/security-config-CTP4IQWw.js +1 -0
  39. package/dist/assets/{select-DPKCWajZ.js → select-BEr0Iy_f.js} +1 -1
  40. package/dist/assets/skeleton-B2NHluWf.js +1 -0
  41. package/dist/assets/{tag-chip-ClBWeQ4p.js → tag-chip-ByqDkn6g.js} +1 -1
  42. package/dist/assets/x-CENkFyK5.js +1 -0
  43. package/dist/index.html +21 -21
  44. package/package.json +6 -6
  45. package/src/app/configs/__tests__/app-navigation.config.test.ts +20 -0
  46. package/src/app/configs/app-navigation.config.ts +10 -0
  47. package/src/app/index.tsx +8 -1
  48. package/src/features/chat/components/conversation/__tests__/chat-conversation-workspace-section.test.tsx +29 -0
  49. package/src/features/chat/components/conversation/chat-conversation-workspace-section.tsx +4 -2
  50. package/src/features/chat/components/layout/__tests__/chat-page-shell.test.tsx +21 -0
  51. package/src/features/chat/components/layout/chat-page-shell.tsx +19 -4
  52. package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-controller.test.tsx +1 -1
  53. package/src/features/chat/features/conversation/hooks/use-session-conversation-controller.ts +0 -1
  54. package/src/features/chat/features/conversation/utils/__tests__/session-message-composer.utils.test.ts +11 -4
  55. package/src/features/chat/features/conversation/utils/__tests__/session-queued-input.utils.test.ts +0 -1
  56. package/src/features/chat/features/conversation/utils/session-message-composer.utils.ts +10 -11
  57. package/src/features/chat/features/input/input-surface-plugins/__tests__/context-reference-plugin.test.ts +1 -0
  58. package/src/features/chat/features/message/hooks/use-chat-message-actions.tsx +0 -2
  59. package/src/features/chat/features/runtime/utils/chat-runtime.utils.ts +0 -14
  60. package/src/features/chat/features/session/components/__tests__/chat-sidebar-project-groups.test.tsx +70 -22
  61. package/src/features/chat/features/session/components/chat-sidebar-project-groups.tsx +79 -32
  62. package/src/features/chat/features/session/utils/__tests__/chat-sidebar-session-groups.utils.test.ts +2 -0
  63. package/src/features/chat/features/session/utils/chat-run-metadata.utils.ts +0 -6
  64. package/src/features/chat/features/session/utils/chat-sidebar-session-groups.utils.ts +2 -0
  65. package/src/features/chat/features/welcome/utils/__tests__/chat-welcome-project-options.utils.test.ts +1 -0
  66. package/src/features/chat/managers/__tests__/chat-session-list.manager.test.ts +27 -0
  67. package/src/features/chat/pages/__tests__/ncp-chat-page.test.ts +2 -14
  68. package/src/features/chat/types/chat-run.types.ts +0 -1
  69. package/src/features/panel-apps/managers/__tests__/panel-app-bridge.manager.test.ts +51 -0
  70. package/src/features/panel-apps/managers/panel-app-bridge.manager.ts +37 -2
  71. package/src/features/projects/AGENTS.md +6 -0
  72. package/src/features/projects/components/project-agreement.tsx +92 -0
  73. package/src/features/projects/components/project-artifacts.test.tsx +77 -0
  74. package/src/features/projects/components/project-artifacts.tsx +188 -0
  75. package/src/features/projects/components/project-overview.tsx +150 -0
  76. package/src/features/projects/components/project-requests.tsx +57 -0
  77. package/src/features/projects/components/project-runs.tsx +60 -0
  78. package/src/features/projects/components/project-section.tsx +60 -0
  79. package/src/features/projects/components/project-skills.tsx +53 -0
  80. package/src/features/projects/components/project-work-items.test.tsx +78 -0
  81. package/src/features/projects/components/project-work-items.tsx +272 -0
  82. package/src/features/projects/hooks/use-project-observation.test.tsx +93 -0
  83. package/src/features/projects/hooks/use-project-observation.ts +85 -0
  84. package/src/features/projects/index.ts +2 -0
  85. package/src/features/projects/pages/project-home-page.tsx +253 -0
  86. package/src/features/projects/pages/projects-page.test.tsx +366 -0
  87. package/src/features/projects/presenters/project-home.presenter.test.ts +74 -0
  88. package/src/features/projects/presenters/project-home.presenter.ts +55 -0
  89. package/src/features/projects/utils/project-artifact-view.utils.test.ts +63 -0
  90. package/src/features/projects/utils/project-artifact-view.utils.ts +62 -0
  91. package/src/features/projects/utils/project-request-response.utils.test.ts +44 -0
  92. package/src/features/projects/utils/project-request-response.utils.ts +39 -0
  93. package/src/shared/lib/i18n/index.ts +6 -2
  94. package/src/shared/lib/i18n/locales/en-US/projects.json +90 -0
  95. package/src/shared/lib/i18n/locales/zh-CN/projects.json +90 -0
  96. package/src/shared/lib/ui-document-title/__tests__/ui-document-title.test.ts +6 -0
  97. package/src/shared/lib/ui-document-title/index.ts +1 -0
  98. package/dist/assets/desktop-update-config-Cl-_XHqU.js +0 -1
  99. package/dist/assets/doc-browser-JlXVLNRe.js +0 -1
  100. package/dist/assets/index-CwCTiMM9.css +0 -1
  101. package/dist/assets/loader-circle-D9etMT9f.js +0 -1
  102. package/dist/assets/mcp-marketplace-page-DxPPmYqh.js +0 -1
  103. package/dist/assets/plus-DKGD04AO.js +0 -1
  104. package/dist/assets/privacy-settings-page-pNhQQT4r.js +0 -1
  105. package/dist/assets/providers-config-page-DftW_6KU.js +0 -1
  106. package/dist/assets/react-CVFx225w.js +0 -8
  107. package/dist/assets/remote-DS2TkMPm.js +0 -1
  108. package/dist/assets/runtime-config-page-Cd9sOYvn.js +0 -1
  109. package/dist/assets/security-config-BnHQyrtG.js +0 -1
  110. package/dist/assets/skeleton-BsR5rwB7.js +0 -1
  111. package/dist/assets/x-Dp5GAFmP.js +0 -1
@@ -0,0 +1,366 @@
1
+ import { fireEvent, render, screen } from "@testing-library/react";
2
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
3
+ import { MemoryRouter, Route, Routes } from "react-router-dom";
4
+ import { beforeEach, describe, expect, it, vi } from "vitest";
5
+ import type { ProjectObservationSnapshot } from "@nextclaw/client-sdk";
6
+ import { ProjectsPage } from "./project-home-page";
7
+
8
+ const mocks = vi.hoisted(() => ({
9
+ useProjects: vi.fn(),
10
+ useProjectObservation: vi.fn(),
11
+ openFilePreview: vi.fn(),
12
+ createSession: vi.fn(),
13
+ }));
14
+
15
+ vi.mock("@/shared/hooks/use-projects", () => ({
16
+ useProjects: mocks.useProjects,
17
+ }));
18
+ vi.mock("@/features/projects/hooks/use-project-observation", () => ({
19
+ projectObservationQueryKey: (projectId: string) => [
20
+ "project-observation",
21
+ projectId,
22
+ ],
23
+ useProjectObservation: mocks.useProjectObservation,
24
+ }));
25
+ vi.mock("@/features/chat/components/providers/chat-presenter.provider", () => ({
26
+ usePresenter: () => ({
27
+ chatThreadManager: { openFilePreview: mocks.openFilePreview },
28
+ chatSessionListManager: { createSession: mocks.createSession },
29
+ }),
30
+ }));
31
+ vi.mock("@/features/chat/components/conversation/chat-conversation-workspace-section", () => ({
32
+ ChatConversationWorkspaceSection: () => <div data-testid="project-workspace-preview" />,
33
+ }));
34
+
35
+ const observedAt = "2026-08-30T00:00:00.000Z";
36
+ const snapshot: ProjectObservationSnapshot = {
37
+ asOf: observedAt,
38
+ project: {
39
+ name: "Research",
40
+ rootPath: "/tmp/research",
41
+ context: [{
42
+ id: "vision",
43
+ role: "Project vision",
44
+ source: "docs/VISION.md",
45
+ accessible: true,
46
+ reference: {
47
+ kind: "file-observation",
48
+ label: "File observation",
49
+ observedAt,
50
+ projectRelativePath: "docs/VISION.md",
51
+ },
52
+ }],
53
+ },
54
+ sources: [
55
+ {
56
+ id: "config",
57
+ label: "Project config",
58
+ status: "error",
59
+ itemCount: 0,
60
+ observedAt,
61
+ diagnosticIds: ["config:broken"],
62
+ },
63
+ {
64
+ id: "files",
65
+ label: "Project files",
66
+ status: "empty",
67
+ itemCount: 0,
68
+ observedAt,
69
+ diagnosticIds: [],
70
+ },
71
+ {
72
+ id: "sessions",
73
+ label: "Project sessions",
74
+ status: "empty",
75
+ itemCount: 0,
76
+ observedAt,
77
+ diagnosticIds: [],
78
+ },
79
+ {
80
+ id: "skills",
81
+ label: "Project Skills",
82
+ status: "empty",
83
+ itemCount: 0,
84
+ observedAt,
85
+ diagnosticIds: [],
86
+ },
87
+ ],
88
+ workflows: [],
89
+ runs: [],
90
+ workItems: [],
91
+ artifactCategories: [
92
+ { id: "designs", label: "Designs" },
93
+ { id: "plans", label: "Plans" },
94
+ ],
95
+ artifacts: [
96
+ {
97
+ id: "designs:docs/designs/observer.md",
98
+ path: "docs/designs/observer.md",
99
+ categoryId: "designs",
100
+ categoryLabel: "Designs",
101
+ exists: true,
102
+ references: [],
103
+ },
104
+ {
105
+ id: "plans:docs/plans/delivery.md",
106
+ path: "docs/plans/delivery.md",
107
+ categoryId: "plans",
108
+ categoryLabel: "Plans",
109
+ exists: true,
110
+ references: [],
111
+ },
112
+ ],
113
+ signals: [],
114
+ requests: [],
115
+ activity: [],
116
+ skills: [
117
+ {
118
+ ref: "project:research-skill",
119
+ name: "Research skill",
120
+ description: "Collects and organizes source material.",
121
+ source: "project",
122
+ path: "/tmp/research/.agents/skills/research/SKILL.md",
123
+ readable: true,
124
+ reference: {
125
+ kind: "project-config",
126
+ label: "Project config",
127
+ observedAt,
128
+ },
129
+ },
130
+ {
131
+ ref: "project:unavailable-skill",
132
+ name: "Unavailable skill",
133
+ source: "project",
134
+ path: "/tmp/research/.agents/skills/unavailable/SKILL.md",
135
+ readable: false,
136
+ reference: {
137
+ kind: "project-config",
138
+ label: "Project config",
139
+ observedAt,
140
+ },
141
+ },
142
+ ],
143
+ diagnostics: [
144
+ {
145
+ id: "config:broken",
146
+ source: "config",
147
+ level: "error",
148
+ code: "PROJECT_CONFIG_PARSE_FAILED",
149
+ message: "The project configuration is invalid.",
150
+ },
151
+ ],
152
+ dataQuality: "partial",
153
+ };
154
+
155
+ describe("ProjectsPage", () => {
156
+ beforeEach(() => {
157
+ mocks.createSession.mockReset();
158
+ mocks.useProjects.mockReturnValue({
159
+ data: {
160
+ projects: [
161
+ {
162
+ id: "project-research",
163
+ name: "Research",
164
+ rootPath: "/tmp/research",
165
+ createdAt: observedAt,
166
+ updatedAt: observedAt,
167
+ },
168
+ ],
169
+ },
170
+ isLoading: false,
171
+ isError: false,
172
+ });
173
+ mocks.useProjectObservation.mockReturnValue({
174
+ data: snapshot,
175
+ isLoading: false,
176
+ isError: false,
177
+ });
178
+ });
179
+
180
+ it("uses the project selected by the sidebar route without rendering another selector", () => {
181
+ render(
182
+ <QueryClientProvider client={new QueryClient()}>
183
+ <MemoryRouter initialEntries={["/projects/project-research/overview"]}>
184
+ <Routes>
185
+ <Route path="/projects/:projectId/:tab" element={<ProjectsPage />} />
186
+ </Routes>
187
+ </MemoryRouter>
188
+ </QueryClientProvider>,
189
+ );
190
+
191
+ expect(mocks.useProjectObservation).toHaveBeenCalledWith(
192
+ "project-research",
193
+ "/tmp/research",
194
+ );
195
+ expect(screen.queryByRole("combobox")).toBeNull();
196
+ expect(screen.getAllByRole("tab")).toHaveLength(5);
197
+ expect(screen.getByRole("tab", { name: "Skills" })).toBeTruthy();
198
+ expect(
199
+ screen.queryByText(
200
+ /This project has no configured summary|项目没有配置摘要/,
201
+ ),
202
+ ).toBeNull();
203
+ expect(screen.queryByText(/Partially available|部分可用/)).toBeNull();
204
+ expect(screen.queryByText(observedAt)).toBeNull();
205
+ expect(
206
+ screen.getByText(
207
+ /Set up project observation first|先建立项目观察配置/,
208
+ ),
209
+ ).toBeTruthy();
210
+ fireEvent.click(
211
+ screen.getByRole("button", {
212
+ name: /Let AI set up project observation|让 AI 帮我建立项目观察/,
213
+ }),
214
+ );
215
+ expect(mocks.createSession).toHaveBeenCalledWith({
216
+ projectRoot: "/tmp/research",
217
+ prompt: expect.stringMatching(/project observation|项目观察/),
218
+ });
219
+ expect(mocks.createSession).toHaveBeenCalledWith(
220
+ expect.objectContaining({
221
+ prompt: expect.stringContaining("AGENTS.md"),
222
+ }),
223
+ );
224
+ fireEvent.click(screen.getByRole("button", { name: /docs\/designs\/observer\.md/ }));
225
+ expect(mocks.openFilePreview).toHaveBeenCalledWith({
226
+ path: "docs/designs/observer.md",
227
+ label: "docs/designs/observer.md",
228
+ viewMode: "preview",
229
+ previewViewer: "rendered",
230
+ });
231
+
232
+ fireEvent.click(screen.getByRole("tab", { name: /Work items|工作项/ }));
233
+ expect(
234
+ screen.queryByRole("heading", { name: /Work items|工作项/ }),
235
+ ).toBeNull();
236
+
237
+ fireEvent.click(screen.getByRole("tab", { name: /Artifacts|产物/ }));
238
+ expect(
239
+ screen.queryByRole("heading", { name: /Artifacts|产物/ }),
240
+ ).toBeNull();
241
+ expect(screen.getByRole("button", { name: /Designs 1/ })).toBeTruthy();
242
+ expect(screen.getByRole("button", { name: /docs\/plans\/delivery\.md/ })).toBeTruthy();
243
+ expect(screen.queryByText(/File observed|文件已观测/)).toBeNull();
244
+
245
+ fireEvent.click(screen.getByRole("tab", { name: /Skills/ }));
246
+ expect(screen.getByText("2 skills")).toBeTruthy();
247
+ expect(screen.queryByRole("heading", { name: "Skills" })).toBeNull();
248
+ const skillPath = screen.getByText(
249
+ "/tmp/research/.agents/skills/research/SKILL.md",
250
+ );
251
+ expect(skillPath.className).toContain("truncate");
252
+ expect(skillPath.getAttribute("title")).toBe(
253
+ "/tmp/research/.agents/skills/research/SKILL.md",
254
+ );
255
+ const skillDescription = screen.getByText(
256
+ "Collects and organizes source material.",
257
+ );
258
+ expect(skillDescription.className).toContain("line-clamp-2");
259
+ expect(skillDescription.getAttribute("title")).toBe(
260
+ "Collects and organizes source material.",
261
+ );
262
+ expect(screen.queryByText(/Readable|可读取/)).toBeNull();
263
+ expect(screen.getByText(/Unavailable|不可用/)).toBeTruthy();
264
+ fireEvent.click(screen.getByRole("button", { name: /Research skill/ }));
265
+ expect(mocks.openFilePreview).toHaveBeenCalledWith({
266
+ path: "/tmp/research/.agents/skills/research/SKILL.md",
267
+ label: "Research skill",
268
+ viewMode: "preview",
269
+ previewViewer: "rendered",
270
+ });
271
+
272
+ fireEvent.click(
273
+ screen.getByRole("tab", { name: /Working rules|工作约定/ }),
274
+ );
275
+ expect(
276
+ screen.queryByRole("heading", { name: /Working rules|工作约定/ }),
277
+ ).toBeNull();
278
+ expect(
279
+ screen.getByRole("heading", { name: /Vision and context|愿景与上下文/ }),
280
+ ).toBeTruthy();
281
+ fireEvent.click(screen.getByRole("button", { name: /Project vision/ }));
282
+ expect(mocks.openFilePreview).toHaveBeenCalledWith({
283
+ path: "docs/VISION.md",
284
+ label: "Project vision",
285
+ viewMode: "preview",
286
+ previewViewer: "rendered",
287
+ });
288
+ expect(screen.getByText("PROJECT_CONFIG_PARSE_FAILED")).toBeTruthy();
289
+ });
290
+
291
+ it("keeps rendering while an older server snapshot has no runs field", () => {
292
+ const { runs: _runs, ...legacySnapshot } = snapshot;
293
+ mocks.useProjectObservation.mockReturnValue({
294
+ data: legacySnapshot as ProjectObservationSnapshot,
295
+ isLoading: false,
296
+ isError: false,
297
+ });
298
+
299
+ render(
300
+ <QueryClientProvider client={new QueryClient()}>
301
+ <MemoryRouter initialEntries={["/projects/project-research/overview"]}>
302
+ <Routes>
303
+ <Route path="/projects/:projectId/:tab" element={<ProjectsPage />} />
304
+ </Routes>
305
+ </MemoryRouter>
306
+ </QueryClientProvider>,
307
+ );
308
+
309
+ expect(screen.getAllByRole("tab")).toHaveLength(5);
310
+ expect(screen.getByText(/No AI-reported activity|暂无 AI 上报动态/)).toBeTruthy();
311
+ });
312
+
313
+ it("asks the user to choose from the sidebar when the route has no registered project", () => {
314
+ render(
315
+ <QueryClientProvider client={new QueryClient()}>
316
+ <MemoryRouter initialEntries={["/projects"]}>
317
+ <Routes>
318
+ <Route path="/projects" element={<ProjectsPage />} />
319
+ </Routes>
320
+ </MemoryRouter>
321
+ </QueryClientProvider>,
322
+ );
323
+
324
+ expect(mocks.useProjectObservation).toHaveBeenLastCalledWith(null, null);
325
+ expect(screen.getByText(/Choose a project|选择项目/)).toBeTruthy();
326
+ expect(screen.queryByRole("combobox")).toBeNull();
327
+ });
328
+
329
+ it("keeps the observation setup entry out of an already configured project", () => {
330
+ mocks.useProjectObservation.mockReturnValue({
331
+ data: {
332
+ ...snapshot,
333
+ sources: snapshot.sources.map((source) =>
334
+ source.id === "config" ? { ...source, status: "available" as const } : source,
335
+ ),
336
+ },
337
+ isLoading: false,
338
+ isError: false,
339
+ });
340
+
341
+ render(
342
+ <QueryClientProvider client={new QueryClient()}>
343
+ <MemoryRouter initialEntries={["/projects/project-research/overview"]}>
344
+ <Routes>
345
+ <Route path="/projects/:projectId/:tab" element={<ProjectsPage />} />
346
+ </Routes>
347
+ </MemoryRouter>
348
+ </QueryClientProvider>,
349
+ );
350
+
351
+ expect(
352
+ screen.queryByRole("button", {
353
+ name: /Let AI set up project observation|让 AI 帮我建立项目观察/,
354
+ }),
355
+ ).toBeNull();
356
+ fireEvent.click(
357
+ screen.getByRole("button", {
358
+ name: /Start project work|开始项目工作/,
359
+ }),
360
+ );
361
+ expect(mocks.createSession).toHaveBeenCalledWith({
362
+ projectRoot: "/tmp/research",
363
+ prompt: expect.stringMatching(/Start working on this project|请开始处理这个项目/),
364
+ });
365
+ });
366
+ });
@@ -0,0 +1,74 @@
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
+ };
15
+
16
+ describe("project home presenter", () => {
17
+ it("accepts only the project home tabs represented in the route", () => {
18
+ expect(isProjectHomeTab("overview")).toBe(true);
19
+ expect(isProjectHomeTab("agreement")).toBe(true);
20
+ expect(isProjectHomeTab("missing")).toBe(false);
21
+ expect(isProjectHomeTab(undefined)).toBe(false);
22
+ });
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
+ });
@@ -0,0 +1,55 @@
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");
55
+ }
@@ -0,0 +1,63 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ formatProjectRelativeTime,
4
+ sortProjectArtifacts,
5
+ } from "./project-artifact-view.utils";
6
+
7
+ const artifacts = [
8
+ {
9
+ id: "older-update",
10
+ path: "docs/designs/older.md",
11
+ categoryId: "designs",
12
+ categoryLabel: "Designs",
13
+ exists: true,
14
+ fileCreatedAt: "2026-08-01T00:00:00.000Z",
15
+ fileUpdatedAt: "2026-08-28T00:00:00.000Z",
16
+ references: [],
17
+ },
18
+ {
19
+ id: "newer-update",
20
+ path: "docs/designs/newer.md",
21
+ categoryId: "designs",
22
+ categoryLabel: "Designs",
23
+ exists: true,
24
+ fileCreatedAt: "2026-08-29T00:00:00.000Z",
25
+ fileUpdatedAt: "2026-08-30T00:00:00.000Z",
26
+ references: [],
27
+ },
28
+ {
29
+ id: "untimed",
30
+ path: "docs/designs/alpha.md",
31
+ categoryId: "designs",
32
+ categoryLabel: "Designs",
33
+ exists: false,
34
+ references: [],
35
+ },
36
+ ];
37
+
38
+ describe("project artifact view", () => {
39
+ it("prioritizes recent updates by default and supports alternate sort orders", () => {
40
+ expect(sortProjectArtifacts(artifacts).map((artifact) => artifact.id)).toEqual([
41
+ "newer-update",
42
+ "older-update",
43
+ "untimed",
44
+ ]);
45
+ expect(sortProjectArtifacts(artifacts, "created-desc").map((artifact) => artifact.id)).toEqual([
46
+ "newer-update",
47
+ "older-update",
48
+ "untimed",
49
+ ]);
50
+ expect(sortProjectArtifacts(artifacts, "name").map((artifact) => artifact.id)).toEqual([
51
+ "untimed",
52
+ "newer-update",
53
+ "older-update",
54
+ ]);
55
+ });
56
+
57
+ it("renders recent timestamps in people-friendly language", () => {
58
+ const now = new Date("2026-08-30T12:00:00.000Z");
59
+ expect(formatProjectRelativeTime("2026-08-30T11:57:00.000Z", now)).toBe("3 minutes ago");
60
+ expect(formatProjectRelativeTime("2026-08-30T09:00:00.000Z", now)).toBe("3 hours ago");
61
+ expect(formatProjectRelativeTime("2026-08-29T12:00:00.000Z", now)).toBe("Yesterday");
62
+ });
63
+ });
@@ -0,0 +1,62 @@
1
+ import type { ProjectObservationSnapshot } from "@nextclaw/client-sdk";
2
+ import { formatDateShort, t } from "@/shared/lib/i18n";
3
+
4
+ export type ProjectArtifactSort =
5
+ | "updated-desc"
6
+ | "updated-asc"
7
+ | "created-desc"
8
+ | "name";
9
+
10
+ export const DEFAULT_PROJECT_ARTIFACT_SORT: ProjectArtifactSort = "updated-desc";
11
+
12
+ function timestamp(value?: string): number {
13
+ if (!value) return Number.NEGATIVE_INFINITY;
14
+ const parsed = new Date(value).getTime();
15
+ return Number.isNaN(parsed) ? Number.NEGATIVE_INFINITY : parsed;
16
+ }
17
+
18
+ function byName(
19
+ left: ProjectObservationSnapshot["artifacts"][number],
20
+ right: ProjectObservationSnapshot["artifacts"][number],
21
+ ): number {
22
+ return left.path.localeCompare(right.path);
23
+ }
24
+
25
+ export function sortProjectArtifacts(
26
+ artifacts: ProjectObservationSnapshot["artifacts"],
27
+ sort: ProjectArtifactSort = DEFAULT_PROJECT_ARTIFACT_SORT,
28
+ ): ProjectObservationSnapshot["artifacts"] {
29
+ return [...artifacts].sort((left, right) => {
30
+ if (sort === "name") return byName(left, right);
31
+ const leftTimestamp = timestamp(
32
+ sort === "created-desc"
33
+ ? left.fileCreatedAt ?? left.fileUpdatedAt
34
+ : left.fileUpdatedAt ?? left.fileCreatedAt,
35
+ );
36
+ const rightTimestamp = timestamp(
37
+ sort === "created-desc"
38
+ ? right.fileCreatedAt ?? right.fileUpdatedAt
39
+ : right.fileUpdatedAt ?? right.fileCreatedAt,
40
+ );
41
+ const order = sort === "updated-asc"
42
+ ? leftTimestamp - rightTimestamp
43
+ : rightTimestamp - leftTimestamp;
44
+ return order || byName(left, right);
45
+ });
46
+ }
47
+
48
+ export function formatProjectRelativeTime(value?: string, now: Date = new Date()): string {
49
+ if (!value) return "-";
50
+ const date = new Date(value);
51
+ const differenceMs = now.getTime() - date.getTime();
52
+ if (Number.isNaN(date.getTime())) return value;
53
+ if (differenceMs < 60_000) return t("projectsTimeJustNow");
54
+ const minutes = Math.floor(differenceMs / 60_000);
55
+ if (minutes < 60) return t("projectsTimeMinutesAgo").replace("{count}", String(minutes));
56
+ const hours = Math.floor(minutes / 60);
57
+ if (hours < 24) return t("projectsTimeHoursAgo").replace("{count}", String(hours));
58
+ const days = Math.floor(hours / 24);
59
+ if (days === 1) return t("projectsTimeYesterday");
60
+ if (days < 7) return t("projectsTimeDaysAgo").replace("{count}", String(days));
61
+ return formatDateShort(value);
62
+ }
@@ -0,0 +1,44 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { nextclawClient } from "@/shared/lib/api";
3
+ import { sendProjectRequestResponse } from "./project-request-response.utils";
4
+
5
+ vi.mock("@/shared/lib/api", () => ({
6
+ nextclawClient: { agentRuns: { send: vi.fn() } },
7
+ }));
8
+
9
+ describe("sendProjectRequestResponse", () => {
10
+ beforeEach(() => vi.mocked(nextclawClient.agentRuns.send).mockReset());
11
+
12
+ it("sends a stable, traceable reply through the existing agent-run ingress", async () => {
13
+ vi.mocked(nextclawClient.agentRuns.send).mockResolvedValue({
14
+ delivery: "queued",
15
+ runId: null,
16
+ sessionId: "session-1",
17
+ userMessageId: "project-response-approve-release-confirmed",
18
+ assistantMessageId: null,
19
+ });
20
+
21
+ await sendProjectRequestResponse({
22
+ requestId: "approve-release",
23
+ sessionId: "session-1",
24
+ decision: "confirmed",
25
+ prompt: "继续发布 v1",
26
+ });
27
+
28
+ expect(nextclawClient.agentRuns.send).toHaveBeenCalledWith(expect.objectContaining({
29
+ sessionId: "session-1",
30
+ idempotencyKey: "project-response-approve-release-confirmed",
31
+ delivery: "prefer-steer",
32
+ message: expect.objectContaining({
33
+ id: "project-response-approve-release-confirmed",
34
+ metadata: {
35
+ project_observation_response: {
36
+ protocol: "nextclaw.project/v1",
37
+ requestId: "approve-release",
38
+ decision: "confirmed",
39
+ },
40
+ },
41
+ }),
42
+ }));
43
+ });
44
+ });