From 31bfe2353a2f81bdbbef798d59eb571c90d01a11 Mon Sep 17 00:00:00 2001 From: yg2224 <2677406151@qq.com> Date: Mon, 7 Sep 2026 18:36:01 +0800 Subject: [PATCH 1/8] fix(desktop): route token-backed images as structured attachments --- apps/desktop/src/stores/app-store.ts | 31 +++++++----- .../desktop/test/composer-send-state.test.mjs | 49 +++++++++++++++++++ 2 files changed, 68 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/stores/app-store.ts b/apps/desktop/src/stores/app-store.ts index 009458140..7c2ac4b6d 100644 --- a/apps/desktop/src/stores/app-store.ts +++ b/apps/desktop/src/stores/app-store.ts @@ -164,18 +164,25 @@ export type { WorkPanelTab } from "../lib/work-panel-tabs"; function promptAttachmentsFromDraft( references: ComposerDraftSnapshot["fileReferences"], ): AgentPromptAttachment[] { - return references - .filter((reference) => !reference.token) - .map((reference) => ({ - path: reference.path, - name: reference.name, - kind: - reference.kind ?? - (/\.(avif|bmp|gif|heic|jpe?g|png|tiff?|webp)$/i.test(reference.path) - ? "image" - : "file"), - ...(reference.mimeType ? { mimeType: reference.mimeType } : {}), - })); + return references.flatMap((reference) => { + const kind = + reference.kind ?? + (/\.(avif|bmp|gif|heic|jpe?g|png|tiff?|webp)$/i.test(reference.path) + ? "image" + : "file"); + // Inline chips use tokens for both files and images. Ordinary file chips + // already serialize to @path text (the model can Read them); only image + // chips need the structured transport for vision/fallback handling. + if (reference.token && kind !== "image") return []; + return [ + { + path: reference.path, + name: reference.name, + kind, + ...(reference.mimeType ? { mimeType: reference.mimeType } : {}), + }, + ]; + }); } function promptAttachmentsFromMessage( diff --git a/apps/desktop/test/composer-send-state.test.mjs b/apps/desktop/test/composer-send-state.test.mjs index 24ce297a0..7890d30a2 100644 --- a/apps/desktop/test/composer-send-state.test.mjs +++ b/apps/desktop/test/composer-send-state.test.mjs @@ -184,6 +184,55 @@ test("mode slash prefixes send the trailing prompt and retain failed drafts", () ); }); +test("draft attachment routing keeps image chips structured and file chips textual", () => { + const helperSource = store.match( + /function promptAttachmentsFromDraft\([\s\S]*?\n\}\n\nfunction promptAttachmentsFromMessage/, + )?.[0]?.replace(/\n\nfunction promptAttachmentsFromMessage[\s\S]*$/, ""); + assert.ok(helperSource, "prompt attachment mapper not found"); + const executable = helperSource.replace( + /function promptAttachmentsFromDraft\(\s*references: ComposerDraftSnapshot\["fileReferences"\],\s*\): AgentPromptAttachment\[\] \{/, + "function promptAttachmentsFromDraft(references) {", + ); + const promptAttachmentsFromDraft = new Function( + `${executable}; return promptAttachmentsFromDraft;`, + )(); + const attachments = promptAttachmentsFromDraft([ + { + path: "/tmp/photo.png", + name: "photo.png", + kind: "image", + mimeType: "image/png", + token: "\uE001", + }, + { + path: "/tmp/notes.txt", + name: "notes.txt", + kind: "file", + token: "\uE002", + }, + { + path: "src/legacy.jpg", + name: "legacy.jpg", + token: "\uE003", + }, + { path: "src/index.ts", name: "index.ts", kind: "file" }, + ]); + assert.deepEqual(attachments, [ + { + path: "/tmp/photo.png", + name: "photo.png", + kind: "image", + mimeType: "image/png", + }, + { + path: "src/legacy.jpg", + name: "legacy.jpg", + kind: "image", + }, + { path: "src/index.ts", name: "index.ts", kind: "file" }, + ]); +}); + test("the user row is inserted before the host round trip and echoed under the same id (D288)", () => { const sendPrompt = store.match(/\n sendPrompt: async \([\s\S]*?\n },\n/)?.[0] ?? ""; assert.ok(sendPrompt.length > 0, "sendPrompt not found"); From b9adef2602ac957ff93b32f2f1a8d1eee7c92210 Mon Sep 17 00:00:00 2001 From: yg2224 <2677406151@qq.com> Date: Mon, 7 Sep 2026 18:55:31 +0800 Subject: [PATCH 2/8] feat(desktop): import picker files into session scratch --- apps/desktop/electron/main/composer-paste.ts | 125 +++++++++++++++++- apps/desktop/electron/main/index.ts | 39 +++++- apps/desktop/src/lib/api.ts | 5 + .../test/composer-paste-files.test.mjs | 73 +++++++++- ...oser-clipboard-files-in-session-scratch.md | 24 ++-- docs/spec/03-runtime/01-ipc-protocol.md | 29 +++- docs/spec/06-delivery/04-e2e-test-plan.md | 27 ++++ packages/shared/src/protocol.ts | 1 + 8 files changed, 302 insertions(+), 21 deletions(-) diff --git a/apps/desktop/electron/main/composer-paste.ts b/apps/desktop/electron/main/composer-paste.ts index 0946214c7..4acd1b539 100644 --- a/apps/desktop/electron/main/composer-paste.ts +++ b/apps/desktop/electron/main/composer-paste.ts @@ -1,6 +1,13 @@ import { randomUUID } from "node:crypto"; -import { mkdir, writeFile } from "node:fs/promises"; -import { basename, extname, join } from "node:path"; +import { + copyFile, + mkdir, + realpath, + stat, + writeFile, +} from "node:fs/promises"; +import { constants as fsConstants } from "node:fs"; +import { basename, extname, isAbsolute, join } from "node:path"; import type { ComposerPasteFile, ComposerPastedFile, @@ -37,6 +44,19 @@ const MIME_EXTENSIONS: Record = { "application/zip": ".zip", }; +const MIME_BY_EXTENSION: Record = Object.fromEntries( + Object.entries(MIME_EXTENSIONS).map(([mimeType, extension]) => [extension, mimeType]), +); + +Object.assign(MIME_BY_EXTENSION, { + ".avif": "image/avif", + ".bmp": "image/bmp", + ".heic": "image/heic", + ".jpeg": "image/jpeg", + ".tif": "image/tiff", + ".tiff": "image/tiff", +}); + function isImageMimeType(mimeType: string): boolean { return mimeType.startsWith("image/"); } @@ -63,6 +83,18 @@ function fileNameOf(name: unknown, mimeType: string, index: number): string { return extname(candidate) ? candidate : `${candidate}${extension}`; } +function mimeTypeForPath(path: string): string { + return MIME_BY_EXTENSION[extname(path).toLowerCase()] ?? "application/octet-stream"; +} + +function scratchPasteRoot(dataDir: string, sessionId: string): string { + return join(dataDir, "scratch", sessionId, "pasted"); +} + +function pastedOutputPath(root: string, name: string): string { + return join(root, `pasted-${randomUUID()}-${name}`); +} + /** * Materialize renderer clipboard bytes in the session scratch directory. * Names are reduced to leaf names and every output receives a unique prefix, @@ -105,12 +137,11 @@ export async function saveComposerPasteFiles( }; }); - const root = join(dataDir, "scratch", sessionId, "pasted"); + const root = scratchPasteRoot(dataDir, sessionId); await mkdir(root, { recursive: true }); return Promise.all( prepared.map(async ({ bytes, mimeType, name }) => { - const outputName = `pasted-${randomUUID()}-${name}`; - const path = join(root, outputName); + const path = pastedOutputPath(root, name); await writeFile(path, bytes, { flag: "wx" }); return { path, @@ -122,3 +153,87 @@ export async function saveComposerPasteFiles( }), ); } + +/** + * Copy native-picker selections into the owning session scratch directory. + * The renderer only receives the resulting safe paths; source paths never + * become prompt references, so arbitrary files remain inside the attachment + * roots enforced by the main-process prompt boundary. + */ +export async function importComposerFiles( + dataDir: string, + sessionId: string, + paths: string[], +): Promise { + if (!SAFE_SESSION_ID.test(sessionId)) { + throw new Error("invalid session id"); + } + if (!Array.isArray(paths) || paths.length === 0) return []; + if (paths.length > MAX_FILES) { + throw new Error(`too many imported files (maximum ${MAX_FILES})`); + } + + const prepared: Array<{ + source: string; + name: string; + mimeType: string; + size: number; + }> = []; + let totalBytes = 0; + for (const [index, rawPath] of paths.entries()) { + if (typeof rawPath !== "string" || !rawPath.trim()) { + throw new Error("import file path is invalid"); + } + const requested = rawPath.trim(); + if (!isAbsolute(requested)) { + throw new Error("import file path must be absolute"); + } + let source: string; + try { + source = await realpath(requested); + } catch { + throw new Error("import file was not found"); + } + let size: number; + try { + const info = await stat(source); + if (!info.isFile()) throw new Error("selected path is not a file"); + size = info.size; + } catch (error) { + if (error instanceof Error && error.message === "selected path is not a file") { + throw error; + } + throw new Error("import file could not be read"); + } + if (size > MAX_FILE_BYTES) { + throw new Error(`imported file is too large (maximum ${MAX_FILE_BYTES} bytes)`); + } + totalBytes += size; + if (totalBytes > MAX_TOTAL_BYTES) { + throw new Error(`imported files are too large (maximum ${MAX_TOTAL_BYTES} bytes)`); + } + const mimeType = mimeTypeForPath(source); + prepared.push({ + source, + mimeType, + size, + name: fileNameOf(basename(source), mimeType, index), + }); + } + + const root = scratchPasteRoot(dataDir, sessionId); + await mkdir(root, { recursive: true }); + return Promise.all( + prepared.map(async ({ source, mimeType, size, name }) => { + const path = pastedOutputPath(root, name); + await copyFile(source, path, fsConstants.COPYFILE_EXCL); + return { + path, + name, + kind: isImageFile(name, mimeType) ? "image" : "file", + mimeType, + size, + }; + }), + ); +} diff --git a/apps/desktop/electron/main/index.ts b/apps/desktop/electron/main/index.ts index b25d991d1..5cd186d17 100644 --- a/apps/desktop/electron/main/index.ts +++ b/apps/desktop/electron/main/index.ts @@ -171,7 +171,10 @@ import { resolveRealOpenablePath, } from "./fs-panel"; import { getWorkspaceFileIndex } from "./fs-index"; -import { saveComposerPasteFiles } from "./composer-paste"; +import { + importComposerFiles, + saveComposerPasteFiles, +} from "./composer-paste"; import { builtinComposerCommands, builtinPaletteItems } from "./builtin-commands"; import { convertSession, @@ -6495,6 +6498,40 @@ function registerIpc() { return { paths: result.filePaths, canceled: false }; }); + handle( + IPC.invoke.composerImportFiles, + async (input: { sessionId?: unknown; paths?: unknown } = {}) => { + if (!host) throw new Error("host unavailable"); + const sessionId = + typeof input.sessionId === "string" ? input.sessionId.trim() : ""; + if (!sessionId) { + throw Object.assign(new Error("session required"), { + errorCode: ErrorCodes.INVALID_ARGUMENT, + }); + } + const session = (await host.call("session.get", { id: sessionId })) as { + session?: unknown; + }; + if (!session.session) { + throw Object.assign(new Error("session not found"), { + errorCode: ErrorCodes.NOT_FOUND, + }); + } + if (!Array.isArray(input.paths)) { + throw Object.assign(new Error("paths must be an array"), { + errorCode: ErrorCodes.INVALID_ARGUMENT, + }); + } + return { + files: await importComposerFiles( + dataDir, + sessionId, + input.paths as string[], + ), + }; + }, + ); + handle( IPC.invoke.composerPasteFiles, async (input: { sessionId?: unknown; files?: unknown } = {}) => { diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index f777e13d6..71577e935 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -407,6 +407,11 @@ export const api = { invoke<{ paths: string[]; canceled?: boolean }>(IPC.invoke.composerPickFiles), pickPhotos: () => invoke<{ paths: string[]; canceled?: boolean }>(IPC.invoke.composerPickPhotos), + importFiles: (sessionId: string, paths: string[]) => + invoke<{ files: ComposerPastedFile[] }>(IPC.invoke.composerImportFiles, { + sessionId, + paths, + }), pasteFiles: (sessionId: string, files: ComposerPasteFile[]) => invoke<{ files: ComposerPastedFile[] }>(IPC.invoke.composerPasteFiles, { sessionId, diff --git a/apps/desktop/test/composer-paste-files.test.mjs b/apps/desktop/test/composer-paste-files.test.mjs index 18aefa959..d1cbdfb92 100644 --- a/apps/desktop/test/composer-paste-files.test.mjs +++ b/apps/desktop/test/composer-paste-files.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { basename, join } from "node:path"; import { tmpdir } from "node:os"; import test from "node:test"; @@ -82,10 +82,14 @@ test("chip sentinels stay unique inside the private-use range", () => { test("paste IPC is a typed renderer-to-main bridge", () => { assert.match(protocol, /composerPasteFiles: "pi-desktop\/composer\/pasteFiles"/); + assert.match(protocol, /composerImportFiles: "pi-desktop\/composer\/importFiles"/); assert.match(api, /pasteFiles: \(sessionId: string, files: ComposerPasteFile\[\]\)/); + assert.match(api, /importFiles: \(sessionId: string, paths: string\[\]\)/); assert.match(api, /IPC\.invoke\.composerPasteFiles/); + assert.match(api, /IPC\.invoke\.composerImportFiles/); assert.match(main, /host\.call\("session\.get", \{ id: sessionId \}\)/); assert.match(main, /saveComposerPasteFiles\(dataDir, sessionId, files\)/); + assert.match(main, /importComposerFiles\(\s*dataDir,\s*sessionId,\s*input\.paths/); }); test("pasted bytes stay in the session scratch directory", () => { @@ -97,6 +101,73 @@ test("pasted bytes stay in the session scratch directory", () => { assert.match(saver, /size: bytes\.byteLength/); }); +test("picker imports are copied into the owning session scratch directory", async () => { + const { importComposerFiles } = await import( + "../electron/main/composer-paste.ts" + ); + const dataRoot = await mkdtemp(join(tmpdir(), "pi-composer-import-data-")); + const sourceRoot = await mkdtemp(join(tmpdir(), "pi-composer-import-source-")); + const textPath = join(sourceRoot, "notes with spaces.txt"); + const imagePath = join(sourceRoot, "marker.png"); + const text = "picker marker: FILE-7f4d2"; + const image = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]); + await writeFile(textPath, text, "utf8"); + await writeFile(imagePath, image); + try { + const files = await importComposerFiles(dataRoot, "session-import", [ + textPath, + imagePath, + ]); + + assert.deepEqual(files.map((file) => file.name), [ + "notes_with_spaces.txt", + "marker.png", + ]); + assert.deepEqual(files.map((file) => file.kind), ["file", "image"]); + assert.deepEqual(files.map((file) => file.mimeType), [ + "text/plain", + "image/png", + ]); + assert.notEqual(files[0].path, files[1].path); + assert.match( + files[0].path, + /scratch[\\/]session-import[\\/]pasted[\\/]pasted-.+-notes_with_spaces\.txt$/, + ); + assert.deepEqual( + (await readFile(files[0].path)).toString("utf8"), + text, + ); + assert.deepEqual(Array.from(await readFile(files[1].path)), Array.from(image)); + // The picker source remains untouched; only the session-owned copies are + // handed back to the renderer. + assert.deepEqual((await readFile(textPath)).toString("utf8"), text); + } finally { + await Promise.all([ + rm(dataRoot, { recursive: true, force: true }), + rm(sourceRoot, { recursive: true, force: true }), + ]); + } +}); + +test("picker import rejects directories and non-absolute paths", async () => { + const { importComposerFiles } = await import( + "../electron/main/composer-paste.ts" + ); + const root = await mkdtemp(join(tmpdir(), "pi-composer-import-invalid-")); + try { + await assert.rejects( + importComposerFiles(root, "session-invalid", [root]), + /selected path is not a file/, + ); + await assert.rejects( + importComposerFiles(root, "session-invalid", ["relative.txt"]), + /must be absolute/, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test("large image attachments avoid whole-file startup reads", () => { assert.match(attachments, /async function hashFile\(path: string\)/); assert.match(attachments, /createReadStream\(path\)/); diff --git a/docs/adr/0059-composer-clipboard-files-in-session-scratch.md b/docs/adr/0059-composer-clipboard-files-in-session-scratch.md index 70641ba51..7662a7f5a 100644 --- a/docs/adr/0059-composer-clipboard-files-in-session-scratch.md +++ b/docs/adr/0059-composer-clipboard-files-in-session-scratch.md @@ -8,11 +8,12 @@ ## Context The composer is a controlled textarea. Chromium exposes pasted operating-system -files and screenshots as `File` objects, but the text-only prompt contract has -no binary `ImageContent` channel and a pasted file must remain available to the -agent after the prompt is sent. Writing the bytes into the project would dirty -git state and would not follow the session-bound workspace/scratch ownership -rules. +files and screenshots as `File` objects, while the native picker exposes files +that may live outside the active workspace. The text-only prompt contract has +no binary `ImageContent` channel and an attached file must remain available to +the agent after the prompt is sent. Writing the bytes into the project would +dirty git state and would not follow the session-bound workspace/scratch +ownership rules. ## Decision @@ -21,7 +22,9 @@ rules. 2. The renderer transfers bounded file bytes plus the browser-provided name and MIME type to Electron main through `composer/pasteFiles`, together with the durable session id. A home composer creates or reuses a session before the - transfer. + transfer. Native picker selections use the additive `composer/importFiles` + channel with the same session id; main resolves and copies those source + paths before returning references to the renderer. 3. Electron main validates that the session exists, limits the request to 20 files, 64 MiB per file, and 128 MiB total, strips directory components and unsafe name characters, and writes unique files with exclusive-create @@ -44,6 +47,8 @@ rules. - The renderer cannot select the destination directory; the session id is checked in main and the output root is constructed from the host data dir. + Picker source paths are resolved through `realpath` and must be regular files; + they never become prompt references or destination paths. - Renderer names are reduced to a basename and sanitized. A UUID prefix and exclusive creation prevent collisions and overwrite-by-name. - The bridge is Electron-only. It adds no host RPC method and does not expose @@ -58,9 +63,10 @@ rules. - **Send binary inline with the prompt:** changes the text-only prompt contract, inflates context, and requires provider-specific attachment handling. Rejected. -- **Use an Electron file picker:** does not support screenshots and adds an - extra interaction for the common clipboard workflow. Rejected as the paste - path, though existing picker channels remain independent. +- **Use an Electron file picker for paste:** does not support screenshots and + adds an extra interaction for the common clipboard workflow. Rejected as the + paste path; the separate picker upload action now reuses this scratch + contract for explicitly selected files. ## Consequences diff --git a/docs/spec/03-runtime/01-ipc-protocol.md b/docs/spec/03-runtime/01-ipc-protocol.md index 4973c211b..afeb89862 100644 --- a/docs/spec/03-runtime/01-ipc-protocol.md +++ b/docs/spec/03-runtime/01-ipc-protocol.md @@ -1306,11 +1306,11 @@ reservation, and background artifacts cannot change visible window geometry. ## 13c. Composer input APIs (D123/D124/D197, ADR 0024/0059) -Electron-only channels backing composer autocomplete and clipboard file -references. `composer/commands` and `fs/index` are read-only and fail soft; -`composer/pasteFiles` writes only to the originating session's Electron-owned -scratch directory. None adds a host RPC method or changes the host protocol -version. +Electron-only channels backing composer autocomplete and file references. +`composer/commands` and `fs/index` are read-only and fail soft; +`composer/importFiles` and `composer/pasteFiles` write only to the originating +session's Electron-owned scratch directory. None adds a host RPC method or +changes the host protocol version. ### composer/commands @@ -1348,6 +1348,25 @@ directories derived from file paths, 8000-entry cap with `truncated: true`, short TTL cache per root. Fails closed to an empty list without a workspace. Fuzzy filtering happens renderer-side. +### composer/importFiles + +```ts +composer/importFiles({ sessionId, paths }) -> { + files: ComposerPastedFile[]; +} +``` + +The native file picker returns absolute paths to the renderer only as a +short-lived handoff. The renderer immediately sends those paths with the +durable `sessionId`; Electron main resolves each path through `realpath`, +requires an existing regular file, applies the same 20-file / 64 MiB per file / +128 MiB total limits as clipboard transfer, and copies the bytes into +`/scratch//pasted/` under a UUID-backed sanitized name. +Directories, relative paths, missing files, and oversized selections fail with +an IPC error. The returned `ComposerPastedFile` records are the only paths the +renderer stores or dispatches, so a picker selection cannot leave an external +absolute path in the prompt or bypass the attachment-root boundary. + ### composer/pasteFiles ```ts diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index 3c7ec0cb4..c1e0d860a 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -4686,6 +4686,33 @@ Each scenario is documented in this format: - **Status**: Unit-covered (`apps/desktop/test/composer-paste-files.test.mjs`); full UI journey Draft (do not run E2E locally unless explicitly requested) +#### E2E-102h: Composer picker imports files into session scratch + +- **Preconditions**: The app is running with a home or Agent composer and a + durable session. The native picker can select a text file and an image outside + the active workspace. +- **Steps**: 1) Open the Composer `+` menu and choose the file action. 2) Select + both fixtures. 3) Inspect the draft chips and send a prompt asking the agent + to read the text fixture and identify the image marker. 4) Inspect the + renderer request, session transcript, and the session scratch directory. +- **Expected**: The picker selections are copied into + `/scratch//pasted/` before they enter the draft. Chips + show sanitized leaf names while prompt attachments reference only the copied + paths; the workspace is unchanged. The agent can call `Read` on the text + fixture, and a vision-capable model receives the image as an image block. + Durable messages retain metadata and refs only, never the source absolute + path or binary bytes. Selecting a directory, a missing path, or an oversized + file returns a visible IPC error and writes nothing. +- **Specs linked**: `03-runtime/01-ipc-protocol.md` §13c, + `03-runtime/04-data-storage.md`, `04-ux/08-component-spec.md` §11.7–11.8, + ADR 0059, ADR 0101 +- **Acceptance**: B (model config), C (conversation & stream), E (tools & + permissions), F (persistence), Security, Quality +- **Milestone**: M5 +- **Status**: Unit-covered (`apps/desktop/test/composer-paste-files.test.mjs`); + provider/UI journey Draft (do not run E2E locally unless explicitly + requested) + #### E2E-102a: Composer file reference results use compact leaf names - **Preconditions**: The app is running with an Agent session in a workspace diff --git a/packages/shared/src/protocol.ts b/packages/shared/src/protocol.ts index 54137551c..f4114b037 100644 --- a/packages/shared/src/protocol.ts +++ b/packages/shared/src/protocol.ts @@ -183,6 +183,7 @@ export const IPC = { devtoolsToggle: "pi-desktop/devtools/toggle", composerPickFiles: "pi-desktop/composer/pickFiles", composerPickPhotos: "pi-desktop/composer/pickPhotos", + composerImportFiles: "pi-desktop/composer/importFiles", composerPasteFiles: "pi-desktop/composer/pasteFiles", composerCommands: "pi-desktop/composer/commands", workspaceDiff: "pi-desktop/workspace/diff", From 5a6405402e0a307abc8c21a68475deee1e3811eb Mon Sep 17 00:00:00 2001 From: yg2224 <2677406151@qq.com> Date: Mon, 7 Sep 2026 19:01:23 +0800 Subject: [PATCH 3/8] feat(desktop): wire composer picker attachments --- apps/desktop/src/components/Composer.tsx | 137 ++++++++++++++++++ apps/desktop/src/styles/composer-menus.css | 14 ++ .../test/composer-paste-files.test.mjs | 16 ++ docs/spec/04-ux/08-component-spec.md | 10 ++ 4 files changed, 177 insertions(+) diff --git a/apps/desktop/src/components/Composer.tsx b/apps/desktop/src/components/Composer.tsx index c6de8c725..e01d74dea 100644 --- a/apps/desktop/src/components/Composer.tsx +++ b/apps/desktop/src/components/Composer.tsx @@ -62,6 +62,9 @@ import { PlanApprovalBar } from "./PlanApprovalBar"; import { IconArrowUp, IconUndo2, + IconPlus, + IconFolder, + IconImage, IconShield, IconStop, IconChevronDown, @@ -654,6 +657,8 @@ export function Composer({ ); const [permissionOpen, setPermissionOpen] = useState(false); const permissionRef = useRef(null); + const [plusOpen, setPlusOpen] = useState(false); + const plusRef = useRef(null); const [modelThinkingOpen, setModelThinkingOpen] = useState(false); const [modelThinkingView, setModelThinkingView] = useState("root"); @@ -934,10 +939,27 @@ export function Composer({ useEffect(() => { if (!controlsBlocked) return; + setPlusOpen(false); setPermissionOpen(false); setModelThinkingOpen(false); }, [controlsBlocked]); + useEffect(() => { + if (!plusOpen) return; + const onPointer = (e: MouseEvent) => { + if (!plusRef.current?.contains(e.target as Node)) setPlusOpen(false); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setPlusOpen(false); + }; + window.addEventListener("mousedown", onPointer); + window.addEventListener("keydown", onKey); + return () => { + window.removeEventListener("mousedown", onPointer); + window.removeEventListener("keydown", onKey); + }; + }, [plusOpen]); + useEffect(() => { // Relative autocomplete references belong to the workspace that produced // them. Session scratch references remain valid across project switches. @@ -1706,6 +1728,82 @@ export function Composer({ ...(token ? { token } : {}), })); + const pickAndAttach = async (kind: "files" | "photos") => { + setPlusOpen(false); + try { + const result = kind === "photos" ? await api.pickPhotos() : await api.pickFiles(); + if (result.canceled || !result.paths.length || inputBlocked) return; + + const editor = ref.current; + const sourceValue = editor ? readEditorValue(editor) : valueRef.current; + const { start: selectionStart, end: selectionEnd } = editor + ? editorSelectionRange(editor) + : { start: sourceValue.length, end: sourceValue.length }; + const sourceSessionId = activeSessionId; + const sourceDraftKey = draftKey; + const previousReferences = snapshotReferences(sourceSessionId ?? ""); + setPasting(true); + try { + // A picker action is real input, so a home draft gets a durable owner + // before native paths are copied into scratch. + const sessionId = sourceSessionId ?? (await materializeDraftSession()); + if (!sessionId) throw new Error("session unavailable"); + const imported = await api.importFiles(sessionId, result.paths); + const chips = imported.files.map((file) => { + const token = nextChipToken(); + return { + token, + reference: createFileReference(file.path, file.name, sessionId, { + kind: file.kind, + mimeType: file.mimeType, + token, + }), + }; + }); + if (!chips.length) return; + const inserted = chips.map((chip) => chip.token).join(""); + const nextText = + sourceValue.slice(0, selectionStart) + + inserted + + sourceValue.slice(selectionEnd); + const nextReferences = [ + ...previousReferences.map((reference) => + createFileReference(reference.path, reference.name, sessionId, reference), + ), + ...chips.map((chip) => chip.reference), + ]; + writeComposerDraft(sessionId, { + text: nextText, + fileReferences: [ + ...previousReferences, + ...chips.map((chip) => ({ + path: chip.reference.path, + name: chip.reference.name, + kind: chip.reference.kind, + ...(chip.reference.mimeType + ? { mimeType: chip.reference.mimeType } + : {}), + token: chip.token, + })), + ], + }); + const currentSessionId = useAppStore.getState().activeSessionId; + if (currentSessionId === sessionId) { + applyEditorDraft(nextText, nextReferences, selectionStart + inserted.length); + } else if (sourceDraftKey === HOME_DRAFT_KEY) { + deleteComposerDraft(HOME_DRAFT_KEY); + } + showToast(t("chat.filesAttached", { count: chips.length }), { + variant: "success", + }); + } finally { + setPasting(false); + } + } catch (e) { + showToast(e instanceof Error ? e.message : String(e), { variant: "error" }); + } + }; + const pasteClipboardFiles = async (event: ClipboardEvent) => { if (inputBlocked) return; const files = clipboardFiles(event.clipboardData); @@ -2139,6 +2237,45 @@ export function Composer({ +
+ + {plusOpen ? ( +
+ + +
+ ) : null} +
{mode === "agent" || mode === "plan" || mode === "goal" ? (
+ {mode === "agent" || mode === "plan" || mode === "goal" ? (