From c736e7cdde12af44b5bc0380fc53a2e476b25d85 Mon Sep 17 00:00:00 2001 From: Fredrik Ekholdt Date: Sun, 17 May 2026 20:03:04 +0200 Subject: [PATCH 01/31] Create compare patch set view --- packages/core/src/schema/deserialize.ts | 15 +- packages/server/src/ValOpsFS.ts | 93 - packages/server/src/ValOpsHttp.ts | 2 +- packages/server/src/ValServer.ts | 372 +--- packages/shared/src/internal/ApiRoutes.ts | 103 +- packages/ui/spa/ValSyncEngine.ts | 121 +- packages/ui/spa/components/AIChat.stories.tsx | 12 +- packages/ui/spa/components/AIChat.tsx | 345 +-- packages/ui/spa/components/AnyField.tsx | 35 +- packages/ui/spa/components/ChatFullscreen.tsx | 61 + .../components/ComparePatchSets.stories.tsx | 1164 +++++++++++ .../ui/spa/components/ComparePatchSets.tsx | 1862 +++++++++++++++++ packages/ui/spa/components/ContentArea.tsx | 152 +- packages/ui/spa/components/DraftChanges.tsx | 2 +- packages/ui/spa/components/Field.tsx | 234 ++- .../components/FieldPatchAuthors.stories.tsx | 44 +- .../ui/spa/components/FieldPatchAuthors.tsx | 198 +- .../components/FieldPatchAuthorsSection.tsx | 33 + .../FileGallery/FileGalleryListView.tsx | 2 + .../components/FileGallery/FilePreview.tsx | 54 +- .../FileGallery/FilePreviewModal.tsx | 47 +- .../FileGallery/FilePropertiesModal.tsx | 17 +- .../stories/FileGallery.stories.tsx | 53 +- .../ui/spa/components/FileGallery/types.ts | 3 + packages/ui/spa/components/InlineAnyField.tsx | 50 + .../ui/spa/components/InlineField.stories.tsx | 465 ++++ packages/ui/spa/components/InlineField.tsx | 172 ++ packages/ui/spa/components/Layout.tsx | 5 +- .../ui/spa/components/ListPreviewItem.tsx | 2 +- .../components/MediaPicker/MediaPicker.tsx | 7 +- packages/ui/spa/components/Module.tsx | 1 + .../spa/components/NavMenu/ExternalButton.tsx | 17 +- .../ui/spa/components/NavMenu/NavMenu.tsx | 4 +- .../spa/components/NavMenu/NavMenuWrapper.tsx | 84 +- .../ui/spa/components/NavMenu/SitemapItem.tsx | 31 +- .../ui/spa/components/NavMenu/mockData.ts | 2 + packages/ui/spa/components/NavMenu/types.ts | 4 + .../spa/components/RichTextEditor.stories.tsx | 2 - .../Search/stories/Search.stories.tsx | 2 - .../Search/stories/SearchItem.stories.tsx | 2 - .../stories/SearchResultsList.stories.tsx | 2 - packages/ui/spa/components/SortableList.tsx | 152 +- packages/ui/spa/components/ToolsMenu.tsx | 69 +- .../ui/spa/components/ValFieldProvider.tsx | 185 +- packages/ui/spa/components/ValOverlay.tsx | 115 +- packages/ui/spa/components/ValProvider.tsx | 146 +- packages/ui/spa/components/ValRouter.tsx | 65 +- .../ui/spa/components/designSystem/button.tsx | 23 +- .../spa/components/designSystem/checkbox.tsx | 2 +- .../components/designSystem/scroll-area.tsx | 12 +- .../ui/spa/components/designSystem/select.tsx | 2 +- .../spa/components/designSystem/sidebar.tsx | 3 +- .../ui/spa/components/fields/ArrayFields.tsx | 65 +- .../ui/spa/components/fields/BooleanField.tsx | 12 +- .../ui/spa/components/fields/DateField.tsx | 27 +- .../ui/spa/components/fields/FileField.tsx | 12 +- .../ui/spa/components/fields/ImageField.tsx | 320 ++- .../ui/spa/components/fields/KeyOfField.tsx | 22 +- .../spa/components/fields/ModuleGallery.tsx | 21 +- .../ui/spa/components/fields/NumberField.tsx | 20 +- .../ui/spa/components/fields/ObjectFields.tsx | 25 +- .../ui/spa/components/fields/RecordFields.tsx | 48 +- .../spa/components/fields/RichTextField.tsx | 6 +- .../ui/spa/components/fields/RouteField.tsx | 26 +- .../ui/spa/components/fields/StringField.tsx | 69 +- .../ui/spa/components/fields/UnionField.tsx | 50 +- packages/ui/spa/components/useFieldState.ts | 67 + packages/ui/spa/hooks/useAI.ts | 681 +----- packages/ui/spa/hooks/useAIWebSocket.ts | 44 +- packages/ui/spa/index.css | 16 + packages/ui/spa/patchsets/patchsets.worker.ts | 28 + .../ui/spa/patchsets/usePatchSetsWorker.ts | 86 + packages/ui/spa/patchsets/worker-types.ts | 20 + .../computeChangedSourcePaths.test.ts.snap | 314 +++ .../utils/computeChangedSourcePaths.test.ts | 260 +++ .../ui/spa/utils/computeChangedSourcePaths.ts | 259 +++ packages/ui/spa/utils/getFilenameFromRef.ts | 38 + .../ui/spa/utils/getUnchangedSiblings.test.ts | 330 +++ packages/ui/spa/utils/getUnchangedSiblings.ts | 164 ++ packages/ui/spa/utils/toolNames.ts | 3 +- 80 files changed, 7385 insertions(+), 2298 deletions(-) create mode 100644 packages/ui/spa/components/ChatFullscreen.tsx create mode 100644 packages/ui/spa/components/ComparePatchSets.stories.tsx create mode 100644 packages/ui/spa/components/ComparePatchSets.tsx create mode 100644 packages/ui/spa/components/FieldPatchAuthorsSection.tsx create mode 100644 packages/ui/spa/components/InlineAnyField.tsx create mode 100644 packages/ui/spa/components/InlineField.stories.tsx create mode 100644 packages/ui/spa/components/InlineField.tsx create mode 100644 packages/ui/spa/components/useFieldState.ts create mode 100644 packages/ui/spa/patchsets/patchsets.worker.ts create mode 100644 packages/ui/spa/patchsets/usePatchSetsWorker.ts create mode 100644 packages/ui/spa/patchsets/worker-types.ts create mode 100644 packages/ui/spa/utils/__snapshots__/computeChangedSourcePaths.test.ts.snap create mode 100644 packages/ui/spa/utils/computeChangedSourcePaths.test.ts create mode 100644 packages/ui/spa/utils/computeChangedSourcePaths.ts create mode 100644 packages/ui/spa/utils/getFilenameFromRef.ts create mode 100644 packages/ui/spa/utils/getUnchangedSiblings.test.ts create mode 100644 packages/ui/spa/utils/getUnchangedSiblings.ts diff --git a/packages/core/src/schema/deserialize.ts b/packages/core/src/schema/deserialize.ts index 4a5b99417..fc6a973ae 100644 --- a/packages/core/src/schema/deserialize.ts +++ b/packages/core/src/schema/deserialize.ts @@ -35,7 +35,6 @@ export function deserializeSchema( regExpMessage: serialized.options?.regexp?.message, }, serialized.opt, - serialized.raw, ); case "literal": return new LiteralSchema(serialized.value, serialized.opt); @@ -145,19 +144,11 @@ export function deserializeSchema( return new RouteSchema(routeOptions, serialized.opt); } case "file": - return new FileSchema( - serialized.options, - serialized.opt, - serialized.remote, - ); + return new FileSchema(serialized.options, serialized.opt); case "image": - return new ImageSchema( - serialized.options, - serialized.opt, - serialized.remote, - ); + return new ImageSchema(serialized.options, serialized.opt); case "date": - return new DateSchema(serialized.options, serialized.opt); + return new DateSchema(serialized.options); default: { const exhaustiveCheck: never = serialized; const unknownSerialized: unknown = exhaustiveCheck; diff --git a/packages/server/src/ValOpsFS.ts b/packages/server/src/ValOpsFS.ts index 05e92825c..9791a052a 100644 --- a/packages/server/src/ValOpsFS.ts +++ b/packages/server/src/ValOpsFS.ts @@ -53,99 +53,6 @@ export class ValOpsFS extends ValOps { // do nothing } - async getPresignedAuthNonce( - project: string, - corsOrigin: string, - auth: { pat: string } | { apiKey: string }, - ): Promise< - | { - status: "success"; - data: { nonce: string; baseUrl: string }; - } - | { status: "error"; statusCode: 401 | 500; error: GenericErrorMessage } - > { - const authHeader: Record = - "pat" in auth - ? { "x-val-pat": auth.pat } - : { Authorization: `Bearer ${auth.apiKey}` }; - try { - const res = await fetch( - `${this.contentUrl}/v1/${project}/presigned-auth-nonce`, - { - method: "POST", - headers: { - ...authHeader, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - corsOrigin, - }), - }, - ); - if (res.ok) { - const json = await res.json(); - const parsed = z - .object({ - nonce: z.string(), - expiresAt: z.string(), - }) - .safeParse(json); - if (parsed.success) { - return { - status: "success", - data: { - nonce: parsed.data.nonce, - baseUrl: `${this.contentUrl}/v1/${project}`, - }, - }; - } - console.error( - "Could not parse presigned auth nonce response. Error: " + - fromError(parsed.error), - ); - return { - status: "error", - statusCode: 500, - error: { - message: - "Could not get presigned auth nonce. The response from the content host was not in the expected format.", - }, - }; - } - if (res.status === 401) { - return { - status: "error", - statusCode: 401, - error: { - message: - "Could not get presigned auth nonce. The local PAT was rejected by the content host. Try re-running `val login`.", - }, - }; - } - const unknownErrorMessage = `Could not get presigned auth nonce. HTTP error: ${res.status} ${res.statusText}`; - console.error(unknownErrorMessage); - return { - status: "error", - statusCode: 500, - error: { message: unknownErrorMessage }, - }; - } catch (e) { - console.error( - "Could not get presigned auth nonce (connection error?):", - e, - ); - return { - status: "error", - statusCode: 500, - error: { - message: `Could not get presigned auth nonce. Error: ${ - e instanceof Error ? e.message : JSON.stringify(e) - }`, - }, - }; - } - } - async getCommitSummary(): Promise< | { commitSummary: string | null; error?: undefined } | { commitSummary?: undefined; error: GenericErrorMessage } diff --git a/packages/server/src/ValOpsHttp.ts b/packages/server/src/ValOpsHttp.ts index a2cb1a744..dff2e9489 100644 --- a/packages/server/src/ValOpsHttp.ts +++ b/packages/server/src/ValOpsHttp.ts @@ -531,7 +531,7 @@ export class ValOpsHttp extends ValOps { return { status: "error" as const, error: { - message: "Could not get nonce. " + message, + message: "Could not get nonce." + message, }, }; } diff --git a/packages/server/src/ValServer.ts b/packages/server/src/ValServer.ts index ba9196661..5cd244908 100644 --- a/packages/server/src/ValServer.ts +++ b/packages/server/src/ValServer.ts @@ -88,17 +88,6 @@ export const ValServer = ( options: ValServerConfig, callbacks: ValServerCallbacks, ): ServerOf => { - const AIContentBlock = z.union([ - z.object({ - type: z.literal("text"), - text: z.string(), - }), - z.object({ - type: z.literal("image_url"), - url: z.string(), - }), - ]); - const AIMessageContent = z.union([z.string(), z.array(AIContentBlock)]); const ProfilesResponse = z.object({ profiles: z.array( z.object({ @@ -1016,49 +1005,15 @@ export const ValServer = ( }; } if (serverOps instanceof ValOpsFS) { - // In FS mode patch-file uploads are buffered through this server (no remote round-trip), - // so baseUrl points at /api/val/upload. AI image uploads, however, go straight to the - // content host — we resolve a contentBaseUrl + a PAT-issued nonce here so the browser - // can POST directly without exposing the PAT. + // In FS mode we do not use the remote server at all and just return an url that points to this server + // which has an endpoint that handles this + // A bit hacky perhaps, but we want to have as similar semantics as possible in client code when it comes to FS / HTTP const host = `/api/val`; - let contentBaseUrl: string | null = null; - let contentAuthNonce: string | null = null; - if (!options.project) { - console.warn( - "Direct content-host uploads (AI images) disabled: no `project` set in val.config (and VAL_PROJECT env var is not set).", - ); - } else { - const authDataRes = await getRemoteFileAuth(); - if (authDataRes.status !== 200) { - console.warn( - "Direct content-host uploads (AI images) disabled: " + - authDataRes.json.message, - ); - } else { - const corsOrigin = "*"; // TODO: add cors origin - const presignedAuthNonce = await serverOps.getPresignedAuthNonce( - options.project, - corsOrigin, - authDataRes.json.remoteFileAuth, - ); - if (presignedAuthNonce.status === "success") { - contentBaseUrl = presignedAuthNonce.data.baseUrl; - contentAuthNonce = presignedAuthNonce.data.nonce; - } else { - console.warn( - "Direct content-host uploads (AI images) disabled: " + - presignedAuthNonce.error.message, - ); - } - } - } return { status: 200, json: { nonce: null, baseUrl: `${host}/upload`, // NOTE: this is the /upload/patches endpoint - the client will add /patches/:patchId/files to this and post to it - contentBaseUrl, - contentAuthNonce, }, }; } @@ -1089,8 +1044,6 @@ export const ValServer = ( json: { nonce: presignedAuthNonce.data.nonce, baseUrl: presignedAuthNonce.data.baseUrl, - contentBaseUrl: presignedAuthNonce.data.baseUrl, - contentAuthNonce: presignedAuthNonce.data.nonce, }, }; }, @@ -1418,6 +1371,7 @@ export const ValServer = ( const patchAnalysis = serverOps.analyzePatches(patchOps.patches); const schemasRes = await serverOps.getSchemas(); let sourcesRes = await serverOps.getSources(); + const unpatchedSources = sourcesRes.sources; const onlyPatchedTreeModules = await serverOps.getSources({ ...patchAnalysis, ...patchOps, @@ -1480,6 +1434,7 @@ export const ValServer = ( ModuleFilePath, { source: Json; + baseSource?: Json; render: ReifiedRender | null; patches?: { applied: PatchId[]; @@ -1514,8 +1469,13 @@ export const ValServer = ( appliedPatches.push(patchId); } } + const hasPatches = + (patchAnalysis.patchesByModule[moduleFilePath]?.length ?? 0) > 0; modules[moduleFilePath] = { source: module, + baseSource: hasPatches + ? unpatchedSources[moduleFilePath] + : undefined, render: renderRes.renders[moduleFilePath] || null, patches: appliedPatches.length > 0 || @@ -1896,14 +1856,14 @@ export const ValServer = ( } if (!options.project) { return { - status: 401, + status: 500, json: { message: "Project is not configured" }, }; } const authDataRes = await getRemoteFileAuth(); if (authDataRes.status !== 200) { return { - status: 401, + status: 500, json: { message: authDataRes.json.message, }, @@ -2190,7 +2150,7 @@ export const ValServer = ( messages: z.array( z.object({ role: z.string(), - content: AIMessageContent, + content: z.string(), }), ), nextCursor: z @@ -2201,16 +2161,7 @@ export const ValServer = ( .nullable() .optional(), }); - const params = new URLSearchParams(); - if (req.query.limit) params.set("limit", req.query.limit); - if (req.query.cursor_updatedAt) - params.set("cursor_updatedAt", req.query.cursor_updatedAt); - if (req.query.cursor_id) - params.set("cursor_id", req.query.cursor_id); - const qs = params.toString(); - const upstreamUrl = - `${options.valContentUrl}/v1/${options.project}/ai/sessions/${encodeURIComponent(sessionId)}/messages` + - (qs ? `?${qs}` : ""); + const upstreamUrl = `${options.valContentUrl}/v1/${options.project}/ai/sessions/${encodeURIComponent(sessionId)}/messages`; const upstreamRes = await fetch(upstreamUrl, { headers }); if (!upstreamRes.ok) { const text = await upstreamRes.text(); @@ -2270,301 +2221,6 @@ export const ValServer = ( ); }, }, - "/ai/session-image-to-patch-file": { - POST: async (req) => { - const cookies = req.cookies; - const auth = getAuth(cookies); - if (auth.error) { - return { status: 401, json: { message: auth.error } }; - } - if (!options.project) { - return { - status: 500, - json: { message: "Project is not configured" }, - }; - } - const authDataRes = await getRemoteFileAuth(); - if (authDataRes.status !== 200) { - return { - status: 500, - json: { - message: authDataRes.json.message, - }, - }; - } - const authData = authDataRes.json.remoteFileAuth; - let headers: HeadersInit; - if (serverOps instanceof ValOpsFS) { - headers = getProfileAuthHeaders(authData, null, "application/json"); - } else { - if (!("id" in auth) || !auth.id) { - return { - status: 401 as const, - json: { message: "Unauthorized" }, - }; - } - headers = getProfileAuthHeaders( - authData, - { sub: auth.id }, - "application/json", - ); - } - const execFetch = async () => { - try { - const upstreamUrl = `${options.valContentUrl}/v1/${options.project}/patches/${encodeURIComponent(req.body.patchId)}/files/from-session-file`; - const upstreamRes = await fetch(upstreamUrl, { - method: "POST", - headers, - body: JSON.stringify({ - files: req.body.files.map((f) => ({ - filePath: f.filePath, - key: f.key, - ...(f.isRemote !== undefined ? { isRemote: f.isRemote } : {}), - })), - }), - }); - if (!upstreamRes.ok) { - const text = await upstreamRes.text(); - let upstreamJson: { - message?: string; - details?: { availableKeys?: string[] }; - } | null = null; - try { - upstreamJson = JSON.parse(text); - } catch { - upstreamJson = null; - } - if (upstreamRes.status === 400 && upstreamJson) { - return { - status: 400 as const, - json: { - message: - upstreamJson.message ?? - `AI session image to patch failed: ${text}`, - ...(upstreamJson.details - ? { details: upstreamJson.details } - : {}), - }, - }; - } - return { - status: 500 as const, - json: { - message: `AI session image to patch failed: ${upstreamRes.status} ${upstreamJson?.message ?? text}`, - }, - }; - } - const rawUpstreamJson = await upstreamRes.json(); - const UpstreamResponse = z.object({ - patchId: z.string(), - files: z.array( - z.object({ - filePath: z.string(), - metadata: z.object({ - width: z.number(), - height: z.number(), - mimeType: z.string(), - }), - }), - ), - }); - const json = UpstreamResponse.safeParse(rawUpstreamJson); - if (!json.success) { - return { - status: 500 as const, - json: { - message: - "Could not parse AI session image patch response: " + - fromError(json.error).toString(), - }, - }; - } - if (json.data.patchId !== req.body.patchId) { - return { - status: 500 as const, - json: { - message: `AI session image to patch upstream returned mismatched patchId: expected '${req.body.patchId}', got '${json.data.patchId}'`, - }, - }; - } - if (serverOps instanceof ValOpsFS) { - // Mirror the binaries from the content host into local patch - // storage so /files?patch_id=... can serve them. Match upstream - // entries to client-provided keys by filePath. - const keysByFilePath = new Map( - req.body.files.map((f) => [f.filePath, f.key]), - ); - for (const file of json.data.files) { - const sessionKey = keysByFilePath.get(file.filePath); - if (!sessionKey) { - return { - status: 500 as const, - json: { - message: `AI session image to patch: upstream returned file '${file.filePath}' which was not in the request`, - }, - }; - } - const downloadUrl = `${options.valContentUrl}/v1/${options.project}/ai/images?key=${encodeURIComponent(sessionKey)}`; - let binaryRes: Response; - try { - binaryRes = await fetch(downloadUrl, { headers }); - } catch (err) { - return { - status: 500 as const, - json: { - message: `Could not download AI session image '${file.filePath}' from content host: ${err instanceof Error ? err.message : String(err)}`, - }, - }; - } - if (!binaryRes.ok) { - return { - status: 500 as const, - json: { - message: `Could not download AI session image '${file.filePath}' from content host: ${binaryRes.status} ${binaryRes.statusText}`, - }, - }; - } - const arrayBuffer = await binaryRes.arrayBuffer(); - const base64 = Buffer.from(arrayBuffer).toString("base64"); - const dataUrl = `data:${file.metadata.mimeType};base64,${base64}`; - const type: "image" | "file" = - file.metadata.mimeType.startsWith("image/") - ? "image" - : "file"; - const saveRes = - await serverOps.saveBase64EncodedBinaryFileFromPatch( - file.filePath, - req.body.parentRef, - req.body.patchId, - dataUrl, - type, - file.metadata, - ); - if (saveRes.error) { - return { - status: 500 as const, - json: { - message: `Could not save AI session image '${file.filePath}' to local patch storage: ${saveRes.error.message}`, - }, - }; - } - } - } - return { - status: 200 as const, - json: { - patchId: req.body.patchId, - files: json.data.files, - }, - }; - } catch (err) { - return { - status: 500 as const, - json: { - message: - err instanceof Error - ? err.message - : "AI session image to patch error", - }, - }; - } - }; - return execFetch(); - }, - }, - "/ai/images": { - PATCH: async (req) => { - const cookies = req.cookies; - const auth = getAuth(cookies); - if (auth.error) { - return { status: 401, json: { message: auth.error } }; - } - if (!options.project) { - return { - status: 500, - json: { message: "Project is not configured" }, - }; - } - const authDataRes = await getRemoteFileAuth(); - if (authDataRes.status !== 200) { - return { - status: 500, - json: { - message: authDataRes.json.message, - }, - }; - } - const authData = authDataRes.json.remoteFileAuth; - let headers: HeadersInit; - if (serverOps instanceof ValOpsFS) { - headers = getProfileAuthHeaders(authData, null, "application/json"); - } else { - if (!("id" in auth) || !auth.id) { - return { - status: 401 as const, - json: { message: "Unauthorized" }, - }; - } - headers = getProfileAuthHeaders( - authData, - { sub: auth.id }, - "application/json", - ); - } - const execFetch = async () => { - try { - const upstreamUrl = `${options.valContentUrl}/v1/${options.project}/ai/images`; - const upstreamRes = await fetch(upstreamUrl, { - method: "PATCH", - headers, - body: JSON.stringify({ - key: req.body.key, - metadata: req.body.metadata, - contentType: req.body.contentType, - }), - }); - if (!upstreamRes.ok) { - const text = await upstreamRes.text(); - return { - status: 500 as const, - json: { - message: `AI images patch failed: ${upstreamRes.status} ${text}`, - }, - }; - } - const UpstreamResponse = z.object({ - key: z.string(), - }); - const json = UpstreamResponse.safeParse(await upstreamRes.json()); - if (!json.success) { - return { - status: 500 as const, - json: { - message: - "Could not parse AI images patch response: " + - fromError(json.error).toString(), - }, - }; - } - return { - status: 200 as const, - json: { - key: json.data.key, - }, - }; - } catch (err) { - return { - status: 500 as const, - json: { - message: - err instanceof Error ? err.message : "AI images patch error", - }, - }; - } - }; - return execFetch(); - }, - }, //#region files "/files": { diff --git a/packages/shared/src/internal/ApiRoutes.ts b/packages/shared/src/internal/ApiRoutes.ts index a9ae85322..2a6c6812f 100644 --- a/packages/shared/src/internal/ApiRoutes.ts +++ b/packages/shared/src/internal/ApiRoutes.ts @@ -641,8 +641,6 @@ export const Api = { json: z.object({ nonce: z.string().nullable(), baseUrl: z.string(), - contentBaseUrl: z.string().nullable(), - contentAuthNonce: z.string().nullable(), }), }), ]), @@ -852,6 +850,7 @@ export const Api = { z.object({ render: z.any().optional(), // TODO: improve this type source: z.any().optional(), //.optional(), // TODO: Json zod type + baseSource: z.any().optional(), patches: z .object({ applied: z.array(PatchId), @@ -1092,11 +1091,6 @@ export const Api = { GET: { req: { path: z.string(), - query: { - limit: onlyOneStringQueryParam.optional(), - cursor_updatedAt: onlyOneStringQueryParam.optional(), - cursor_id: onlyOneStringQueryParam.optional(), - }, cookies: { [VAL_SESSION_COOKIE]: z.string().optional() }, }, res: z.union([ @@ -1107,21 +1101,7 @@ export const Api = { messages: z.array( z.object({ role: z.string(), - content: z.union([ - z.string(), - z.array( - z.union([ - z.object({ - type: z.literal("text"), - text: z.string(), - }), - z.object({ - type: z.literal("image_url"), - url: z.string(), - }), - ]), - ), - ]), + content: z.string(), }), ), nextCursor: z @@ -1140,85 +1120,6 @@ export const Api = { ]), }, }, - "/ai/session-image-to-patch-file": { - POST: { - req: { - body: z.object({ - patchId: PatchId, - parentRef: ParentRef, - files: z - .array( - z.object({ - filePath: z.string(), - key: z.string(), - isRemote: z.boolean().optional(), - }), - ) - .min(1), - }), - cookies: { [VAL_SESSION_COOKIE]: z.string().optional() }, - }, - res: z.union([ - unauthorizedResponse, - z.object({ - status: z.literal(200), - json: z.object({ - patchId: PatchId, - files: z.array( - z.object({ - filePath: z.string(), - metadata: z.object({ - width: z.number(), - height: z.number(), - mimeType: z.string(), - }), - }), - ), - }), - }), - z.object({ - status: z.literal(400), - json: z.object({ - message: z.string(), - details: z - .object({ - availableKeys: z.array(z.string()).optional(), - }) - .optional(), - }), - }), - z.object({ - status: z.literal(500), - json: GenericError, - }), - ]), - }, - }, - "/ai/images": { - PATCH: { - req: { - body: z.object({ - key: z.string(), - metadata: z.any(), - contentType: z.string(), - }), - cookies: { [VAL_SESSION_COOKIE]: z.string().optional() }, - }, - res: z.union([ - unauthorizedResponse, - z.object({ - status: z.literal(200), - json: z.object({ - key: z.string(), - }), - }), - z.object({ - status: z.literal(500), - json: GenericError, - }), - ]), - }, - }, } satisfies ApiGuard; // Types and helper types: diff --git a/packages/ui/spa/ValSyncEngine.ts b/packages/ui/spa/ValSyncEngine.ts index d3efc1304..89c9375fa 100644 --- a/packages/ui/spa/ValSyncEngine.ts +++ b/packages/ui/spa/ValSyncEngine.ts @@ -90,6 +90,8 @@ export class ValSyncEngine { private patchSets: PatchSets; /** serverSources is the state on the server, it is the actual state */ private serverSources: Record | null; + /** baseSources holds un-patched sources (before any pending patches are applied) for diff views */ + private baseSources: Record | null; /** optimisticClientSources is the state of the client, optimistic means that patches have been applied in client-only */ private optimisticClientSources: Record< ModuleFilePath, @@ -176,6 +178,7 @@ export class ValSyncEngine { this.mode = null; this.optimisticClientSources = {}; this.serverSources = null; + this.baseSources = null; this.renders = null; this.globalServerSidePatchIds = []; this.syncedServerSidePatchIds = []; @@ -191,6 +194,8 @@ export class ValSyncEngine { this.commitSha = null; // this.cachedSourceSnapshots = null; + this.cachedServerSourceSnapshots = null; + this.cachedBaseSourceSnapshots = null; this.cachedSchemaSnapshots = null; this.cachedRenderSnapshots = null; this.cachedPatchData = null; @@ -288,6 +293,7 @@ export class ValSyncEngine { this.sourcesSha = null; this.optimisticClientSources = {}; this.serverSources = null; + this.baseSources = null; this.renders = null; this.globalServerSidePatchIds = []; this.syncedServerSidePatchIds = []; @@ -303,6 +309,8 @@ export class ValSyncEngine { this.commitSha = null; // this.cachedSourceSnapshots = null; + this.cachedServerSourceSnapshots = null; + this.cachedBaseSourceSnapshots = null; this.cachedSchemaSnapshots = null; this.cachedRenderSnapshots = null; this.cachedPatchData = null; @@ -448,6 +456,18 @@ export class ValSyncEngine { [moduleFilePath]: undefined, }; } + if (this.cachedServerSourceSnapshots !== null) { + this.cachedServerSourceSnapshots = { + ...this.cachedServerSourceSnapshots, + [moduleFilePath]: undefined, + }; + } + if (this.cachedBaseSourceSnapshots !== null) { + this.cachedBaseSourceSnapshots = { + ...this.cachedBaseSourceSnapshots, + [moduleFilePath]: undefined, + }; + } this.cachedAllSourcesSnapshot = null; this.cachedSourcesSnapshot = null; this.emit(this.listeners["sources"]?.[moduleFilePath]); @@ -674,6 +694,85 @@ export class ValSyncEngine { return this.cachedSourceSnapshots[sourcePath]; } + private cachedServerSourceSnapshots: Record< + ModuleFilePath, + | { status: "success"; data: Json } + | { status: "no-schemas" | "source-not-found" | "schema-not-found" } + > | null; + getServerSourceSnapshot( + sourcePath: ModuleFilePath, + ): + | { status: "success"; data: Json } + | { status: "no-schemas" | "source-not-found" | "schema-not-found" } { + if (this.cachedServerSourceSnapshots === null) { + this.cachedServerSourceSnapshots = {}; + } + if (this.cachedServerSourceSnapshots[sourcePath] === undefined) { + if (this.schemas === null) { + this.cachedServerSourceSnapshots[sourcePath] = { + status: "no-schemas", + }; + } else if (!this.schemas[sourcePath]) { + this.cachedServerSourceSnapshots[sourcePath] = { + status: "schema-not-found", + }; + } else { + const moduleData = this.serverSources?.[sourcePath]; + if (moduleData === undefined) { + this.cachedServerSourceSnapshots[sourcePath] = { + status: "source-not-found", + }; + } else { + this.cachedServerSourceSnapshots[sourcePath] = { + status: "success", + data: deepClone(moduleData), + }; + } + } + } + return this.cachedServerSourceSnapshots[sourcePath]; + } + + private cachedBaseSourceSnapshots: Record< + ModuleFilePath, + | { status: "success"; data: Json } + | { status: "no-schemas" | "source-not-found" | "schema-not-found" } + > | null; + getBaseSourceSnapshot( + sourcePath: ModuleFilePath, + ): + | { status: "success"; data: Json } + | { status: "no-schemas" | "source-not-found" | "schema-not-found" } { + if (this.cachedBaseSourceSnapshots === null) { + this.cachedBaseSourceSnapshots = {}; + } + if (this.cachedBaseSourceSnapshots[sourcePath] === undefined) { + if (this.schemas === null) { + this.cachedBaseSourceSnapshots[sourcePath] = { + status: "no-schemas", + }; + } else if (!this.schemas[sourcePath]) { + this.cachedBaseSourceSnapshots[sourcePath] = { + status: "schema-not-found", + }; + } else { + const moduleData = + this.baseSources?.[sourcePath] ?? this.serverSources?.[sourcePath]; + if (moduleData === undefined) { + this.cachedBaseSourceSnapshots[sourcePath] = { + status: "source-not-found", + }; + } else { + this.cachedBaseSourceSnapshots[sourcePath] = { + status: "success", + data: deepClone(moduleData), + }; + } + } + } + return this.cachedBaseSourceSnapshots[sourcePath]; + } + private cachedAllSourcesSnapshot: Record | null; getAllSourcesSnapshot() { if (this.cachedAllSourcesSnapshot === null) { @@ -2144,6 +2243,13 @@ export class ValSyncEngine { this.serverSources = {}; } this.serverSources[moduleFilePath] = valModule.source; + if (this.baseSources === null) { + this.baseSources = {}; + } + this.baseSources[moduleFilePath] = + valModule.baseSource !== undefined + ? valModule.baseSource + : valModule.source; if (this.renders === null) { this.renders = {}; } @@ -2445,11 +2551,14 @@ export class ValSyncEngine { /** * Mock method for testing and Storybook. - * Sets both serverSources and optimisticClientSources to the same value and invalidates related caches. + * Sets serverSources (and baseSources to the same value) and invalidates related caches. */ setSources(sources: Record): void { this.serverSources = sources; + this.baseSources = sources; this.cachedSourceSnapshots = null; + this.cachedServerSourceSnapshots = null; + this.cachedBaseSourceSnapshots = null; this.cachedAllSourcesSnapshot = null; this.cachedSourcesSnapshot = null; for (const moduleFilePath in sources) { @@ -2481,6 +2590,16 @@ export class ValSyncEngine { this.cachedInitializedAtSnapshot = null; this.emit(this.listeners["initialized-at"]?.[globalNamespace]); } + + /** + * Mock method for testing and Storybook. + * Sets baseSha so that getParentRef() returns a valid ref, + * which allows the sync loop to flush pending ops. + */ + setBaseSha(sha: string): void { + this.baseSha = sha; + this.cachedParentRef = undefined; + } } // #region Supporting code diff --git a/packages/ui/spa/components/AIChat.stories.tsx b/packages/ui/spa/components/AIChat.stories.tsx index b12c2be7a..30d1f7ff1 100644 --- a/packages/ui/spa/components/AIChat.stories.tsx +++ b/packages/ui/spa/components/AIChat.stories.tsx @@ -156,8 +156,6 @@ function AutoStartStreamingDemo() { - ); + return ; } export const Interactive: Story = { diff --git a/packages/ui/spa/components/AIChat.tsx b/packages/ui/spa/components/AIChat.tsx index 2cd1a4f59..f275dd703 100644 --- a/packages/ui/spa/components/AIChat.tsx +++ b/packages/ui/spa/components/AIChat.tsx @@ -16,7 +16,6 @@ import { Sparkles, Check, Loader2, - LogIn, Search, FileText, Database, @@ -30,16 +29,12 @@ import { History, ChevronLeft, Tag, - Paperclip, - X, + GitCompareArrows, } from "lucide-react"; import type { AISession } from "../hooks/useAIWebSocket"; -import type { AIContentBlock, AIMessageContent } from "./ValProvider"; import { ToolName } from "../utils/toolNames"; import { useValConfig } from "./ValFieldProvider"; import { DEFAULT_APP_HOST } from "@valbuild/core"; -import { urlOf } from "@valbuild/shared/internal"; -import { CopyableCodeBlock } from "./designSystem/CopyableCodeBlock"; // --------------------------------------------------------------------------- // Types @@ -55,30 +50,14 @@ export type ToolActivity = { status: ToolActivityStatus; }; -export type ChatMessageAttachment = { - key: string; - name: string; - mimeType?: string; - previewUrl?: string; -}; - export type ChatMessage = { id: string; role: "user" | "assistant"; - content: AIMessageContent; + content: string; status: ChatMessageStatus; error?: string; errorCode?: string; toolActivities?: ToolActivity[]; - attachments?: ChatMessageAttachment[]; -}; - -type AttachedFile = { - id: string; - file: File; - status: "uploading" | "done" | "error"; - key?: string; - previewUrl?: string; }; type CurrentMessage = { @@ -113,12 +92,7 @@ export type AIChatHandle = { export type AIChatProps = { /** Called when the user submits a message (via input or suggestion chip). Returns true if sent successfully. */ - onSendMessage?: ( - text: string, - attachments?: ChatMessageAttachment[], - ) => boolean; - /** Called to upload a file to the current AI session. Returns the server key. */ - onUploadFile?: (file: File) => Promise<{ key: string }>; + onSendMessage?: (text: string) => boolean; /** Called when the user clicks "New Chat" to start a fresh session */ onNewSession?: () => void; /** Prompt suggestion chips shown on the empty state */ @@ -127,10 +101,6 @@ export type AIChatProps = { className?: string; /** Whether the underlying WebSocket connection is ready */ isConnected: boolean; - /** Set when /ai/initialize returned 401 — the user needs to authenticate */ - authError: boolean; - /** Val server mode — controls which auth instructions to show on authError */ - mode: "http" | "fs" | "unknown"; /** List of past sessions (fetched on demand) */ sessions?: AISession[]; /** The currently active session ID */ @@ -167,31 +137,6 @@ function nextId(): string { return `chat-${++_msgId}-${Date.now()}`; } -function getTextContent(content: AIMessageContent): string { - if (typeof content === "string") { - return content; - } - return content - .filter( - (block): block is Extract => - block.type === "text", - ) - .map((block) => block.text) - .join("\n\n"); -} - -function getImageUrls(content: AIMessageContent): string[] { - if (typeof content === "string") { - return []; - } - return content - .filter( - (block): block is Extract => - block.type === "image_url", - ) - .map((block) => block.url); -} - // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- @@ -199,13 +144,10 @@ function getImageUrls(content: AIMessageContent): string[] { export const AIChat = forwardRef(function AIChat( { onSendMessage, - onUploadFile, onNewSession, suggestions = DEFAULT_SUGGESTIONS, className, isConnected, - authError, - mode, sessions, currentSessionId, onLoadSession, @@ -222,8 +164,6 @@ export const AIChat = forwardRef(function AIChat( null, ); const [inputValue, setInputValue] = useState(""); - const [attachedFiles, setAttachedFiles] = useState([]); - const fileInputRef = useRef(null); const textareaRef = useRef(null); const bottomRef = useRef(null); const [showSessions, setShowSessions] = useState(false); @@ -293,7 +233,7 @@ export const AIChat = forwardRef(function AIChat( ...prev, message: { ...prev.message, - content: getTextContent(prev.message.content) + chunk, + content: prev.message.content + chunk, }, } : prev, @@ -386,105 +326,26 @@ export const AIChat = forwardRef(function AIChat( // ---- Derived state ---- const isStreaming = currentMessage !== null; - const isUploading = attachedFiles.some((f) => f.status === "uploading"); const isEmpty = messages.length === 0; // ---- Handlers ---- - const handleFileChange = useCallback( - (e: React.ChangeEvent) => { - const files = Array.from(e.target.files ?? []); - if (files.length === 0) return; - // Reset input so the same file can be re-selected - e.target.value = ""; - - const newEntries: AttachedFile[] = files.map((file) => ({ - id: crypto.randomUUID(), - file, - status: "uploading" as const, - previewUrl: file.type.startsWith("image/") - ? URL.createObjectURL(file) - : undefined, - })); - - setAttachedFiles((prev) => [...prev, ...newEntries]); - - if (onUploadFile) { - newEntries.forEach((entry) => { - onUploadFile(entry.file) - .then(({ key }) => { - setAttachedFiles((prev) => - prev.map((f) => - f.id === entry.id ? { ...f, status: "done", key } : f, - ), - ); - }) - .catch((err) => { - console.error("Failed to upload file", { - fileName: entry.file.name, - error: err, - }); - setAttachedFiles((prev) => - prev.map((f) => - f.id === entry.id ? { ...f, status: "error" } : f, - ), - ); - }); - }); - } - }, - [onUploadFile], - ); - - const removeAttachedFile = useCallback((id: string) => { - setAttachedFiles((prev) => { - const file = prev.find((f) => f.id === id); - if (file?.previewUrl) URL.revokeObjectURL(file.previewUrl); - return prev.filter((f) => f.id !== id); - }); - }, []); - const handleSend = useCallback( (text?: string) => { const content = (text ?? inputValue).trim(); if (!content || isStreaming) return; - const doneAttachments: ChatMessageAttachment[] = attachedFiles - .filter( - (f): f is AttachedFile & { key: string } => - f.status === "done" && f.key !== undefined, - ) - .map((f) => ({ - key: f.key, - name: f.file.name, - mimeType: f.file.type || undefined, - previewUrl: f.previewUrl, - })); - - // Revoke object URLs for files we're sending (they'll be in the message) - attachedFiles.forEach((f) => { - if (f.previewUrl && f.status !== "done") - URL.revokeObjectURL(f.previewUrl); - }); - setAttachedFiles([]); - const msgId = nextId(); const userMsg: ChatMessage = { id: msgId, role: "user", content, status: "complete", - attachments: doneAttachments.length > 0 ? doneAttachments : undefined, }; setCompletedMessages((prev) => [...prev, userMsg]); setInputValue(""); - const sent = onSendMessage - ? onSendMessage( - content, - doneAttachments.length > 0 ? doneAttachments : undefined, - ) - : true; + const sent = onSendMessage ? onSendMessage(content) : true; if (!sent) { setCompletedMessages((prev) => prev.map((m) => @@ -498,7 +359,7 @@ export const AIChat = forwardRef(function AIChat( // Refocus textarea after send requestAnimationFrame(() => textareaRef.current?.focus()); }, - [inputValue, isStreaming, attachedFiles, onSendMessage], + [inputValue, isStreaming, onSendMessage], ); const handleRetry = useCallback( @@ -514,10 +375,7 @@ export const AIChat = forwardRef(function AIChat( : m, ), ); - const retryText = getTextContent(errorMsg.content); - const sent = onSendMessage - ? onSendMessage(retryText, errorMsg.attachments) - : true; + const sent = onSendMessage ? onSendMessage(errorMsg.content) : true; if (!sent) { setCompletedMessages((prev) => prev.map((m) => @@ -542,10 +400,7 @@ export const AIChat = forwardRef(function AIChat( // Remove the errored assistant message setCompletedMessages((prev) => prev.filter((m) => m.id !== errorMsgId)); - onSendMessage?.( - getTextContent(prevUserMsg.content), - prevUserMsg.attachments, - ); + onSendMessage?.(prevUserMsg.content); }, [messages, onSendMessage], ); @@ -720,9 +575,7 @@ export const AIChat = forwardRef(function AIChat( {/* Message list */}
- {authError ? ( - - ) : isEmpty ? ( + {isEmpty ? ( (function AIChat( {/* Input area */}
- {!isConnected && !authError && ( -
+ {!isConnected && ( +
Connecting…
)} - {/* Attached file previews */} - {attachedFiles.length > 0 && ( -
- {attachedFiles.map((f) => ( -
- {f.previewUrl ? ( - {f.file.name} - ) : ( - - )} - {f.file.name} - {f.status === "uploading" && ( - - )} - {f.status === "error" && ( - - )} - -
- ))} -
- )} - {onUploadFile && ( - - )}
- {onUploadFile && ( - - )}