@agent-native/core 0.164.11 → 0.164.12
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/corpus/README.md +1 -1
- package/corpus/templates/calendar/.agents/skills/event-management/SKILL.md +84 -0
- package/corpus/templates/calendar/AGENTS.md +5 -0
- package/corpus/templates/calendar/actions/delete-event.ts +1 -1
- package/corpus/templates/calendar/actions/delete-events.ts +508 -0
- package/corpus/templates/calendar/actions/event-action-helpers.ts +1 -1
- package/corpus/templates/calendar/actions/list-events.ts +53 -0
- package/corpus/templates/calendar/changelog/2026-08-18-the-agent-can-now-remove-many-meetings-at-once-such-as-every.md +6 -0
- package/corpus/templates/calendar/server/lib/event-weekday.ts +112 -0
- package/corpus/templates/factory/AGENTS.md +2 -1
- package/corpus/templates/factory/actions/get-factory-graph-version.ts +69 -0
- package/corpus/templates/factory/actions/list-factory-graph-versions.ts +82 -0
- package/corpus/templates/factory/actions/restore-factory-graph-version.ts +163 -0
- package/corpus/templates/factory/actions/save-factory-graph.ts +93 -60
- package/corpus/templates/factory/app/components/factory/FactoryHistoryView.tsx +709 -0
- package/corpus/templates/factory/app/components/factory/FactoryInspector.tsx +62 -2
- package/corpus/templates/factory/app/components/ui/alert-dialog.tsx +1 -0
- package/corpus/templates/factory/app/i18n/en-US.ts +43 -0
- package/corpus/templates/factory/app/routes/factory.tsx +115 -15
- package/corpus/templates/factory/server/plugins/agent-chat.ts +3 -1
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/observability/routes.d.ts +3 -3
- package/dist/progress/routes.d.ts +1 -1
- package/dist/secrets/routes.d.ts +9 -9
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/realtime-token.d.ts +1 -1
- package/docs/content/actions-access-control.mdx +136 -2
- package/docs/content/actions-agent-tools.mdx +101 -0
- package/docs/content/actions-defining.mdx +2 -2
- package/docs/content/audit-log.mdx +1 -1
- package/docs/content/authentication.mdx +1 -1
- package/docs/content/cloudflare.mdx +43 -0
- package/docs/content/creating-templates.mdx +1 -1
- package/docs/content/deploy-an-app.mdx +109 -0
- package/docs/content/deployment-environment-variables.mdx +101 -0
- package/docs/content/deployment.mdx +125 -792
- package/docs/content/docs-components.mdx +2 -2
- package/docs/content/environment-variables.mdx +41 -41
- package/docs/content/file-uploads.mdx +74 -40
- package/docs/content/harness-agents.mdx +2 -2
- package/docs/content/internationalization.mdx +61 -65
- package/docs/content/key-concepts.mdx +3 -3
- package/docs/content/multi-app-workspace.mdx +2 -2
- package/docs/content/netlify.mdx +47 -0
- package/docs/content/node-docker.mdx +138 -0
- package/docs/content/organizations-teams-permissions.mdx +5 -5
- package/docs/content/other-platforms.mdx +27 -0
- package/docs/content/ssr-caching.mdx +45 -0
- package/docs/content/template-assets-developers.mdx +2 -2
- package/docs/content/template-chat-developers.mdx +2 -2
- package/docs/content/template-content-developers.mdx +2 -2
- package/docs/content/{local-file-mode.mdx → template-content-local-files.mdx} +2 -1
- package/docs/content/template-content-sync.mdx +2 -2
- package/docs/content/toolkit-agent-ux.mdx +1 -1
- package/docs/content/toolkit-resources.mdx +2 -2
- package/docs/content/updating-ui-in-production.mdx +35 -0
- package/docs/content/vercel.mdx +38 -0
- package/docs/content/what-is-agent-native.mdx +1 -1
- package/docs/content/workspace-deployment.mdx +350 -0
- package/package.json +1 -1
- package/docs/content/agents.mdx +0 -9
- package/docs/content/database.mdx +0 -317
- package/docs/content/human-approval.mdx +0 -156
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useActionMutation,
|
|
3
|
+
useActionQuery,
|
|
4
|
+
} from "@agent-native/core/client/hooks";
|
|
5
|
+
import { useT } from "@agent-native/core/client/i18n";
|
|
6
|
+
import {
|
|
7
|
+
IconAlertCircle,
|
|
8
|
+
IconCheck,
|
|
9
|
+
IconHistory,
|
|
10
|
+
IconLoader2,
|
|
11
|
+
IconRestore,
|
|
12
|
+
} from "@tabler/icons-react";
|
|
13
|
+
import { useEffect, useState } from "react";
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
AlertDialog,
|
|
17
|
+
AlertDialogAction,
|
|
18
|
+
AlertDialogCancel,
|
|
19
|
+
AlertDialogContent,
|
|
20
|
+
AlertDialogDescription,
|
|
21
|
+
AlertDialogFooter,
|
|
22
|
+
AlertDialogHeader,
|
|
23
|
+
AlertDialogTitle,
|
|
24
|
+
} from "@/components/ui/alert-dialog";
|
|
25
|
+
import { Button } from "@/components/ui/button";
|
|
26
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
27
|
+
|
|
28
|
+
import { FactoryCanvas, type FactoryCanvasGraph } from "./FactoryCanvas";
|
|
29
|
+
|
|
30
|
+
type FactoryGraphVersion = {
|
|
31
|
+
id: string;
|
|
32
|
+
factoryId: string;
|
|
33
|
+
version: number;
|
|
34
|
+
source: string;
|
|
35
|
+
changeSummary: string;
|
|
36
|
+
createdAt: string;
|
|
37
|
+
createdBy: string;
|
|
38
|
+
isCurrent: boolean;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type FactoryGraphVersionSnapshot = FactoryGraphVersion & {
|
|
42
|
+
graph: FactoryCanvasGraph;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
type FactoryGraphHistoryResponse = {
|
|
46
|
+
factoryId: string;
|
|
47
|
+
currentVersion: number | null;
|
|
48
|
+
hasMore: boolean;
|
|
49
|
+
nextBeforeVersion: number | null;
|
|
50
|
+
versions: FactoryGraphVersion[];
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
type RestoreResult = {
|
|
54
|
+
versionId: string;
|
|
55
|
+
graphVersion: number;
|
|
56
|
+
name: string;
|
|
57
|
+
graph: FactoryCanvasGraph;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
interface FactoryHistoryViewProps {
|
|
61
|
+
factoryId: string;
|
|
62
|
+
hasUnsavedChanges: boolean;
|
|
63
|
+
onRestored: (result?: RestoreResult) => Promise<void>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const HISTORY_PAGE_SIZE = 25;
|
|
67
|
+
|
|
68
|
+
export function FactoryHistoryView({
|
|
69
|
+
factoryId,
|
|
70
|
+
hasUnsavedChanges,
|
|
71
|
+
onRestored,
|
|
72
|
+
}: FactoryHistoryViewProps) {
|
|
73
|
+
const t = useT();
|
|
74
|
+
const [beforeVersion, setBeforeVersion] = useState<number | undefined>();
|
|
75
|
+
const firstPageHistoryQuery = useActionQuery<FactoryGraphHistoryResponse>(
|
|
76
|
+
"list-factory-graph-versions",
|
|
77
|
+
{
|
|
78
|
+
factoryId,
|
|
79
|
+
limit: HISTORY_PAGE_SIZE,
|
|
80
|
+
},
|
|
81
|
+
);
|
|
82
|
+
const olderHistoryQuery = useActionQuery<FactoryGraphHistoryResponse>(
|
|
83
|
+
"list-factory-graph-versions",
|
|
84
|
+
{
|
|
85
|
+
factoryId,
|
|
86
|
+
limit: HISTORY_PAGE_SIZE,
|
|
87
|
+
beforeVersion,
|
|
88
|
+
},
|
|
89
|
+
{ enabled: beforeVersion !== undefined },
|
|
90
|
+
);
|
|
91
|
+
const historyQuery =
|
|
92
|
+
beforeVersion === undefined ? firstPageHistoryQuery : olderHistoryQuery;
|
|
93
|
+
const [versions, setVersions] = useState<FactoryGraphVersion[]>([]);
|
|
94
|
+
const [hasMore, setHasMore] = useState(false);
|
|
95
|
+
const [nextBeforeVersion, setNextBeforeVersion] = useState<number | null>(
|
|
96
|
+
null,
|
|
97
|
+
);
|
|
98
|
+
const restoreMutation = useActionMutation("restore-factory-graph-version");
|
|
99
|
+
const [selectedVersionId, setSelectedVersionId] = useState<string | null>(
|
|
100
|
+
null,
|
|
101
|
+
);
|
|
102
|
+
const [pendingRestore, setPendingRestore] =
|
|
103
|
+
useState<FactoryGraphVersion | null>(null);
|
|
104
|
+
const [restoringVersionId, setRestoringVersionId] = useState<string | null>(
|
|
105
|
+
null,
|
|
106
|
+
);
|
|
107
|
+
const [restoreError, setRestoreError] = useState<string | null>(null);
|
|
108
|
+
const [restoreRefreshError, setRestoreRefreshError] = useState<string | null>(
|
|
109
|
+
null,
|
|
110
|
+
);
|
|
111
|
+
const [restoreStatus, setRestoreStatus] = useState<string | null>(null);
|
|
112
|
+
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
const page = historyQuery.data;
|
|
115
|
+
if (!page) return;
|
|
116
|
+
setVersions((current) => {
|
|
117
|
+
if (beforeVersion === undefined) return page.versions;
|
|
118
|
+
const existingIds = new Set(current.map((version) => version.id));
|
|
119
|
+
return [
|
|
120
|
+
...current,
|
|
121
|
+
...page.versions.filter((version) => !existingIds.has(version.id)),
|
|
122
|
+
];
|
|
123
|
+
});
|
|
124
|
+
setHasMore(page.hasMore);
|
|
125
|
+
setNextBeforeVersion(page.nextBeforeVersion);
|
|
126
|
+
}, [beforeVersion, historyQuery.data]);
|
|
127
|
+
|
|
128
|
+
const selectedVersion =
|
|
129
|
+
versions.find((version) => version.id === selectedVersionId) ??
|
|
130
|
+
versions[0] ??
|
|
131
|
+
null;
|
|
132
|
+
const selectedSnapshotQuery = useActionQuery<FactoryGraphVersionSnapshot>(
|
|
133
|
+
"get-factory-graph-version",
|
|
134
|
+
selectedVersion
|
|
135
|
+
? { factoryId, versionId: selectedVersion.id }
|
|
136
|
+
: { factoryId, versionId: "" },
|
|
137
|
+
{ enabled: Boolean(selectedVersion) },
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
async function refreshHistory(preferredVersionId?: string) {
|
|
141
|
+
const refreshed = await firstPageHistoryQuery.refetch();
|
|
142
|
+
if (refreshed.error || !refreshed.data) {
|
|
143
|
+
throw refreshed.error ?? new Error("Factory history refresh failed.");
|
|
144
|
+
}
|
|
145
|
+
const page = refreshed.data;
|
|
146
|
+
setBeforeVersion(undefined);
|
|
147
|
+
setVersions(page.versions);
|
|
148
|
+
setHasMore(page.hasMore);
|
|
149
|
+
setNextBeforeVersion(page.nextBeforeVersion);
|
|
150
|
+
setSelectedVersionId(
|
|
151
|
+
preferredVersionId &&
|
|
152
|
+
page.versions.some((version) => version.id === preferredVersionId)
|
|
153
|
+
? preferredVersionId
|
|
154
|
+
: (page.versions[0]?.id ?? null),
|
|
155
|
+
);
|
|
156
|
+
return page;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isCurrent(version: FactoryGraphVersion) {
|
|
160
|
+
return version.isCurrent;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function requestRestore(version: FactoryGraphVersion) {
|
|
164
|
+
setRestoreError(null);
|
|
165
|
+
setRestoreRefreshError(null);
|
|
166
|
+
setRestoreStatus(null);
|
|
167
|
+
setPendingRestore(version);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function loadOlderVersions() {
|
|
171
|
+
if (historyQuery.isFetching || nextBeforeVersion === null) return;
|
|
172
|
+
setBeforeVersion(nextBeforeVersion);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function confirmRestore(event: React.MouseEvent<HTMLButtonElement>) {
|
|
176
|
+
event.preventDefault();
|
|
177
|
+
const version = pendingRestore;
|
|
178
|
+
if (!version || restoringVersionId) return;
|
|
179
|
+
|
|
180
|
+
setRestoreError(null);
|
|
181
|
+
setRestoreRefreshError(null);
|
|
182
|
+
setRestoringVersionId(version.id);
|
|
183
|
+
const expectedCurrentVersion = historyQuery.data?.currentVersion ?? null;
|
|
184
|
+
let rawResult: unknown;
|
|
185
|
+
try {
|
|
186
|
+
rawResult = await restoreMutation.mutateAsync({
|
|
187
|
+
factoryId,
|
|
188
|
+
versionId: version.id,
|
|
189
|
+
});
|
|
190
|
+
} catch (error) {
|
|
191
|
+
let reconciliation: RestoreReconciliation = "unverified";
|
|
192
|
+
try {
|
|
193
|
+
const page = await refreshHistory();
|
|
194
|
+
reconciliation = reconcileRestoreFailure(
|
|
195
|
+
page,
|
|
196
|
+
version,
|
|
197
|
+
expectedCurrentVersion,
|
|
198
|
+
);
|
|
199
|
+
} catch {
|
|
200
|
+
reconciliation = "unverified";
|
|
201
|
+
}
|
|
202
|
+
if (reconciliation === "committed") {
|
|
203
|
+
setPendingRestore(null);
|
|
204
|
+
setRestoreError(null);
|
|
205
|
+
setRestoreStatus(t("factoryRoute.historyRestored"));
|
|
206
|
+
try {
|
|
207
|
+
await onRestored();
|
|
208
|
+
} catch {
|
|
209
|
+
setRestoreRefreshError(t("factoryRoute.historyRefreshFailed"));
|
|
210
|
+
}
|
|
211
|
+
} else if (reconciliation === "conflict") {
|
|
212
|
+
setPendingRestore(null);
|
|
213
|
+
setRestoreError(t("factoryRoute.historyRestoreConflict"));
|
|
214
|
+
} else if (reconciliation === "unverified") {
|
|
215
|
+
setPendingRestore(null);
|
|
216
|
+
setRestoreError(t("factoryRoute.historyRestoreUnverified"));
|
|
217
|
+
} else {
|
|
218
|
+
setRestoreError(
|
|
219
|
+
error instanceof Error
|
|
220
|
+
? error.message
|
|
221
|
+
: t("factoryRoute.historyRestoreFailed"),
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
setRestoringVersionId(null);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
let result: RestoreResult;
|
|
229
|
+
try {
|
|
230
|
+
result = readRestoreResult(rawResult);
|
|
231
|
+
} catch {
|
|
232
|
+
setPendingRestore(null);
|
|
233
|
+
setRestoreStatus(null);
|
|
234
|
+
setRestoreError(t("factoryRoute.historyRestoreUnverified"));
|
|
235
|
+
try {
|
|
236
|
+
await onRestored();
|
|
237
|
+
} catch {
|
|
238
|
+
setRestoreRefreshError(t("factoryRoute.historyRefreshFailed"));
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
await refreshHistory();
|
|
242
|
+
} catch {
|
|
243
|
+
setRestoreRefreshError(t("factoryRoute.historyRefreshFailed"));
|
|
244
|
+
}
|
|
245
|
+
setRestoringVersionId(null);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
setPendingRestore(null);
|
|
250
|
+
setRestoreStatus(t("factoryRoute.historyRestored"));
|
|
251
|
+
let refreshFailed = false;
|
|
252
|
+
try {
|
|
253
|
+
await onRestored(result);
|
|
254
|
+
} catch {
|
|
255
|
+
refreshFailed = true;
|
|
256
|
+
}
|
|
257
|
+
try {
|
|
258
|
+
await refreshHistory(result.versionId);
|
|
259
|
+
} catch {
|
|
260
|
+
refreshFailed = true;
|
|
261
|
+
}
|
|
262
|
+
if (refreshFailed) {
|
|
263
|
+
setRestoreRefreshError(t("factoryRoute.historyRefreshFailed"));
|
|
264
|
+
}
|
|
265
|
+
setRestoringVersionId(null);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (
|
|
269
|
+
historyQuery.isLoading ||
|
|
270
|
+
(versions.length === 0 && historyQuery.isFetching)
|
|
271
|
+
) {
|
|
272
|
+
return (
|
|
273
|
+
<div
|
|
274
|
+
className="grid gap-4 p-4 lg:grid-cols-[minmax(250px,.34fr)_minmax(0,1fr)] lg:p-6"
|
|
275
|
+
aria-label={t("factoryRoute.historyLoading")}
|
|
276
|
+
>
|
|
277
|
+
<div className="h-[520px] animate-pulse rounded-xl bg-card shadow-sm" />
|
|
278
|
+
<div className="h-[520px] animate-pulse rounded-xl bg-card shadow-sm" />
|
|
279
|
+
</div>
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (historyQuery.isError && versions.length === 0) {
|
|
284
|
+
return (
|
|
285
|
+
<div className="p-4 lg:p-6">
|
|
286
|
+
<Card>
|
|
287
|
+
<CardContent className="flex items-center gap-2 p-4 text-sm text-destructive">
|
|
288
|
+
<IconAlertCircle className="size-4 shrink-0" />
|
|
289
|
+
<span>{t("factoryRoute.historyLoadError")}</span>
|
|
290
|
+
<Button
|
|
291
|
+
type="button"
|
|
292
|
+
variant="link"
|
|
293
|
+
size="sm"
|
|
294
|
+
className="h-auto p-0"
|
|
295
|
+
onClick={() => void historyQuery.refetch()}
|
|
296
|
+
>
|
|
297
|
+
{t("triage.refresh")}
|
|
298
|
+
</Button>
|
|
299
|
+
</CardContent>
|
|
300
|
+
</Card>
|
|
301
|
+
</div>
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (versions.length === 0) {
|
|
306
|
+
return (
|
|
307
|
+
<div className="p-4 lg:p-6">
|
|
308
|
+
<Card>
|
|
309
|
+
<CardContent className="flex min-h-72 flex-col items-center justify-center gap-3 p-6 text-center">
|
|
310
|
+
<span className="flex size-10 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
|
311
|
+
<IconHistory className="size-5" />
|
|
312
|
+
</span>
|
|
313
|
+
<div className="space-y-1">
|
|
314
|
+
<p className="text-sm font-medium">
|
|
315
|
+
{t("factoryRoute.historyEmpty")}
|
|
316
|
+
</p>
|
|
317
|
+
<p className="max-w-sm text-sm text-muted-foreground">
|
|
318
|
+
{t("factoryRoute.historyEmptyHint")}
|
|
319
|
+
</p>
|
|
320
|
+
</div>
|
|
321
|
+
</CardContent>
|
|
322
|
+
</Card>
|
|
323
|
+
</div>
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return (
|
|
328
|
+
<>
|
|
329
|
+
<div className="grid gap-4 p-4 lg:grid-cols-[minmax(250px,.34fr)_minmax(0,1fr)] lg:p-6">
|
|
330
|
+
<Card className="h-fit lg:sticky lg:top-4">
|
|
331
|
+
<CardHeader className="flex-row items-center justify-between gap-3 px-4 pb-3 pt-4">
|
|
332
|
+
<CardTitle className="flex items-center gap-2 text-base">
|
|
333
|
+
<IconHistory className="size-4 text-muted-foreground" />
|
|
334
|
+
{t("factoryRoute.historyTitle")}
|
|
335
|
+
</CardTitle>
|
|
336
|
+
<span className="text-xs tabular-nums text-muted-foreground">
|
|
337
|
+
{versions.length}
|
|
338
|
+
</span>
|
|
339
|
+
</CardHeader>
|
|
340
|
+
<CardContent className="space-y-1 p-2">
|
|
341
|
+
{versions.map((version) => {
|
|
342
|
+
const current = isCurrent(version);
|
|
343
|
+
const selected = selectedVersion?.id === version.id;
|
|
344
|
+
return (
|
|
345
|
+
<button
|
|
346
|
+
key={version.id}
|
|
347
|
+
type="button"
|
|
348
|
+
data-testid={`factory-history-version-${version.version}`}
|
|
349
|
+
aria-pressed={selected}
|
|
350
|
+
className={`flex w-full items-start gap-3 rounded-lg border-s-2 px-3 py-3 text-start transition-colors ${selected ? "border-s-primary bg-muted/70" : "border-transparent hover:bg-muted/50"}`}
|
|
351
|
+
onClick={() => {
|
|
352
|
+
setSelectedVersionId(version.id);
|
|
353
|
+
setRestoreError(null);
|
|
354
|
+
setRestoreRefreshError(null);
|
|
355
|
+
setRestoreStatus(null);
|
|
356
|
+
}}
|
|
357
|
+
>
|
|
358
|
+
<span
|
|
359
|
+
className={`mt-1.5 size-2 shrink-0 rounded-full ${current ? "bg-primary" : "bg-muted-foreground/40"}`}
|
|
360
|
+
aria-hidden="true"
|
|
361
|
+
/>
|
|
362
|
+
<span className="min-w-0 flex-1">
|
|
363
|
+
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
|
364
|
+
<span className="text-sm font-medium">
|
|
365
|
+
{t("factoryRoute.historyVersion", {
|
|
366
|
+
version: version.version,
|
|
367
|
+
})}
|
|
368
|
+
</span>
|
|
369
|
+
{current ? (
|
|
370
|
+
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
|
|
371
|
+
{t("factoryRoute.historyCurrent")}
|
|
372
|
+
</span>
|
|
373
|
+
) : null}
|
|
374
|
+
</span>
|
|
375
|
+
<span className="mt-1 block text-xs text-muted-foreground">
|
|
376
|
+
{sourceLabel(version.source, t)} ·{" "}
|
|
377
|
+
{formatDate(version.createdAt)}
|
|
378
|
+
</span>
|
|
379
|
+
<span className="mt-1 block line-clamp-2 text-xs leading-5 text-muted-foreground">
|
|
380
|
+
{version.changeSummary ||
|
|
381
|
+
t("factoryRoute.historyNoSummary")}
|
|
382
|
+
</span>
|
|
383
|
+
</span>
|
|
384
|
+
</button>
|
|
385
|
+
);
|
|
386
|
+
})}
|
|
387
|
+
{historyQuery.isError ? (
|
|
388
|
+
<div
|
|
389
|
+
className="flex items-center gap-2 px-3 py-2 text-xs text-destructive"
|
|
390
|
+
role="alert"
|
|
391
|
+
>
|
|
392
|
+
<IconAlertCircle className="size-3.5 shrink-0" />
|
|
393
|
+
<span>{t("factoryRoute.historyLoadError")}</span>
|
|
394
|
+
<Button
|
|
395
|
+
type="button"
|
|
396
|
+
variant="link"
|
|
397
|
+
size="sm"
|
|
398
|
+
className="h-auto p-0 text-destructive"
|
|
399
|
+
onClick={() => void historyQuery.refetch()}
|
|
400
|
+
>
|
|
401
|
+
{t("triage.refresh")}
|
|
402
|
+
</Button>
|
|
403
|
+
</div>
|
|
404
|
+
) : null}
|
|
405
|
+
{hasMore ? (
|
|
406
|
+
<Button
|
|
407
|
+
type="button"
|
|
408
|
+
variant="ghost"
|
|
409
|
+
size="sm"
|
|
410
|
+
className="mt-1 w-full"
|
|
411
|
+
disabled={historyQuery.isFetching || nextBeforeVersion === null}
|
|
412
|
+
onClick={loadOlderVersions}
|
|
413
|
+
>
|
|
414
|
+
{historyQuery.isFetching ? (
|
|
415
|
+
<IconLoader2 className="size-3.5 animate-spin" />
|
|
416
|
+
) : null}
|
|
417
|
+
{t("factoryRoute.historyLoadOlder")}
|
|
418
|
+
</Button>
|
|
419
|
+
) : null}
|
|
420
|
+
</CardContent>
|
|
421
|
+
</Card>
|
|
422
|
+
|
|
423
|
+
{selectedVersion ? (
|
|
424
|
+
<Card className="min-w-0">
|
|
425
|
+
<CardHeader className="gap-2 px-4 pb-3 pt-4 sm:flex-row sm:items-start sm:justify-between">
|
|
426
|
+
<div className="min-w-0">
|
|
427
|
+
<CardTitle className="text-base">
|
|
428
|
+
{t("factoryRoute.historyVersion", {
|
|
429
|
+
version: selectedVersion.version,
|
|
430
|
+
})}
|
|
431
|
+
</CardTitle>
|
|
432
|
+
</div>
|
|
433
|
+
{isCurrent(selectedVersion) ? (
|
|
434
|
+
<span className="inline-flex w-fit shrink-0 items-center gap-1.5 rounded-full bg-primary/10 px-2.5 py-1 text-xs font-medium text-primary">
|
|
435
|
+
<IconCheck className="size-3.5" />
|
|
436
|
+
{t("factoryRoute.historyCurrent")}
|
|
437
|
+
</span>
|
|
438
|
+
) : null}
|
|
439
|
+
</CardHeader>
|
|
440
|
+
<CardContent className="space-y-4 px-3 pb-4 pt-0 sm:px-4">
|
|
441
|
+
{selectedSnapshotQuery.isLoading ? (
|
|
442
|
+
<div
|
|
443
|
+
className="h-[360px] animate-pulse rounded-lg bg-muted/40"
|
|
444
|
+
aria-label={t("factoryRoute.historyLoading")}
|
|
445
|
+
/>
|
|
446
|
+
) : selectedSnapshotQuery.isError ? (
|
|
447
|
+
<div
|
|
448
|
+
className="flex items-center gap-2 rounded-lg border border-destructive/20 bg-destructive/5 p-4 text-sm text-destructive"
|
|
449
|
+
role="alert"
|
|
450
|
+
>
|
|
451
|
+
<IconAlertCircle className="size-4 shrink-0" />
|
|
452
|
+
<span>{t("factoryRoute.historySnapshotLoadError")}</span>
|
|
453
|
+
<Button
|
|
454
|
+
type="button"
|
|
455
|
+
variant="link"
|
|
456
|
+
size="sm"
|
|
457
|
+
className="h-auto p-0 text-destructive"
|
|
458
|
+
onClick={() => void selectedSnapshotQuery.refetch()}
|
|
459
|
+
>
|
|
460
|
+
{t("triage.refresh")}
|
|
461
|
+
</Button>
|
|
462
|
+
</div>
|
|
463
|
+
) : selectedSnapshotQuery.data ? (
|
|
464
|
+
<>
|
|
465
|
+
<div className="overflow-hidden rounded-lg border bg-muted/10 p-2">
|
|
466
|
+
<FactoryCanvas
|
|
467
|
+
graph={selectedSnapshotQuery.data.graph}
|
|
468
|
+
preview
|
|
469
|
+
/>
|
|
470
|
+
</div>
|
|
471
|
+
|
|
472
|
+
<dl className="grid gap-3 rounded-lg bg-muted/25 p-3 text-sm sm:grid-cols-3">
|
|
473
|
+
<div>
|
|
474
|
+
<dt className="text-xs text-muted-foreground">
|
|
475
|
+
{t("factoryRoute.historySource")}
|
|
476
|
+
</dt>
|
|
477
|
+
<dd className="mt-1 font-medium">
|
|
478
|
+
{sourceLabel(selectedVersion.source, t)}
|
|
479
|
+
</dd>
|
|
480
|
+
</div>
|
|
481
|
+
<div>
|
|
482
|
+
<dt className="text-xs text-muted-foreground">
|
|
483
|
+
{t("factoryRoute.historyNodes")}
|
|
484
|
+
</dt>
|
|
485
|
+
<dd className="mt-1 font-medium tabular-nums">
|
|
486
|
+
{selectedSnapshotQuery.data.graph.nodes.length}
|
|
487
|
+
</dd>
|
|
488
|
+
</div>
|
|
489
|
+
<div>
|
|
490
|
+
<dt className="text-xs text-muted-foreground">
|
|
491
|
+
{t("factoryRoute.historyConnections")}
|
|
492
|
+
</dt>
|
|
493
|
+
<dd className="mt-1 font-medium tabular-nums">
|
|
494
|
+
{selectedSnapshotQuery.data.graph.edges.length}
|
|
495
|
+
</dd>
|
|
496
|
+
</div>
|
|
497
|
+
</dl>
|
|
498
|
+
|
|
499
|
+
<div className="space-y-1">
|
|
500
|
+
<p className="text-xs font-medium text-muted-foreground">
|
|
501
|
+
{t("factoryRoute.historyChangeSummary")}
|
|
502
|
+
</p>
|
|
503
|
+
<p className="text-sm leading-6">
|
|
504
|
+
{selectedVersion.changeSummary ||
|
|
505
|
+
t("factoryRoute.historyNoSummary")}
|
|
506
|
+
</p>
|
|
507
|
+
</div>
|
|
508
|
+
|
|
509
|
+
{isCurrent(selectedVersion) ? (
|
|
510
|
+
<div className="flex items-center gap-2 rounded-lg border border-primary/20 bg-primary/5 px-3 py-2.5 text-sm text-primary">
|
|
511
|
+
<IconCheck className="size-4 shrink-0" />
|
|
512
|
+
<span>{t("factoryRoute.historyCurrentHint")}</span>
|
|
513
|
+
</div>
|
|
514
|
+
) : (
|
|
515
|
+
<Button
|
|
516
|
+
type="button"
|
|
517
|
+
variant="outline"
|
|
518
|
+
className="w-full sm:w-auto"
|
|
519
|
+
data-testid="factory-history-restore"
|
|
520
|
+
onClick={() => requestRestore(selectedVersion)}
|
|
521
|
+
disabled={restoreMutation.isPending}
|
|
522
|
+
>
|
|
523
|
+
{restoreMutation.isPending ? (
|
|
524
|
+
<IconLoader2 className="size-4 animate-spin" />
|
|
525
|
+
) : (
|
|
526
|
+
<IconRestore className="size-4" />
|
|
527
|
+
)}
|
|
528
|
+
{t("factoryRoute.historyRestore")}
|
|
529
|
+
</Button>
|
|
530
|
+
)}
|
|
531
|
+
</>
|
|
532
|
+
) : null}
|
|
533
|
+
|
|
534
|
+
{restoreStatus ? (
|
|
535
|
+
<p
|
|
536
|
+
className="flex items-center gap-2 text-sm text-primary"
|
|
537
|
+
role="status"
|
|
538
|
+
>
|
|
539
|
+
<IconCheck className="size-4 shrink-0" />
|
|
540
|
+
{restoreStatus}
|
|
541
|
+
</p>
|
|
542
|
+
) : null}
|
|
543
|
+
{restoreRefreshError ? (
|
|
544
|
+
<p
|
|
545
|
+
className="flex items-center gap-2 text-sm text-amber-700 dark:text-amber-300"
|
|
546
|
+
role="status"
|
|
547
|
+
>
|
|
548
|
+
<IconAlertCircle className="size-4 shrink-0" />
|
|
549
|
+
{restoreRefreshError}
|
|
550
|
+
</p>
|
|
551
|
+
) : null}
|
|
552
|
+
{restoreError && !pendingRestore ? (
|
|
553
|
+
<p
|
|
554
|
+
className="flex items-center gap-2 text-sm text-destructive"
|
|
555
|
+
role="alert"
|
|
556
|
+
>
|
|
557
|
+
<IconAlertCircle className="size-4 shrink-0" />
|
|
558
|
+
{restoreError}
|
|
559
|
+
</p>
|
|
560
|
+
) : null}
|
|
561
|
+
</CardContent>
|
|
562
|
+
</Card>
|
|
563
|
+
) : null}
|
|
564
|
+
</div>
|
|
565
|
+
|
|
566
|
+
<AlertDialog
|
|
567
|
+
open={pendingRestore !== null}
|
|
568
|
+
onOpenChange={(open) => {
|
|
569
|
+
if (!open && !restoringVersionId) {
|
|
570
|
+
setPendingRestore(null);
|
|
571
|
+
setRestoreError(null);
|
|
572
|
+
setRestoreRefreshError(null);
|
|
573
|
+
}
|
|
574
|
+
}}
|
|
575
|
+
>
|
|
576
|
+
<AlertDialogContent>
|
|
577
|
+
<AlertDialogHeader>
|
|
578
|
+
<AlertDialogTitle>
|
|
579
|
+
{t("factoryRoute.historyRestoreTitle", {
|
|
580
|
+
version: pendingRestore?.version ?? "",
|
|
581
|
+
})}
|
|
582
|
+
</AlertDialogTitle>
|
|
583
|
+
<AlertDialogDescription>
|
|
584
|
+
{t("factoryRoute.historyRestoreDescription")}
|
|
585
|
+
{hasUnsavedChanges ? (
|
|
586
|
+
<span className="mt-2 block font-medium text-amber-700 dark:text-amber-300">
|
|
587
|
+
{t("factoryRoute.historyRestoreUnsaved")}
|
|
588
|
+
</span>
|
|
589
|
+
) : null}
|
|
590
|
+
{restoreError ? (
|
|
591
|
+
<span className="mt-2 block text-destructive" role="alert">
|
|
592
|
+
{restoreError}
|
|
593
|
+
</span>
|
|
594
|
+
) : null}
|
|
595
|
+
</AlertDialogDescription>
|
|
596
|
+
</AlertDialogHeader>
|
|
597
|
+
<AlertDialogFooter>
|
|
598
|
+
<AlertDialogCancel disabled={Boolean(restoringVersionId)}>
|
|
599
|
+
{t("factoryRoute.historyCancel")}
|
|
600
|
+
</AlertDialogCancel>
|
|
601
|
+
<AlertDialogAction
|
|
602
|
+
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
603
|
+
disabled={Boolean(restoringVersionId)}
|
|
604
|
+
onClick={confirmRestore}
|
|
605
|
+
>
|
|
606
|
+
{restoringVersionId ? (
|
|
607
|
+
<IconLoader2 className="size-4 animate-spin" />
|
|
608
|
+
) : (
|
|
609
|
+
<IconRestore className="size-4" />
|
|
610
|
+
)}
|
|
611
|
+
{t("factoryRoute.historyRestoreConfirm")}
|
|
612
|
+
</AlertDialogAction>
|
|
613
|
+
</AlertDialogFooter>
|
|
614
|
+
</AlertDialogContent>
|
|
615
|
+
</AlertDialog>
|
|
616
|
+
</>
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function sourceLabel(source: string, t: ReturnType<typeof useT>) {
|
|
621
|
+
switch (source) {
|
|
622
|
+
case "ai":
|
|
623
|
+
return t("factoryRoute.historySourceAi");
|
|
624
|
+
case "restore":
|
|
625
|
+
return t("factoryRoute.historySourceRestore");
|
|
626
|
+
case "seed":
|
|
627
|
+
return t("factoryRoute.historySourceSeed");
|
|
628
|
+
case "manual":
|
|
629
|
+
return t("factoryRoute.historySourceManual");
|
|
630
|
+
default:
|
|
631
|
+
return t("factoryRoute.historySourceSaved");
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function formatDate(value: string): string {
|
|
636
|
+
const date = new Date(value);
|
|
637
|
+
if (Number.isNaN(date.getTime())) return value;
|
|
638
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
639
|
+
dateStyle: "medium",
|
|
640
|
+
timeStyle: "short",
|
|
641
|
+
}).format(date);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function readRestoreResult(value: unknown): RestoreResult {
|
|
645
|
+
if (!value || typeof value !== "object") {
|
|
646
|
+
throw new Error("Restore did not return a usable version.");
|
|
647
|
+
}
|
|
648
|
+
const result = value as Record<string, unknown>;
|
|
649
|
+
if (
|
|
650
|
+
typeof result.versionId !== "string" ||
|
|
651
|
+
typeof result.graphVersion !== "number" ||
|
|
652
|
+
typeof result.name !== "string" ||
|
|
653
|
+
!isFactoryCanvasGraph(result.graph)
|
|
654
|
+
) {
|
|
655
|
+
throw new Error("Restore did not return a usable version.");
|
|
656
|
+
}
|
|
657
|
+
return {
|
|
658
|
+
versionId: result.versionId,
|
|
659
|
+
graphVersion: result.graphVersion,
|
|
660
|
+
name: result.name,
|
|
661
|
+
graph: result.graph,
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function isFactoryCanvasGraph(value: unknown): value is FactoryCanvasGraph {
|
|
666
|
+
if (!value || typeof value !== "object") return false;
|
|
667
|
+
const graph = value as Record<string, unknown>;
|
|
668
|
+
return (
|
|
669
|
+
typeof graph.version === "number" &&
|
|
670
|
+
typeof graph.name === "string" &&
|
|
671
|
+
Array.isArray(graph.nodes) &&
|
|
672
|
+
Array.isArray(graph.edges)
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
type RestoreReconciliation =
|
|
677
|
+
| "committed"
|
|
678
|
+
| "conflict"
|
|
679
|
+
| "retryable"
|
|
680
|
+
| "unverified";
|
|
681
|
+
|
|
682
|
+
function reconcileRestoreFailure(
|
|
683
|
+
page: FactoryGraphHistoryResponse,
|
|
684
|
+
target: FactoryGraphVersion,
|
|
685
|
+
expectedCurrentVersion: number | null,
|
|
686
|
+
): RestoreReconciliation {
|
|
687
|
+
const latest = page.versions[0];
|
|
688
|
+
const targetSummary = `Restored version ${target.version}.`;
|
|
689
|
+
if (
|
|
690
|
+
page.currentVersion === target.version ||
|
|
691
|
+
(page.currentVersion !== null &&
|
|
692
|
+
page.currentVersion > target.version &&
|
|
693
|
+
latest?.isCurrent === true &&
|
|
694
|
+
latest.source === "restore" &&
|
|
695
|
+
latest.changeSummary === targetSummary)
|
|
696
|
+
) {
|
|
697
|
+
return "committed";
|
|
698
|
+
}
|
|
699
|
+
if (
|
|
700
|
+
expectedCurrentVersion !== null &&
|
|
701
|
+
page.currentVersion !== expectedCurrentVersion
|
|
702
|
+
) {
|
|
703
|
+
return "conflict";
|
|
704
|
+
}
|
|
705
|
+
if (expectedCurrentVersion === null && page.currentVersion !== null) {
|
|
706
|
+
return "conflict";
|
|
707
|
+
}
|
|
708
|
+
return "retryable";
|
|
709
|
+
}
|