@nextclaw/ui 0.24.0-beta.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 (80) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/assets/api-BqVIq0lF.js +13 -0
  3. package/dist/assets/{appearance-settings-page-CPoFZLw8.js → appearance-settings-page-CTWn1eAT.js} +1 -1
  4. package/dist/assets/{book-open-jcUt0rr9.js → book-open-o2x7OLiO.js} +1 -1
  5. package/dist/assets/{channels-list-page-anMJAkfK.js → channels-list-page-Bzn-pzyJ.js} +1 -1
  6. package/dist/assets/{config-split-page-AcuFnbIC.js → config-split-page-CnRbXXlh.js} +1 -1
  7. package/dist/assets/{confirm-dialog-B-atCfC5.js → confirm-dialog-B_Hbcra2.js} +1 -1
  8. package/dist/assets/{createLucideIcon-Blz0FzrV.js → createLucideIcon-rwwpqyks.js} +1 -1
  9. package/dist/assets/{desktop-capabilities-page-8lKF_Ob-.js → desktop-capabilities-page-C2ayYQTr.js} +1 -1
  10. package/dist/assets/{desktop-update-config-DABXDpbj.js → desktop-update-config-C13czpyL.js} +1 -1
  11. package/dist/assets/{dist-eH-_j8yU.js → dist-BsF3hfOi.js} +1 -1
  12. package/dist/assets/{dist-D8kcEpTf.js → dist-aNN3iQxV.js} +1 -1
  13. package/dist/assets/{doc-browser-DzXRehlW.js → doc-browser-D8dIOo9E.js} +1 -1
  14. package/dist/assets/doc-browser-DcuOOtpP.js +1 -0
  15. package/dist/assets/{doc-browser-context-BlbUcg2J.js → doc-browser-context-Dhsit2Ma.js} +1 -1
  16. package/dist/assets/{extensions-mquwAJ2Z.js → extensions-CD6xvKF_.js} +1 -1
  17. package/dist/assets/{form-actions-CaZDGEmB.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-gFtgyueL.js → mcp-marketplace-page-r2MmD9xO.js} +1 -1
  23. package/dist/assets/{model-config-page-hFBpeRAO.js → model-config-page-DboxoT-I.js} +1 -1
  24. package/dist/assets/{navigation-link-Hb5Z0UxZ.js → navigation-link-C_6oj3zz.js} +1 -1
  25. package/dist/assets/plus-DRsq9Kvs.js +1 -0
  26. package/dist/assets/{privacy-settings-page-C18zo3ox.js → privacy-settings-page-C2sDBqhb.js} +1 -1
  27. package/dist/assets/projects-BcPcx-uD.js +1 -0
  28. package/dist/assets/{providers-config-page-YU0-AOXJ.js → providers-config-page-CmUFl9RF.js} +1 -1
  29. package/dist/assets/react-BjO9I4WZ.js +8 -0
  30. package/dist/assets/{refresh-cw-ZHKfE3yx.js → refresh-cw-DsT2kvXi.js} +1 -1
  31. package/dist/assets/remote-Ci6x91ES.js +1 -0
  32. package/dist/assets/{rotate-cw-CLhOXGwE.js → rotate-cw-BCAs6c3R.js} +1 -1
  33. package/dist/assets/{runtime-config-page-Dmy1A_0Y.js → runtime-config-page-cQKNeAjL.js} +1 -1
  34. package/dist/assets/{save-CWuuZs77.js → save-CadRMTMQ.js} +1 -1
  35. package/dist/assets/{search-DcmsKAn2.js → search-B6tB4fzB.js} +1 -1
  36. package/dist/assets/{search-config-page-CxBacx6W.js → search-config-page-C8lOIkxn.js} +1 -1
  37. package/dist/assets/{secrets-config-page-B4py8-KR.js → secrets-config-page-8clzAag2.js} +1 -1
  38. package/dist/assets/security-config-DPbM6IVT.js +1 -0
  39. package/dist/assets/{select-BEr0Iy_f.js → select-CO9S_C2C.js} +1 -1
  40. package/dist/assets/skeleton-IHILNhIW.js +1 -0
  41. package/dist/assets/{tag-chip-ByqDkn6g.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 +5 -5
  45. package/src/features/apps/components/__tests__/app-packages-panel.test.tsx +362 -241
  46. package/src/features/apps/components/app-document-access-section.tsx +187 -0
  47. package/src/features/apps/components/app-package-card.tsx +150 -275
  48. package/src/features/apps/hooks/use-app-packages.ts +91 -35
  49. package/src/features/chat/index.ts +5 -1
  50. package/src/features/projects/AGENTS.md +3 -2
  51. package/src/features/projects/components/project-overview.tsx +70 -139
  52. package/src/features/projects/components/work/project-work-item-drawer.test.tsx +65 -0
  53. package/src/features/projects/components/work/project-work-item-drawer.tsx +367 -0
  54. package/src/features/projects/components/work/project-work-items.test.tsx +98 -0
  55. package/src/features/projects/components/work/project-work-items.tsx +199 -0
  56. package/src/features/projects/components/work/project-work-state-settings.tsx +285 -0
  57. package/src/features/projects/hooks/use-project-observation.ts +56 -26
  58. package/src/features/projects/hooks/use-project-work.test.tsx +58 -0
  59. package/src/features/projects/hooks/use-project-work.ts +215 -0
  60. package/src/features/projects/pages/project-home-page.tsx +119 -138
  61. package/src/features/projects/pages/projects-page.test.tsx +79 -308
  62. package/src/shared/lib/i18n/locales/en-US/doc-browser.json +16 -1
  63. package/src/shared/lib/i18n/locales/en-US/projects.json +60 -1
  64. package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +16 -1
  65. package/src/shared/lib/i18n/locales/zh-CN/projects.json +60 -1
  66. package/dist/assets/api-CoqPRKmF.js +0 -13
  67. package/dist/assets/doc-browser-DJEMr7Zk.js +0 -1
  68. package/dist/assets/index-DyR5iR0O.css +0 -1
  69. package/dist/assets/index-XxXj8WDS.js +0 -109
  70. package/dist/assets/loader-circle-0qPOOYQ5.js +0 -1
  71. package/dist/assets/mcp-marketplace-page-uanamJ6L.js +0 -1
  72. package/dist/assets/plus-CYOvNvcV.js +0 -1
  73. package/dist/assets/projects-C8Gm9RS8.js +0 -1
  74. package/dist/assets/react-DF-kP7iN.js +0 -8
  75. package/dist/assets/remote-B_utkBra.js +0 -1
  76. package/dist/assets/security-config-CTP4IQWw.js +0 -1
  77. package/dist/assets/skeleton-B2NHluWf.js +0 -1
  78. package/dist/assets/x-CENkFyK5.js +0 -1
  79. package/src/features/projects/components/project-work-items.test.tsx +0 -78
  80. package/src/features/projects/components/project-work-items.tsx +0 -272
@@ -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
+ }
@@ -1,13 +1,14 @@
1
1
  import { useEffect } from "react";
