@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.
- package/CHANGELOG.md +17 -0
- package/dist/assets/api-BqVIq0lF.js +13 -0
- package/dist/assets/{appearance-settings-page-CPoFZLw8.js → appearance-settings-page-CTWn1eAT.js} +1 -1
- package/dist/assets/{book-open-jcUt0rr9.js → book-open-o2x7OLiO.js} +1 -1
- package/dist/assets/{channels-list-page-anMJAkfK.js → channels-list-page-Bzn-pzyJ.js} +1 -1
- package/dist/assets/{config-split-page-AcuFnbIC.js → config-split-page-CnRbXXlh.js} +1 -1
- package/dist/assets/{confirm-dialog-B-atCfC5.js → confirm-dialog-B_Hbcra2.js} +1 -1
- package/dist/assets/{createLucideIcon-Blz0FzrV.js → createLucideIcon-rwwpqyks.js} +1 -1
- package/dist/assets/{desktop-capabilities-page-8lKF_Ob-.js → desktop-capabilities-page-C2ayYQTr.js} +1 -1
- package/dist/assets/{desktop-update-config-DABXDpbj.js → desktop-update-config-C13czpyL.js} +1 -1
- package/dist/assets/{dist-eH-_j8yU.js → dist-BsF3hfOi.js} +1 -1
- package/dist/assets/{dist-D8kcEpTf.js → dist-aNN3iQxV.js} +1 -1
- package/dist/assets/{doc-browser-DzXRehlW.js → doc-browser-D8dIOo9E.js} +1 -1
- package/dist/assets/doc-browser-DcuOOtpP.js +1 -0
- package/dist/assets/{doc-browser-context-BlbUcg2J.js → doc-browser-context-Dhsit2Ma.js} +1 -1
- package/dist/assets/{extensions-mquwAJ2Z.js → extensions-CD6xvKF_.js} +1 -1
- package/dist/assets/{form-actions-CaZDGEmB.js → form-actions-CC4Wz_lL.js} +1 -1
- package/dist/assets/index-BTsbdtv6.js +109 -0
- package/dist/assets/index-Bcfy2kTh.css +1 -0
- package/dist/assets/loader-circle-B6aFbB-8.js +1 -0
- package/dist/assets/mcp-marketplace-page-BtUAypA7.js +1 -0
- package/dist/assets/{mcp-marketplace-page-gFtgyueL.js → mcp-marketplace-page-r2MmD9xO.js} +1 -1
- package/dist/assets/{model-config-page-hFBpeRAO.js → model-config-page-DboxoT-I.js} +1 -1
- package/dist/assets/{navigation-link-Hb5Z0UxZ.js → navigation-link-C_6oj3zz.js} +1 -1
- package/dist/assets/plus-DRsq9Kvs.js +1 -0
- package/dist/assets/{privacy-settings-page-C18zo3ox.js → privacy-settings-page-C2sDBqhb.js} +1 -1
- package/dist/assets/projects-BcPcx-uD.js +1 -0
- package/dist/assets/{providers-config-page-YU0-AOXJ.js → providers-config-page-CmUFl9RF.js} +1 -1
- package/dist/assets/react-BjO9I4WZ.js +8 -0
- package/dist/assets/{refresh-cw-ZHKfE3yx.js → refresh-cw-DsT2kvXi.js} +1 -1
- package/dist/assets/remote-Ci6x91ES.js +1 -0
- package/dist/assets/{rotate-cw-CLhOXGwE.js → rotate-cw-BCAs6c3R.js} +1 -1
- package/dist/assets/{runtime-config-page-Dmy1A_0Y.js → runtime-config-page-cQKNeAjL.js} +1 -1
- package/dist/assets/{save-CWuuZs77.js → save-CadRMTMQ.js} +1 -1
- package/dist/assets/{search-DcmsKAn2.js → search-B6tB4fzB.js} +1 -1
- package/dist/assets/{search-config-page-CxBacx6W.js → search-config-page-C8lOIkxn.js} +1 -1
- package/dist/assets/{secrets-config-page-B4py8-KR.js → secrets-config-page-8clzAag2.js} +1 -1
- package/dist/assets/security-config-DPbM6IVT.js +1 -0
- package/dist/assets/{select-BEr0Iy_f.js → select-CO9S_C2C.js} +1 -1
- package/dist/assets/skeleton-IHILNhIW.js +1 -0
- package/dist/assets/{tag-chip-ByqDkn6g.js → tag-chip-BZKk0UkK.js} +1 -1
- package/dist/assets/x-BuvHIb1d.js +1 -0
- package/dist/index.html +21 -21
- package/package.json +5 -5
- package/src/features/apps/components/__tests__/app-packages-panel.test.tsx +362 -241
- package/src/features/apps/components/app-document-access-section.tsx +187 -0
- package/src/features/apps/components/app-package-card.tsx +150 -275
- package/src/features/apps/hooks/use-app-packages.ts +91 -35
- package/src/features/chat/index.ts +5 -1
- package/src/features/projects/AGENTS.md +3 -2
- package/src/features/projects/components/project-overview.tsx +70 -139
- package/src/features/projects/components/work/project-work-item-drawer.test.tsx +65 -0
- package/src/features/projects/components/work/project-work-item-drawer.tsx +367 -0
- package/src/features/projects/components/work/project-work-items.test.tsx +98 -0
- package/src/features/projects/components/work/project-work-items.tsx +199 -0
- package/src/features/projects/components/work/project-work-state-settings.tsx +285 -0
- package/src/features/projects/hooks/use-project-observation.ts +56 -26
- package/src/features/projects/hooks/use-project-work.test.tsx +58 -0
- package/src/features/projects/hooks/use-project-work.ts +215 -0
- package/src/features/projects/pages/project-home-page.tsx +119 -138
- package/src/features/projects/pages/projects-page.test.tsx +79 -308
- package/src/shared/lib/i18n/locales/en-US/doc-browser.json +16 -1
- package/src/shared/lib/i18n/locales/en-US/projects.json +60 -1
- package/src/shared/lib/i18n/locales/zh-CN/doc-browser.json +16 -1
- package/src/shared/lib/i18n/locales/zh-CN/projects.json +60 -1
- package/dist/assets/api-CoqPRKmF.js +0 -13
- package/dist/assets/doc-browser-DJEMr7Zk.js +0 -1
- package/dist/assets/index-DyR5iR0O.css +0 -1
- package/dist/assets/index-XxXj8WDS.js +0 -109
- package/dist/assets/loader-circle-0qPOOYQ5.js +0 -1
- package/dist/assets/mcp-marketplace-page-uanamJ6L.js +0 -1
- package/dist/assets/plus-CYOvNvcV.js +0 -1
- package/dist/assets/projects-C8Gm9RS8.js +0 -1
- package/dist/assets/react-DF-kP7iN.js +0 -8
- package/dist/assets/remote-B_utkBra.js +0 -1
- package/dist/assets/security-config-CTP4IQWw.js +0 -1
- package/dist/assets/skeleton-B2NHluWf.js +0 -1
- package/dist/assets/x-CENkFyK5.js +0 -1
- package/src/features/projects/components/project-work-items.test.tsx +0 -78
- 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 {
|
|
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 (
|
|
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()
|
|
34
|
+
return typeof sessionId === "string" && sessionId.trim()
|
|
35
|
+
? sessionId.trim()
|
|
36
|
+
: null;
|
|
29
37
|
};
|
|
30
38
|
|
|
31
|
-
const readProjectRoot = (
|
|
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(
|
|
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 =
|
|
64
|
+
const snapshot =
|
|
65
|
+
queryClient.getQueryData<ProjectObservationSnapshot>(queryKey);
|
|
51
66
|
return Boolean(
|
|
52
|
-
snapshot?.runs?.some((run) => run.sessionId === sessionId)
|
|
53
|
-
|
|
54
|
-
|
|
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(
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
nextclawClient.eventBus.on(
|
|
70
|
-
|
|
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 () =>
|
|
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
|
+
}
|