From 23dc507eec60b92660fbeef39ec0c84498bf4731 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 17 Sep 2026 15:18:54 -0700 Subject: [PATCH] feat(desktop): attach images to a cloud workspace at create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Desktop was worse than mobile here: mobile refused with an error, desktop uploaded the files and then called create without them, so they vanished with no signal at all. The comment at the cloud branch said why — "written to a host, and this workspace's host doesn't exist yet". An upload's target is now a host URL *or* `CLOUD_UPLOAD_TARGET`. For the cloud target the bytes go to cloud storage over a presigned PUT (`attachment.createUpload`) instead of to a host, and a ready entry carries a `files.id` the sandbox resolves rather than a host attachment id. The store already keyed uploads by `(fileId, target)`, so the pill list, the pruning and the await path needed nothing. This is the same id space mobile sends, which the sandbox imports on first boot. --- .../useSubmitWorkspace/useSubmitWorkspace.ts | 8 ++- .../hooks/useUploadAttachments/index.ts | 1 + .../hooks/useUploadAttachments/store.ts | 55 ++++++++++++++++--- .../useUploadAttachments.ts | 9 ++- .../NewWorkspaceScreen/NewWorkspaceScreen.tsx | 11 +++- 5 files changed, 68 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace/useSubmitWorkspace.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace/useSubmitWorkspace.ts index b17a4a1133b..b54ade15465 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace/useSubmitWorkspace.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace/useSubmitWorkspace.ts @@ -133,8 +133,9 @@ export function useSubmitWorkspace( // Returns as soon as the row exists — the sandbox is still being // provisioned behind it, which the workspace screen renders. // Same rule as a local create: an agent launches only when there - // is something to say to it. Attachments stay behind — they are - // written to a host, and this workspace's host doesn't exist yet. + // is something to say to it. Attachments were uploaded to cloud + // storage rather than a host, so the ids here are `files.id`s the + // sandbox resolves and pulls once it is up. const wantCloudAgent = selectedAgent !== "none" && (!!draft.prompt.trim() || @@ -163,6 +164,9 @@ export function useSubmitWorkspace( model: selectedModel ?? undefined, effort: selectedEffort ?? undefined, mode: selectedMode ?? undefined, + ...(attachmentIds.length > 0 + ? { attachmentFileIds: attachmentIds } + : {}), } : {}), }); diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useUploadAttachments/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useUploadAttachments/index.ts index 5eb6a71bfcc..481939e4410 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useUploadAttachments/index.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useUploadAttachments/index.ts @@ -1,4 +1,5 @@ export { + CLOUD_UPLOAD_TARGET, type UploadState, useFileIdsForHost, useUploadStateFor, diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useUploadAttachments/store.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useUploadAttachments/store.ts index a567ae41b80..5f6b20e1ccf 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useUploadAttachments/store.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useUploadAttachments/store.ts @@ -1,3 +1,4 @@ +import { apiTrpcClient } from "renderer/lib/api-trpc-client"; import { getHostServiceClientByUrl } from "renderer/lib/host-service-client"; import { create } from "zustand"; import { useShallow } from "zustand/react/shallow"; @@ -21,6 +22,38 @@ export const useAttachmentUploadsStore = create(() => ({ // observed by React. Keyed identically to entries: outer fileId, inner hostUrl. const promiseMap = new Map>>(); +/** + * The target of an upload when there is no host to send it to: a cloud + * workspace's sandbox does not exist until after the create. Bytes go to + * cloud storage instead, and the id a `ready` entry carries is a `files.id` + * the API resolves, not a host attachment id. + */ +export const CLOUD_UPLOAD_TARGET = "cloud"; + +async function fetchBlob(url: string): Promise { + return await (await fetch(url)).blob(); +} + +async function uploadToCloud(file: StartUploadInput): Promise { + const blob = await fetchBlob(file.url); + const { fileId, upload } = await apiTrpcClient.attachment.createUpload.mutate( + { + name: file.filename ?? "attachment", + contentType: file.mediaType, + sizeBytes: blob.size, + }, + ); + const response = await fetch(upload.url, { + method: "PUT", + headers: upload.headers, + body: blob, + }); + if (!response.ok) { + throw new Error(`Upload failed (${response.status})`); + } + return fileId; +} + async function fetchBase64(url: string): Promise { if (url.startsWith("data:")) { const commaIndex = url.indexOf(","); @@ -71,17 +104,21 @@ export function startUpload(hostUrl: string, file: StartUploadInput): void { const promise = (async (): Promise => { try { - const data = await fetchBase64(file.url); - const result = await getHostServiceClientByUrl( - hostUrl, - ).attachments.upload.mutate({ - data: { kind: "base64", data }, - mediaType: file.mediaType, - originalFilename: file.filename, - }); + const attachmentId = + hostUrl === CLOUD_UPLOAD_TARGET + ? await uploadToCloud(file) + : ( + await getHostServiceClientByUrl( + hostUrl, + ).attachments.upload.mutate({ + data: { kind: "base64", data: await fetchBase64(file.url) }, + mediaType: file.mediaType, + originalFilename: file.filename, + }) + ).attachmentId; const next: UploadState = { kind: "ready", - attachmentId: result.attachmentId, + attachmentId, }; setEntry(file.id, hostUrl, next); return next; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useUploadAttachments/useUploadAttachments.ts b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useUploadAttachments/useUploadAttachments.ts index d4b864858ab..61953615323 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useUploadAttachments/useUploadAttachments.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/PromptGroup/hooks/useUploadAttachments/useUploadAttachments.ts @@ -16,10 +16,13 @@ export interface UseUploadAttachmentsApi { /** * Drives background attachment uploads. Each file uploads exactly once, to - * whichever host was active when the user added it; switching hosts does not - * re-upload. The upload store keys results by `(fileId, hostUrl)` so the + * whichever target was active when the user added it; switching targets does + * not re-upload. The upload store keys results by `(fileId, target)` so the * visible pill list (filtered via `useFileIdsForHost`) follows the picker - * while previous-host attachments stay cached for return visits. + * while previous targets' attachments stay cached for return visits. + * + * A target is a host URL, or `CLOUD_UPLOAD_TARGET` when the workspace will be + * a cloud one and has no host yet. */ export function useUploadAttachments({ files, diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/NewWorkspaceScreen/NewWorkspaceScreen.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/NewWorkspaceScreen/NewWorkspaceScreen.tsx index 07905266ad0..83bf12c6ef2 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/NewWorkspaceScreen/NewWorkspaceScreen.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/NewWorkspaceScreen/NewWorkspaceScreen.tsx @@ -84,6 +84,7 @@ import { useBranchPickerController } from "../DashboardNewWorkspaceForm/PromptGr import { useLinkedContext } from "../DashboardNewWorkspaceForm/PromptGroup/hooks/useLinkedContext"; import { useSubmitWorkspace } from "../DashboardNewWorkspaceForm/PromptGroup/hooks/useSubmitWorkspace"; import { + CLOUD_UPLOAD_TARGET, useFileIdsForHost, useUploadAttachments, } from "../DashboardNewWorkspaceForm/PromptGroup/hooks/useUploadAttachments"; @@ -525,11 +526,17 @@ export function NewWorkspaceScreen({ }); // ── Submit ─────────────────────────────────────────────────────── + // A cloud workspace has no host to upload to, so its attachments go to + // cloud storage and the sandbox pulls them once it is up. + const uploadTarget = + (draft.hostId ?? machineId) === CLOUD_HOST_ID + ? CLOUD_UPLOAD_TARGET + : launchHostUrl; const uploadAttachments = useUploadAttachments({ files: attachments.files, - hostUrl: launchHostUrl, + hostUrl: uploadTarget, }); - const fileIdsForCurrentHost = useFileIdsForHost(launchHostUrl); + const fileIdsForCurrentHost = useFileIdsForHost(uploadTarget); const visibleFiles = useMemo(() => { const idSet = new Set(fileIdsForCurrentHost); return attachments.files.filter((file) => idSet.has(file.id));