@agent-native/core 0.84.30 → 0.84.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +24 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/client/session-replay.ts +5 -0
  5. package/corpus/core/src/client/use-action.ts +5 -1
  6. package/corpus/core/src/server/ssr-handler.ts +12 -0
  7. package/corpus/core/src/vite/action-types-plugin.ts +6 -2
  8. package/corpus/core/src/vite/client.ts +152 -17
  9. package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +13 -1
  10. package/corpus/templates/analytics/changelog/2026-07-01-session-replays-keep-inlined-css-without-live-resource-loa.md +6 -0
  11. package/corpus/templates/design/AGENTS.md +18 -0
  12. package/corpus/templates/design/actions/apply-source-edit.ts +87 -0
  13. package/corpus/templates/design/actions/import-design-source.ts +158 -0
  14. package/corpus/templates/design/actions/list-source-files.ts +52 -0
  15. package/corpus/templates/design/actions/navigate.ts +4 -2
  16. package/corpus/templates/design/actions/preview-source-edit.ts +85 -0
  17. package/corpus/templates/design/actions/read-source-file.ts +55 -0
  18. package/corpus/templates/design/actions/resolve-selection-source.ts +101 -0
  19. package/corpus/templates/design/actions/view-screen.ts +43 -1
  20. package/corpus/templates/design/app/components/design/CodeWorkbenchHost.tsx +630 -0
  21. package/corpus/templates/design/app/components/design/DesignImportPanel.tsx +519 -0
  22. package/corpus/templates/design/app/hooks/use-navigation-state.ts +32 -6
  23. package/corpus/templates/design/app/i18n/zh-TW.ts +40 -0
  24. package/corpus/templates/design/app/i18n-data.ts +508 -0
  25. package/corpus/templates/design/app/pages/DesignEditor.tsx +171 -9
  26. package/corpus/templates/design/changelog/2026-07-01-design-can-import-figma-paste-fig-files-and-standalone-html.md +6 -0
  27. package/corpus/templates/design/changelog/2026-07-01-design-code-workspace.md +6 -0
  28. package/corpus/templates/design/package.json +3 -0
  29. package/corpus/templates/design/server/handlers/import-design-file.ts +168 -0
  30. package/corpus/templates/design/server/lib/figma-import/clipboard.ts +122 -0
  31. package/corpus/templates/design/server/lib/figma-import/decode.ts +455 -0
  32. package/corpus/templates/design/server/lib/figma-import/processor.ts +116 -0
  33. package/corpus/templates/design/server/lib/figma-import/render-html.ts +397 -0
  34. package/corpus/templates/design/server/lib/figma-import/types.ts +60 -0
  35. package/corpus/templates/design/server/lib/import-design-files.ts +314 -0
  36. package/corpus/templates/design/server/routes/api/import-design-file.post.ts +1 -0
  37. package/corpus/templates/design/server/source-workspace.ts +215 -0
  38. package/corpus/templates/design/shared/design-source-capabilities.ts +5 -5
  39. package/corpus/templates/design/shared/source-workspace.ts +149 -0
  40. package/dist/client/session-replay.d.ts +1 -0
  41. package/dist/client/session-replay.d.ts.map +1 -1
  42. package/dist/client/session-replay.js +2 -0
  43. package/dist/client/session-replay.js.map +1 -1
  44. package/dist/client/use-action.d.ts +5 -1
  45. package/dist/client/use-action.d.ts.map +1 -1
  46. package/dist/client/use-action.js.map +1 -1
  47. package/dist/collab/awareness.d.ts +2 -2
  48. package/dist/collab/awareness.d.ts.map +1 -1
  49. package/dist/collab/routes.d.ts +1 -1
  50. package/dist/notifications/routes.d.ts +3 -3
  51. package/dist/observability/routes.d.ts +3 -3
  52. package/dist/progress/routes.d.ts +1 -1
  53. package/dist/resources/handlers.d.ts +3 -3
  54. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  55. package/dist/server/ssr-handler.d.ts.map +1 -1
  56. package/dist/server/ssr-handler.js +7 -0
  57. package/dist/server/ssr-handler.js.map +1 -1
  58. package/dist/server/transcribe-voice.d.ts +1 -1
  59. package/dist/vite/action-types-plugin.d.ts.map +1 -1
  60. package/dist/vite/action-types-plugin.js +6 -2
  61. package/dist/vite/action-types-plugin.js.map +1 -1
  62. package/dist/vite/client.d.ts.map +1 -1
  63. package/dist/vite/client.js +132 -11
  64. package/dist/vite/client.js.map +1 -1
  65. package/package.json +1 -1
