@nextclaw/ui 0.23.0 → 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 (109) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/assets/{api-n96IfCKb.js → api-CoqPRKmF.js} +5 -5
  3. package/dist/assets/{appearance-settings-page-C_AqxSF_.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-uR-M4Ls8.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-CHVBMpla.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-phMuNq1B.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-CQKXTqTI.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-CLw9DuCJ.js → index-XxXj8WDS.js} +9 -9
  20. package/dist/assets/loader-circle-0qPOOYQ5.js +1 -0
  21. package/dist/assets/{mcp-marketplace-page-s9PTnSDT.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-CxnCUWRW.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-D3_qNXbB.js → search-config-page-CxBacx6W.js} +1 -1
  37. package/dist/assets/{secrets-config-page-Be4P1E8o.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-Dc_NcCEe.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/projects/AGENTS.md +6 -0
  70. package/src/features/projects/components/project-agreement.tsx +92 -0
  71. package/src/features/projects/components/project-artifacts.test.tsx +77 -0
  72. package/src/features/projects/components/project-artifacts.tsx +188 -0
  73. package/src/features/projects/components/project-overview.tsx +150 -0
  74. package/src/features/projects/components/project-requests.tsx +57 -0
  75. package/src/features/projects/components/project-runs.tsx +60 -0
  76. package/src/features/projects/components/project-section.tsx +60 -0
  77. package/src/features/projects/components/project-skills.tsx +53 -0
  78. package/src/features/projects/components/project-work-items.test.tsx +78 -0
  79. package/src/features/projects/components/project-work-items.tsx +272 -0
  80. package/src/features/projects/hooks/use-project-observation.test.tsx +93 -0
  81. package/src/features/projects/hooks/use-project-observation.ts +85 -0
  82. package/src/features/projects/index.ts +2 -0
  83. package/src/features/projects/pages/project-home-page.tsx +253 -0
  84. package/src/features/projects/pages/projects-page.test.tsx +366 -0
  85. package/src/features/projects/presenters/project-home.presenter.test.ts +74 -0
  86. package/src/features/projects/presenters/project-home.presenter.ts +55 -0
  87. package/src/features/projects/utils/project-artifact-view.utils.test.ts +63 -0
  88. package/src/features/projects/utils/project-artifact-view.utils.ts +62 -0
  89. package/src/features/projects/utils/project-request-response.utils.test.ts +44 -0
  90. package/src/features/projects/utils/project-request-response.utils.ts +39 -0
  91. package/src/shared/lib/i18n/index.ts +6 -2
  92. package/src/shared/lib/i18n/locales/en-US/projects.json +90 -0
  93. package/src/shared/lib/i18n/locales/zh-CN/projects.json +90 -0
  94. package/src/shared/lib/ui-document-title/__tests__/ui-document-title.test.ts +6 -0
  95. package/src/shared/lib/ui-document-title/index.ts +1 -0
  96. package/dist/assets/desktop-update-config-C77hkbor.js +0 -1
  97. package/dist/assets/doc-browser-JlXVLNRe.js +0 -1
  98. package/dist/assets/index-CwCTiMM9.css +0 -1
  99. package/dist/assets/loader-circle-D9etMT9f.js +0 -1
  100. package/dist/assets/mcp-marketplace-page-CbWOamyW.js +0 -1
  101. package/dist/assets/plus-DKGD04AO.js +0 -1
  102. package/dist/assets/privacy-settings-page-D8HZHHjL.js +0 -1
  103. package/dist/assets/providers-config-page-C0GIoGxm.js +0 -1
  104. package/dist/assets/react-CVFx225w.js +0 -8
  105. package/dist/assets/remote-DvTCSAxQ.js +0 -1
  106. package/dist/assets/runtime-config-page-VNJZui5f.js +0 -1
  107. package/dist/assets/security-config-BtcpKIhQ.js +0 -1
  108. package/dist/assets/skeleton-BsR5rwB7.js +0 -1
  109. package/dist/assets/x-Dp5GAFmP.js +0 -1
@@ -0,0 +1,253 @@
1
+ import { useState } from "react";
2
+ import { useNavigate, useParams } from "react-router-dom";
3
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
4
+ import type {
5
+ ObservedRequest,
6
+ ProjectObservationSnapshot,
7
+ } from "@nextclaw/client-sdk";
8
+ import { toast } from "sonner";
9
+ import { useProjects } from "@/shared/hooks/use-projects";
10
+ import { t } from "@/shared/lib/i18n";
11
+ import {
12
+ Tabs,
13
+ TabsContent,
14
+ TabsList,
15
+ TabsTrigger,
16
+ } from "@/shared/components/ui/tabs";
17
+ import { ProjectAgreement } from "@/features/projects/components/project-agreement";
18
+ import { ProjectArtifacts } from "@/features/projects/components/project-artifacts";
19
+ import { ProjectOverview } from "@/features/projects/components/project-overview";
20
+ import { ProjectRequests } from "@/features/projects/components/project-requests";
21
+ import { ProjectSkills } from "@/features/projects/components/project-skills";
22
+ import { ProjectWorkItems } from "@/features/projects/components/project-work-items";
23
+ import { ChatConversationWorkspaceSection } from "@/features/chat/components/conversation/chat-conversation-workspace-section";
24
+ import { usePresenter } from "@/features/chat/components/providers/chat-presenter.provider";
25
+ import {
26
+ projectObservationQueryKey,
27
+ useProjectObservation,
28
+ } from "@/features/projects/hooks/use-project-observation";
29
+ import {
30
+ getOpenProjectRequests,
31
+ isProjectHomeTab,
32
+ type ProjectHomeTab,
33
+ } from "@/features/projects/presenters/project-home.presenter";
34
+ import {
35
+ sendProjectRequestResponse,
36
+ type ProjectRequestDecision,
37
+ } from "@/features/projects/utils/project-request-response.utils";
38
+
39
+ const PROJECT_TABS: ProjectHomeTab[] = [
40
+ "overview",
41
+ "work",
42
+ "artifacts",
43
+ "skills",
44
+ "agreement",
45
+ ];
46
+
47
+ function shouldOfferObservationSetup(
48
+ sources: ProjectObservationSnapshot["sources"],
49
+ ): boolean {
50
+ const config = sources.find((source) => source.id === "config");
51
+ return !config || config.status !== "available";
52
+ }
53
+
54
+ function hasObservedProjectSessions(
55
+ sources: ProjectObservationSnapshot["sources"],
56
+ ): boolean {
57
+ return (sources.find((source) => source.id === "sessions")?.itemCount ?? 0) > 0;
58
+ }
59
+
60
+ export function ProjectsPage() {
61
+ const { projectId, tab: tabParam } = useParams<{ projectId?: string; tab?: string }>();
62
+ const navigate = useNavigate();
63
+ const [pendingRequestId, setPendingRequestId] = useState<string | null>(null);
64
+ const queryClient = useQueryClient();
65
+ const presenter = usePresenter();
66
+ const projects = useProjects();
67
+ const registered = projects.data?.projects ?? [];
68
+ const selectedProject = registered.find((project) => project.id === projectId) ?? null;
69
+ const tab: ProjectHomeTab | null = isProjectHomeTab(tabParam) ? tabParam : null;
70
+ const observation = useProjectObservation(
71
+ selectedProject?.id ?? null,
72
+ selectedProject?.rootPath ?? null,
73
+ );
74
+ const response = useMutation({
75
+ mutationFn: sendProjectRequestResponse,
76
+ onMutate: (input) => setPendingRequestId(input.requestId),
77
+ onSuccess: async () => {
78
+ if (selectedProject) {
79
+ await queryClient.invalidateQueries({
80
+ queryKey: projectObservationQueryKey(selectedProject.id),
81
+ });
82
+ }
83
+ toast.success(t("projectsResponseSent"));
84
+ },
85
+ onError: (error) =>
86
+ toast.error(
87
+ `${t("projectsResponseFailed")}: ${error instanceof Error ? error.message : String(error)}`,
88
+ ),
89
+ onSettled: () => setPendingRequestId(null),
90
+ });
91
+
92
+ const reply = (
93
+ request: ObservedRequest,
94
+ decision: ProjectRequestDecision,
95
+ ) => {
96
+ if (!request.reference.sessionId) return;
97
+ response.mutate({
98
+ requestId: request.id,
99
+ sessionId: request.reference.sessionId,
100
+ decision,
101
+ prompt: request.prompt,
102
+ });
103
+ };
104
+
105
+ if (projects.isLoading) {
106
+ return (
107
+ <main className="flex h-full items-center justify-center p-6 text-sm text-muted-foreground">
108
+ {t("projectsLoading")}
109
+ </main>
110
+ );
111
+ }
112
+ if (projects.isError) {
113
+ return (
114
+ <main className="p-6 text-sm text-destructive">
115
+ {t("projectsLoadFailed")}: {projects.error.message}
116
+ </main>
117
+ );
118
+ }
119
+ if (registered.length === 0) {
120
+ return (
121
+ <main className="p-6 text-sm text-muted-foreground">
122
+ {t("projectsEmpty")}
123
+ </main>
124
+ );
125
+ }
126
+ if (!selectedProject || !tab) {
127
+ return (
128
+ <main className="p-6 text-sm text-muted-foreground">
129
+ {t("projectsChoose")}
130
+ </main>
131
+ );
132
+ }
133
+
134
+ const snapshot = observation.data;
135
+ const startObservationSetup = () =>
136
+ presenter.chatSessionListManager.createSession({
137
+ projectRoot: selectedProject.rootPath,
138
+ prompt: t("projectsObservationSetupPrompt"),
139
+ });
140
+ const startProjectWork = () =>
141
+ presenter.chatSessionListManager.createSession({
142
+ projectRoot: selectedProject.rootPath,
143
+ prompt: t("projectsStartProjectWorkPrompt"),
144
+ });
145
+ const openProjectFile = (path: string, label: string) =>
146
+ presenter.chatThreadManager.openFilePreview({
147
+ path,
148
+ label,
149
+ viewMode: "preview",
150
+ previewViewer: "rendered",
151
+ });
152
+ return (
153
+ <>
154
+ <main className="h-full min-w-0 flex-1 overflow-y-auto p-4 md:p-6">
155
+ <div className="mx-auto max-w-6xl space-y-4">
156
+ <header className="flex flex-wrap items-center gap-3">
157
+ <h1 className="text-xl font-semibold">{t("projectsTitle")}</h1>
158
+ </header>
159
+
160
+ {observation.isLoading ? (
161
+ <div className="rounded-xl border border-border/60 p-5 text-sm text-muted-foreground">
162
+ {t("projectsLoading")}
163
+ </div>
164
+ ) : null}
165
+ {observation.isError ? (
166
+ <div className="rounded-xl border border-destructive/40 bg-destructive/5 p-5 text-sm text-destructive">
167
+ {t("projectsLoadFailed")}: {observation.error.message}
168
+ </div>
169
+ ) : null}
170
+ {snapshot ? (
171
+ <>
172
+ <section className="rounded-2xl border border-border/60 bg-card p-4">
173
+ <h2 className="text-lg font-semibold">{snapshot.project.name}</h2>
174
+ {snapshot.project.summary ? (
175
+ <p className="mt-1 text-sm text-muted-foreground">
176
+ {snapshot.project.summary}
177
+ </p>
178
+ ) : null}
179
+ <p className="mt-2 break-all text-xs text-muted-foreground">
180
+ {snapshot.project.rootPath}
181
+ </p>
182
+ </section>
183
+ <ProjectRequests
184
+ requests={getOpenProjectRequests(snapshot)}
185
+ pendingRequestId={pendingRequestId}
186
+ onReply={reply}
187
+ />
188
+ <Tabs
189
+ value={tab}
190
+ onValueChange={(value) =>
191
+ navigate(`/projects/${encodeURIComponent(selectedProject.id)}/${value}`)
192
+ }
193
+ >
194
+ <div className="min-w-0 overflow-x-auto pb-1">
195
+ <TabsList className="w-max">
196
+ {PROJECT_TABS.map((key) => (
197
+ <TabsTrigger key={key} value={key}>
198
+ {t(`projects${key[0].toUpperCase()}${key.slice(1)}`)}
199
+ </TabsTrigger>
200
+ ))}
201
+ </TabsList>
202
+ </div>
203
+ <TabsContent value="overview" className="mt-4">
204
+ <ProjectOverview
205
+ hasProjectSessions={hasObservedProjectSessions(snapshot.sources)}
206
+ snapshot={snapshot}
207
+ shouldOfferObservationSetup={shouldOfferObservationSetup(snapshot.sources)}
208
+ onOpenFile={openProjectFile}
209
+ onStartObservationSetup={startObservationSetup}
210
+ onStartProjectWork={startProjectWork}
211
+ />
212
+ </TabsContent>
213
+ <TabsContent value="work" className="mt-4">
214
+ <ProjectWorkItems
215
+ hasProjectSessions={hasObservedProjectSessions(snapshot.sources)}
216
+ snapshot={snapshot}
217
+ shouldOfferObservationSetup={shouldOfferObservationSetup(snapshot.sources)}
218
+ onStartObservationSetup={startObservationSetup}
219
+ onStartProjectWork={startProjectWork}
220
+ />
221
+ </TabsContent>
222
+ <TabsContent value="artifacts" className="mt-4">
223
+ <ProjectArtifacts snapshot={snapshot} onOpenFile={openProjectFile} />
224
+ </TabsContent>
225
+ <TabsContent value="skills" className="mt-4">
226
+ <ProjectSkills
227
+ snapshot={snapshot}
228
+ onOpen={(skill) =>
229
+ presenter.chatThreadManager.openFilePreview({
230
+ path: skill.path,
231
+ label: skill.name,
232
+ viewMode: "preview",
233
+ previewViewer: "rendered",
234
+ })
235
+ }
236
+ />
237
+ </TabsContent>
238
+ <TabsContent value="agreement" className="mt-4">
239
+ <ProjectAgreement snapshot={snapshot} onOpenFile={openProjectFile} />
240
+ </TabsContent>
241
+ </Tabs>
242
+ </>
243
+ ) : null}
244
+ </div>
245
+ </main>
246
+ <ChatConversationWorkspaceSection
247
+ layoutMode="desktop"
248
+ sessionKey={null}
249
+ projectRoot={selectedProject.rootPath}
250
+ />
251
+ </>
252
+ );
253
+ }
@@ -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
+ });