@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,78 @@
1
+ import { fireEvent, render, screen } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import type { ProjectObservationSnapshot } from "@nextclaw/client-sdk";
4
+ import { ProjectWorkItems } from "./project-work-items";
5
+
6
+ const reference = {
7
+ kind: "ai-report" as const,
8
+ label: "AI report",
9
+ observedAt: "2026-08-30T00:00:00.000Z",
10
+ sessionId: "session-1",
11
+ };
12
+
13
+ function createSnapshot(): ProjectObservationSnapshot {
14
+ return {
15
+ asOf: reference.observedAt,
16
+ project: { name: "Demo", rootPath: "/tmp/demo", context: [] },
17
+ sources: [],
18
+ workflows: [{
19
+ id: "research",
20
+ label: "Research",
21
+ stages: [{ id: "draft", label: "Draft" }],
22
+ reference,
23
+ }],
24
+ runs: [],
25
+ workItems: [{
26
+ id: "report",
27
+ name: "Write the report",
28
+ status: "active",
29
+ workflowId: "research",
30
+ stageId: "draft",
31
+ updatedAt: reference.observedAt,
32
+ reference,
33
+ }],
34
+ artifactCategories: [], artifacts: [], signals: [], requests: [], activity: [], skills: [], diagnostics: [],
35
+ dataQuality: "complete",
36
+ };
37
+ }
38
+
39
+ describe("ProjectWorkItems", () => {
40
+ it("switches list, board, and gantt over one snapshot without fabricating a schedule", () => {
41
+ render(
42
+ <ProjectWorkItems
43
+ hasProjectSessions
44
+ snapshot={createSnapshot()}
45
+ shouldOfferObservationSetup={false}
46
+ onStartObservationSetup={vi.fn()}
47
+ onStartProjectWork={vi.fn()}
48
+ />,
49
+ );
50
+ expect(screen.getByText("Write the report")).toBeTruthy();
51
+
52
+ fireEvent.click(screen.getByRole("button", { name: /Board|看板/ }));
53
+ expect(screen.getByText("Draft")).toBeTruthy();
54
+ expect(screen.getByText("Write the report")).toBeTruthy();
55
+
56
+ fireEvent.click(screen.getByRole("button", { name: /Gantt|甘特图/ }));
57
+ expect(screen.getByText(/No schedule markers|没有 schedule Marker/)).toBeTruthy();
58
+ });
59
+
60
+ it("offers an ordinary project conversation before rendering empty view controls", () => {
61
+ const onStartProjectWork = vi.fn();
62
+ render(
63
+ <ProjectWorkItems
64
+ hasProjectSessions={false}
65
+ snapshot={{ ...createSnapshot(), workItems: [] }}
66
+ shouldOfferObservationSetup={false}
67
+ onStartObservationSetup={vi.fn()}
68
+ onStartProjectWork={onStartProjectWork}
69
+ />,
70
+ );
71
+
72
+ expect(screen.queryByRole("group", { name: /Work item views|工作项视图/ })).toBeNull();
73
+ fireEvent.click(
74
+ screen.getByRole("button", { name: /Start project work|开始项目工作/ }),
75
+ );
76
+ expect(onStartProjectWork).toHaveBeenCalledOnce();
77
+ });
78
+ });
@@ -0,0 +1,272 @@
1
+ import { useState } from "react";
2
+ import type {
3
+ ObservedWorkItem,
4
+ ProjectObservationSnapshot,
5
+ } from "@nextclaw/client-sdk";
6
+ import { Button } from "@/shared/components/ui/button";
7
+ import { t } from "@/shared/lib/i18n";
8
+ import {
9
+ createProjectBoardColumns,
10
+ getScheduledWorkItems,
11
+ getUnstagedWorkItems,
12
+ type ProjectWorkView,
13
+ } from "@/features/projects/presenters/project-home.presenter";
14
+ import { ProjectEmptyState } from "./project-section";
15
+ import { ProjectRuns } from "./project-runs";
16
+
17
+ function WorkItemCard({
18
+ item,
19
+ onSelect,
20
+ }: {
21
+ item: ObservedWorkItem;
22
+ onSelect: () => void;
23
+ }) {
24
+ return (
25
+ <button
26
+ type="button"
27
+ className="w-full rounded-xl bg-card p-3 text-left text-sm shadow-sm hover:bg-[var(--interaction-hover)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border"
28
+ onClick={onSelect}
29
+ >
30
+ <span className="block font-medium">{item.name}</span>
31
+ <span className="mt-1 block text-xs text-muted-foreground">
32
+ {item.reference.label} · {t(`projectsStatus_${item.status}`)}
33
+ </span>
34
+ </button>
35
+ );
36
+ }
37
+
38
+ function ProjectWorkItemsEmptyState({
39
+ hasProjectSessions,
40
+ onStartObservationSetup,
41
+ onStartProjectWork,
42
+ shouldOfferObservationSetup,
43
+ }: {
44
+ hasProjectSessions: boolean;
45
+ onStartObservationSetup: () => void;
46
+ onStartProjectWork: () => void;
47
+ shouldOfferObservationSetup: boolean;
48
+ }) {
49
+ const messageKey = shouldOfferObservationSetup
50
+ ? "projectsWorkNeedsSetup"
51
+ : hasProjectSessions
52
+ ? "projectsWorkNoReports"
53
+ : "projectsWorkNoSessions";
54
+ return (
55
+ <div className="min-w-0">
56
+ <ProjectEmptyState>{t(messageKey)}</ProjectEmptyState>
57
+ <Button
58
+ size="sm"
59
+ variant="outline"
60
+ onClick={
61
+ shouldOfferObservationSetup
62
+ ? onStartObservationSetup
63
+ : onStartProjectWork
64
+ }
65
+ >
66
+ {t(
67
+ shouldOfferObservationSetup
68
+ ? "projectsObservationSetupAction"
69
+ : "projectsStartProjectWork",
70
+ )}
71
+ </Button>
72
+ </div>
73
+ );
74
+ }
75
+
76
+ export function ProjectWorkItems({
77
+ hasProjectSessions,
78
+ onStartObservationSetup,
79
+ onStartProjectWork,
80
+ snapshot,
81
+ shouldOfferObservationSetup,
82
+ }: {
83
+ hasProjectSessions: boolean;
84
+ onStartObservationSetup: () => void;
85
+ onStartProjectWork: () => void;
86
+ snapshot: ProjectObservationSnapshot;
87
+ shouldOfferObservationSetup: boolean;
88
+ }) {
89
+ const [view, setView] = useState<ProjectWorkView>("list");
90
+ const [selectedId, setSelectedId] = useState<string | null>(null);
91
+ const selected =
92
+ snapshot.workItems.find((item) => item.id === selectedId) ?? null;
93
+ const columns = createProjectBoardColumns(
94
+ snapshot.workflows,
95
+ snapshot.workItems,
96
+ );
97
+ const unstaged = getUnstagedWorkItems(snapshot.workItems);
98
+ const scheduled = getScheduledWorkItems(snapshot.workItems);
99
+ return (
100
+ <section className="min-w-0" aria-label={t("projectsWork")}>
101
+ {(snapshot.runs?.length ?? 0) > 0 ? (
102
+ <div className="mb-5">
103
+ <h2 className="mb-2 text-sm font-semibold">{t("projectsAiActivity")}</h2>
104
+ <ProjectRuns snapshot={snapshot} />
105
+ </div>
106
+ ) : null}
107
+ {snapshot.workItems.length === 0 ? (
108
+ <ProjectWorkItemsEmptyState
109
+ hasProjectSessions={hasProjectSessions}
110
+ onStartObservationSetup={onStartObservationSetup}
111
+ onStartProjectWork={onStartProjectWork}
112
+ shouldOfferObservationSetup={shouldOfferObservationSetup}
113
+ />
114
+ ) : (
115
+ <>
116
+ <div
117
+ className="mb-4 flex flex-wrap gap-1"
118
+ role="group"
119
+ aria-label={t("projectsWorkViews")}
120
+ >
121
+ {(["list", "board", "gantt"] as const).map((key) => (
122
+ <Button
123
+ key={key}
124
+ size="sm"
125
+ variant={view === key ? "secondary" : "ghost"}
126
+ aria-pressed={view === key}
127
+ onClick={() => setView(key)}
128
+ >
129
+ {t(`projectsWork${key[0].toUpperCase()}${key.slice(1)}`)}
130
+ </Button>
131
+ ))}
132
+ </div>
133
+ {view === "list" ? (
134
+ <div className="space-y-2">
135
+ {snapshot.workItems.map((item) => (
136
+ <WorkItemCard
137
+ key={item.id}
138
+ item={item}
139
+ onSelect={() => setSelectedId(item.id)}
140
+ />
141
+ ))}
142
+ </div>
143
+ ) : null}
144
+ {view === "board" ? (
145
+ columns.length ? (
146
+ <div className="grid min-w-0 gap-3 md:grid-cols-2 xl:grid-cols-4">
147
+ {columns.map((column) => (
148
+ <section
149
+ key={column.id}
150
+ className="min-w-0 rounded-xl bg-muted/35 p-3"
151
+ >
152
+ <h3 className="mb-3 text-xs font-semibold">{column.label}</h3>
153
+ <div className="space-y-2">
154
+ {column.items.map((item) => (
155
+ <WorkItemCard
156
+ key={item.id}
157
+ item={item}
158
+ onSelect={() => setSelectedId(item.id)}
159
+ />
160
+ ))}
161
+ {!column.items.length ? (
162
+ <p className="py-3 text-xs text-muted-foreground">
163
+ {t("projectsNoData")}
164
+ </p>
165
+ ) : null}
166
+ </div>
167
+ </section>
168
+ ))}
169
+ {unstaged.length ? (
170
+ <section className="min-w-0 rounded-xl bg-muted/35 p-3">
171
+ <h3 className="mb-3 text-xs font-semibold">
172
+ {t("projectsUnstaged")}
173
+ </h3>
174
+ <div className="space-y-2">
175
+ {unstaged.map((item) => (
176
+ <WorkItemCard
177
+ key={item.id}
178
+ item={item}
179
+ onSelect={() => setSelectedId(item.id)}
180
+ />
181
+ ))}
182
+ </div>
183
+ </section>
184
+ ) : null}
185
+ </div>
186
+ ) : (
187
+ <ProjectEmptyState>
188
+ {t("projectsBoardNeedsWorkflow")}
189
+ </ProjectEmptyState>
190
+ )
191
+ ) : null}
192
+ {view === "gantt" ? (
193
+ scheduled.length ? (
194
+ <div className="overflow-x-auto rounded-xl border border-border/60">
195
+ <div className="min-w-[42rem] divide-y divide-border/60">
196
+ {scheduled.map((item) => (
197
+ <button
198
+ key={item.id}
199
+ type="button"
200
+ className="grid w-full grid-cols-[minmax(12rem,1fr)_9rem_9rem] gap-3 px-4 py-3 text-left text-sm hover:bg-[var(--interaction-hover)]"
201
+ onClick={() => setSelectedId(item.id)}
202
+ >
203
+ <span className="truncate font-medium">{item.name}</span>
204
+ <span>{item.schedule?.start ?? "—"}</span>
205
+ <span>{item.schedule?.end ?? "—"}</span>
206
+ </button>
207
+ ))}
208
+ </div>
209
+ </div>
210
+ ) : (
211
+ <ProjectEmptyState>
212
+ {t("projectsGanttNeedsSchedule")}
213
+ </ProjectEmptyState>
214
+ )
215
+ ) : null}
216
+ {selected ? (
217
+ <aside
218
+ className="mt-4 rounded-xl border border-border/60 bg-muted/25 p-4"
219
+ aria-label={t("projectsWorkDetails")}
220
+ >
221
+ <div className="flex items-start justify-between gap-3">
222
+ <div>
223
+ <h3 className="font-semibold">{selected.name}</h3>
224
+ <p className="mt-1 text-xs text-muted-foreground">
225
+ {selected.id} · {selected.reference.label}
226
+ </p>
227
+ </div>
228
+ <Button
229
+ size="sm"
230
+ variant="ghost"
231
+ onClick={() => setSelectedId(null)}
232
+ >
233
+ {t("projectsCloseDetails")}
234
+ </Button>
235
+ </div>
236
+ <dl className="mt-3 grid gap-2 text-sm sm:grid-cols-2">
237
+ <div>
238
+ <dt className="text-xs text-muted-foreground">
239
+ {t("projectsStatus")}
240
+ </dt>
241
+ <dd>{t(`projectsStatus_${selected.status}`)}</dd>
242
+ </div>
243
+ <div>
244
+ <dt className="text-xs text-muted-foreground">
245
+ {t("projectsStage")}
246
+ </dt>
247
+ <dd>{selected.stageId ?? t("projectsNoData")}</dd>
248
+ </div>
249
+ <div>
250
+ <dt className="text-xs text-muted-foreground">
251
+ {t("projectsUpdated")}
252
+ </dt>
253
+ <dd>{selected.updatedAt}</dd>
254
+ </div>
255
+ <div>
256
+ <dt className="text-xs text-muted-foreground">
257
+ {t("projectsSchedule")}
258
+ </dt>
259
+ <dd>
260
+ {selected.schedule
261
+ ? `${selected.schedule.start ?? "—"} → ${selected.schedule.end ?? "—"}`
262
+ : t("projectsNoData")}
263
+ </dd>
264
+ </div>
265
+ </dl>
266
+ </aside>
267
+ ) : null}
268
+ </>
269
+ )}
270
+ </section>
271
+ );
272
+ }
@@ -0,0 +1,93 @@
1
+ import { act, renderHook, waitFor } from "@testing-library/react";
2
+ import type { ReactNode } from "react";
3
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4
+ import { NcpEventType } from "@nextclaw/ncp";
5
+ import { eventKeys, type ProjectObservationSnapshot } from "@nextclaw/client-sdk";
6
+ import { EventBus } from "@nextclaw/shared";
7
+ import { afterEach, describe, expect, it, vi } from "vitest";
8
+ import { nextclawClient } from "@/shared/lib/api";
9
+ import { useProjectObservation } from "./use-project-observation";
10
+
11
+ const mocks = vi.hoisted(() => ({ getObservation: vi.fn() }));
12
+
13
+ vi.mock("@/shared/lib/api", () => ({
14
+ nextclawClient: {
15
+ eventBus: new EventBus(),
16
+ projects: { getObservation: mocks.getObservation },
17
+ },
18
+ }));
19
+
20
+ const observedAt = "2026-08-31T00:00:00.000Z";
21
+ const snapshot: ProjectObservationSnapshot = {
22
+ asOf: observedAt,
23
+ project: { name: "Demo", rootPath: "/tmp/demo", context: [] },
24
+ sources: [],
25
+ workflows: [],
26
+ runs: [{
27
+ sessionId: "session-1",
28
+ state: "running",
29
+ updatedAt: observedAt,
30
+ reference: {
31
+ kind: "system-record",
32
+ label: "Session run",
33
+ observedAt,
34
+ sessionId: "session-1",
35
+ },
36
+ }],
37
+ workItems: [],
38
+ artifactCategories: [],
39
+ artifacts: [],
40
+ signals: [],
41
+ requests: [],
42
+ activity: [],
43
+ skills: [],
44
+ diagnostics: [],
45
+ dataQuality: "complete",
46
+ };
47
+
48
+ afterEach(() => {
49
+ vi.useRealTimers();
50
+ mocks.getObservation.mockReset();
51
+ });
52
+
53
+ describe("useProjectObservation", () => {
54
+ it("refreshes only for the active project's session lifecycle", async () => {
55
+ mocks.getObservation.mockResolvedValue(snapshot);
56
+ const queryClient = new QueryClient({
57
+ defaultOptions: { queries: { retry: false } },
58
+ });
59
+ const wrapper = ({ children }: { children: ReactNode }) => (
60
+ <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
61
+ );
62
+ const { result } = renderHook(
63
+ () => useProjectObservation("project-1", "/tmp/demo"),
64
+ { wrapper },
65
+ );
66
+ await waitFor(() => expect(result.current.data).toEqual(snapshot));
67
+
68
+ act(() => {
69
+ nextclawClient.eventBus.emit(eventKeys.sessionSummaryUpsert, {
70
+ summary: {
71
+ sessionId: "other-session",
72
+ messageCount: 0,
73
+ updatedAt: observedAt,
74
+ metadata: { project_root: "/tmp/other" },
75
+ },
76
+ });
77
+ });
78
+ await new Promise((resolve) => setTimeout(resolve, 150));
79
+ expect(mocks.getObservation).toHaveBeenCalledTimes(1);
80
+
81
+ act(() => {
82
+ nextclawClient.eventBus.emit(eventKeys.ncpEvent, {
83
+ type: NcpEventType.MessageTextDelta,
84
+ payload: {
85
+ sessionId: "session-1",
86
+ messageId: "assistant-1",
87
+ delta: '[nextclaw.project/v1 id=wi_7km4q2x9dn name="Live work" stage=exploration]',
88
+ },
89
+ });
90
+ });
91
+ await waitFor(() => expect(mocks.getObservation).toHaveBeenCalledTimes(2));
92
+ });
93
+ });
@@ -0,0 +1,85 @@
1
+ import { useEffect } from "react";
2
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
3
+ import { NcpEventType, type NcpEndpointEvent } from "@nextclaw/ncp";
4
+ import { eventKeys, type ProjectObservationSnapshot } from "@nextclaw/client-sdk";
5
+ import { nextclawClient } from "@/shared/lib/api";
6
+
7
+ export const projectObservationQueryKey = (projectId: string) => [
8
+ "project-observation",
9
+ projectId,
10
+ ] as const;
11
+
12
+ const REFRESHING_NCP_EVENTS = new Set<NcpEventType>([
13
+ NcpEventType.MessageTextEnd,
14
+ NcpEventType.MessageTextDelta,
15
+ NcpEventType.MessageCompleted,
16
+ NcpEventType.MessageFailed,
17
+ NcpEventType.MessageAbort,
18
+ NcpEventType.RunStarted,
19
+ NcpEventType.RunFinished,
20
+ NcpEventType.RunError,
21
+ ]);
22
+
23
+ const readNcpEventSessionId = (event: NcpEndpointEvent): string | null => {
24
+ if (!("payload" in event) || !event.payload || typeof event.payload !== "object" || !("sessionId" in event.payload)) {
25
+ return null;
26
+ }
27
+ const { sessionId } = event.payload;
28
+ return typeof sessionId === "string" && sessionId.trim() ? sessionId.trim() : null;
29
+ };
30
+
31
+ const readProjectRoot = (metadata: Record<string, unknown> | undefined): string | null => {
32
+ const value = metadata?.project_root ?? metadata?.projectRoot;
33
+ return typeof value === "string" && value.trim() ? value.trim() : null;
34
+ };
35
+
36
+ export function useProjectObservation(projectId: string | null, projectRoot: string | null) {
37
+ const queryClient = useQueryClient();
38
+ useEffect(() => {
39
+ if (!projectId || !projectRoot) return;
40
+ const queryKey = projectObservationQueryKey(projectId);
41
+ let refreshTimer: ReturnType<typeof setTimeout> | null = null;
42
+ const scheduleRefresh = () => {
43
+ if (refreshTimer) return;
44
+ refreshTimer = setTimeout(() => {
45
+ refreshTimer = null;
46
+ void queryClient.invalidateQueries({ queryKey, exact: true });
47
+ }, 100);
48
+ };
49
+ const hasObservedSession = (sessionId: string): boolean => {
50
+ const snapshot = queryClient.getQueryData<ProjectObservationSnapshot>(queryKey);
51
+ return Boolean(
52
+ snapshot?.runs?.some((run) => run.sessionId === sessionId)
53
+ || snapshot?.workItems.some((item) => item.reference.sessionId === sessionId)
54
+ || snapshot?.activity.some((activity) => activity.reference.sessionId === sessionId),
55
+ );
56
+ };
57
+ const unsubscribers = [
58
+ nextclawClient.eventBus.on(eventKeys.ncpEvent, (event) => {
59
+ if (!REFRESHING_NCP_EVENTS.has(event.type)) return;
60
+ const sessionId = readNcpEventSessionId(event);
61
+ if (sessionId && hasObservedSession(sessionId)) scheduleRefresh();
62
+ }),
63
+ nextclawClient.eventBus.on(eventKeys.sessionRunStatus, ({ sessionKey }) => {
64
+ if (hasObservedSession(sessionKey)) scheduleRefresh();
65
+ }),
66
+ nextclawClient.eventBus.on(eventKeys.sessionSummaryUpsert, ({ summary }) => {
67
+ if (readProjectRoot(summary.metadata) === projectRoot) scheduleRefresh();
68
+ }),
69
+ nextclawClient.eventBus.on(eventKeys.sessionSummaryDelete, ({ sessionKey }) => {
70
+ if (hasObservedSession(sessionKey)) scheduleRefresh();
71
+ }),
72
+ ];
73
+ return () => {
74
+ if (refreshTimer) clearTimeout(refreshTimer);
75
+ for (const unsubscribe of unsubscribers) unsubscribe();
76
+ };
77
+ }, [projectId, projectRoot, queryClient]);
78
+
79
+ return useQuery({
80
+ queryKey: projectObservationQueryKey(projectId ?? ""),
81
+ enabled: Boolean(projectId),
82
+ staleTime: 5_000,
83
+ queryFn: async () => await nextclawClient.projects.getObservation(projectId!),
84
+ });
85
+ }
@@ -0,0 +1,2 @@
1
+ /** Public UI boundary for Projects. */
2
+ export { ProjectsPage } from "./pages/project-home-page";