2
2
  import { useQuery, useQueryClient } from "@tanstack/react-query";
3
3
  import { NcpEventType, type NcpEndpointEvent } from "@nextclaw/ncp";
4
- import { eventKeys, type ProjectObservationSnapshot } from "@nextclaw/client-sdk";
4
+ import {
5
+ eventKeys,
6
+ type ProjectObservationSnapshot,
7
+ } from "@nextclaw/client-sdk";
5
8
  import { nextclawClient } from "@/shared/lib/api";
6
9
 
7
- export const projectObservationQueryKey = (projectId: string) => [
8
- "project-observation",
9
- projectId,
10
- ] as const;
10
+ export const projectObservationQueryKey = (projectId: string) =>
11
+ ["project-observation", projectId] as const;
11
12
 
12
13
  const REFRESHING_NCP_EVENTS = new Set<NcpEventType>([
13
14
  NcpEventType.MessageTextEnd,
@@ -21,22 +22,35 @@ const REFRESHING_NCP_EVENTS = new Set<NcpEventType>([
21
22
  ]);
22
23
 
23
24
  const readNcpEventSessionId = (event: NcpEndpointEvent): string | null => {
24
- if (!("payload" in event) || !event.payload || typeof event.payload !== "object" || !("sessionId" in event.payload)) {
25
+ if (
26
+ !("payload" in event) ||
27
+ !event.payload ||
28
+ typeof event.payload !== "object" ||
29
+ !("sessionId" in event.payload)
30
+ ) {
25
31
  return null;
26
32
  }
27
33
  const { sessionId } = event.payload;
28
- return typeof sessionId === "string" && sessionId.trim() ? sessionId.trim() : null;
34
+ return typeof sessionId === "string" && sessionId.trim()
35
+ ? sessionId.trim()
36
+ : null;
29
37
  };
30
38
 
31
- const readProjectRoot = (metadata: Record<string, unknown> | undefined): string | null => {
39
+ const readProjectRoot = (
40
+ metadata: Record<string, unknown> | undefined,
41
+ ): string | null => {
32
42
  const value = metadata?.project_root ?? metadata?.projectRoot;
33
43
  return typeof value === "string" && value.trim() ? value.trim() : null;
34
44
  };
35
45
 
36
- export function useProjectObservation(projectId: string | null, projectRoot: string | null) {
46
+ export function useProjectObservation(
47
+ projectId: string | null,
48
+ projectRoot: string | null,
49
+ enabled = true,
50
+ ) {
37
51
  const queryClient = useQueryClient();
38
52
  useEffect(() => {
39
- if (!projectId || !projectRoot) return;
53
+ if (!enabled || !projectId || !projectRoot) return;
40
54
  const queryKey = projectObservationQueryKey(projectId);
41
55
  let refreshTimer: ReturnType<typeof setTimeout> | null = null;
42
56
  const scheduleRefresh = () => {
@@ -47,11 +61,16 @@ export function useProjectObservation(projectId: string | null, projectRoot: str
47
61
  }, 100);
48
62
  };
49
63
  const hasObservedSession = (sessionId: string): boolean => {
50
- const snapshot = queryClient.getQueryData<ProjectObservationSnapshot>(queryKey);
64
+ const snapshot =
65
+ queryClient.getQueryData<ProjectObservationSnapshot>(queryKey);
51
66
  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),
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
+ ),
55
74
  );
56
75
  };
57
76
  const unsubscribers = [
@@ -60,26 +79,37 @@ export function useProjectObservation(projectId: string | null, projectRoot: str
60
79
  const sessionId = readNcpEventSessionId(event);
61
80
  if (sessionId && hasObservedSession(sessionId)) scheduleRefresh();
62
81
  }),
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
- }),
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
+ ),
72
101
  ];
73
102
  return () => {
74
103
  if (refreshTimer) clearTimeout(refreshTimer);
75
104
  for (const unsubscribe of unsubscribers) unsubscribe();
76
105
  };
77
- }, [projectId, projectRoot, queryClient]);
106
+ }, [enabled, projectId, projectRoot, queryClient]);
78
107
 
79
108
  return useQuery({
80
109
  queryKey: projectObservationQueryKey(projectId ?? ""),
81
- enabled: Boolean(projectId),
110
+ enabled: enabled && Boolean(projectId),
82
111
  staleTime: 5_000,
83
- queryFn: async () => await nextclawClient.projects.getObservation(projectId!),
112
+ queryFn: async () =>
113
+ await nextclawClient.projects.getObservation(projectId!),
84
114
  });
85
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
+ });
@@ -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
+ }