@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,285 @@
1
+ import { useState } from "react";
2
+ import type {
3
+ ProjectWorkState,
4
+ ProjectWorkStateCategory,
5
+ } from "@nextclaw/client-sdk";
6
+ import { ChevronDown, ChevronUp, Plus, Trash2 } from "lucide-react";
7
+ import { toast } from "sonner";
8
+ import { Button } from "@/shared/components/ui/button";
9
+ import {
10
+ Dialog,
11
+ DialogContent,
12
+ DialogDescription,
13
+ DialogFooter,
14
+ DialogHeader,
15
+ DialogTitle,
16
+ } from "@/shared/components/ui/dialog";
17
+ import { Input } from "@/shared/components/ui/input";
18
+ import {
19
+ Select,
20
+ SelectContent,
21
+ SelectItem,
22
+ SelectTrigger,
23
+ SelectValue,
24
+ } from "@/shared/components/ui/select";
25
+ import { t } from "@/shared/lib/i18n";
26
+ import {
27
+ sortProjectWorkStates,
28
+ useProjectWorkActions,
29
+ } from "@/features/projects/hooks/use-project-work";
30
+
31
+ const CATEGORIES: ProjectWorkStateCategory[] = [
32
+ "backlog",
33
+ "unstarted",
34
+ "started",
35
+ "completed",
36
+ "canceled",
37
+ ];
38
+
39
+ export function ProjectWorkStateSettings({
40
+ open,
41
+ onOpenChange,
42
+ projectId,
43
+ states,
44
+ }: {
45
+ open: boolean;
46
+ onOpenChange: (open: boolean) => void;
47
+ projectId: string;
48
+ states: ProjectWorkState[];
49
+ }) {
50
+ const actions = useProjectWorkActions(projectId);
51
+ const sorted = sortProjectWorkStates(states);
52
+ const [name, setName] = useState("");
53
+ const [category, setCategory] =
54
+ useState<ProjectWorkStateCategory>("unstarted");
55
+ const [deleting, setDeleting] = useState<ProjectWorkState | null>(null);
56
+ const [migrateToStateId, setMigrateToStateId] = useState<string>("");
57
+ const run = async (action: () => Promise<unknown>) => {
58
+ try {
59
+ await action();
60
+ } catch (error) {
61
+ toast.error(
62
+ error instanceof Error ? error.message : t("projectsWorkActionFailed"),
63
+ );
64
+ }
65
+ };
66
+ const create = async () => {
67
+ if (!name.trim()) return;
68
+ await run(async () => {
69
+ await actions.createState.mutateAsync({ name: name.trim(), category });
70
+ setName("");
71
+ });
72
+ };
73
+ const move = async (index: number, direction: -1 | 1) => {
74
+ const current = sorted[index];
75
+ const target = sorted[index + direction];
76
+ if (!current || !target) return;
77
+ await run(async () => {
78
+ await actions.updateState.mutateAsync({
79
+ stateId: current.id,
80
+ patch: { position: target.position },
81
+ });
82
+ await actions.updateState.mutateAsync({
83
+ stateId: target.id,
84
+ patch: { position: current.position },
85
+ });
86
+ });
87
+ };
88
+ return (
89
+ <>
90
+ <Dialog open={open} onOpenChange={onOpenChange}>
91
+ <DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
92
+ <DialogHeader>
93
+ <DialogTitle>{t("projectsWorkStateSettings")}</DialogTitle>
94
+ <DialogDescription>
95
+ {t("projectsWorkStateSettingsDescription")}
96
+ </DialogDescription>
97
+ </DialogHeader>
98
+ <div className="space-y-2">
99
+ {sorted.map((state, index) => (
100
+ <div
101
+ key={state.id}
102
+ className="grid gap-2 rounded-xl border border-border/60 p-3 sm:grid-cols-[1fr_11rem_auto]"
103
+ >
104
+ <Input
105
+ aria-label={t("projectsWorkStateName")}
106
+ defaultValue={state.name}
107
+ onBlur={(event) => {
108
+ const nextName = event.currentTarget.value.trim();
109
+ if (nextName && nextName !== state.name)
110
+ void run(() =>
111
+ actions.updateState.mutateAsync({
112
+ stateId: state.id,
113
+ patch: { name: nextName },
114
+ }),
115
+ );
116
+ }}
117
+ />
118
+ <Select
119
+ value={state.category}
120
+ onValueChange={(value) =>
121
+ void run(() =>
122
+ actions.updateState.mutateAsync({
123
+ stateId: state.id,
124
+ patch: { category: value as ProjectWorkStateCategory },
125
+ }),
126
+ )
127
+ }
128
+ >
129
+ <SelectTrigger aria-label={t("projectsWorkStateCategory")}>
130
+ <SelectValue />
131
+ </SelectTrigger>
132
+ <SelectContent>
133
+ {CATEGORIES.map((value) => (
134
+ <SelectItem key={value} value={value}>
135
+ {t(`projectsWorkCategory_${value}`)}
136
+ </SelectItem>
137
+ ))}
138
+ </SelectContent>
139
+ </Select>
140
+ <div className="flex items-center justify-end gap-1">
141
+ <Button
142
+ size="icon"
143
+ variant="ghost"
144
+ aria-label={t("projectsMoveUp")}
145
+ disabled={index === 0}
146
+ onClick={() => void move(index, -1)}
147
+ >
148
+ <ChevronUp className="h-4 w-4" />
149
+ </Button>
150
+ <Button
151
+ size="icon"
152
+ variant="ghost"
153
+ aria-label={t("projectsMoveDown")}
154
+ disabled={index === sorted.length - 1}
155
+ onClick={() => void move(index, 1)}
156
+ >
157
+ <ChevronDown className="h-4 w-4" />
158
+ </Button>
159
+ <Button
160
+ size="icon"
161
+ variant="ghost"
162
+ aria-label={t("projectsDeleteState")}
163
+ disabled={sorted.length <= 1}
164
+ onClick={() => {
165
+ setDeleting(state);
166
+ setMigrateToStateId("");
167
+ }}
168
+ >
169
+ <Trash2 className="h-4 w-4" />
170
+ </Button>
171
+ </div>
172
+ <label className="flex items-center gap-2 text-xs text-muted-foreground sm:col-span-3">
173
+ <input
174
+ type="radio"
175
+ name="default-state"
176
+ checked={state.isDefault}
177
+ onChange={() =>
178
+ void run(() =>
179
+ actions.updateState.mutateAsync({
180
+ stateId: state.id,
181
+ patch: { isDefault: true },
182
+ }),
183
+ )
184
+ }
185
+ />
186
+ {t("projectsWorkDefaultState")}
187
+ </label>
188
+ </div>
189
+ ))}
190
+ </div>
191
+ <div className="grid gap-2 border-t border-border/60 pt-4 sm:grid-cols-[1fr_11rem_auto]">
192
+ <Input
193
+ value={name}
194
+ onChange={(event) => setName(event.target.value)}
195
+ placeholder={t("projectsWorkNewState")}
196
+ />
197
+ <Select
198
+ value={category}
199
+ onValueChange={(value) =>
200
+ setCategory(value as ProjectWorkStateCategory)
201
+ }
202
+ >
203
+ <SelectTrigger>
204
+ <SelectValue />
205
+ </SelectTrigger>
206
+ <SelectContent>
207
+ {CATEGORIES.map((value) => (
208
+ <SelectItem key={value} value={value}>
209
+ {t(`projectsWorkCategory_${value}`)}
210
+ </SelectItem>
211
+ ))}
212
+ </SelectContent>
213
+ </Select>
214
+ <Button
215
+ onClick={() => void create()}
216
+ disabled={!name.trim() || actions.createState.isPending}
217
+ >
218
+ <Plus className="mr-2 h-4 w-4" />
219
+ {t("projectsAdd")}
220
+ </Button>
221
+ </div>
222
+ </DialogContent>
223
+ </Dialog>
224
+ <Dialog
225
+ open={Boolean(deleting)}
226
+ onOpenChange={(next) => {
227
+ if (!next) setDeleting(null);
228
+ }}
229
+ >
230
+ <DialogContent>
231
+ <DialogHeader>
232
+ <DialogTitle>{t("projectsDeleteState")}</DialogTitle>
233
+ <DialogDescription>
234
+ {t("projectsDeleteStateDescription")}
235
+ </DialogDescription>
236
+ </DialogHeader>
237
+ <div>
238
+ <p className="mb-2 text-xs text-muted-foreground">
239
+ {t("projectsWorkStateMigration")}
240
+ </p>
241
+ <Select
242
+ value={migrateToStateId}
243
+ onValueChange={setMigrateToStateId}
244
+ >
245
+ <SelectTrigger>
246
+ <SelectValue
247
+ placeholder={t("projectsWorkStateMigrationOptional")}
248
+ />
249
+ </SelectTrigger>
250
+ <SelectContent>
251
+ {sorted
252
+ .filter((state) => state.id !== deleting?.id)
253
+ .map((state) => (
254
+ <SelectItem key={state.id} value={state.id}>
255
+ {state.name}
256
+ </SelectItem>
257
+ ))}
258
+ </SelectContent>
259
+ </Select>
260
+ </div>
261
+ <DialogFooter>
262
+ <Button variant="outline" onClick={() => setDeleting(null)}>
263
+ {t("cancel")}
264
+ </Button>
265
+ <Button
266
+ variant="destructive"
267
+ onClick={() => {
268
+ if (deleting)
269
+ void run(async () => {
270
+ await actions.deleteState.mutateAsync({
271
+ stateId: deleting.id,
272
+ migrateToStateId: migrateToStateId || null,
273
+ });
274
+ setDeleting(null);
275
+ });
276
+ }}
277
+ >
278
+ {t("projectsDelete")}
279
+ </Button>
280
+ </DialogFooter>
281
+ </DialogContent>
282
+ </Dialog>
283
+ </>
284
+ );
285
+ }
@@ -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,115 @@
1
+ import { useEffect } from "react";
2
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
3
+ import { NcpEventType, type NcpEndpointEvent } from "@nextclaw/ncp";
4
+ import {
5
+ eventKeys,
6
+ type ProjectObservationSnapshot,
7
+ } from "@nextclaw/client-sdk";
8
+ import { nextclawClient } from "@/shared/lib/api";
9
+
10
+ export const projectObservationQueryKey = (projectId: string) =>
11
+ ["project-observation", projectId] as const;
12
+
13
+ const REFRESHING_NCP_EVENTS = new Set<NcpEventType>([
14
+ NcpEventType.MessageTextEnd,
15
+ NcpEventType.MessageTextDelta,
16
+ NcpEventType.MessageCompleted,
17
+ NcpEventType.MessageFailed,
18
+ NcpEventType.MessageAbort,
19
+ NcpEventType.RunStarted,
20
+ NcpEventType.RunFinished,
21
+ NcpEventType.RunError,
22
+ ]);
23
+
24
+ const readNcpEventSessionId = (event: NcpEndpointEvent): string | null => {
25
+ if (
26
+ !("payload" in event) ||
27
+ !event.payload ||
28
+ typeof event.payload !== "object" ||
29
+ !("sessionId" in event.payload)
30
+ ) {
31
+ return null;
32
+ }
33
+ const { sessionId } = event.payload;
34
+ return typeof sessionId === "string" && sessionId.trim()
35
+ ? sessionId.trim()
36
+ : null;
37
+ };
38
+
39
+ const readProjectRoot = (
40
+ metadata: Record<string, unknown> | undefined,
41
+ ): string | null => {
42
+ const value = metadata?.project_root ?? metadata?.projectRoot;
43
+ return typeof value === "string" && value.trim() ? value.trim() : null;
44
+ };
45
+
46
+ export function useProjectObservation(
47
+ projectId: string | null,
48
+ projectRoot: string | null,
49
+ enabled = true,
50
+ ) {
51
+ const queryClient = useQueryClient();
52
+ useEffect(() => {
53
+ if (!enabled || !projectId || !projectRoot) return;
54
+ const queryKey = projectObservationQueryKey(projectId);
55
+ let refreshTimer: ReturnType<typeof setTimeout> | null = null;
56
+ const scheduleRefresh = () => {
57
+ if (refreshTimer) return;
58
+ refreshTimer = setTimeout(() => {
59
+ refreshTimer = null;
60
+ void queryClient.invalidateQueries({ queryKey, exact: true });
61
+ }, 100);
62
+ };
63
+ const hasObservedSession = (sessionId: string): boolean => {
64
+ const snapshot =
65
+ queryClient.getQueryData<ProjectObservationSnapshot>(queryKey);
66
+ return Boolean(
67
+ snapshot?.runs?.some((run) => run.sessionId === sessionId) ||
68
+ snapshot?.workItems.some(
69
+ (item) => item.reference.sessionId === sessionId,
70
+ ) ||
71
+ snapshot?.activity.some(
72
+ (activity) => activity.reference.sessionId === sessionId,
73
+ ),
74
+ );
75
+ };
76
+ const unsubscribers = [
77
+ nextclawClient.eventBus.on(eventKeys.ncpEvent, (event) => {
78
+ if (!REFRESHING_NCP_EVENTS.has(event.type)) return;
79
+ const sessionId = readNcpEventSessionId(event);
80
+ if (sessionId && hasObservedSession(sessionId)) scheduleRefresh();
81
+ }),
82
+ nextclawClient.eventBus.on(
83
+ eventKeys.sessionRunStatus,
84
+ ({ sessionKey }) => {
85
+ if (hasObservedSession(sessionKey)) scheduleRefresh();
86
+ },
87
+ ),
88
+ nextclawClient.eventBus.on(
89
+ eventKeys.sessionSummaryUpsert,
90
+ ({ summary }) => {
91
+ if (readProjectRoot(summary.metadata) === projectRoot)
92
+ scheduleRefresh();
93
+ },
94
+ ),
95
+ nextclawClient.eventBus.on(
96
+ eventKeys.sessionSummaryDelete,
97
+ ({ sessionKey }) => {
98
+ if (hasObservedSession(sessionKey)) scheduleRefresh();
99
+ },
100
+ ),
101
+ ];
102
+ return () => {
103
+ if (refreshTimer) clearTimeout(refreshTimer);
104
+ for (const unsubscribe of unsubscribers) unsubscribe();
105
+ };
106
+ }, [enabled, projectId, projectRoot, queryClient]);
107
+
108
+ return useQuery({
109
+ queryKey: projectObservationQueryKey(projectId ?? ""),
110
+ enabled: enabled && Boolean(projectId),
111
+ staleTime: 5_000,
112
+ queryFn: async () =>
113
+ await nextclawClient.projects.getObservation(projectId!),
114
+ });
115
+ }
@@ -0,0 +1,58 @@
1
+ import type { ReactNode } from "react";
2
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
3
+ import { renderHook, waitFor } from "@testing-library/react";
4
+ import { beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { useProjectWork } from "./use-project-work";
6
+
7
+ const mocks = vi.hoisted(() => ({
8
+ handler: null as
9
+ | null
10
+ | ((event: { projectId: string; workItemId?: string }) => void),
11
+ listWork: vi.fn(),
12
+ }));
13
+
14
+ vi.mock("@/shared/lib/api", () => ({
15
+ nextclawClient: {
16
+ projects: { listWork: mocks.listWork },
17
+ eventBus: {
18
+ on: (
19
+ _key: unknown,
20
+ handler: (event: { projectId: string; workItemId?: string }) => void,
21
+ ) => {
22
+ mocks.handler = handler;
23
+ return () => {
24
+ mocks.handler = null;
25
+ };
26
+ },
27
+ },
28
+ },
29
+ }));
30
+
31
+ describe("useProjectWork", () => {
32
+ beforeEach(() => {
33
+ mocks.listWork.mockReset();
34
+ mocks.listWork.mockResolvedValue({ items: [], states: [], total: 0 });
35
+ });
36
+
37
+ it("refreshes the project query from committed work events only for the matching project", async () => {
38
+ const client = new QueryClient({
39
+ defaultOptions: { queries: { retry: false } },
40
+ });
41
+ const wrapper = ({ children }: { children: ReactNode }) => (
42
+ <QueryClientProvider client={client}>{children}</QueryClientProvider>
43
+ );
44
+ const { unmount } = renderHook(() => useProjectWork("project-1"), {
45
+ wrapper,
46
+ });
47
+ await waitFor(() => expect(mocks.listWork).toHaveBeenCalledTimes(1));
48
+
49
+ mocks.handler?.({ projectId: "project-2" });
50
+ await new Promise((resolve) => setTimeout(resolve, 0));
51
+ expect(mocks.listWork).toHaveBeenCalledTimes(1);
52
+
53
+ mocks.handler?.({ projectId: "project-1", workItemId: "work-1" });
54
+ await waitFor(() => expect(mocks.listWork).toHaveBeenCalledTimes(2));
55
+ unmount();
56
+ expect(mocks.handler).toBeNull();
57
+ });
58
+ });