@nextclaw/ui 0.23.0 → 0.24.0-beta.1

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 (123) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/assets/api-BqVIq0lF.js +13 -0
  3. package/dist/assets/{appearance-settings-page-C_AqxSF_.js → appearance-settings-page-CTWn1eAT.js} +1 -1
  4. package/dist/assets/{book-open-DYRapW_h.js → book-open-o2x7OLiO.js} +1 -1
  5. package/dist/assets/{channels-list-page-uR-M4Ls8.js → channels-list-page-Bzn-pzyJ.js} +2 -2
  6. package/dist/assets/{config-split-page-DJDp86Ne.js → config-split-page-CnRbXXlh.js} +1 -1
  7. package/dist/assets/{confirm-dialog-CHVBMpla.js → confirm-dialog-B_Hbcra2.js} +1 -1
  8. package/dist/assets/{createLucideIcon-CgM0rnsa.js → createLucideIcon-rwwpqyks.js} +1 -1
  9. package/dist/assets/{desktop-capabilities-page-phMuNq1B.js → desktop-capabilities-page-C2ayYQTr.js} +1 -1
  10. package/dist/assets/desktop-update-config-C13czpyL.js +1 -0
  11. package/dist/assets/{dist-N9zc5bkM.js → dist-BsF3hfOi.js} +1 -1
  12. package/dist/assets/{dist-BjYvUhPL.js → dist-aNN3iQxV.js} +1 -1
  13. package/dist/assets/{doc-browser-DwRLokCI.js → doc-browser-D8dIOo9E.js} +1 -1
  14. package/dist/assets/doc-browser-DcuOOtpP.js +1 -0
  15. package/dist/assets/{doc-browser-context-BDuypObS.js → doc-browser-context-Dhsit2Ma.js} +1 -1
  16. package/dist/assets/{extensions-CQKXTqTI.js → extensions-CD6xvKF_.js} +1 -1
  17. package/dist/assets/{form-actions-CzGmrPAv.js → form-actions-CC4Wz_lL.js} +1 -1
  18. package/dist/assets/index-BTsbdtv6.js +109 -0
  19. package/dist/assets/index-Bcfy2kTh.css +1 -0
  20. package/dist/assets/loader-circle-B6aFbB-8.js +1 -0
  21. package/dist/assets/mcp-marketplace-page-BtUAypA7.js +1 -0
  22. package/dist/assets/{mcp-marketplace-page-s9PTnSDT.js → mcp-marketplace-page-r2MmD9xO.js} +1 -1
  23. package/dist/assets/{model-config-page-CxnCUWRW.js → model-config-page-DboxoT-I.js} +1 -1
  24. package/dist/assets/{navigation-link-Ci4Z7Phi.js → navigation-link-C_6oj3zz.js} +1 -1
  25. package/dist/assets/plus-DRsq9Kvs.js +1 -0
  26. package/dist/assets/privacy-settings-page-C2sDBqhb.js +1 -0
  27. package/dist/assets/projects-BcPcx-uD.js +1 -0
  28. package/dist/assets/providers-config-page-CmUFl9RF.js +1 -0
  29. package/dist/assets/react-BjO9I4WZ.js +8 -0
  30. package/dist/assets/{refresh-cw-C-3_thgr.js → refresh-cw-DsT2kvXi.js} +1 -1
  31. package/dist/assets/remote-Ci6x91ES.js +1 -0
  32. package/dist/assets/{rotate-cw-DSFTq_yu.js → rotate-cw-BCAs6c3R.js} +1 -1
  33. package/dist/assets/runtime-config-page-cQKNeAjL.js +1 -0
  34. package/dist/assets/{save-CpPr78_S.js → save-CadRMTMQ.js} +1 -1
  35. package/dist/assets/{search-msqk2-rd.js → search-B6tB4fzB.js} +1 -1
  36. package/dist/assets/{search-config-page-D3_qNXbB.js → search-config-page-C8lOIkxn.js} +1 -1
  37. package/dist/assets/{secrets-config-page-Be4P1E8o.js → secrets-config-page-8clzAag2.js} +2 -2
  38. package/dist/assets/security-config-DPbM6IVT.js +1 -0
  39. package/dist/assets/{select-DPKCWajZ.js → select-CO9S_C2C.js} +1 -1
  40. package/dist/assets/skeleton-IHILNhIW.js +1 -0
  41. package/dist/assets/{tag-chip-Dc_NcCEe.js → tag-chip-BZKk0UkK.js} +1 -1
  42. package/dist/assets/x-BuvHIb1d.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/apps/components/__tests__/app-packages-panel.test.tsx +362 -241
  49. package/src/features/apps/components/app-document-access-section.tsx +187 -0
  50. package/src/features/apps/components/app-package-card.tsx +150 -275
  51. package/src/features/apps/hooks/use-app-packages.ts +91 -35
  52. package/src/features/chat/components/conversation/__tests__/chat-conversation-workspace-section.test.tsx +29 -0
  53. package/src/features/chat/components/conversation/chat-conversation-workspace-section.tsx +4 -2
  54. package/src/features/chat/components/layout/__tests__/chat-page-shell.test.tsx +21 -0
  55. package/src/features/chat/components/layout/chat-page-shell.tsx +19 -4
  56. package/src/features/chat/features/conversation/hooks/__tests__/use-session-conversation-controller.test.tsx +1 -1
  57. package/src/features/chat/features/conversation/hooks/use-session-conversation-controller.ts +0 -1
  58. package/src/features/chat/features/conversation/utils/__tests__/session-message-composer.utils.test.ts +11 -4
  59. package/src/features/chat/features/conversation/utils/__tests__/session-queued-input.utils.test.ts +0 -1
  60. package/src/features/chat/features/conversation/utils/session-message-composer.utils.ts +10 -11
  61. package/src/features/chat/features/input/input-surface-plugins/__tests__/context-reference-plugin.test.ts +1 -0
  62. package/src/features/chat/features/message/hooks/use-chat-message-actions.tsx +0 -2
  63. package/src/features/chat/features/runtime/utils/chat-runtime.utils.ts +0 -14
  64. package/src/features/chat/features/session/components/__tests__/chat-sidebar-project-groups.test.tsx +70 -22
  65. package/src/features/chat/features/session/components/chat-sidebar-project-groups.tsx +79 -32
  66. package/src/features/chat/features/session/utils/__tests__/chat-sidebar-session-groups.utils.test.ts +2 -0
  67. package/src/features/chat/features/session/utils/chat-run-metadata.utils.ts +0 -6
  68. package/src/features/chat/features/session/utils/chat-sidebar-session-groups.utils.ts +2 -0
  69. package/src/features/chat/features/welcome/utils/__tests__/chat-welcome-project-options.utils.test.ts +1 -0
  70. package/src/features/chat/index.ts +5 -1
  71. package/src/features/chat/managers/__tests__/chat-session-list.manager.test.ts +27 -0
  72. package/src/features/chat/pages/__tests__/ncp-chat-page.test.ts +2 -14
  73. package/src/features/chat/types/chat-run.types.ts +0 -1
  74. package/src/features/projects/AGENTS.md +7 -0
  75. package/src/features/projects/components/project-agreement.tsx +92 -0
  76. package/src/features/projects/components/project-artifacts.test.tsx +77 -0
  77. package/src/features/projects/components/project-artifacts.tsx +188 -0
  78. package/src/features/projects/components/project-overview.tsx +81 -0
  79. package/src/features/projects/components/project-requests.tsx +57 -0
  80. package/src/features/projects/components/project-runs.tsx +60 -0
  81. package/src/features/projects/components/project-section.tsx +60 -0
  82. package/src/features/projects/components/project-skills.tsx +53 -0
  83. package/src/features/projects/components/work/project-work-item-drawer.test.tsx +65 -0
  84. package/src/features/projects/components/work/project-work-item-drawer.tsx +367 -0
  85. package/src/features/projects/components/work/project-work-items.test.tsx +98 -0
  86. package/src/features/projects/components/work/project-work-items.tsx +199 -0
  87. package/src/features/projects/components/work/project-work-state-settings.tsx +285 -0
  88. package/src/features/projects/hooks/use-project-observation.test.tsx +93 -0
  89. package/src/features/projects/hooks/use-project-observation.ts +115 -0
  90. package/src/features/projects/hooks/use-project-work.test.tsx +58 -0
  91. package/src/features/projects/hooks/use-project-work.ts +215 -0
  92. package/src/features/projects/index.ts +2 -0
  93. package/src/features/projects/pages/project-home-page.tsx +234 -0
  94. package/src/features/projects/pages/projects-page.test.tsx +137 -0
  95. package/src/features/projects/presenters/project-home.presenter.test.ts +74 -0
  96. package/src/features/projects/presenters/project-home.presenter.ts +55 -0
  97. package/src/features/projects/utils/project-artifact-view.utils.test.ts +63 -0
  98. package/src/features/projects/utils/project-artifact-view.utils.ts +62 -0
  99. package/src/features/projects/utils/project-request-response.utils.test.ts +44 -0
  100. package/src/features/projects/utils/project-request-response.utils.ts +39 -0
  101. package/src/shared/lib/i18n/index.ts +6 -2
  102. package/src/shared/lib/i18n/locales/en-US/doc-browser.json +16 -1
  103. package/src/shared/lib/i18n/locales/en-US/projects.json +149 -0
  104. package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +16 -1
  105. package/src/shared/lib/i18n/locales/zh-CN/projects.json +149 -0
  106. package/src/shared/lib/ui-document-title/__tests__/ui-document-title.test.ts +6 -0
  107. package/src/shared/lib/ui-document-title/index.ts +1 -0
  108. package/dist/assets/api-n96IfCKb.js +0 -13
  109. package/dist/assets/desktop-update-config-C77hkbor.js +0 -1
  110. package/dist/assets/doc-browser-JlXVLNRe.js +0 -1
  111. package/dist/assets/index-CLw9DuCJ.js +0 -109
  112. package/dist/assets/index-CwCTiMM9.css +0 -1
  113. package/dist/assets/loader-circle-D9etMT9f.js +0 -1
  114. package/dist/assets/mcp-marketplace-page-CbWOamyW.js +0 -1
  115. package/dist/assets/plus-DKGD04AO.js +0 -1
  116. package/dist/assets/privacy-settings-page-D8HZHHjL.js +0 -1
  117. package/dist/assets/providers-config-page-C0GIoGxm.js +0 -1
  118. package/dist/assets/react-CVFx225w.js +0 -8
  119. package/dist/assets/remote-DvTCSAxQ.js +0 -1
  120. package/dist/assets/runtime-config-page-VNJZui5f.js +0 -1
  121. package/dist/assets/security-config-BtcpKIhQ.js +0 -1
  122. package/dist/assets/skeleton-BsR5rwB7.js +0 -1
  123. package/dist/assets/x-Dp5GAFmP.js +0 -1