@@ -0,0 +1,314 @@
1
+ import { readAppStateForCurrentTab } from "@agent-native/core/application-state";
2
+ import {
3
+ applyText,
4
+ hasCollabState,
5
+ seedFromText,
6
+ } from "@agent-native/core/collab";
7
+ import { assertAccess } from "@agent-native/core/sharing";
8
+ import { eq } from "drizzle-orm";
9
+ import { nanoid } from "nanoid";
10
+
11
+ import {
12
+ mergeCanvasFramePlacements,
13
+ type CanvasFramePlacement,
14
+ } from "../../shared/canvas-frames.js";
15
+ import { getDb, schema } from "../db/index.js";
16
+
17
+ const DEFAULT_FRAME_WIDTH = 1440;
18
+ const DEFAULT_FRAME_HEIGHT = 900;
19
+ const FRAME_GAP = 96;
20
+
21
+ export interface ImportedDesignFile {
22
+ filename: string;
23
+ fileType: "html" | "css" | "jsx" | "asset";
24
+ content: string;
25
+ source?: Record<string, unknown>;
26
+ preferredFrame?: {
27
+ title?: string;
28
+ width?: number;
29
+ height?: number;
30
+ x?: number;
31
+ y?: number;
32
+ };
33
+ }
34
+
35
+ export interface SaveImportedDesignFilesInput {
36
+ designId?: string;
37
+ files: ImportedDesignFile[];
38
+ sourceType: string;
39
+ warnings?: string[];
40
+ }
41
+
42
+ export interface SavedImportedDesignFile {
43
+ id: string;
44
+ filename: string;
45
+ fileType: string;
46
+ source?: Record<string, unknown>;
47
+ }
48
+
49
+ function isRecord(value: unknown): value is Record<string, unknown> {
50
+ return value !== null && typeof value === "object" && !Array.isArray(value);
51
+ }
52
+
53
+ function stringFromState(value: unknown, key: string): string | undefined {
54
+ return isRecord(value) && typeof value[key] === "string"
55
+ ? (value[key] as string)
56
+ : undefined;
57
+ }
58
+
59
+ export async function resolveImportDesignId(
60
+ explicitDesignId?: string,
61
+ ): Promise<string> {
62
+ if (explicitDesignId?.trim()) return explicitDesignId.trim();
63
+ const navigation = await readAppStateForCurrentTab("navigation").catch(
64
+ () => null,
65
+ );
66
+ const designId = stringFromState(navigation, "designId");
67
+ if (!designId) {
68
+ throw new Error(
69
+ "No designId was provided and no active design was found in the current editor.",
70
+ );
71
+ }
72
+ return designId;
73
+ }
74
+
75
+ function parseJson(value: string | null | undefined): Record<string, unknown> {
76
+ if (!value) return {};
77
+ try {
78
+ const parsed = JSON.parse(value);
79
+ return isRecord(parsed) ? parsed : {};
80
+ } catch {
81
+ return {};
82
+ }
83
+ }
84
+
85
+ export function sanitizeImportedFilename(filename: string): string {
86
+ const trimmed = filename.trim();
87
+ if (!trimmed || trimmed.includes("..") || /[\\/]/.test(trimmed)) {
88
+ throw new Error("Imported filename is invalid.");
89
+ }
90
+ const cleaned = trimmed
91
+ .replace(/[^\w. -]+/g, "-")
92
+ .replace(/\s+/g, "-")
93
+ .replace(/-+/g, "-")
94
+ .replace(/^-+|-+$/g, "");
95
+ if (!cleaned) throw new Error("Imported filename is invalid.");
96
+ return cleaned;
97
+ }
98
+
99
+ function ensureExtension(
100
+ filename: string,
101
+ fileType: ImportedDesignFile["fileType"],
102
+ ) {
103
+ if (/\.[A-Za-z0-9]+$/.test(filename)) return filename;
104
+ if (fileType === "css") return `${filename}.css`;
105
+ if (fileType === "jsx") return `${filename}.jsx`;
106
+ if (fileType === "asset") return filename;
107
+ return `${filename}.html`;
108
+ }
109
+
110
+ function uniqueFilename(filename: string, used: Set<string>): string {
111
+ if (!used.has(filename)) {
112
+ used.add(filename);
113
+ return filename;
114
+ }
115
+ const dot = filename.lastIndexOf(".");
116
+ const base = dot > 0 ? filename.slice(0, dot) : filename;
117
+ const ext = dot > 0 ? filename.slice(dot) : "";
118
+ let index = 2;
119
+ while (used.has(`${base}-${index}${ext}`)) index += 1;
120
+ const next = `${base}-${index}${ext}`;
121
+ used.add(next);
122
+ return next;
123
+ }
124
+
125
+ function positiveDimension(value: unknown, fallback: number): number {
126
+ return typeof value === "number" && Number.isFinite(value) && value > 0
127
+ ? Math.round(value)
128
+ : fallback;
129
+ }
130
+
131
+ function sanitizeImportedHtml(content: string): string {
132
+ return content
133
+ .replace(/<\s*script\b[^>]*>[\s\S]*?<\s*\/\s*script\s*>/gi, "")
134
+ .replace(/<\s*script\b[^>]*\/?\s*>/gi, "")
135
+ .replace(/<\s*(iframe|object|embed)\b[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi, "")
136
+ .replace(/<\s*(iframe|object|embed)\b[^>]*\/?\s*>/gi, "")
137
+ .replace(/\s+on[a-z][\w:-]*\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "")
138
+ .replace(/\s+srcdoc\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "")
139
+ .replace(
140
+ /\s+(href|src|xlink:href|action|formaction)\s*=\s*"[\s]*(?:javascript|vbscript):[^"]*"/gi,
141
+ "",
142
+ )
143
+ .replace(
144
+ /\s+(href|src|xlink:href|action|formaction)\s*=\s*'[\s]*(?:javascript|vbscript):[^']*'/gi,
145
+ "",
146
+ )
147
+ .replace(
148
+ /\s+(href|src|xlink:href|action|formaction)\s*=\s*(?:javascript|vbscript):[^\s>]*/gi,
149
+ "",
150
+ )
151
+ .replace(
152
+ /\s+style\s*=\s*(["'])(?:(?!\1).)*(?:expression\s*\(|javascript:)(?:(?!\1).)*\1/gi,
153
+ "",
154
+ );
155
+ }
156
+
157
+ export function normalizeImportedHtmlDocument(
158
+ content: string,
159
+ sourceLabel: string,
160
+ ): string {
161
+ const normalized = sanitizeImportedHtml(content.replace(/\0/g, "")).trim();
162
+ if (!normalized) throw new Error("HTML import content is empty.");
163
+ const safeSourceLabel = sourceLabel.replace(/--+/g, "-").replace(/[<>]/g, "");
164
+ const comment = `<!-- Imported into Design from ${safeSourceLabel}. -->`;
165
+ if (/<html[\s>]/i.test(normalized)) {
166
+ return normalized.replace(
167
+ /<head(\s[^>]*)?>/i,
168
+ (match) => `${match}\n ${comment}`,
169
+ );
170
+ }
171
+ return `<!doctype html>
172
+ <html>
173
+ <head>
174
+ <meta charset="utf-8" />
175
+ ${comment}
176
+ </head>
177
+ <body>
178
+ ${normalized}
179
+ </body>
180
+ </html>`;
181
+ }
182
+
183
+ export async function saveImportedDesignFiles(
184
+ input: SaveImportedDesignFilesInput,
185
+ ) {
186
+ if (input.files.length === 0) {
187
+ throw new Error("No files were produced by the import.");
188
+ }
189
+ const designId = await resolveImportDesignId(input.designId);
190
+ await assertAccess("design", designId, "editor");
191
+
192
+ const db = getDb();
193
+ const now = new Date().toISOString();
194
+ const savedFiles: SavedImportedDesignFile[] = [];
195
+ const seedRecords: Array<{ id: string; content: string }> = [];
196
+ let placedFrames:
197
+ | Array<{
198
+ fileId: string;
199
+ filename?: string;
200
+ frame: CanvasFramePlacement;
201
+ }>
202
+ | undefined;
203
+
204
+ await db.transaction(async (tx) => {
205
+ const [design] = await tx
206
+ .select()
207
+ .from(schema.designs)
208
+ .where(eq(schema.designs.id, designId))
209
+ .limit(1);
210
+ if (!design) throw new Error(`Design ${designId} was not found.`);
211
+
212
+ const existingFiles = await tx
213
+ .select()
214
+ .from(schema.designFiles)
215
+ .where(eq(schema.designFiles.designId, designId));
216
+ const usedFilenames = new Set(existingFiles.map((file) => file.filename));
217
+ const prevData = parseJson(design.data);
218
+ const previousMetadata = isRecord(prevData.screenMetadata)
219
+ ? { ...prevData.screenMetadata }
220
+ : {};
221
+ const placements: CanvasFramePlacement[] = [];
222
+ let nextFrameX = 0;
223
+
224
+ for (let index = 0; index < input.files.length; index += 1) {
225
+ const file = input.files[index]!;
226
+ const filename = uniqueFilename(
227
+ ensureExtension(sanitizeImportedFilename(file.filename), file.fileType),
228
+ usedFilenames,
229
+ );
230
+ const fileId = nanoid();
231
+ await tx.insert(schema.designFiles).values({
232
+ id: fileId,
233
+ designId,
234
+ filename,
235
+ fileType: file.fileType,
236
+ content: file.content,
237
+ createdAt: now,
238
+ updatedAt: now,
239
+ });
240
+ seedRecords.push({ id: fileId, content: file.content });
241
+
242
+ const width = positiveDimension(
243
+ file.preferredFrame?.width,
244
+ DEFAULT_FRAME_WIDTH,
245
+ );
246
+ const height = positiveDimension(
247
+ file.preferredFrame?.height,
248
+ DEFAULT_FRAME_HEIGHT,
249
+ );
250
+ placements.push({
251
+ fileId,
252
+ filename,
253
+ x: file.preferredFrame?.x ?? nextFrameX,
254
+ y: file.preferredFrame?.y ?? 0,
255
+ width,
256
+ height,
257
+ z: index,
258
+ });
259
+ nextFrameX += width + FRAME_GAP;
260
+ const source = {
261
+ sourceType: input.sourceType,
262
+ previewState: "static",
263
+ title: file.preferredFrame?.title ?? filename.replace(/\.[^.]+$/, ""),
264
+ width,
265
+ height,
266
+ ...file.source,
267
+ };
268
+ previousMetadata[fileId] = source;
269
+ savedFiles.push({
270
+ id: fileId,
271
+ filename,
272
+ fileType: file.fileType,
273
+ source,
274
+ });
275
+ }
276
+
277
+ const mergedFrames = mergeCanvasFramePlacements({
278
+ existing: prevData.canvasFrames,
279
+ placements,
280
+ resolveFileId: (placement) => placement.fileId,
281
+ });
282
+ placedFrames = mergedFrames.placedFrames;
283
+ await tx
284
+ .update(schema.designs)
285
+ .set({
286
+ data: JSON.stringify({
287
+ ...prevData,
288
+ sourceMode: "import",
289
+ canvasFrames: mergedFrames.canvasFrames,
290
+ screenMetadata: previousMetadata,
291
+ updatedAt: now,
292
+ }),
293
+ updatedAt: now,
294
+ })
295
+ .where(eq(schema.designs.id, designId));
296
+ });
297
+
298
+ for (const record of seedRecords) {
299
+ if (await hasCollabState(record.id)) {
300
+ await applyText(record.id, record.content, "content", "agent");
301
+ } else {
302
+ await seedFromText(record.id, record.content);
303
+ }
304
+ }
305
+
306
+ return {
307
+ designId,
308
+ files: savedFiles,
309
+ warnings: input.warnings ?? [],
310
+ placedFrames: placedFrames ?? [],
311
+ overview: true,
312
+ urlPath: `/design/${designId}`,
313
+ };
314
+ }
@@ -0,0 +1 @@
1
+ export { importDesignFile as default } from "../../handlers/import-design-file.js";
@@ -0,0 +1,215 @@
1
+ import {
2
+ hasCollabState,
3
+ getText,
4
+ applyText,
5
+ seedFromText,
6
+ } from "@agent-native/core/collab";
7
+ import { assertAccess, resolveAccess } from "@agent-native/core/sharing";
8
+ import { eq } from "drizzle-orm";
9
+
10
+ import { isBoardFile } from "../shared/board-file.js";
11
+ import { normalizeDesignSourceType } from "../shared/source-mode.js";
12
+ import type { DesignSourceType } from "../shared/source-mode.js";
13
+ import {
14
+ languageForSourcePath,
15
+ normalizeInlineSourcePath,
16
+ sourceContentHash,
17
+ } from "../shared/source-workspace.js";
18
+ import { getDb, schema } from "./db/index.js";
19
+ import "./db/index.js"; // ensure registerShareableResource runs
20
+
21
+ export interface SourceWorkspaceFile {
22
+ id: string;
23
+ designId: string;
24
+ filename: string;
25
+ fileType: string;
26
+ content?: string | null;
27
+ createdAt: string | null;
28
+ updatedAt: string | null;
29
+ }
30
+
31
+ export interface SourceWorkspaceContext {
32
+ designId: string;
33
+ sourceType: DesignSourceType;
34
+ canEdit: boolean;
35
+ files: SourceWorkspaceFile[];
36
+ }
37
+
38
+ function parseDesignDataSourceType(value: unknown): DesignSourceType {
39
+ if (typeof value !== "string") return "inline";
40
+ try {
41
+ const parsed: unknown = JSON.parse(value);
42
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
43
+ const raw = (parsed as Record<string, unknown>).sourceType;
44
+ return normalizeDesignSourceType(raw) ?? "inline";
45
+ }
46
+ } catch {
47
+ // Invalid design data falls back to inline, matching existing actions.
48
+ }
49
+ return "inline";
50
+ }
51
+
52
+ function roleCanEdit(role: unknown): boolean {
53
+ return role === "owner" || role === "admin" || role === "editor";
54
+ }
55
+
56
+ export async function resolveSourceWorkspace(
57
+ designId: string,
58
+ options: { includeContent?: boolean } = {},
59
+ ): Promise<SourceWorkspaceContext> {
60
+ const access = await resolveAccess("design", designId);
61
+ if (!access) throw new Error("Design not found");
62
+
63
+ const db = getDb();
64
+ const files = options.includeContent
65
+ ? await db
66
+ .select({
67
+ id: schema.designFiles.id,
68
+ designId: schema.designFiles.designId,
69
+ filename: schema.designFiles.filename,
70
+ fileType: schema.designFiles.fileType,
71
+ content: schema.designFiles.content,
72
+ createdAt: schema.designFiles.createdAt,
73
+ updatedAt: schema.designFiles.updatedAt,
74
+ })
75
+ .from(schema.designFiles)
76
+ .where(eq(schema.designFiles.designId, designId))
77
+ : await db
78
+ .select({
79
+ id: schema.designFiles.id,
80
+ designId: schema.designFiles.designId,
81
+ filename: schema.designFiles.filename,
82
+ fileType: schema.designFiles.fileType,
83
+ createdAt: schema.designFiles.createdAt,
84
+ updatedAt: schema.designFiles.updatedAt,
85
+ })
86
+ .from(schema.designFiles)
87
+ .where(eq(schema.designFiles.designId, designId));
88
+
89
+ return {
90
+ designId,
91
+ sourceType: parseDesignDataSourceType(
92
+ (access.resource as { data?: unknown }).data,
93
+ ),
94
+ canEdit: roleCanEdit(access.role),
95
+ files: files.filter((file) => !isBoardFile(file.filename)),
96
+ };
97
+ }
98
+
99
+ export function findSourceWorkspaceFile(
100
+ files: SourceWorkspaceFile[],
101
+ target: { fileId?: string; path?: string },
102
+ ): SourceWorkspaceFile {
103
+ const normalizedPath =
104
+ target.path !== undefined ? normalizeInlineSourcePath(target.path) : null;
105
+ const file = target.fileId
106
+ ? files.find((candidate) => candidate.id === target.fileId)
107
+ : files.find((candidate) => candidate.filename === normalizedPath);
108
+ if (!file) {
109
+ throw new Error(
110
+ target.fileId
111
+ ? `Source file id "${target.fileId}" not found.`
112
+ : `Source file "${normalizedPath}" not found.`,
113
+ );
114
+ }
115
+ return file;
116
+ }
117
+
118
+ export async function readLiveSourceFile(file: SourceWorkspaceFile): Promise<{
119
+ content: string;
120
+ versionHash: string;
121
+ language: string;
122
+ }> {
123
+ let content = file.content ?? "";
124
+ try {
125
+ if (await hasCollabState(file.id)) {
126
+ const live = await getText(file.id, "content");
127
+ if (typeof live === "string") content = live;
128
+ }
129
+ } catch {
130
+ // Collab reads are best-effort; SQL content is the fallback.
131
+ }
132
+ return {
133
+ content,
134
+ versionHash: sourceContentHash(content),
135
+ language: languageForSourcePath(file.filename),
136
+ };
137
+ }
138
+
139
+ export async function writeInlineSourceFile(args: {
140
+ designId: string;
141
+ file: SourceWorkspaceFile;
142
+ content: string;
143
+ expectedVersionHash?: string;
144
+ }): Promise<{ versionHash: string; changed: boolean; updatedAt: string }> {
145
+ await assertAccess("design", args.designId, "editor");
146
+ const db = getDb();
147
+ const [currentFile] = await db
148
+ .select({
149
+ id: schema.designFiles.id,
150
+ designId: schema.designFiles.designId,
151
+ filename: schema.designFiles.filename,
152
+ fileType: schema.designFiles.fileType,
153
+ content: schema.designFiles.content,
154
+ createdAt: schema.designFiles.createdAt,
155
+ updatedAt: schema.designFiles.updatedAt,
156
+ })
157
+ .from(schema.designFiles)
158
+ .where(eq(schema.designFiles.id, args.file.id))
159
+ .limit(1);
160
+ if (!currentFile || currentFile.designId !== args.designId) {
161
+ throw new Error("Source file not found.");
162
+ }
163
+ const current = await readLiveSourceFile(currentFile);
164
+ if (
165
+ args.expectedVersionHash &&
166
+ args.expectedVersionHash !== current.versionHash
167
+ ) {
168
+ throw new Error(
169
+ "Source file changed since it was read. Re-read the file and retry.",
170
+ );
171
+ }
172
+
173
+ const changed = args.content !== current.content;
174
+ const updatedAt = new Date().toISOString();
175
+ if (!changed) {
176
+ return {
177
+ versionHash: current.versionHash,
178
+ changed: false,
179
+ updatedAt: currentFile.updatedAt ?? updatedAt,
180
+ };
181
+ }
182
+
183
+ if (await hasCollabState(args.file.id)) {
184
+ const liveBeforeApply = await getText(args.file.id, "content");
185
+ if (
186
+ args.expectedVersionHash &&
187
+ args.expectedVersionHash !== sourceContentHash(liveBeforeApply)
188
+ ) {
189
+ throw new Error(
190
+ "Source file changed since it was read. Re-read the file and retry.",
191
+ );
192
+ }
193
+ if (liveBeforeApply !== args.content) {
194
+ await applyText(args.file.id, args.content, "content", "agent");
195
+ }
196
+ } else {
197
+ await seedFromText(args.file.id, args.content);
198
+ }
199
+
200
+ await db
201
+ .update(schema.designFiles)
202
+ .set({ content: args.content, updatedAt })
203
+ .where(eq(schema.designFiles.id, args.file.id));
204
+
205
+ await db
206
+ .update(schema.designs)
207
+ .set({ updatedAt })
208
+ .where(eq(schema.designs.id, args.designId));
209
+
210
+ return {
211
+ versionHash: sourceContentHash(args.content),
212
+ changed: true,
213
+ updatedAt,
214
+ };
215
+ }
@@ -147,15 +147,15 @@ export function unavailable(reason?: string): DesignSourceCapabilityEntry {
147
147
  *
148
148
  * - CSS-var token edits and motion are available through the Tweaks loop and
149
149
  * the managed `<style data-agent-native-motion>` block respectively.
150
- * - File-level write ops (`readFile`, `writeFile`, `applyEdit`) are `planned`
151
- * pending bridge write hardening.
150
+ * - File-level ops (`readFile`, `writeFile`, `applyEdit`) are available for
151
+ * inline SQL-backed design_files through the Design source action surface.
152
152
  * - Real-app-only capabilities (`indexComponents`, `writeTokens`, `branch`,
153
153
  * `deploy*`) are `unavailable` and trigger the "Make it real" CTA.
154
154
  */
155
155
  export const INLINE_DEFAULT_CAPABILITIES: DesignSourceCapabilities = {
156
- readFile: planned("Local file reads require bridge hardening"),
157
- writeFile: planned("Local file writes require bridge hardening"),
158
- applyEdit: planned("Source edits require bridge hardening"),
156
+ readFile: available("Inline design files can be read from Design"),
157
+ writeFile: available("Inline design files can be saved through Design"),
158
+ applyEdit: available("Inline design files can be edited through Design"),
159
159
  resolveNodeToFile: available(),
160
160
  previewPatch: available(),
161
161
  diffPatch: available(),
@@ -0,0 +1,149 @@
1
+ export type SourceEdit =
2
+ | {
3
+ kind: "full-replace";
4
+ content: string;
5
+ }
6
+ | {
7
+ kind: "exact-replace";
8
+ search: string;
9
+ replace: string;
10
+ };
11
+
12
+ export function sourceContentHash(content: string): string {
13
+ let hash = 2166136261;
14
+ for (let index = 0; index < content.length; index += 1) {
15
+ hash ^= content.charCodeAt(index);
16
+ hash = Math.imul(hash, 16777619) >>> 0;
17
+ }
18
+ return `${content.length}:${hash.toString(36)}`;
19
+ }
20
+
21
+ export function normalizeInlineSourcePath(path: string): string {
22
+ const normalized = path.trim().replace(/^\/+/, "");
23
+ if (!normalized) throw new Error("Source path is required.");
24
+ if (
25
+ normalized.includes("..") ||
26
+ normalized.startsWith("~") ||
27
+ normalized.includes("\\")
28
+ ) {
29
+ throw new Error("Invalid source path.");
30
+ }
31
+ return normalized;
32
+ }
33
+
34
+ export function languageForSourcePath(path: string): string {
35
+ const lower = path.toLowerCase();
36
+ if (lower.endsWith(".html") || lower.endsWith(".htm")) return "html";
37
+ if (lower.endsWith(".css")) return "css";
38
+ if (lower.endsWith(".jsx")) return "javascriptreact";
39
+ if (lower.endsWith(".tsx")) return "typescriptreact";
40
+ if (lower.endsWith(".ts")) return "typescript";
41
+ if (lower.endsWith(".js")) return "javascript";
42
+ if (lower.endsWith(".json")) return "json";
43
+ if (lower.endsWith(".md") || lower.endsWith(".mdx")) return "markdown";
44
+ return "plaintext";
45
+ }
46
+
47
+ export function applySourceEdit(
48
+ content: string,
49
+ edit: SourceEdit,
50
+ ): {
51
+ content: string;
52
+ changed: boolean;
53
+ editsApplied: number;
54
+ } {
55
+ if (edit.kind === "full-replace") {
56
+ return {
57
+ content: edit.content,
58
+ changed: edit.content !== content,
59
+ editsApplied: edit.content === content ? 0 : 1,
60
+ };
61
+ }
62
+
63
+ if (!edit.search) {
64
+ throw new Error("exact-replace requires non-empty search text.");
65
+ }
66
+ const firstIndex = content.indexOf(edit.search);
67
+ if (firstIndex === -1) {
68
+ throw new Error("Search text was not found in the source file.");
69
+ }
70
+ const secondIndex = content.indexOf(edit.search, firstIndex + 1);
71
+ if (secondIndex !== -1) {
72
+ throw new Error(
73
+ "Search text matched more than once. Add more surrounding context.",
74
+ );
75
+ }
76
+ const nextContent =
77
+ content.slice(0, firstIndex) +
78
+ edit.replace +
79
+ content.slice(firstIndex + edit.search.length);
80
+ return {
81
+ content: nextContent,
82
+ changed: nextContent !== content,
83
+ editsApplied: nextContent === content ? 0 : 1,
84
+ };
85
+ }
86
+
87
+ function lineNumberAtOffset(content: string, offset: number): number {
88
+ let line = 1;
89
+ for (let index = 0; index < offset; index += 1) {
90
+ if (content.charCodeAt(index) === 10) line += 1;
91
+ }
92
+ return line;
93
+ }
94
+
95
+ function commonPrefixLength(a: string, b: string): number {
96
+ const max = Math.min(a.length, b.length);
97
+ let index = 0;
98
+ while (index < max && a[index] === b[index]) index += 1;
99
+ return index;
100
+ }
101
+
102
+ function commonSuffixLength(
103
+ a: string,
104
+ b: string,
105
+ prefixLength: number,
106
+ ): number {
107
+ const max = Math.min(a.length, b.length) - prefixLength;
108
+ let index = 0;
109
+ while (index < max && a[a.length - 1 - index] === b[b.length - 1 - index]) {
110
+ index += 1;
111
+ }
112
+ return index;
113
+ }
114
+
115
+ function excerpt(value: string, max = 1200): string {
116
+ if (value.length <= max) return value;
117
+ return `${value.slice(0, max)}\n...`;
118
+ }
119
+
120
+ export function previewSourceDiff(before: string, after: string) {
121
+ if (before === after) {
122
+ return {
123
+ changed: false,
124
+ lineStart: null,
125
+ lineEnd: null,
126
+ bytesBefore: before.length,
127
+ bytesAfter: after.length,
128
+ beforeExcerpt: "",
129
+ afterExcerpt: "",
130
+ };
131
+ }
132
+
133
+ const prefix = commonPrefixLength(before, after);
134
+ const suffix = commonSuffixLength(before, after, prefix);
135
+ const beforeEnd = before.length - suffix;
136
+ const afterEnd = after.length - suffix;
137
+ const lineStart = lineNumberAtOffset(before, prefix);
138
+ const lineEnd = lineNumberAtOffset(before, beforeEnd);
139
+
140
+ return {
141
+ changed: true,
142
+ lineStart,
143
+ lineEnd,
144
+ bytesBefore: before.length,
145
+ bytesAfter: after.length,
146
+ beforeExcerpt: excerpt(before.slice(prefix, beforeEnd)),
147
+ afterExcerpt: excerpt(after.slice(prefix, afterEnd)),
148
+ };
149
+ }