@agent-native/core 0.80.7 → 0.80.8
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/core/CHANGELOG.md +6 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/client/AssistantChat.tsx +70 -27
- package/corpus/core/src/client/analytics.ts +3 -1
- package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +69 -16
- package/corpus/templates/content/changelog/2026-06-29-read-only-pages-no-longer-try-to-autosave-or-open-editor-onl.md +6 -0
- package/corpus/templates/design/AGENTS.md +4 -2
- package/corpus/templates/design/actions/edit-design.ts +6 -3
- package/corpus/templates/design/actions/generate-screens.ts +5 -3
- package/corpus/templates/design/actions/update-design.ts +47 -10
- package/corpus/templates/design/actions/update-file.ts +33 -8
- package/corpus/templates/design/actions/view-screen.ts +14 -0
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +75 -17
- package/corpus/templates/design/app/components/design/EditPanel.tsx +121 -22
- package/corpus/templates/design/app/components/design/LayersPanel.tsx +51 -8
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +148 -33
- package/corpus/templates/design/app/components/design/QuestionFlow.tsx +11 -11
- package/corpus/templates/design/app/components/design/TweaksPanel.tsx +27 -13
- package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +3 -37
- package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +18 -2
- package/corpus/templates/design/app/components/design/inspector/ExportSettingsPanel.tsx +15 -3
- package/corpus/templates/design/app/components/design/inspector/GradientEditor.tsx +4 -2
- package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +18 -4
- package/corpus/templates/design/app/components/design/inspector/scrub-input-utils.ts +10 -4
- package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +448 -259
- package/corpus/templates/design/app/hooks/use-variant-flow.ts +176 -92
- package/corpus/templates/design/app/i18n/zh-TW.ts +1 -0
- package/corpus/templates/design/app/i18n-data.ts +10 -0
- package/corpus/templates/design/app/pages/DesignEditor.tsx +220 -40
- package/corpus/templates/design/changelog/2026-06-29-agent-edits-to-a-design-are-no-longer-occasionally-dropped-o.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-box-shadow-and-gradient-colors-keep-named-css-colors-like-re.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-cut-cmd-ctrl-x-now-removes-the-selected-element-to-the-clipb.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-deleting-with-multiple-layers-selected-in-the-layers-panel-n.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-duplicating-a-screen-now-gives-the-copy-its-own-layer-ids-so.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-hiding-and-re-showing-a-gradient-fill-now-preserves-each-sto.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-picking-a-design-direction-now-submits-to-the-agent-right-aw.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-style-changes-that-set-several-properties-at-once-fixed-size.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-the-editor-no-longer-crashes-when-a-screen-ends-up-with-dupl.md +6 -0
- package/corpus/templates/design/e2e/README.md +1 -0
- package/corpus/templates/design/e2e/global-setup.ts +13 -1
- package/corpus/templates/design/playwright.config.ts +10 -1
- package/corpus/templates/design/shared/color-utils.ts +159 -0
- package/corpus/templates/design/shared/generation-session.ts +1 -0
- package/dist/client/AssistantChat.d.ts.map +1 -1
- package/dist/client/AssistantChat.js +54 -24
- package/dist/client/AssistantChat.js.map +1 -1
- package/dist/client/analytics.d.ts.map +1 -1
- package/dist/client/analytics.js +2 -1
- package/dist/client/analytics.js.map +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +2 -2
- package/dist/notifications/routes.d.ts +2 -2
- package/dist/observability/routes.d.ts +8 -8
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/package.json +1 -1
package/corpus/README.md
CHANGED
package/corpus/core/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @agent-native/core
|
|
2
2
|
|
|
3
|
+
## 0.80.8
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 24deb20: Reduce noisy browser Sentry captures by filtering public-site source-less errors that only report their page URL as a Sentry tag, delaying reconnect aborts until active runs are truly stuck on the server clock, and recovering assistant-ui duplicate message-id append races before they escape.
|
|
8
|
+
|
|
3
9
|
## 0.80.7
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
package/corpus/core/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-native/core",
|
|
3
|
-
"version": "0.80.
|
|
3
|
+
"version": "0.80.8",
|
|
4
4
|
"description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
|
|
5
5
|
"homepage": "https://github.com/BuilderIO/agent-native#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -228,6 +228,7 @@ function createUserMessageRunConfig(
|
|
|
228
228
|
|
|
229
229
|
const PENDING_SELECTION_KEY = "pending-selection-context";
|
|
230
230
|
const ACTIVE_RUN_CLEAR_TIMEOUT_MS = 5_000;
|
|
231
|
+
const ACTIVE_RUN_STUCK_THRESHOLD_MS = 90_000;
|
|
231
232
|
const ACTIVE_RUN_POLL_INTERVAL_MS = 150;
|
|
232
233
|
const SUBMIT_ENGINE_STATUS_TIMEOUT_MS = 1000;
|
|
233
234
|
|
|
@@ -237,15 +238,27 @@ type ActiveRunLookup = {
|
|
|
237
238
|
threadId?: string;
|
|
238
239
|
status?: string;
|
|
239
240
|
heartbeatAt?: number | null;
|
|
241
|
+
lastProgressAt?: number | null;
|
|
242
|
+
serverNow?: number;
|
|
240
243
|
};
|
|
241
244
|
|
|
242
245
|
function activeRunLooksStale(runInfo: ActiveRunLookup): boolean {
|
|
243
|
-
const
|
|
244
|
-
typeof runInfo.
|
|
246
|
+
const lastProgressAt =
|
|
247
|
+
typeof runInfo.lastProgressAt === "number" ? runInfo.lastProgressAt : null;
|
|
248
|
+
const nowMs =
|
|
249
|
+
typeof runInfo.serverNow === "number" ? runInfo.serverNow : Date.now();
|
|
245
250
|
return (
|
|
246
251
|
runInfo.status === "running" &&
|
|
247
|
-
|
|
248
|
-
|
|
252
|
+
lastProgressAt != null &&
|
|
253
|
+
nowMs - lastProgressAt > ACTIVE_RUN_STUCK_THRESHOLD_MS
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function isAssistantUiDuplicateMessageIdError(error: unknown): boolean {
|
|
258
|
+
const message = error instanceof Error ? error.message : String(error ?? "");
|
|
259
|
+
return (
|
|
260
|
+
message.includes("MessageRepository") &&
|
|
261
|
+
message.includes("same id already exists")
|
|
249
262
|
);
|
|
250
263
|
}
|
|
251
264
|
|
|
@@ -295,14 +308,13 @@ async function waitForThreadRunToClear(apiUrl: string, threadId?: string) {
|
|
|
295
308
|
`${apiUrl}/runs/active?threadId=${encodeURIComponent(threadId)}`,
|
|
296
309
|
);
|
|
297
310
|
if (res.ok) {
|
|
298
|
-
const info = await res.json();
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
info
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
if (!info?.active || info?.status !== "running" || stale) return;
|
|
311
|
+
const info = (await res.json()) as ActiveRunLookup;
|
|
312
|
+
if (
|
|
313
|
+
!info?.active ||
|
|
314
|
+
info?.status !== "running" ||
|
|
315
|
+
activeRunLooksStale(info)
|
|
316
|
+
)
|
|
317
|
+
return;
|
|
306
318
|
}
|
|
307
319
|
} catch {
|
|
308
320
|
// Transient poll failure — try again until the short grace period ends.
|
|
@@ -1467,9 +1479,40 @@ const AssistantChatInner = forwardRef<
|
|
|
1467
1479
|
}
|
|
1468
1480
|
}, [apiUrl, importThreadData, loadHistoryRepository, threadId]);
|
|
1469
1481
|
|
|
1482
|
+
const exportCleanThreadRepo = useCallback(
|
|
1483
|
+
() =>
|
|
1484
|
+
ensureMessageMetadata(normalizeThreadRepository(threadRuntime.export())),
|
|
1485
|
+
[threadRuntime],
|
|
1486
|
+
);
|
|
1487
|
+
|
|
1488
|
+
const appendThreadMessage = useCallback(
|
|
1489
|
+
(message: Parameters<typeof threadRuntime.append>[0]) => {
|
|
1490
|
+
try {
|
|
1491
|
+
threadRuntime.append(message);
|
|
1492
|
+
return;
|
|
1493
|
+
} catch (error) {
|
|
1494
|
+
if (!isAssistantUiDuplicateMessageIdError(error)) throw error;
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
try {
|
|
1498
|
+
threadRuntime.import(exportCleanThreadRepo());
|
|
1499
|
+
} catch {
|
|
1500
|
+
// Best effort cleanup; retry below handles the still-duplicated case.
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
try {
|
|
1504
|
+
threadRuntime.append(message);
|
|
1505
|
+
} catch (retryError) {
|
|
1506
|
+
if (isAssistantUiDuplicateMessageIdError(retryError)) return;
|
|
1507
|
+
throw retryError;
|
|
1508
|
+
}
|
|
1509
|
+
},
|
|
1510
|
+
[exportCleanThreadRepo, threadRuntime],
|
|
1511
|
+
);
|
|
1512
|
+
|
|
1470
1513
|
const cacheCurrentThreadSnapshot = useCallback(() => {
|
|
1471
1514
|
if (!threadId || messages.length === 0) return;
|
|
1472
|
-
const repo =
|
|
1515
|
+
const repo = exportCleanThreadRepo();
|
|
1473
1516
|
const threadData = JSON.stringify(stripBase64FromRepo(repo));
|
|
1474
1517
|
const { title, preview } = extractThreadMeta(repo);
|
|
1475
1518
|
writeCachedThreadSnapshot(apiUrl, threadId, {
|
|
@@ -1478,7 +1521,7 @@ const AssistantChatInner = forwardRef<
|
|
|
1478
1521
|
preview,
|
|
1479
1522
|
messageCount: messages.length,
|
|
1480
1523
|
});
|
|
1481
|
-
}, [apiUrl, messages.length, threadId
|
|
1524
|
+
}, [apiUrl, exportCleanThreadRepo, messages.length, threadId]);
|
|
1482
1525
|
|
|
1483
1526
|
useBrowserLayoutEffect(() => {
|
|
1484
1527
|
if (hasImportedInitialCachedSnapshotRef.current) return;
|
|
@@ -1975,7 +2018,7 @@ const AssistantChatInner = forwardRef<
|
|
|
1975
2018
|
const timeSinceLastSave = now - lastSaveTimeRef.current;
|
|
1976
2019
|
if (timeSinceLastSave < 5000) return;
|
|
1977
2020
|
|
|
1978
|
-
const repo =
|
|
2021
|
+
const repo = exportCleanThreadRepo();
|
|
1979
2022
|
const { title, preview } = extractThreadMeta(repo);
|
|
1980
2023
|
const threadData = JSON.stringify(stripBase64FromRepo(repo));
|
|
1981
2024
|
const snapshot = {
|
|
@@ -1989,7 +2032,7 @@ const AssistantChatInner = forwardRef<
|
|
|
1989
2032
|
savedTitleRef.current = title;
|
|
1990
2033
|
writeCachedThreadSnapshot(apiUrl, threadId, snapshot);
|
|
1991
2034
|
onSaveThreadRef.current(threadId, snapshot);
|
|
1992
|
-
}, [apiUrl, messages, isRunning, threadId
|
|
2035
|
+
}, [apiUrl, exportCleanThreadRepo, messages, isRunning, threadId]);
|
|
1993
2036
|
|
|
1994
2037
|
// Persist full thread data after each completed response
|
|
1995
2038
|
useEffect(() => {
|
|
@@ -1997,7 +2040,7 @@ const AssistantChatInner = forwardRef<
|
|
|
1997
2040
|
if (isRunning) return;
|
|
1998
2041
|
if (messages.length === 0) return;
|
|
1999
2042
|
|
|
2000
|
-
const repo =
|
|
2043
|
+
const repo = exportCleanThreadRepo();
|
|
2001
2044
|
|
|
2002
2045
|
if (threadId && onSaveThreadRef.current) {
|
|
2003
2046
|
// Save to server via the hook callback
|
|
@@ -2019,7 +2062,7 @@ const AssistantChatInner = forwardRef<
|
|
|
2019
2062
|
sessionStorage.setItem(storageKey, JSON.stringify(repo));
|
|
2020
2063
|
} catch {}
|
|
2021
2064
|
}
|
|
2022
|
-
}, [apiUrl, messages, isRunning, threadId, tabId
|
|
2065
|
+
}, [apiUrl, exportCleanThreadRepo, messages, isRunning, threadId, tabId]);
|
|
2023
2066
|
|
|
2024
2067
|
useEffect(() => {
|
|
2025
2068
|
onMessageCountChange?.(messages.length);
|
|
@@ -2280,7 +2323,7 @@ const AssistantChatInner = forwardRef<
|
|
|
2280
2323
|
next.attachments && next.attachments.length > 0
|
|
2281
2324
|
? next.attachments
|
|
2282
2325
|
: (imageAttachments ?? []);
|
|
2283
|
-
|
|
2326
|
+
appendThreadMessage({
|
|
2284
2327
|
role: "user",
|
|
2285
2328
|
content: [{ type: "text", text: next.text }],
|
|
2286
2329
|
...(messageAttachments.length > 0
|
|
@@ -2334,12 +2377,12 @@ const AssistantChatInner = forwardRef<
|
|
|
2334
2377
|
};
|
|
2335
2378
|
}, [
|
|
2336
2379
|
apiUrl,
|
|
2380
|
+
appendThreadMessage,
|
|
2337
2381
|
applyLocalQueuedMessages,
|
|
2338
2382
|
isRestoring,
|
|
2339
2383
|
isRunning,
|
|
2340
2384
|
queuedMessages,
|
|
2341
2385
|
threadId,
|
|
2342
|
-
threadRuntime,
|
|
2343
2386
|
]);
|
|
2344
2387
|
|
|
2345
2388
|
// Clear frozen reconnect content + forceStopped only on the false→true
|
|
@@ -2683,7 +2726,7 @@ const AssistantChatInner = forwardRef<
|
|
|
2683
2726
|
},
|
|
2684
2727
|
]);
|
|
2685
2728
|
} else {
|
|
2686
|
-
|
|
2729
|
+
appendThreadMessage({
|
|
2687
2730
|
role: "user",
|
|
2688
2731
|
content: [{ type: "text", text: submittedText }],
|
|
2689
2732
|
...(messageAttachments.length > 0
|
|
@@ -2708,7 +2751,7 @@ const AssistantChatInner = forwardRef<
|
|
|
2708
2751
|
execMode,
|
|
2709
2752
|
isRunning,
|
|
2710
2753
|
materializeFrozenReconnectContent,
|
|
2711
|
-
|
|
2754
|
+
appendThreadMessage,
|
|
2712
2755
|
updateComposerContextItems,
|
|
2713
2756
|
],
|
|
2714
2757
|
);
|
|
@@ -2774,7 +2817,7 @@ const AssistantChatInner = forwardRef<
|
|
|
2774
2817
|
},
|
|
2775
2818
|
exportThreadSnapshot() {
|
|
2776
2819
|
if (messages.length === 0) return null;
|
|
2777
|
-
const repo =
|
|
2820
|
+
const repo = exportCleanThreadRepo();
|
|
2778
2821
|
const { title, preview } = extractThreadMeta(repo);
|
|
2779
2822
|
return {
|
|
2780
2823
|
threadData: JSON.stringify(repo),
|
|
@@ -2786,10 +2829,10 @@ const AssistantChatInner = forwardRef<
|
|
|
2786
2829
|
}),
|
|
2787
2830
|
[
|
|
2788
2831
|
addToQueue,
|
|
2832
|
+
exportCleanThreadRepo,
|
|
2789
2833
|
messages.length,
|
|
2790
2834
|
stageComposerContextItem,
|
|
2791
2835
|
thread.isRunning,
|
|
2792
|
-
threadRuntime,
|
|
2793
2836
|
],
|
|
2794
2837
|
);
|
|
2795
2838
|
|
|
@@ -2986,7 +3029,7 @@ const AssistantChatInner = forwardRef<
|
|
|
2986
3029
|
const approvalCtx = useMemo<ApprovalContextValue>(
|
|
2987
3030
|
() => ({
|
|
2988
3031
|
onApprove: (approvalKey: string) => {
|
|
2989
|
-
|
|
3032
|
+
appendThreadMessage({
|
|
2990
3033
|
role: "user",
|
|
2991
3034
|
content: [
|
|
2992
3035
|
{
|
|
@@ -3008,7 +3051,7 @@ const AssistantChatInner = forwardRef<
|
|
|
3008
3051
|
} as Parameters<typeof threadRuntime.append>[0]);
|
|
3009
3052
|
},
|
|
3010
3053
|
}),
|
|
3011
|
-
[
|
|
3054
|
+
[appendThreadMessage, execMode],
|
|
3012
3055
|
);
|
|
3013
3056
|
|
|
3014
3057
|
return (
|
|
@@ -3201,7 +3244,7 @@ const AssistantChatInner = forwardRef<
|
|
|
3201
3244
|
setMissingKeyBouncePulse((p) => p + 1);
|
|
3202
3245
|
return;
|
|
3203
3246
|
}
|
|
3204
|
-
|
|
3247
|
+
appendThreadMessage({
|
|
3205
3248
|
role: "user",
|
|
3206
3249
|
content: [{ type: "text", text: suggestion }],
|
|
3207
3250
|
});
|
|
@@ -610,7 +610,9 @@ function isAgentNativeDocsUrl(url: string): boolean {
|
|
|
610
610
|
|
|
611
611
|
function shouldDropBrowserSentryNoise(event: Sentry.Event): boolean {
|
|
612
612
|
const exceptionValues = event.exception?.values ?? [];
|
|
613
|
-
const
|
|
613
|
+
const taggedUrl =
|
|
614
|
+
typeof event.tags?.url === "string" ? event.tags.url : undefined;
|
|
615
|
+
const requestUrl = (event.request?.url ?? taggedUrl ?? "").toLowerCase();
|
|
614
616
|
const isDocsPage = isAgentNativeDocsUrl(requestUrl);
|
|
615
617
|
// AgentAutoContinueSignal is a control-flow sentinel thrown to bubble
|
|
616
618
|
// out of the SSE stream parser when the agent run needs to be
|
|
@@ -157,10 +157,19 @@ interface DocumentEditorBodyProps {
|
|
|
157
157
|
type PendingDocumentSave = {
|
|
158
158
|
title: string;
|
|
159
159
|
content: string;
|
|
160
|
-
save: (
|
|
160
|
+
save: (
|
|
161
|
+
title: string,
|
|
162
|
+
content: string,
|
|
163
|
+
options?: DocumentSaveOptions,
|
|
164
|
+
) => unknown | Promise<unknown>;
|
|
165
|
+
canEditWhenQueued: boolean;
|
|
161
166
|
timeout: ReturnType<typeof setTimeout>;
|
|
162
167
|
};
|
|
163
168
|
|
|
169
|
+
type DocumentSaveOptions = {
|
|
170
|
+
allowQueuedSave?: boolean;
|
|
171
|
+
};
|
|
172
|
+
|
|
164
173
|
type DocumentSaveResult = {
|
|
165
174
|
contentPersisted: boolean;
|
|
166
175
|
};
|
|
@@ -274,6 +283,8 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
|
|
|
274
283
|
const updateDocument = useUpdateDocument();
|
|
275
284
|
const queryClient = useQueryClient();
|
|
276
285
|
const canEdit = document.canEdit ?? true;
|
|
286
|
+
const canEditRef = useRef(canEdit);
|
|
287
|
+
canEditRef.current = canEdit;
|
|
277
288
|
// The block render context (asset/upload resolvers, inline markdown reader,
|
|
278
289
|
// panel popover) is stable for the editor's lifetime. Created once here and
|
|
279
290
|
// provided alongside the content block registry so every registry block in the
|
|
@@ -324,6 +335,15 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
|
|
|
324
335
|
document.updatedAt ?? null,
|
|
325
336
|
);
|
|
326
337
|
documentUpdatedAtRef.current = document.updatedAt ?? null;
|
|
338
|
+
const handleBackgroundSaveError = useCallback(
|
|
339
|
+
(error: unknown) => {
|
|
340
|
+
toast.error(t("empty.genericError"), {
|
|
341
|
+
description:
|
|
342
|
+
error instanceof Error ? error.message : t("empty.genericError"),
|
|
343
|
+
});
|
|
344
|
+
},
|
|
345
|
+
[t],
|
|
346
|
+
);
|
|
327
347
|
const titleFocusedRef = useRef(false);
|
|
328
348
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
329
349
|
const titleInputRef = useRef<HTMLTextAreaElement>(null);
|
|
@@ -375,7 +395,8 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
|
|
|
375
395
|
}
|
|
376
396
|
: undefined;
|
|
377
397
|
|
|
378
|
-
// Collaborative editing
|
|
398
|
+
// Collaborative editing is write-only for now. Viewers get the SQL snapshot
|
|
399
|
+
// and skip collab endpoints that reject non-editor access.
|
|
379
400
|
const {
|
|
380
401
|
ydoc,
|
|
381
402
|
awareness,
|
|
@@ -384,7 +405,7 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
|
|
|
384
405
|
agentActive,
|
|
385
406
|
agentPresent,
|
|
386
407
|
} = useCollaborativeDoc({
|
|
387
|
-
docId: isLocalFileDocument ?
|
|
408
|
+
docId: canEdit && !isLocalFileDocument ? documentId : "",
|
|
388
409
|
requestSource: TAB_ID,
|
|
389
410
|
user: currentUser,
|
|
390
411
|
});
|
|
@@ -566,11 +587,16 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
|
|
|
566
587
|
}, [document, isLinkedLocalSourceDocument, localTitle, localContent]);
|
|
567
588
|
|
|
568
589
|
const persistDocumentUpdates = useCallback(
|
|
569
|
-
async (
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
590
|
+
async (
|
|
591
|
+
updates: {
|
|
592
|
+
title?: string;
|
|
593
|
+
content?: string;
|
|
594
|
+
icon?: string | null;
|
|
595
|
+
},
|
|
596
|
+
options: DocumentSaveOptions = {},
|
|
597
|
+
): Promise<Document> => {
|
|
598
|
+
if (!options.allowQueuedSave && !canEditRef.current) return document;
|
|
599
|
+
|
|
574
600
|
const localSource = document.source;
|
|
575
601
|
const isLinkedLocalSource = canWriteLinkedLocalSource(
|
|
576
602
|
documentId,
|
|
@@ -629,7 +655,11 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
|
|
|
629
655
|
);
|
|
630
656
|
|
|
631
657
|
const saveDocumentImmediately = useCallback(
|
|
632
|
-
async (
|
|
658
|
+
async (
|
|
659
|
+
title: string,
|
|
660
|
+
content: string,
|
|
661
|
+
options: DocumentSaveOptions = {},
|
|
662
|
+
): Promise<DocumentSaveResult> => {
|
|
633
663
|
// Never clobber a newer server version (e.g. an agent edit we haven't
|
|
634
664
|
// reconciled into the editor yet) with the editor's current — possibly
|
|
635
665
|
// stale — content. Guard per-field using the field's own watermark.
|
|
@@ -653,7 +683,7 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
|
|
|
653
683
|
return { contentPersisted: !contentChanged };
|
|
654
684
|
}
|
|
655
685
|
|
|
656
|
-
const saved = await persistDocumentUpdates(updates);
|
|
686
|
+
const saved = await persistDocumentUpdates(updates, options);
|
|
657
687
|
// Adopt the server updatedAt per saved field.
|
|
658
688
|
const savedAt = saved?.updatedAt ?? new Date().toISOString();
|
|
659
689
|
if (updates.title !== undefined) {
|
|
@@ -700,25 +730,38 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
|
|
|
700
730
|
queryClient,
|
|
701
731
|
],
|
|
702
732
|
);
|
|
733
|
+
const flushPendingDocumentSave = useCallback(
|
|
734
|
+
(pending: PendingDocumentSave) => {
|
|
735
|
+
if (!pending.canEditWhenQueued) return;
|
|
736
|
+
void Promise.resolve(
|
|
737
|
+
pending.save(pending.title, pending.content, {
|
|
738
|
+
allowQueuedSave: true,
|
|
739
|
+
}),
|
|
740
|
+
).catch(handleBackgroundSaveError);
|
|
741
|
+
},
|
|
742
|
+
[handleBackgroundSaveError],
|
|
743
|
+
);
|
|
703
744
|
const debouncedSave = useCallback(
|
|
704
745
|
(title: string, content: string) => {
|
|
746
|
+
if (!canEditRef.current) return;
|
|
705
747
|
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
|
706
748
|
const pending: PendingDocumentSave = {
|
|
707
749
|
title,
|
|
708
750
|
content,
|
|
709
751
|
save: saveDocumentImmediately,
|
|
752
|
+
canEditWhenQueued: canEditRef.current,
|
|
710
753
|
timeout: setTimeout(() => {
|
|
711
754
|
if (pendingDocumentSaveRef.current === pending) {
|
|
712
755
|
pendingDocumentSaveRef.current = null;
|
|
713
756
|
}
|
|
714
757
|
saveTimeoutRef.current = null;
|
|
715
|
-
|
|
758
|
+
flushPendingDocumentSave(pending);
|
|
716
759
|
}, 500),
|
|
717
760
|
};
|
|
718
761
|
pendingDocumentSaveRef.current = pending;
|
|
719
762
|
saveTimeoutRef.current = pending.timeout;
|
|
720
763
|
},
|
|
721
|
-
[saveDocumentImmediately],
|
|
764
|
+
[flushPendingDocumentSave, saveDocumentImmediately],
|
|
722
765
|
);
|
|
723
766
|
|
|
724
767
|
useEffect(() => {
|
|
@@ -728,9 +771,19 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
|
|
|
728
771
|
clearTimeout(pending.timeout);
|
|
729
772
|
saveTimeoutRef.current = null;
|
|
730
773
|
pendingDocumentSaveRef.current = null;
|
|
731
|
-
|
|
774
|
+
flushPendingDocumentSave(pending);
|
|
732
775
|
};
|
|
733
|
-
}, [documentId]);
|
|
776
|
+
}, [documentId, flushPendingDocumentSave]);
|
|
777
|
+
|
|
778
|
+
useEffect(() => {
|
|
779
|
+
if (canEdit) return;
|
|
780
|
+
const pending = pendingDocumentSaveRef.current;
|
|
781
|
+
if (!pending) return;
|
|
782
|
+
clearTimeout(pending.timeout);
|
|
783
|
+
saveTimeoutRef.current = null;
|
|
784
|
+
pendingDocumentSaveRef.current = null;
|
|
785
|
+
flushPendingDocumentSave(pending);
|
|
786
|
+
}, [canEdit, documentId, flushPendingDocumentSave]);
|
|
734
787
|
|
|
735
788
|
// Collab-aware ingest flush: the `pull-document` action writes a one-shot
|
|
736
789
|
// `flush-request-<id>` app-state key when an external agent wants to ingest
|
|
@@ -846,7 +899,7 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
|
|
|
846
899
|
const [hoveredThreadId, setHoveredThreadId] = useState<string | null>(null);
|
|
847
900
|
const activeThreadId = hoveredThreadId ?? selectedThreadId;
|
|
848
901
|
const { data: threads, isLoading: commentsLoading } = useComments(
|
|
849
|
-
isLocalFileDocument ?
|
|
902
|
+
canEdit && !isLocalFileDocument ? documentId : null,
|
|
850
903
|
);
|
|
851
904
|
const hasComments =
|
|
852
905
|
!isLocalFileDocument &&
|
|
@@ -1061,7 +1114,7 @@ function DocumentEditorBody({ documentId, document }: DocumentEditorBodyProps) {
|
|
|
1061
1114
|
onSelect={(emoji) => {
|
|
1062
1115
|
void (async () => {
|
|
1063
1116
|
await persistDocumentUpdates({ icon: emoji });
|
|
1064
|
-
})();
|
|
1117
|
+
})().catch(handleBackgroundSaveError);
|
|
1065
1118
|
}}
|
|
1066
1119
|
/>
|
|
1067
1120
|
) : document.icon ? (
|
|
@@ -127,8 +127,10 @@ patterns live in `.agents/skills/`.
|
|
|
127
127
|
- `navigate` moves the UI and is auto-deleted after the client consumes it.
|
|
128
128
|
- `design-selection` includes active screen, selected element, overview mode,
|
|
129
129
|
inspector tab, zoom, and screen list for the current tab.
|
|
130
|
-
- `design-generation-session:<designId>` contains
|
|
131
|
-
generation
|
|
130
|
+
- `design-generation-session:<designId>` contains agent-facing multi-screen
|
|
131
|
+
generation planning state created by `generate-screens` (canvas region
|
|
132
|
+
assignments and per-frame instructions consumed by `generate-design` and
|
|
133
|
+
`view-screen`; not rendered as canvas overlays).
|
|
132
134
|
- `show-design-questions` opens focused pre-generation questions in the main
|
|
133
135
|
design canvas (`show-questions` application state).
|
|
134
136
|
- `design-variants` contains in-progress candidates for the variant picker.
|
|
@@ -91,11 +91,14 @@ function findUniqueStableIdAgnosticSpan(
|
|
|
91
91
|
}
|
|
92
92
|
if (count !== 1) return null;
|
|
93
93
|
|
|
94
|
+
// Anchor `end` to one byte past the LAST matched stripped character rather than
|
|
95
|
+
// the mapped index of the NEXT character. Mapping the next index can land before
|
|
96
|
+
// a stripped node-id attribute that sits right after the match, so the splice
|
|
97
|
+
// would cross it and corrupt the file (e.g. duplicate/mangled tags).
|
|
98
|
+
const lastMatched = onlyIndex + strippedSearch.length - 1;
|
|
94
99
|
return {
|
|
95
100
|
start: strippedContent.indexMap[onlyIndex] ?? 0,
|
|
96
|
-
end:
|
|
97
|
-
strippedContent.indexMap[onlyIndex + strippedSearch.length] ??
|
|
98
|
-
content.length,
|
|
101
|
+
end: (strippedContent.indexMap[lastMatched] ?? content.length - 1) + 1,
|
|
99
102
|
};
|
|
100
103
|
}
|
|
101
104
|
|
|
@@ -66,10 +66,11 @@ const requestedScreenSchema = z
|
|
|
66
66
|
|
|
67
67
|
export default defineAction({
|
|
68
68
|
description:
|
|
69
|
-
"Start a
|
|
69
|
+
"Start a multi-screen generation session on the Design canvas. " +
|
|
70
70
|
"Use this before generating multiple screens or variations in parallel: it " +
|
|
71
|
-
"assigns non-overlapping canvas regions
|
|
72
|
-
"
|
|
71
|
+
"assigns non-overlapping canvas regions and returns per-frame generation " +
|
|
72
|
+
"instructions including canvasFrame placements. The session state is " +
|
|
73
|
+
"agent-facing planning state consumed by generate-design and view-screen. " +
|
|
73
74
|
"After this action, fan out calls to generate-design for each returned " +
|
|
74
75
|
"frame, passing the returned canvasFrame values to generate-design so " +
|
|
75
76
|
"screens appear in the infinite overview canvas.",
|
|
@@ -167,6 +168,7 @@ export default defineAction({
|
|
|
167
168
|
prompt,
|
|
168
169
|
contextRefs,
|
|
169
170
|
frames,
|
|
171
|
+
startedAt: new Date().toISOString(),
|
|
170
172
|
};
|
|
171
173
|
|
|
172
174
|
await writeAppState(
|
|
@@ -49,17 +49,54 @@ export default defineAction({
|
|
|
49
49
|
const db = getDb();
|
|
50
50
|
const now = new Date().toISOString();
|
|
51
51
|
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
52
|
+
const asRecord = (raw: string | null | undefined) => {
|
|
53
|
+
if (!raw) return {} as Record<string, unknown>;
|
|
54
|
+
try {
|
|
55
|
+
const parsed = JSON.parse(raw);
|
|
56
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
57
|
+
? (parsed as Record<string, unknown>)
|
|
58
|
+
: ({} as Record<string, unknown>);
|
|
59
|
+
} catch {
|
|
60
|
+
return {} as Record<string, unknown>;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// Read-merge-write inside one transaction so a partial `data` update can't
|
|
65
|
+
// race a concurrent write and either destroy framework-owned keys (e.g.
|
|
66
|
+
// canvasFrames/tweaks) or overwrite newer server-side keys. Mirrors
|
|
67
|
+
// generate-design's transactional read-merge.
|
|
68
|
+
await db.transaction(async (tx) => {
|
|
69
|
+
const updates: Record<string, unknown> = { updatedAt: now };
|
|
70
|
+
if (title !== undefined) updates.title = title;
|
|
71
|
+
if (description !== undefined) updates.description = description;
|
|
72
|
+
if (data !== undefined) {
|
|
73
|
+
const [existing] = await tx
|
|
74
|
+
.select({ data: schema.designs.data })
|
|
75
|
+
.from(schema.designs)
|
|
76
|
+
.where(eq(schema.designs.id, id));
|
|
77
|
+
const incomingParsed = JSON.parse(data);
|
|
78
|
+
if (
|
|
79
|
+
incomingParsed &&
|
|
80
|
+
typeof incomingParsed === "object" &&
|
|
81
|
+
!Array.isArray(incomingParsed)
|
|
82
|
+
) {
|
|
83
|
+
updates.data = JSON.stringify({
|
|
84
|
+
...asRecord(existing?.data),
|
|
85
|
+
...(incomingParsed as Record<string, unknown>),
|
|
86
|
+
});
|
|
87
|
+
} else {
|
|
88
|
+
// Non-object JSON (array/primitive): keep the original verbatim write.
|
|
89
|
+
updates.data = data;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (projectType !== undefined) updates.projectType = projectType;
|
|
93
|
+
if (designSystemId !== undefined) updates.designSystemId = designSystemId;
|
|
58
94
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
95
|
+
await tx
|
|
96
|
+
.update(schema.designs)
|
|
97
|
+
.set(updates)
|
|
98
|
+
.where(eq(schema.designs.id, id));
|
|
99
|
+
});
|
|
63
100
|
|
|
64
101
|
return { id, updated: true };
|
|
65
102
|
},
|
|
@@ -70,15 +70,40 @@ export default defineAction({
|
|
|
70
70
|
|
|
71
71
|
await assertAccess("design", file.designId, "editor");
|
|
72
72
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
73
|
+
// Reject a rename that would collide with an existing filename in the same
|
|
74
|
+
// design. The collision check and the write run in one transaction so they
|
|
75
|
+
// can't be interleaved by a concurrent rename. (A DB-level UNIQUE index on
|
|
76
|
+
// (designId, filename) would be the strongest guarantee but is a non-additive
|
|
77
|
+
// schema change on existing data, so it's deferred.)
|
|
78
|
+
await db.transaction(async (tx) => {
|
|
79
|
+
if (filename !== undefined) {
|
|
80
|
+
const [collision] = await tx
|
|
81
|
+
.select({ id: schema.designFiles.id })
|
|
82
|
+
.from(schema.designFiles)
|
|
83
|
+
.where(
|
|
84
|
+
and(
|
|
85
|
+
eq(schema.designFiles.designId, file.designId),
|
|
86
|
+
eq(schema.designFiles.filename, filename),
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
.limit(1);
|
|
90
|
+
if (collision && collision.id !== id) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`File "${filename}" already exists in design ${file.designId}`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
77
96
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
97
|
+
const updates: Record<string, unknown> = { updatedAt: now };
|
|
98
|
+
if (content !== undefined) updates.content = content;
|
|
99
|
+
if (filename !== undefined) updates.filename = filename;
|
|
100
|
+
if (fileType !== undefined) updates.fileType = fileType;
|
|
101
|
+
|
|
102
|
+
await tx
|
|
103
|
+
.update(schema.designFiles)
|
|
104
|
+
.set(updates)
|
|
105
|
+
.where(eq(schema.designFiles.id, id));
|
|
106
|
+
});
|
|
82
107
|
|
|
83
108
|
// Push content through the collab layer so live editors see the change
|
|
84
109
|
if (content !== undefined && syncCollab) {
|
|
@@ -120,7 +120,21 @@ export default defineAction({
|
|
|
120
120
|
'A variant picker is open. Wait for the user to choose a direction before generating further. In an inline MCP app their pick returns to you automatically; if it opened as a browser tab (a CLI or code editor), they paste an auto-copied summary or just tell you which one (e.g. "use variant A"). Once you know the choice, read the saved index.html with get-design-snapshot. Do not call generate-design while this picker is open.';
|
|
121
121
|
}
|
|
122
122
|
if (generationSession) {
|
|
123
|
+
const GENERATION_SESSION_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
124
|
+
const startedAt =
|
|
125
|
+
typeof (generationSession as { startedAt?: unknown }).startedAt ===
|
|
126
|
+
"string"
|
|
127
|
+
? new Date(
|
|
128
|
+
(generationSession as { startedAt: string }).startedAt,
|
|
129
|
+
).getTime()
|
|
130
|
+
: 0;
|
|
131
|
+
const isStale =
|
|
132
|
+
startedAt > 0 && Date.now() - startedAt > GENERATION_SESSION_TTL_MS;
|
|
123
133
|
screen.generationSession = generationSession;
|
|
134
|
+
if (isStale) {
|
|
135
|
+
screen.generationSessionNote =
|
|
136
|
+
"This generation session may be stale or abandoned (started more than 10 minutes ago). Verify saved screens via the design file list rather than assuming generation is still in progress.";
|
|
137
|
+
}
|
|
124
138
|
}
|
|
125
139
|
|
|
126
140
|
if (Object.keys(screen).length === 0) {
|