@@ -0,0 +1,215 @@
1
+ import { useEffect } from "react";
2
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3
+ import type {
4
+ CreateProjectWorkItemInput,
5
+ CreateProjectWorkStateInput,
6
+ ProjectWorkState,
7
+ UpdateProjectWorkItemInput,
8
+ UpdateProjectWorkStateInput,
9
+ } from "@nextclaw/client-sdk";
10
+ import { eventKeys } from "@nextclaw/client-sdk";
11
+ import { nextclawClient } from "@/shared/lib/api";
12
+
13
+ export const projectWorkQueryKey = (projectId: string) =>
14
+ ["project-work", projectId] as const;
15
+ export const projectWorkSummaryQueryKey = (projectId: string) =>
16
+ ["project-work-summary", projectId] as const;
17
+ export const projectWorkItemQueryKey = (
18
+ projectId: string,
19
+ workItemId: string,
20
+ ) => ["project-work-item", projectId, workItemId] as const;
21
+ export const projectWorkActivityQueryKey = (
22
+ projectId: string,
23
+ workItemId: string,
24
+ ) => ["project-work-activity", projectId, workItemId] as const;
25
+
26
+ export function useProjectWork(
27
+ projectId: string | null,
28
+ includeDeleted = false,
29
+ ) {
30
+ useProjectWorkEvents(projectId);
31
+ return useQuery({
32
+ queryKey: [...projectWorkQueryKey(projectId ?? ""), includeDeleted],
33
+ enabled: Boolean(projectId),
34
+ queryFn: async () =>
35
+ await nextclawClient.projects.listWork(projectId!, includeDeleted),
36
+ });
37
+ }
38
+
39
+ export function useProjectWorkSummary(projectId: string | null) {
40
+ useProjectWorkEvents(projectId);
41
+ return useQuery({
42
+ queryKey: projectWorkSummaryQueryKey(projectId ?? ""),
43
+ enabled: Boolean(projectId),
44
+ queryFn: async () =>
45
+ await nextclawClient.projects.getWorkSummary(projectId!),
46
+ });
47
+ }
48
+
49
+ export function useProjectWorkItem(
50
+ projectId: string | null,
51
+ workItemId: string | null,
52
+ ) {
53
+ return useQuery({
54
+ queryKey: projectWorkItemQueryKey(projectId ?? "", workItemId ?? ""),
55
+ enabled: Boolean(projectId && workItemId),
56
+ queryFn: async () =>
57
+ await nextclawClient.projects.getWorkItem(projectId!, workItemId!),
58
+ });
59
+ }
60
+
61
+ export function useProjectWorkActivity(
62
+ projectId: string | null,
63
+ workItemId: string | null,
64
+ ) {
65
+ return useQuery({
66
+ queryKey: projectWorkActivityQueryKey(projectId ?? "", workItemId ?? ""),
67
+ enabled: Boolean(projectId && workItemId),
68
+ queryFn: async () =>
69
+ await nextclawClient.projects.listWorkItemActivities(
70
+ projectId!,
71
+ workItemId!,
72
+ { limit: 100 },
73
+ ),
74
+ });
75
+ }
76
+
77
+ export function useProjectWorkActions(projectId: string) {
78
+ const queryClient = useQueryClient();
79
+ const refresh = async (workItemId?: string) => {
80
+ await Promise.all([
81
+ queryClient.invalidateQueries({
82
+ queryKey: projectWorkQueryKey(projectId),
83
+ }),
84
+ queryClient.invalidateQueries({
85
+ queryKey: projectWorkSummaryQueryKey(projectId),
86
+ }),
87
+ ...(workItemId
88
+ ? [
89
+ queryClient.invalidateQueries({
90
+ queryKey: projectWorkItemQueryKey(projectId, workItemId),
91
+ }),
92
+ queryClient.invalidateQueries({
93
+ queryKey: projectWorkActivityQueryKey(projectId, workItemId),
94
+ }),
95
+ ]
96
+ : []),
97
+ ]);
98
+ };
99
+ return {
100
+ createItem: useMutation({
101
+ mutationFn: async (input: CreateProjectWorkItemInput) =>
102
+ await nextclawClient.projects.createWorkItem(projectId, input),
103
+ onSuccess: async (item) => await refresh(item.id),
104
+ }),
105
+ updateItem: useMutation({
106
+ mutationFn: async (input: {
107
+ workItemId: string;
108
+ patch: UpdateProjectWorkItemInput;
109
+ }) =>
110
+ await nextclawClient.projects.updateWorkItem(
111
+ projectId,
112
+ input.workItemId,
113
+ input.patch,
114
+ ),
115
+ onSuccess: async (item) => await refresh(item.id),
116
+ }),
117
+ deleteItem: useMutation({
118
+ mutationFn: async (workItemId: string) =>
119
+ await nextclawClient.projects.deleteWorkItem(projectId, workItemId),
120
+ onSuccess: async (item) => await refresh(item.id),
121
+ }),
122
+ restoreItem: useMutation({
123
+ mutationFn: async (workItemId: string) =>
124
+ await nextclawClient.projects.restoreWorkItem(projectId, workItemId),
125
+ onSuccess: async (item) => await refresh(item.id),
126
+ }),
127
+ linkArtifact: useMutation({
128
+ mutationFn: async (input: {
129
+ workItemId: string;
130
+ path: string;
131
+ label?: string;
132
+ }) =>
133
+ await nextclawClient.projects.linkWorkItemArtifact(
134
+ projectId,
135
+ input.workItemId,
136
+ { path: input.path, ...(input.label ? { label: input.label } : {}) },
137
+ ),
138
+ onSuccess: async (_link, input) => await refresh(input.workItemId),
139
+ }),
140
+ unlinkArtifact: useMutation({
141
+ mutationFn: async (input: {
142
+ workItemId: string;
143
+ artifactLinkId: string;
144
+ }) =>
145
+ await nextclawClient.projects.unlinkWorkItemArtifact(
146
+ projectId,
147
+ input.workItemId,
148
+ input.artifactLinkId,
149
+ ),
150
+ onSuccess: async (_result, input) => await refresh(input.workItemId),
151
+ }),
152
+ createState: useMutation({
153
+ mutationFn: async (input: CreateProjectWorkStateInput) =>
154
+ await nextclawClient.projects.createWorkState(projectId, input),
155
+ onSuccess: async () => await refresh(),
156
+ }),
157
+ updateState: useMutation({
158
+ mutationFn: async (input: {
159
+ stateId: string;
160
+ patch: UpdateProjectWorkStateInput;
161
+ }) =>
162
+ await nextclawClient.projects.updateWorkState(
163
+ projectId,
164
+ input.stateId,
165
+ input.patch,
166
+ ),
167
+ onSuccess: async () => await refresh(),
168
+ }),
169
+ deleteState: useMutation({
170
+ mutationFn: async (input: {
171
+ stateId: string;
172
+ migrateToStateId?: string | null;
173
+ }) =>
174
+ await nextclawClient.projects.deleteWorkState(
175
+ projectId,
176
+ input.stateId,
177
+ input.migrateToStateId,
178
+ ),
179
+ onSuccess: async () => await refresh(),
180
+ }),
181
+ };
182
+ }
183
+
184
+ function useProjectWorkEvents(projectId: string | null): void {
185
+ const queryClient = useQueryClient();
186
+ useEffect(() => {
187
+ if (!projectId) return;
188
+ return nextclawClient.eventBus.on(eventKeys.projectWorkChanged, (event) => {
189
+ if (event.projectId !== projectId) return;
190
+ void queryClient.invalidateQueries({
191
+ queryKey: projectWorkQueryKey(projectId),
192
+ });
193
+ void queryClient.invalidateQueries({
194
+ queryKey: projectWorkSummaryQueryKey(projectId),
195
+ });
196
+ if (event.workItemId) {
197
+ void queryClient.invalidateQueries({
198
+ queryKey: projectWorkItemQueryKey(projectId, event.workItemId),
199
+ });
200
+ void queryClient.invalidateQueries({
201
+ queryKey: projectWorkActivityQueryKey(projectId, event.workItemId),
202
+ });
203
+ }
204
+ });
205
+ }, [projectId, queryClient]);
206
+ }
207
+
208
+ export function sortProjectWorkStates(
209
+ states: ProjectWorkState[],
210
+ ): ProjectWorkState[] {
211
+ return [...states].sort(
212
+ (left, right) =>
213
+ left.position - right.position || left.name.localeCompare(right.name),
214
+ );
215
+ }
@@ -0,0 +1,2 @@
1
+ /** Public UI boundary for Projects. */
2
+ export { ProjectsPage } from "./pages/project-home-page";
@@ -0,0 +1,234 @@
1
+ import { useState } from "react";
2
+ import { useNavigate, useParams } from "react-router-dom";
3
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
4
+ import type { ObservedRequest } from "@nextclaw/client-sdk";
5
+ import { toast } from "sonner";
6
+ import { useProjects } from "@/shared/hooks/use-projects";
7
+ import { useViewportLayout } from "@/app/hooks/use-viewport-layout";
8
+ import { t } from "@/shared/lib/i18n";
9
+ import {
10
+ Tabs,
11
+ TabsContent,
12
+ TabsList,
13
+ TabsTrigger,
14
+ } from "@/shared/components/ui/tabs";
15
+ import { ProjectAgreement } from "@/features/projects/components/project-agreement";
16
+ import { ProjectArtifacts } from "@/features/projects/components/project-artifacts";
17
+ import { ProjectOverview } from "@/features/projects/components/project-overview";
18
+ import { ProjectRequests } from "@/features/projects/components/project-requests";
19
+ import { ProjectSkills } from "@/features/projects/components/project-skills";
20
+ import { ProjectWorkItemDrawer } from "@/features/projects/components/work/project-work-item-drawer";
21
+ import { ProjectWorkItems } from "@/features/projects/components/work/project-work-items";
22
+ import {
23
+ ChatConversationWorkspaceSection,
24
+ usePresenter,
25
+ } from "@/features/chat";
26
+ import {
27
+ projectObservationQueryKey,
28
+ useProjectObservation,
29
+ } from "@/features/projects/hooks/use-project-observation";
30
+ import {
31
+ getOpenProjectRequests,
32
+ isProjectHomeTab,
33
+ type ProjectHomeTab,
34
+ } from "@/features/projects/presenters/project-home.presenter";
35
+ import {
36
+ sendProjectRequestResponse,
37
+ type ProjectRequestDecision,
38
+ } from "@/features/projects/utils/project-request-response.utils";
39
+
40
+ const PROJECT_TABS: ProjectHomeTab[] = [
41
+ "overview",
42
+ "work",
43
+ "artifacts",
44
+ "skills",
45
+ "agreement",
46
+ ];
47
+
48
+ export function ProjectsPage() {
49
+ const { projectId, tab: tabParam } = useParams<{
50
+ projectId?: string;
51
+ tab?: string;
52
+ }>();
53
+ const navigate = useNavigate();
54
+ const presenter = usePresenter();
55
+ const queryClient = useQueryClient();
56
+ const { isMobile } = useViewportLayout();
57
+ const projects = useProjects();
58
+ const [selectedWorkItemId, setSelectedWorkItemId] = useState<string | null>(
59
+ null,
60
+ );
61
+ const [pendingRequestId, setPendingRequestId] = useState<string | null>(null);
62
+ const registered = projects.data?.projects ?? [];
63
+ const selectedProject =
64
+ registered.find((project) => project.id === projectId) ?? null;
65
+ const tab: ProjectHomeTab | null = isProjectHomeTab(tabParam)
66
+ ? tabParam
67
+ : null;
68
+ const needsObservation =
69
+ tab === "artifacts" || tab === "skills" || tab === "agreement";
70
+ const observation = useProjectObservation(
71
+ selectedProject?.id ?? null,
72
+ selectedProject?.rootPath ?? null,
73
+ needsObservation,
74
+ );
75
+ const response = useMutation({
76
+ mutationFn: sendProjectRequestResponse,
77
+ onMutate: (input) => setPendingRequestId(input.requestId),
78
+ onSuccess: async () => {
79
+ if (selectedProject)
80
+ await queryClient.invalidateQueries({
81
+ queryKey: projectObservationQueryKey(selectedProject.id),
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
+ const reply = (
92
+ request: ObservedRequest,
93
+ decision: ProjectRequestDecision,
94
+ ) => {
95
+ if (!request.reference.sessionId) return;
96
+ response.mutate({
97
+ requestId: request.id,
98
+ sessionId: request.reference.sessionId,
99
+ decision,
100
+ prompt: request.prompt,
101
+ });
102
+ };
103
+
104
+ if (projects.isLoading)
105
+ return (
106
+ <main className="flex h-full items-center justify-center p-6 text-sm text-muted-foreground">
107
+ {t("projectsLoading")}
108
+ </main>
109
+ );
110
+ if (projects.isError)
111
+ return (
112
+ <main className="p-6 text-sm text-destructive">
113
+ {t("projectsLoadFailed")}: {projects.error.message}
114
+ </main>
115
+ );
116
+ if (!registered.length)
117
+ return (
118
+ <main className="p-6 text-sm text-muted-foreground">
119
+ {t("projectsEmpty")}
120
+ </main>
121
+ );
122
+ if (!selectedProject || !tab)
123
+ return (
124
+ <main className="p-6 text-sm text-muted-foreground">
125
+ {t("projectsChoose")}
126
+ </main>
127
+ );
128
+
129
+ const openProjectFile = (path: string, label: string) =>
130
+ presenter.chatThreadManager.openFilePreview({
131
+ path,
132
+ label,
133
+ viewMode: "preview",
134
+ previewViewer: "rendered",
135
+ });
136
+ const snapshot = observation.data;
137
+ return (
138
+ <>
139
+ <main className="h-full min-w-0 flex-1 overflow-y-auto p-3 sm:p-4 md:p-6">
140
+ <div className="mx-auto max-w-6xl space-y-4">
141
+ <header className="rounded-2xl border border-border/60 bg-card p-3 sm:p-4">
142
+ <h1 className="text-lg font-semibold">{selectedProject.name}</h1>
143
+ <p className="mt-1 break-all text-xs text-muted-foreground">
144
+ {selectedProject.rootPath}
145
+ </p>
146
+ </header>
147
+ {snapshot ? (
148
+ <ProjectRequests
149
+ requests={getOpenProjectRequests(snapshot)}
150
+ pendingRequestId={pendingRequestId}
151
+ onReply={reply}
152
+ />
153
+ ) : null}
154
+ <Tabs
155
+ value={tab}
156
+ onValueChange={(value) =>
157
+ navigate(
158
+ `/projects/${encodeURIComponent(selectedProject.id)}/${value}`,
159
+ )
160
+ }
161
+ >
162
+ <div className="min-w-0 overflow-x-auto pb-1">
163
+ <TabsList className="w-max">
164
+ {PROJECT_TABS.map((key) => (
165
+ <TabsTrigger key={key} value={key}>
166
+ {t(`projects${key[0].toUpperCase()}${key.slice(1)}`)}
167
+ </TabsTrigger>
168
+ ))}
169
+ </TabsList>
170
+ </div>
171
+ <TabsContent value="overview" className="mt-4">
172
+ <ProjectOverview
173
+ projectId={selectedProject.id}
174
+ onOpenWorkItem={setSelectedWorkItemId}
175
+ />
176
+ </TabsContent>
177
+ <TabsContent value="work" className="mt-4">
178
+ <ProjectWorkItems
179
+ projectId={selectedProject.id}
180
+ onOpenWorkItem={setSelectedWorkItemId}
181
+ />
182
+ </TabsContent>
183
+ {needsObservation && observation.isLoading ? (
184
+ <div className="mt-4 rounded-xl border border-border/60 p-5 text-sm text-muted-foreground">
185
+ {t("projectsLoading")}
186
+ </div>
187
+ ) : null}
188
+ {needsObservation && observation.isError ? (
189
+ <div className="mt-4 rounded-xl border border-destructive/40 p-5 text-sm text-destructive">
190
+ {t("projectsLoadFailed")}: {observation.error.message}
191
+ </div>
192
+ ) : null}
193
+ {snapshot ? (
194
+ <>
195
+ <TabsContent value="artifacts" className="mt-4">
196
+ <ProjectArtifacts
197
+ snapshot={snapshot}
198
+ onOpenFile={openProjectFile}
199
+ />
200
+ </TabsContent>
201
+ <TabsContent value="skills" className="mt-4">
202
+ <ProjectSkills
203
+ snapshot={snapshot}
204
+ onOpen={(skill) => openProjectFile(skill.path, skill.name)}
205
+ />
206
+ </TabsContent>
207
+ <TabsContent value="agreement" className="mt-4">
208
+ <ProjectAgreement
209
+ snapshot={snapshot}
210
+ onOpenFile={openProjectFile}
211
+ />
212
+ </TabsContent>
213
+ </>
214
+ ) : null}
215
+ </Tabs>
216
+ </div>
217
+ </main>
218
+ <ProjectWorkItemDrawer
219
+ projectId={selectedProject.id}
220
+ projectRoot={selectedProject.rootPath}
221
+ workItemId={selectedWorkItemId}
222
+ onOpenChange={(open) => {
223
+ if (!open) setSelectedWorkItemId(null);
224
+ }}
225
+ onOpenArtifact={openProjectFile}
226
+ />
227
+ <ChatConversationWorkspaceSection
228
+ layoutMode={isMobile ? "mobile" : "desktop"}
229
+ sessionKey={null}
230
+ projectRoot={selectedProject.rootPath}
231
+ />
232
+ </>
233
+ );
234
+ }
@@ -0,0 +1,137 @@
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 { ProjectsPage } from "./project-home-page";
6
+
7
+ const mocks = vi.hoisted(() => ({
8
+ drawerProps: vi.fn(),
9
+ useProjectObservation: vi.fn(),
10
+ useProjects: vi.fn(),
11
+ }));
12
+
13
+ vi.mock("@/shared/hooks/use-projects", () => ({
14
+ useProjects: mocks.useProjects,
15
+ }));
16
+ vi.mock("@/features/projects/hooks/use-project-observation", () => ({
17
+ projectObservationQueryKey: (projectId: string) => [
18
+ "project-observation",
19
+ projectId,
20
+ ],
21
+ useProjectObservation: mocks.useProjectObservation,
22
+ }));
23
+ vi.mock("@/features/chat", () => ({
24
+ ChatConversationWorkspaceSection: () => <div data-testid="workspace" />,
25
+ usePresenter: () => ({ chatThreadManager: { openFilePreview: vi.fn() } }),
26
+ }));
27
+ vi.mock("@/app/hooks/use-viewport-layout", () => ({
28
+ useViewportLayout: () => ({ isMobile: false }),
29
+ }));
30
+ vi.mock("@/features/projects/components/project-overview", () => ({
31
+ ProjectOverview: ({
32
+ onOpenWorkItem,
33
+ }: {
34
+ onOpenWorkItem: (id: string) => void;
35
+ }) => (
36
+ <button onClick={() => onOpenWorkItem("work-overview")}>
37
+ Overview work
38
+ </button>
39
+ ),
40
+ }));
41
+ vi.mock("@/features/projects/components/work/project-work-items", () => ({
42
+ ProjectWorkItems: ({
43
+ onOpenWorkItem,
44
+ }: {
45
+ onOpenWorkItem: (id: string) => void;
46
+ }) => <button onClick={() => onOpenWorkItem("work-list")}>List work</button>,
47
+ }));
48
+ vi.mock("@/features/projects/components/work/project-work-item-drawer", () => ({
49
+ ProjectWorkItemDrawer: ({ workItemId }: { workItemId: string | null }) => {
50
+ mocks.drawerProps({ workItemId });
51
+ return <div data-testid="work-drawer">{workItemId}</div>;
52
+ },
53
+ }));
54
+ vi.mock("@/features/projects/components/project-artifacts", () => ({
55
+ ProjectArtifacts: () => <div>Artifact content</div>,
56
+ }));
57
+ vi.mock("@/features/projects/components/project-skills", () => ({
58
+ ProjectSkills: () => <div>Skills content</div>,
59
+ }));
60
+ vi.mock("@/features/projects/components/project-agreement", () => ({
61
+ ProjectAgreement: () => <div>Agreement content</div>,
62
+ }));
63
+ vi.mock("@/features/projects/components/project-requests", () => ({
64
+ ProjectRequests: () => null,
65
+ }));
66
+
67
+ function renderPage(path: string) {
68
+ return render(
69
+ <QueryClientProvider client={new QueryClient()}>
70
+ <MemoryRouter initialEntries={[path]}>
71
+ <Routes>
72
+ <Route path="/projects" element={<ProjectsPage />} />
73
+ <Route path="/projects/:projectId/:tab" element={<ProjectsPage />} />
74
+ </Routes>
75
+ </MemoryRouter>
76
+ </QueryClientProvider>,
77
+ );
78
+ }
79
+
80
+ describe("ProjectsPage", () => {
81
+ beforeEach(() => {
82
+ mocks.drawerProps.mockReset();
83
+ mocks.useProjects.mockReturnValue({
84
+ data: {
85
+ projects: [
86
+ { id: "project-1", name: "Research", rootPath: "/tmp/research" },
87
+ ],
88
+ },
89
+ isLoading: false,
90
+ isError: false,
91
+ });
92
+ mocks.useProjectObservation.mockReturnValue({
93
+ data: undefined,
94
+ isLoading: false,
95
+ isError: false,
96
+ });
97
+ });
98
+
99
+ it("keeps overview scan-free and opens its work item in the unified drawer", () => {
100
+ renderPage("/projects/project-1/overview");
101
+
102
+ expect(mocks.useProjectObservation).toHaveBeenCalledWith(
103
+ "project-1",
104
+ "/tmp/research",
105
+ false,
106
+ );
107
+ expect(screen.getAllByRole("tab")).toHaveLength(5);
108
+ fireEvent.click(screen.getByRole("button", { name: "Overview work" }));
109
+ expect(screen.getByTestId("work-drawer").textContent).toBe("work-overview");
110
+ });
111
+
112
+ it("enables legacy observation only for preserved artifact, skill, and agreement surfaces", () => {
113
+ mocks.useProjectObservation.mockReturnValue({
114
+ data: { requests: [] },
115
+ isLoading: false,
116
+ isError: false,
117
+ });
118
+ renderPage("/projects/project-1/artifacts");
119
+
120
+ expect(mocks.useProjectObservation).toHaveBeenCalledWith(
121
+ "project-1",
122
+ "/tmp/research",
123
+ true,
124
+ );
125
+ expect(screen.getByText("Artifact content")).toBeTruthy();
126
+ expect(screen.getByRole("tab", { name: /Skills/ })).toBeTruthy();
127
+ expect(
128
+ screen.getByRole("tab", { name: /Working rules|工作约定/ }),
129
+ ).toBeTruthy();
130
+ });
131
+
132
+ it("asks the user to choose from the sidebar when no project route is selected", () => {
133
+ renderPage("/projects");
134
+ expect(mocks.useProjectObservation).toHaveBeenCalledWith(null, null, false);
135
+ expect(screen.getByText(/Choose a project|选择项目/)).toBeTruthy();
136
+ });
137
+ });
@@ -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
+ });