From 5306b273f830435ae9c6a975c849c9f156410a2b Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:53:30 -0400 Subject: [PATCH 1/6] fix: make prompt stashes viewport-safe and preserve images --- .../main/services/adeActions/registry.test.ts | 30 ++ .../src/main/services/adeActions/registry.ts | 13 +- .../services/chat/agentChatService.test.ts | 25 ++ .../main/services/chat/agentChatService.ts | 15 +- .../services/chat/promptStashService.test.ts | 47 ++- .../main/services/chat/promptStashService.ts | 139 +++++++- apps/desktop/src/main/services/state/kvDb.ts | 4 + .../components/chat/AgentChatComposer.tsx | 4 + .../chat/ComposerPromptStash.test.tsx | 316 ++++++++++++++++- .../components/chat/ComposerPromptStash.tsx | 318 ++++++++++++++++-- apps/desktop/src/shared/types/chat.ts | 8 + apps/ios/ADE/Resources/DatabaseBootstrap.sql | 5 + apps/ios/ADETests/ADETests.swift | 68 +++- docs/features/chat/README.md | 2 +- docs/features/chat/composer-and-ui.md | 4 +- 15 files changed, 943 insertions(+), 55 deletions(-) diff --git a/apps/desktop/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index 1ea1afd0b..3f0b157dc 100644 --- a/apps/desktop/src/main/services/adeActions/registry.test.ts +++ b/apps/desktop/src/main/services/adeActions/registry.test.ts @@ -384,6 +384,36 @@ describe("ADE_ACTION_ALLOWLIST shape", () => { expect(isCtoOnlyAdeAction("chat", "deletePromptStash")).toBe(true); }); + it("preserves prompt stash images through the runtime-backed action path", async () => { + const run = vi.fn(); + const runtime = { + agentChatService: {}, + db: { + get: vi.fn().mockReturnValue(undefined), + run, + }, + } as unknown as Parameters[0]; + const chat = getAdeActionDomainServices(runtime).chat as { + createPromptStash?: (args: unknown) => Promise | unknown; + }; + const attachment = { + path: "/project/.ade/attachments/design.png", + type: "image", + }; + + await expect(Promise.resolve(chat.createPromptStash?.({ + text: "", + attachments: [attachment], + }))).resolves.toMatchObject({ + text: "", + attachments: [attachment], + }); + expect(run).toHaveBeenCalledWith( + expect.stringContaining("insert into prompt_stashes"), + expect.arrayContaining([JSON.stringify([attachment])]), + ); + }); + it("exposes Linear issue tracker composite reads for runtime-backed CTO views", () => { const actions = ADE_ACTION_ALLOWLIST.linear_issue_tracker ?? []; expect(actions).toContain("getWorkflowCatalog"); diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index c0e365666..e424311af 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -1575,15 +1575,10 @@ function buildChatDomainService(runtime: AdeRuntime): OpaqueService | null { listPromptStashes: () => listPromptStashes(runtime.db), createPromptStash: (args?: PromptStashCreateArgs) => { const record = readObjectActionArg(args, "chat.createPromptStash"); - const text = typeof record.text === "string" ? record.text : ""; - if (!text.trim()) { - throw new Error("Expected 'text' to be a non-empty string."); - } - return createPromptStash(runtime.db, { - text, - provider: typeof record.provider === "string" ? record.provider : null, - modelId: typeof record.modelId === "string" ? record.modelId : null, - }); + // The service owns validation for both text and attachment-only stashes. + // Keeping the full object intact is essential on the daemon path: this is + // the path every runtime-backed desktop uses. + return createPromptStash(runtime.db, record); }, deletePromptStash: (args?: PromptStashDeleteArgs) => { const record = readObjectActionArg(args, "chat.deletePromptStash"); diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 7f3f26110..e5952b165 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -17122,6 +17122,31 @@ describe("createAgentChatService", () => { expect(fs.existsSync(oldFile)).toBe(false); expect(fs.existsSync(recentFile)).toBe(true); }); + + it("preserves old image files still referenced by a prompt stash", () => { + const attachDir = path.join(tmpRoot, ".ade", "attachments"); + fs.mkdirSync(attachDir, { recursive: true }); + const stashedImage = path.join(attachDir, "stashed-image.png"); + fs.writeFileSync(stashedImage, "image data"); + const eightDaysAgo = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000); + fs.utimesSync(stashedImage, eightDaysAgo, eightDaysAgo); + + const { service } = createService({ + db: { + getJson: vi.fn(), + setJson: vi.fn(), + get: vi.fn().mockReturnValue({ site_id: "site-a" }), + all: vi.fn().mockReturnValue([{ + attachments_json: JSON.stringify([{ path: stashedImage, type: "image" }]), + attachment_origin_site_id: "site-a", + }]), + }, + }); + + service.cleanupStaleAttachments(); + + expect(fs.existsSync(stashedImage)).toBe(true); + }); }); // -------------------------------------------------------------------------- diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 01b4986e0..eaa734946 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -47,6 +47,7 @@ import type { } from "@anthropic-ai/claude-agent-sdk"; import { z, type ZodType } from "zod"; import { buildClaudeV2MessageAsync, inferAttachmentMediaType } from "./buildClaudeV2Message"; +import { listPromptStashAttachmentPaths } from "./promptStashService"; import { ClaudeInputPump } from "./claudeInputPump"; import { createClaudeStructuredActivityState, @@ -6576,7 +6577,10 @@ export function createAgentChatService(args: { sessionService: ReturnType; processRegistry?: ProcessRegistryService | null; projectConfigService: ReturnType; - db?: Pick | null; + db?: ( + Pick + & Partial> + ) | null; aiIntegrationService: ReturnType; logger: Logger; appVersion: string; @@ -42313,12 +42317,21 @@ export function createAgentChatService(args: { try { const projectRoot = args.projectRoot; if (!projectRoot) return; + const promptStashDb = args.db; + const protectedAttachmentPaths = promptStashDb + && typeof promptStashDb.get === "function" + && typeof promptStashDb.all === "function" + ? new Set(Array.from(listPromptStashAttachmentPaths( + promptStashDb as Pick & Partial>, + ), (filePath) => path.resolve(filePath))) + : new Set(); const cleanupDir = (dirPath: string) => { if (!fs.existsSync(dirPath)) return; const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000; for (const entry of fs.readdirSync(dirPath)) { try { const filePath = path.join(dirPath, entry); + if (protectedAttachmentPaths.has(path.resolve(filePath))) continue; const stat = fs.statSync(filePath); if (stat.mtimeMs < cutoff) { fs.rmSync(filePath, { recursive: true, force: true }); diff --git a/apps/desktop/src/main/services/chat/promptStashService.test.ts b/apps/desktop/src/main/services/chat/promptStashService.test.ts index 02ed03a57..a715982ab 100644 --- a/apps/desktop/src/main/services/chat/promptStashService.test.ts +++ b/apps/desktop/src/main/services/chat/promptStashService.test.ts @@ -6,8 +6,10 @@ import { openKvDb, type AdeDb } from "../state/kvDb"; import { createPromptStash, deletePromptStash, + listPromptStashAttachmentPaths, listPromptStashes, MAX_PROMPT_STASHES, + MAX_PROMPT_STASH_ATTACHMENTS, MAX_PROMPT_STASH_TEXT_CHARS, } from "./promptStashService"; @@ -50,11 +52,54 @@ describe("promptStashService", () => { expect(listPromptStashes(db)).toEqual([created]); }); - it("rejects empty and excessively large prompts", () => { + it("persists runtime-owned attachments for connected desktops", () => { + const attachments = [ + { path: "/project/.ade/attachments/design.png", type: "image" as const }, + { path: "https://example.com/reference.png", type: "image-url" as const, url: "https://example.com/reference.png" }, + ]; + + const created = createPromptStash(db, { text: "", attachments }); + + expect(created.attachments).toEqual(attachments); + expect(listPromptStashes(db)).toEqual([created]); + expect(listPromptStashAttachmentPaths(db)).toEqual(new Set([attachments[0]!.path])); + }); + + it("does not expose or consume machine-bound image paths on another synced runtime", () => { + const image = { path: "/source/.ade/attachments/design.png", type: "image" as const }; + const created = createPromptStash(db, { text: "Use this design", attachments: [image] }); + db.run( + "update prompt_stashes set attachment_origin_site_id = ? where id = ?", + ["different-runtime", created.id], + ); + + expect(listPromptStashes(db)).toEqual([ + expect.objectContaining({ + id: created.id, + attachments: [], + attachmentCount: 1, + attachmentsAvailable: false, + }), + ]); + expect(listPromptStashAttachmentPaths(db)).toEqual(new Set()); + }); + + it("rejects empty, excessively large, and malformed stashes", () => { expect(() => createPromptStash(db, { text: " \n\t " })).toThrow("cannot be empty"); expect(() => createPromptStash(db, { text: "x".repeat(MAX_PROMPT_STASH_TEXT_CHARS + 1), })).toThrow("too large"); + expect(() => createPromptStash(db, { + text: "bad attachment", + attachments: [{ path: "javascript:alert(1)", type: "image-url", url: "javascript:alert(1)" }], + })).toThrow("image URL is invalid"); + expect(() => createPromptStash(db, { + text: "too many", + attachments: Array.from({ length: MAX_PROMPT_STASH_ATTACHMENTS + 1 }, (_, index) => ({ + path: `/project/image-${index}.png`, + type: "image" as const, + })), + })).toThrow("at most"); expect(listPromptStashes(db)).toEqual([]); }); diff --git a/apps/desktop/src/main/services/chat/promptStashService.ts b/apps/desktop/src/main/services/chat/promptStashService.ts index 2c87093a3..6f5c71b49 100644 --- a/apps/desktop/src/main/services/chat/promptStashService.ts +++ b/apps/desktop/src/main/services/chat/promptStashService.ts @@ -1,17 +1,22 @@ import { randomUUID } from "node:crypto"; import { + MAX_PROMPT_STASH_ATTACHMENTS, MAX_PROMPT_STASHES, - type PromptStashCreateArgs, + type AgentChatFileRef, type PromptStashEntry, } from "../../../shared/types/chat"; import type { AdeDb } from "../state/kvDb"; export { MAX_PROMPT_STASHES }; export const MAX_PROMPT_STASH_TEXT_CHARS = 200_000; +export { MAX_PROMPT_STASH_ATTACHMENTS }; +export const MAX_PROMPT_STASH_ATTACHMENT_PATH_CHARS = 8_192; type PromptStashRow = { id: string; text: string; + attachments_json: string; + attachment_origin_site_id: string | null; provider: string | null; model_id: string | null; created_at: string; @@ -23,10 +28,86 @@ function optionalString(value: unknown): string | null { return trimmed || null; } -function fromRow(row: PromptStashRow): PromptStashEntry { +function objectRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : {}; +} + +function normalizeAttachments(value: unknown): AgentChatFileRef[] { + if (!Array.isArray(value)) return []; + if (value.length > MAX_PROMPT_STASH_ATTACHMENTS) { + throw new Error(`A prompt stash can include at most ${MAX_PROMPT_STASH_ATTACHMENTS} attachments.`); + } + return value.map((candidate) => { + if (!candidate || typeof candidate !== "object") { + throw new Error("Prompt stash attachments are invalid."); + } + const attachment = candidate as Partial; + const path = typeof attachment.path === "string" ? attachment.path.trim() : ""; + if (!path || path.length > MAX_PROMPT_STASH_ATTACHMENT_PATH_CHARS) { + throw new Error("Prompt stash attachment path is invalid."); + } + if (attachment.type === "image-url") { + const url = typeof attachment.url === "string" ? attachment.url.trim() : ""; + if (!url || url !== path || url.length > MAX_PROMPT_STASH_ATTACHMENT_PATH_CHARS) { + throw new Error("Prompt stash image URL is invalid."); + } + let protocol: string; + try { + protocol = new URL(url).protocol; + } catch { + throw new Error("Prompt stash image URL is invalid."); + } + if (protocol !== "https:" && protocol !== "http:") { + throw new Error("Prompt stash image URL is invalid."); + } + return { path: url, type: "image-url", url }; + } + if (attachment.type !== "image") { + throw new Error("Prompt stash attachment type is invalid."); + } + return { path, type: "image" }; + }); +} + +function parseAttachments(json: string): AgentChatFileRef[] { + try { + return normalizeAttachments(JSON.parse(json)); + } catch { + return []; + } +} + +type PromptStashSiteDb = Pick & Partial>; + +function currentSiteId(db: PromptStashSiteDb): string | null { + try { + const siteId = db.sync?.getSiteId().trim().toLowerCase(); + if (siteId) return siteId; + } catch { + // Fall through to the SQL read for narrow test/runtime adapters. + } + try { + return db.get<{ site_id: string }>( + "select lower(hex(crsql_site_id())) as site_id", + )?.site_id ?? null; + } catch { + return null; + } +} + +function fromRow(row: PromptStashRow, localSiteId: string | null): PromptStashEntry { + const storedAttachments = parseAttachments(row.attachments_json); + const hasMachineBoundImages = storedAttachments.some((attachment) => attachment.type === "image"); + const attachmentsAvailable = !hasMachineBoundImages + || Boolean(localSiteId && row.attachment_origin_site_id === localSiteId); return { id: row.id, text: row.text, + attachments: attachmentsAvailable ? storedAttachments : [], + attachmentCount: storedAttachments.length, + attachmentsAvailable, provider: row.provider, modelId: row.model_id, createdAt: row.created_at, @@ -52,21 +133,46 @@ export function listPromptStashes( const safeLimit = Math.max(1, Math.min(MAX_PROMPT_STASHES, normalizedLimit)); return db.all( ` - select id, text, provider, model_id, created_at + select id, text, attachments_json, attachment_origin_site_id, provider, model_id, created_at from prompt_stashes order by created_at desc, id desc limit ? `, [safeLimit], - ).map(fromRow); + ).map((row) => fromRow(row, currentSiteId(db))); +} + +export function listPromptStashAttachmentPaths( + db: Pick & Partial>, +): Set { + const localSiteId = currentSiteId(db); + if (!localSiteId) return new Set(); + const rows = db.all>( + ` + select attachments_json, attachment_origin_site_id + from prompt_stashes + where attachment_origin_site_id = ? + `, + [localSiteId], + ); + return new Set(rows.flatMap((row) => ( + parseAttachments(row.attachments_json) + .filter((attachment) => attachment.type === "image") + .map((attachment) => attachment.path) + ))); } export function createPromptStash( db: AdeDb, - args: PromptStashCreateArgs, + value: unknown, ): PromptStashEntry { - const text = typeof args?.text === "string" ? args.text : ""; - if (!text.trim()) { + const args = objectRecord(value); + const text = typeof args.text === "string" ? args.text : ""; + const attachments = normalizeAttachments(args.attachments); + const attachmentOriginSiteId = attachments.some((attachment) => attachment.type === "image") + ? currentSiteId(db) + : null; + if (!text.trim() && attachments.length === 0) { throw new Error("A prompt stash cannot be empty."); } if (text.length > MAX_PROMPT_STASH_TEXT_CHARS) { @@ -76,16 +182,29 @@ export function createPromptStash( const entry: PromptStashEntry = { id: randomUUID(), text, + attachments, + attachmentCount: attachments.length, + attachmentsAvailable: true, provider: optionalString(args.provider), modelId: optionalString(args.modelId), createdAt: nextCreatedAt(db), }; db.run( ` - insert into prompt_stashes(id, text, provider, model_id, created_at) - values (?, ?, ?, ?, ?) + insert into prompt_stashes( + id, text, attachments_json, attachment_origin_site_id, provider, model_id, created_at + ) + values (?, ?, ?, ?, ?, ?, ?) `, - [entry.id, entry.text, entry.provider, entry.modelId, entry.createdAt], + [ + entry.id, + entry.text, + JSON.stringify(entry.attachments), + attachmentOriginSiteId, + entry.provider, + entry.modelId, + entry.createdAt, + ], ); db.run( diff --git a/apps/desktop/src/main/services/state/kvDb.ts b/apps/desktop/src/main/services/state/kvDb.ts index c7756f7ec..c0aea2acc 100644 --- a/apps/desktop/src/main/services/state/kvDb.ts +++ b/apps/desktop/src/main/services/state/kvDb.ts @@ -2889,11 +2889,15 @@ function migrate(db: MigrationDb, rawDb: DatabaseSyncType) { create table if not exists prompt_stashes ( id text primary key, text text not null, + attachments_json text not null default '[]', + attachment_origin_site_id text, provider text, model_id text, created_at text not null ) `); + safeAddColumn(db, "alter table prompt_stashes add column attachments_json text not null default '[]'"); + safeAddColumn(db, "alter table prompt_stashes add column attachment_origin_site_id text"); db.run("create index if not exists idx_prompt_stashes_created on prompt_stashes(created_at)"); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index 7e5feb2d3..646676a68 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -4644,12 +4644,16 @@ export function AgentChatComposer({ 0} onDraftChange={onDraftChange} + onAddAttachment={onAddAttachment} + onRemoveAttachment={handleRemoveAttachment} /> {!parallelChatMode && usageViewModel ? ( diff --git a/apps/desktop/src/renderer/components/chat/ComposerPromptStash.test.tsx b/apps/desktop/src/renderer/components/chat/ComposerPromptStash.test.tsx index 699fcc363..34c500fd2 100644 --- a/apps/desktop/src/renderer/components/chat/ComposerPromptStash.test.tsx +++ b/apps/desktop/src/renderer/components/chat/ComposerPromptStash.test.tsx @@ -1,14 +1,38 @@ /* @vitest-environment jsdom */ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { createRef } from "react"; +import { createRef, forwardRef, type ComponentProps } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { PromptStashEntry } from "../../../shared/types"; import { - ComposerPromptStash, + ComposerPromptStash as ProductionComposerPromptStash, type ComposerPromptStashHandle, } from "./ComposerPromptStash"; +const noopAddAttachment = () => {}; +const noopRemoveAttachment = () => {}; +type ProductionPromptStashProps = ComponentProps; +type TestPromptStashProps = Omit & { + onAddAttachment?: ProductionPromptStashProps["onAddAttachment"]; + onRemoveAttachment?: ProductionPromptStashProps["onRemoveAttachment"]; +}; +const ComposerPromptStash = forwardRef( + function TestComposerPromptStash({ + onAddAttachment = noopAddAttachment, + onRemoveAttachment = noopRemoveAttachment, + ...props + }, ref) { + return ( + + ); + }, +); + const savedEntry: PromptStashEntry = { id: "stash-1", text: "Fix the parser", @@ -21,6 +45,8 @@ function installBridge(overrides?: { list?: ReturnType; create?: ReturnType; delete?: ReturnType; + getImageDataUrl?: ReturnType; + saveTempAttachment?: ReturnType; }) { const promptStashes = { list: overrides?.list ?? vi.fn().mockResolvedValue([]), @@ -28,7 +54,15 @@ function installBridge(overrides?: { delete: overrides?.delete ?? vi.fn().mockResolvedValue(true), }; (window as unknown as { ade: unknown }).ade = { - agentChat: { promptStashes }, + agentChat: { + promptStashes, + getImageDataUrl: overrides?.getImageDataUrl ?? vi.fn().mockResolvedValue({ + dataUrl: "data:image/png;base64,cHJldmlldw==", + }), + saveTempAttachment: overrides?.saveTempAttachment ?? vi.fn().mockResolvedValue({ + path: "/project/.ade/attachments/stashed-design.png", + }), + }, }; return promptStashes; } @@ -46,6 +80,39 @@ afterEach(() => { }); describe("ComposerPromptStash", () => { + it("stays out of the toolbar when the composer and stash list are both empty", async () => { + const bridge = installBridge(); + render( + , + ); + + await waitFor(() => expect(bridge.list).toHaveBeenCalled()); + expect(screen.queryByRole("button", { name: /stashed prompt/i })).toBeNull(); + expect(screen.queryByRole("button", { name: "Stash prompt" })).toBeNull(); + }); + + it("honors the appearance toggle even when shared stashes exist", async () => { + const bridge = installBridge({ list: vi.fn().mockResolvedValue([savedEntry]) }); + render( + , + ); + + await waitFor(() => expect(bridge.list).toHaveBeenCalled()); + expect(screen.queryByRole("button", { name: "Open 1 stashed prompt" })).toBeNull(); + }); + it("clears only after the runtime durably saves the prompt", async () => { const create = vi.fn().mockResolvedValue(savedEntry); const bridge = installBridge({ create }); @@ -119,6 +186,197 @@ describe("ComposerPromptStash", () => { expect(screen.queryByText("Stashed prompts")).toBeNull(); }); + it("moves image attachments into a stash and restores their thumbnail and attachment", async () => { + const imageAttachment = { + path: "/Users/me/Desktop/design.png", + type: "image" as const, + }; + const storedImageAttachment = { + path: "/project/.ade/attachments/stashed-design.png", + type: "image" as const, + }; + const imageEntry: PromptStashEntry = { + ...savedEntry, + text: "Use this design", + attachments: [storedImageAttachment], + }; + const create = vi.fn().mockResolvedValue(imageEntry); + const saveTempAttachment = vi.fn().mockResolvedValue({ + path: storedImageAttachment.path, + }); + installBridge({ create, saveTempAttachment }); + const onDraftChange = vi.fn(); + const onRemoveAttachment = vi.fn(); + const saveView = render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Stash prompt" })); + await waitFor(() => expect(create).toHaveBeenCalledWith({ + text: "Use this design", + attachments: [storedImageAttachment], + provider: undefined, + modelId: undefined, + })); + expect(saveTempAttachment).toHaveBeenCalledWith({ + data: "cHJldmlldw==", + filename: "design.png", + }); + expect(onDraftChange).toHaveBeenCalledWith(""); + expect(onRemoveAttachment).toHaveBeenCalledWith(imageAttachment.path); + saveView.unmount(); + + const onAddAttachment = vi.fn(); + const remove = vi.fn().mockResolvedValue(true); + const getImageDataUrl = vi.fn().mockResolvedValue({ + dataUrl: "data:image/png;base64,cHJldmlldw==", + }); + installBridge({ + list: vi.fn().mockResolvedValue([imageEntry]), + delete: remove, + getImageDataUrl, + }); + render( + , + ); + + fireEvent.click(await screen.findByRole("button", { name: "Open 1 stashed prompt" })); + await waitFor(() => expect(getImageDataUrl).toHaveBeenCalledWith(storedImageAttachment.path)); + expect(document.querySelector("[data-prompt-stash-menu] img")?.getAttribute("src")) + .toBe("data:image/png;base64,cHJldmlldw=="); + fireEvent.click(screen.getByRole("button", { name: /Use this design/i })); + expect(onAddAttachment).toHaveBeenCalledWith(storedImageAttachment); + await waitFor(() => expect(remove).toHaveBeenCalledWith({ id: imageEntry.id })); + }); + + it("keeps the original image when an older runtime cannot confirm attachment persistence", async () => { + const imageAttachment = { + path: "/Users/me/Desktop/design.png", + type: "image" as const, + }; + const create = vi.fn().mockResolvedValue(savedEntry); + const bridge = installBridge({ create }); + const onDraftChange = vi.fn(); + const onRemoveAttachment = vi.fn(); + const view = render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Stash prompt" })); + await waitFor(() => expect(view.container.querySelector(".animate-spin")).toBeNull()); + expect(create).toHaveBeenCalledTimes(1); + expect(bridge.delete).toHaveBeenCalledWith({ id: savedEntry.id }); + expect(onDraftChange).not.toHaveBeenCalled(); + expect(onRemoveAttachment).not.toHaveBeenCalled(); + }); + + it("rejects too many images before creating runtime copies", async () => { + const create = vi.fn(); + const getImageDataUrl = vi.fn(); + const saveTempAttachment = vi.fn(); + installBridge({ create, getImageDataUrl, saveTempAttachment }); + render( + ({ + path: `/Users/me/Desktop/image-${index}.png`, + type: "image" as const, + }))} + active + buttonVisible + shortcutLabel="⌘+S" + onDraftChange={vi.fn()} + />, + ); + + fireEvent.click(screen.getByRole("button", { name: "Stash prompt" })); + + expect((await screen.findByRole("alert")).textContent).toContain("up to 10 images"); + expect(create).not.toHaveBeenCalled(); + expect(getImageDataUrl).not.toHaveBeenCalled(); + expect(saveTempAttachment).not.toHaveBeenCalled(); + }); + + it("keeps a machine-bound image stash intact when viewed from another synced runtime", async () => { + const unavailableEntry: PromptStashEntry = { + ...savedEntry, + text: "", + attachments: [], + attachmentCount: 1, + attachmentsAvailable: false, + }; + const remove = vi.fn().mockResolvedValue(true); + installBridge({ + list: vi.fn().mockResolvedValue([unavailableEntry]), + delete: remove, + }); + const onDraftChange = vi.fn(); + const onAddAttachment = vi.fn(); + render( + , + ); + + fireEvent.click(await screen.findByRole("button", { name: "Open 1 stashed prompt" })); + expect(screen.getByText("1 stashed image")).toBeTruthy(); + expect(screen.getByText("1 image on another machine")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: /1 stashed image/i })); + + expect((await screen.findByRole("alert")).textContent).toContain("machine where this prompt was stashed"); + expect(remove).not.toHaveBeenCalled(); + expect(onDraftChange).not.toHaveBeenCalled(); + expect(onAddAttachment).not.toHaveBeenCalled(); + }); + + it("renders the menu in a body portal so composer overflow cannot clip it", async () => { + installBridge({ list: vi.fn().mockResolvedValue([savedEntry]) }); + render( +
+ +
, + ); + + fireEvent.click(await screen.findByRole("button", { name: "Open 1 stashed prompt" })); + const menu = screen.getByRole("dialog", { name: "Stashed prompts" }); + expect(menu.parentElement).toBe(document.body); + expect(menu.className).toContain("fixed"); + }); + it("closes the stash menu without consuming an entry when the user starts a new draft", async () => { const remove = vi.fn().mockResolvedValue(true); installBridge({ @@ -203,7 +461,7 @@ describe("ComposerPromptStash", () => { ref.current?.activate(); ref.current?.activate(); - expect(create).toHaveBeenCalledTimes(1); + await waitFor(() => expect(create).toHaveBeenCalledTimes(1)); resolveCreate?.(savedEntry); await waitFor(() => expect(onDraftChange).toHaveBeenCalledWith("")); }); @@ -238,6 +496,7 @@ describe("ComposerPromptStash", () => { onDraftChange={onDraftChange} />, ); + await waitFor(() => expect(create).toHaveBeenCalledTimes(1)); resolveCreate?.(savedEntry); await waitFor(() => expect(view.container.querySelector(".animate-spin")).toBeNull()); @@ -245,6 +504,55 @@ describe("ComposerPromptStash", () => { expect(onDraftChange).not.toHaveBeenCalled(); }); + it("does not clear text or images when attachments change during a remote save", async () => { + const originalImage = { path: "/Users/me/Desktop/original.png", type: "image" as const }; + const newerImage = { path: "/Users/me/Desktop/newer.png", type: "image" as const }; + const storedImage = { path: "/project/.ade/attachments/original.png", type: "image" as const }; + let resolveCreate: ((entry: PromptStashEntry) => void) | undefined; + const create = vi.fn().mockImplementation(() => new Promise((resolve) => { + resolveCreate = resolve; + })); + installBridge({ + create, + saveTempAttachment: vi.fn().mockResolvedValue({ path: storedImage.path }), + }); + const onDraftChange = vi.fn(); + const onRemoveAttachment = vi.fn(); + const ref = createRef(); + const view = render( + , + ); + + ref.current?.activate(); + await waitFor(() => expect(create).toHaveBeenCalledTimes(1)); + view.rerender( + , + ); + resolveCreate?.({ ...savedEntry, attachments: [storedImage] }); + + await waitFor(() => expect(view.container.querySelector(".animate-spin")).toBeNull()); + expect(onDraftChange).not.toHaveBeenCalled(); + expect(onRemoveAttachment).not.toHaveBeenCalled(); + }); + it("never overwrites edits made while a restored stash is being consumed remotely", async () => { let resolveDelete: ((deleted: boolean) => void) | undefined; const remove = vi.fn().mockImplementation(() => new Promise((resolve) => { diff --git a/apps/desktop/src/renderer/components/chat/ComposerPromptStash.tsx b/apps/desktop/src/renderer/components/chat/ComposerPromptStash.tsx index d54c1975b..e65778178 100644 --- a/apps/desktop/src/renderer/components/chat/ComposerPromptStash.tsx +++ b/apps/desktop/src/renderer/components/chat/ComposerPromptStash.tsx @@ -1,6 +1,8 @@ import { BookmarkSimple, Check, + File, + Image, SpinnerGap, Trash, } from "@phosphor-icons/react"; @@ -9,11 +11,15 @@ import React, { useCallback, useEffect, useImperativeHandle, + useLayoutEffect, useMemo, useRef, useState, } from "react"; +import { createPortal } from "react-dom"; import { + type AgentChatFileRef, + MAX_PROMPT_STASH_ATTACHMENTS, MAX_PROMPT_STASHES, type PromptStashEntry, } from "../../../shared/types"; @@ -21,6 +27,9 @@ import { cn } from "../ui/cn"; import { SmartTooltip } from "../ui/SmartTooltip"; const STASH_SNIPPET_MAX_CHARS = 110; +const STASH_MENU_MAX_WIDTH = 380; +const STASH_MENU_VIEWPORT_MARGIN = 16; +const STASH_MENU_GAP = 10; export type ComposerPromptStashHandle = { activate: () => void; @@ -56,8 +65,95 @@ function providerLabel(entry: PromptStashEntry): string | null { return provider.charAt(0).toUpperCase() + provider.slice(1); } -export const ComposerPromptStash = forwardRef 0; +} + +function StashImageThumbnail({ attachment }: { attachment: AgentChatFileRef }) { + const directUrl = attachment.type === "image-url" ? attachment.url : null; + const [src, setSrc] = useState(directUrl); + const [failed, setFailed] = useState(false); + + useEffect(() => { + let cancelled = false; + setSrc(directUrl); + setFailed(false); + if (directUrl || attachment.type !== "image") return () => { cancelled = true; }; + const readImage = window.ade?.agentChat?.getImageDataUrl; + if (!readImage) { + setFailed(true); + return () => { cancelled = true; }; + } + void readImage(attachment.path) + .then(({ dataUrl }) => { + if (!cancelled) setSrc(dataUrl); + }) + .catch(() => { + if (!cancelled) setFailed(true); + }); + return () => { cancelled = true; }; + }, [attachment, directUrl]); + + return ( + + {src && !failed ? ( + setFailed(true)} + /> + ) : ( + + )} + + ); +} + +export type ComposerPromptStashProps = { draft: string; + attachments?: AgentChatFileRef[]; provider?: string | null; modelId?: string | null; active: boolean; @@ -65,8 +161,13 @@ export const ComposerPromptStash = forwardRef void; -}>(function ComposerPromptStash({ + onAddAttachment: (attachment: AgentChatFileRef) => void; + onRemoveAttachment: (path: string) => void; +}; + +export const ComposerPromptStash = forwardRef(function ComposerPromptStash({ draft, + attachments = [], provider, modelId, active, @@ -74,12 +175,17 @@ export const ComposerPromptStash = forwardRef(null); + const menuRef = useRef(null); const operationInFlightRef = useRef(false); const refreshSequenceRef = useRef(0); const latestDraftRef = useRef(draft); latestDraftRef.current = draft; + const latestAttachmentsRef = useRef(attachments); + latestAttachmentsRef.current = attachments; const [entries, setEntries] = useState([]); const [menuOpen, setMenuOpen] = useState(false); const [highlightedId, setHighlightedId] = useState(null); @@ -87,6 +193,17 @@ export const ComposerPromptStash = forwardRef(null); const [saveReceiptKey, setSaveReceiptKey] = useState(0); const [saveReceiptVisible, setSaveReceiptVisible] = useState(false); + const [menuPosition, setMenuPosition] = useState({ left: 0, top: 0 }); + const [errorNoticePosition, setErrorNoticePosition] = useState({ left: 0, bottom: 0 }); + const stashableComposerAttachments = useMemo( + () => attachments.filter(isStashableAttachment), + [attachments], + ); + const hasComposerContent = draft.trim().length > 0 || stashableComposerAttachments.length > 0; + const renderButton = buttonVisible && (hasComposerContent || entries.length > 0); + const attachmentSignature = attachments.map((attachment) => ( + `${attachment.type}:${attachment.path}` + )).join("\n"); const highlightedEntry = useMemo( () => entries.find((entry) => entry.id === highlightedId) ?? entries[0] ?? null, @@ -121,10 +238,14 @@ export const ComposerPromptStash = forwardRef window.clearTimeout(timer); }, [saveReceiptKey, saveReceiptVisible]); + useEffect(() => { + setError(null); + }, [attachmentSignature, draft]); + useEffect(() => { if (!menuOpen) return; const handlePointerDown = (event: PointerEvent) => { - if (rootRef.current?.contains(event.target as Node)) return; + if (rootRef.current?.contains(event.target as Node) || menuRef.current?.contains(event.target as Node)) return; setMenuOpen(false); }; document.addEventListener("pointerdown", handlePointerDown, true); @@ -132,10 +253,69 @@ export const ComposerPromptStash = forwardRef { - if (menuOpen && draft.trim()) { + if (menuOpen && hasComposerContent) { setMenuOpen(false); } - }, [draft, menuOpen]); + }, [hasComposerContent, menuOpen]); + + useEffect(() => { + if (menuOpen && entries.length === 0 && !busy && !error) { + setMenuOpen(false); + } + }, [busy, entries.length, error, menuOpen]); + + useLayoutEffect(() => { + if (!menuOpen) return; + const updatePosition = () => { + const anchor = rootRef.current?.getBoundingClientRect(); + const menu = menuRef.current; + if (!anchor || !menu) return; + const width = Math.min(STASH_MENU_MAX_WIDTH, window.innerWidth - (STASH_MENU_VIEWPORT_MARGIN * 2)); + const maxLeft = Math.max(STASH_MENU_VIEWPORT_MARGIN, window.innerWidth - width - STASH_MENU_VIEWPORT_MARGIN); + const menuHeight = menu.getBoundingClientRect().height; + const above = anchor.top - STASH_MENU_GAP - menuHeight; + const below = anchor.bottom + STASH_MENU_GAP; + const maxTop = Math.max(STASH_MENU_VIEWPORT_MARGIN, window.innerHeight - menuHeight - STASH_MENU_VIEWPORT_MARGIN); + let top = above; + if (above < STASH_MENU_VIEWPORT_MARGIN) { + top = below + menuHeight <= window.innerHeight - STASH_MENU_VIEWPORT_MARGIN + ? below + : Math.min(Math.max(STASH_MENU_VIEWPORT_MARGIN, above), maxTop); + } + setMenuPosition({ + left: Math.min(Math.max(STASH_MENU_VIEWPORT_MARGIN, anchor.right - width), maxLeft), + top, + }); + }; + updatePosition(); + window.addEventListener("resize", updatePosition); + window.addEventListener("scroll", updatePosition, true); + return () => { + window.removeEventListener("resize", updatePosition); + window.removeEventListener("scroll", updatePosition, true); + }; + }, [entries.length, error, menuOpen]); + + useLayoutEffect(() => { + if (!error || menuOpen) return; + const updatePosition = () => { + const anchor = rootRef.current?.getBoundingClientRect(); + if (!anchor) return; + const width = Math.min(320, window.innerWidth - (STASH_MENU_VIEWPORT_MARGIN * 2)); + const maxLeft = Math.max(STASH_MENU_VIEWPORT_MARGIN, window.innerWidth - width - STASH_MENU_VIEWPORT_MARGIN); + setErrorNoticePosition({ + left: Math.min(Math.max(STASH_MENU_VIEWPORT_MARGIN, anchor.right - width), maxLeft), + bottom: Math.max(STASH_MENU_VIEWPORT_MARGIN, window.innerHeight - anchor.top + STASH_MENU_GAP), + }); + }; + updatePosition(); + window.addEventListener("resize", updatePosition); + window.addEventListener("scroll", updatePosition, true); + return () => { + window.removeEventListener("resize", updatePosition); + window.removeEventListener("scroll", updatePosition, true); + }; + }, [error, menuOpen]); useEffect(() => { const handleFocus = () => { @@ -152,7 +332,14 @@ export const ComposerPromptStash = forwardRef { if (disabled || operationInFlightRef.current) return; const savedText = latestDraftRef.current; - if (!savedText.trim()) { + const savedComposerAttachments = [...latestAttachmentsRef.current]; + const savedAttachments = savedComposerAttachments.filter(isStashableAttachment); + if (savedAttachments.length > MAX_PROMPT_STASH_ATTACHMENTS) { + setError(`You can stash up to ${MAX_PROMPT_STASH_ATTACHMENTS} images at a time.`); + return; + } + if (!savedText.trim() && savedAttachments.length === 0) { + if (!entries.length) return; setMenuOpen(true); await refresh(); return; @@ -163,11 +350,43 @@ export const ComposerPromptStash = forwardRef => { + if (attachment.type === "image-url") return attachment; + let dataUrl: string; + try { + dataUrl = (await window.ade.agentChat.getImageDataUrl(attachment.path)).dataUrl; + } catch (runtimeReadError) { + const localRead = window.ade?.app?.getImageDataUrl; + if (!localRead) throw runtimeReadError; + dataUrl = (await localRead(attachment.path)).dataUrl; + } + const saved = await window.ade.agentChat.saveTempAttachment({ + data: base64FromDataUrl(dataUrl), + filename: attachmentName(attachment.path), + }); + return { path: saved.path, type: "image" }; + })); const created = await window.ade.agentChat.promptStashes.create({ text: savedText, + ...(storedAttachments.length ? { attachments: storedAttachments } : {}), provider, modelId, }); + if (storedAttachments.length > 0) { + const confirmedAttachments = stashAttachments(created); + const runtimeConfirmedImages = storedAttachments.every((stored) => ( + confirmedAttachments.some((confirmed) => sameAttachment(confirmed, stored)) + )); + if (!runtimeConfirmedImages) { + try { + await window.ade.agentChat.promptStashes.delete({ id: created.id }); + } catch { + // The composer remains intact even if an older runtime cannot + // roll back the text-only compatibility write. + } + throw new Error("The connected ADE runtime could not preserve the attached images. They are still in your composer."); + } + } setEntries((current) => [ created, ...current.filter((entry) => entry.id !== created.id), @@ -179,8 +398,16 @@ export const ComposerPromptStash = forwardRef ( + Boolean(savedComposerAttachments[index] && sameAttachment(current, savedComposerAttachments[index]!)) + )); + if (composerUnchanged) { onDraftChange(""); + for (const savedAttachment of savedAttachments) { + onRemoveAttachment(savedAttachment.path); + } } } catch (saveError) { setError(saveError instanceof Error ? saveError.message : "Could not stash this prompt."); @@ -189,11 +416,15 @@ export const ComposerPromptStash = forwardRef { if (operationInFlightRef.current) return; - if (latestDraftRef.current.trim()) { + if (stashAttachmentsUnavailable(entry)) { + setError("These images live on the machine where this prompt was stashed. Connect to that machine to restore it."); + return; + } + if (latestDraftRef.current.trim() || latestAttachmentsRef.current.length > 0) { setMenuOpen(false); return; } @@ -209,6 +440,9 @@ export const ComposerPromptStash = forwardRef { if (operationInFlightRef.current) return; @@ -280,21 +514,21 @@ export const ComposerPromptStash = forwardRef - {buttonVisible ? ( +
+ {renderButton ? ( +
+ ), document.body) : null} ); }); diff --git a/apps/desktop/src/shared/types/chat.ts b/apps/desktop/src/shared/types/chat.ts index 65326b4c4..53bbd8cb2 100644 --- a/apps/desktop/src/shared/types/chat.ts +++ b/apps/desktop/src/shared/types/chat.ts @@ -2708,15 +2708,23 @@ export type AgentChatFileSearchResult = { export type PromptStashEntry = { id: string; text: string; + /** Absent when talking to a pre-attachment ADE runtime. */ + attachments?: AgentChatFileRef[]; + /** Includes images that exist only on the originating ADE runtime. */ + attachmentCount?: number; + /** False when this synced runtime does not own the stash's image files. */ + attachmentsAvailable?: boolean; provider: string | null; modelId: string | null; createdAt: string; }; export const MAX_PROMPT_STASHES = 20; +export const MAX_PROMPT_STASH_ATTACHMENTS = 10; export type PromptStashCreateArgs = { text: string; + attachments?: AgentChatFileRef[]; provider?: string | null; modelId?: string | null; }; diff --git a/apps/ios/ADE/Resources/DatabaseBootstrap.sql b/apps/ios/ADE/Resources/DatabaseBootstrap.sql index e26265b42..d1d021870 100644 --- a/apps/ios/ADE/Resources/DatabaseBootstrap.sql +++ b/apps/ios/ADE/Resources/DatabaseBootstrap.sql @@ -833,11 +833,16 @@ create index if not exists idx_computer_use_artifact_links_artifact on computer_ create table if not exists prompt_stashes ( id text primary key, text text not null, + attachments_json text not null default '[]', + attachment_origin_site_id text, provider text, model_id text, created_at text not null ); +alter table prompt_stashes add column attachments_json text not null default '[]'; +alter table prompt_stashes add column attachment_origin_site_id text; + create index if not exists idx_prompt_stashes_created on prompt_stashes(created_at); create table if not exists phase_cards ( diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index a0b6a603c..50cb4c5c6 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -7718,23 +7718,81 @@ final class ADETests: XCTestCase { let siteId = "b00e9b92c864a27958669c1595fcb2c3" let result = try database.applyChanges([ CrsqlChangeRow(table: "prompt_stashes", pk: packedPk, cid: "text", val: .string(" preserve this draft\n"), colVersion: 1, dbVersion: 2, siteId: siteId, cl: 1, seq: 0), - CrsqlChangeRow(table: "prompt_stashes", pk: packedPk, cid: "provider", val: .string("codex"), colVersion: 1, dbVersion: 2, siteId: siteId, cl: 1, seq: 1), - CrsqlChangeRow(table: "prompt_stashes", pk: packedPk, cid: "model_id", val: .string("gpt-5"), colVersion: 1, dbVersion: 2, siteId: siteId, cl: 1, seq: 2), - CrsqlChangeRow(table: "prompt_stashes", pk: packedPk, cid: "created_at", val: .string("2026-07-28T12:00:00.000Z"), colVersion: 1, dbVersion: 2, siteId: siteId, cl: 1, seq: 3), + CrsqlChangeRow(table: "prompt_stashes", pk: packedPk, cid: "attachments_json", val: .string("[{\"path\":\"/project/.ade/attachments/design.png\",\"type\":\"image\"}]"), colVersion: 1, dbVersion: 2, siteId: siteId, cl: 1, seq: 1), + CrsqlChangeRow(table: "prompt_stashes", pk: packedPk, cid: "attachment_origin_site_id", val: .string(siteId), colVersion: 1, dbVersion: 2, siteId: siteId, cl: 1, seq: 2), + CrsqlChangeRow(table: "prompt_stashes", pk: packedPk, cid: "provider", val: .string("codex"), colVersion: 1, dbVersion: 2, siteId: siteId, cl: 1, seq: 3), + CrsqlChangeRow(table: "prompt_stashes", pk: packedPk, cid: "model_id", val: .string("gpt-5"), colVersion: 1, dbVersion: 2, siteId: siteId, cl: 1, seq: 4), + CrsqlChangeRow(table: "prompt_stashes", pk: packedPk, cid: "created_at", val: .string("2026-07-28T12:00:00.000Z"), colVersion: 1, dbVersion: 2, siteId: siteId, cl: 1, seq: 5), ]) - XCTAssertEqual(result.appliedCount, 4) + XCTAssertEqual(result.appliedCount, 6) XCTAssertEqual(result.touchedTables, ["prompt_stashes"]) XCTAssertFalse(database.skippedUnknownSyncTables.contains("prompt_stashes")) let promptChanges = database.exportChangesSince(version: 0).filter { $0.table == "prompt_stashes" } - XCTAssertEqual(promptChanges.count, 4) + XCTAssertEqual(promptChanges.count, 6) XCTAssertTrue(promptChanges.allSatisfy { $0.pk == packedPk }) XCTAssertEqual(promptChanges.first(where: { $0.cid == "text" })?.val, .string(" preserve this draft\n")) + XCTAssertEqual(promptChanges.first(where: { $0.cid == "attachments_json" })?.val, .string("[{\"path\":\"/project/.ade/attachments/design.png\",\"type\":\"image\"}]")) + XCTAssertEqual(promptChanges.first(where: { $0.cid == "attachment_origin_site_id" })?.val, .string(siteId)) database.close() } + func testDatabaseBootstrapMigratesExistingPromptStashesForAttachmentChanges() throws { + let baseURL = makeTemporaryDirectory() + let legacyDatabase = DatabaseService(baseURL: baseURL, bootstrapSQL: """ + create table if not exists prompt_stashes ( + id text primary key, + text text not null, + provider text, + model_id text, + created_at text not null + ); + """) + XCTAssertNil(legacyDatabase.initializationError) + legacyDatabase.close() + + let upgradedDatabase = DatabaseService(baseURL: baseURL, bootstrapSQL: """ + create table if not exists prompt_stashes ( + id text primary key, + text text not null, + attachments_json text not null default '[]', + attachment_origin_site_id text, + provider text, + model_id text, + created_at text not null + ); + alter table prompt_stashes add column attachments_json text not null default '[]'; + alter table prompt_stashes add column attachment_origin_site_id text; + """) + XCTAssertNil(upgradedDatabase.initializationError) + + let stashId = "stash-upgraded-mobile-schema" + let packedPk = packedDesktopTextPrimaryKey(stashId) + let siteId = "b00e9b92c864a27958669c1595fcb2c3" + let attachmentJson = "[{\"path\":\"/project/.ade/attachments/design.png\",\"type\":\"image\"}]" + let result = try upgradedDatabase.applyChanges([ + CrsqlChangeRow(table: "prompt_stashes", pk: packedPk, cid: "text", val: .string("preserve this draft"), colVersion: 1, dbVersion: 2, siteId: siteId, cl: 1, seq: 0), + CrsqlChangeRow(table: "prompt_stashes", pk: packedPk, cid: "attachments_json", val: .string(attachmentJson), colVersion: 1, dbVersion: 2, siteId: siteId, cl: 1, seq: 1), + CrsqlChangeRow(table: "prompt_stashes", pk: packedPk, cid: "attachment_origin_site_id", val: .string(siteId), colVersion: 1, dbVersion: 2, siteId: siteId, cl: 1, seq: 2), + CrsqlChangeRow(table: "prompt_stashes", pk: packedPk, cid: "provider", val: .string("codex"), colVersion: 1, dbVersion: 2, siteId: siteId, cl: 1, seq: 3), + CrsqlChangeRow(table: "prompt_stashes", pk: packedPk, cid: "model_id", val: .string("gpt-5"), colVersion: 1, dbVersion: 2, siteId: siteId, cl: 1, seq: 4), + CrsqlChangeRow(table: "prompt_stashes", pk: packedPk, cid: "created_at", val: .string("2026-07-28T12:00:00.000Z"), colVersion: 1, dbVersion: 2, siteId: siteId, cl: 1, seq: 5), + ]) + + XCTAssertEqual(result.appliedCount, 6) + XCTAssertEqual(result.touchedTables, ["prompt_stashes"]) + XCTAssertFalse(upgradedDatabase.skippedUnknownSyncTables.contains("prompt_stashes")) + + let promptChanges = upgradedDatabase.exportChangesSince(version: 0).filter { $0.table == "prompt_stashes" } + XCTAssertEqual(promptChanges.count, 6) + XCTAssertEqual(promptChanges.first(where: { $0.cid == "attachments_json" })?.val, .string(attachmentJson)) + XCTAssertEqual(promptChanges.first(where: { $0.cid == "attachment_origin_site_id" })?.val, .string(siteId)) + + upgradedDatabase.close() + } + func testDatabaseApplyChangesDoesNotTrapOnOutOfRangeIntegralDouble() throws { let database = makeDatabase(baseURL: makeTemporaryDirectory()) XCTAssertNil(database.initializationError) diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index da2628356..43d63550a 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -23,7 +23,7 @@ for its separate RPC, sync, storage, and UI contracts. | `apps/desktop/src/shared/crossMachineHandoff.ts` and `apps/desktop/src/shared/types/chat.ts` | Renderer-safe Git-origin normalization, portable remote sanitization, untrusted remote-response decoders, and the versioned capsule/preflight/accept DTOs shared across renderer, preload, Electron main, and the ADE runtime. `chat.ts` also owns the fork-handoff contract: `HANDOFF_FORK_PROVIDERS` (`claude`, `codex`, `opencode`, `droid`) + `providerSupportsHandoffFork()`, `AgentChatHandoffArgs.targetLaneId` (brief may retarget any lane in the project; fork must stay in the source lane), the cross-machine capsule's optional `mode: "brief" \| "fork"` with `forkTransport` (provider-native session files) and `transcriptEnvelopes` (gzipped ADE JSONL), and the preflight's optional `forkHandoffSupport` (absent = older destination the source must treat as fork-unsupported, so a fork never silently downgrades to a brief). Cross-machine fork has its own narrower list: `CROSS_MACHINE_HANDOFF_FORK_PROVIDERS` + `providerSupportsCrossMachineHandoffFork()`, derived from `HANDOFF_FORK_PROVIDERS` by filtering Droid out (its session index is machine-local) so the two lists cannot drift. The preflight also carries an optional `laneFastForward` (`laneId`, `laneName`, `behindBy`) — the destination's own assertion that its existing lane is clean and a strict ancestor of the source commit. `decodeCrossMachineDestinationPreflightResult` decodes `forkHandoffSupport` and `laneFastForward` only when present, and rejects a `behindBy` that is not a positive integer because the destination refuses a zero-distance fast-forward. `chat.ts` is also the canonical cross-client contract for context-usage state/sample metadata, Claude result provenance/error/correlation fields, queue-aware interrupt results, the bounded `queue_recovery` lifecycle, and the desktop prompt-stash DTOs plus `MAX_PROMPT_STASHES`. | | `apps/desktop/src/main/services/chat/crossMachineForkTransport.ts` | Node-only fork-transport plumbing shared by the source packaging and destination materialization paths. Owns the uncompressed limits (18 MiB provider main session file, 4 MiB total Claude sidecars, 3 MiB ADE transcript envelopes), the independent base64 bounds that reject oversized input before decoding, and `CROSS_MACHINE_FORK_ENCODED_BUDGET_BYTES` (20 MiB) — a whole-capsule encoded budget kept under the 25 MiB sync-envelope/WebSocket payload caps. `gzipToBase64` / `gunzipFromBase64` (the latter enforces a max output length) do the compression; `enforceCrossMachineForkEncodedBudget` drops the sidecar group first and only throws a "too large, send a brief" error when the main file plus transcript alone blow the budget; `crossMachineForkOversizeError` returns the typed `CROSS_MACHINE_FORK_OVERSIZE` failure; `runCliCapture` buffers `opencode export` / `import` stdout/stderr with a timeout; and `validateForkTransport` re-validates a received capsule's transport (provider match, kind allowlist, base64 shape, path-traversal-safe side-file paths, per-file and total size caps) before any decode. | | `apps/desktop/src/main/services/chat/agentChatService.ts` | Main service: session lifecycle, external chat import orchestration (`importExternalChatSession` for Claude/Codex sessions discovered by the external-session service), turn dispatch, event emission, provider adapters, steer queue, handoff, auto-title, prompt-derived lane-name suggestions for auto-created / parallel lanes, event-history snapshots, durable chat transcript replay/storage compaction, slash-command discovery/merge (delegates to per-provider discovery modules and `slashCommandPromptExpansion` for unified prompt expansion), and active-workload detection used by project/window close guards. Codex non-retrying app-server failures are deduplicated by turn plus semantic error identity across the early `error` notification and terminal `turn/completed`; retrying notifications (`willRetry: true`) remain provider-health notices while the turn stays active. Lane naming runs through the session-intelligence prompt path, retries the configured/requested/default title models — the auto-title candidate order prefers the configured `titleModelId` before the session's `requestedModelId` — then falls back to a deterministic prompt slug; branch uniqueness is handled by the lane id suffix added by lane creation. Tracks Fast Mode with the legacy `codexFastMode: boolean` session field for every provider whose descriptor advertises `serviceTiers: ["fast"]`; Codex forwards it as `serviceTier: "fast" \| null` on every `thread/start` and `turn/start` JSON-RPC call, while Cursor SDK sessions resolve it through discovered model parameters (see [Agent Routing](agent-routing.md#provider-service-tiers-fast-mode)). Codex chat goals are managed through the app-server `thread/goal/get` / `set` / `clear` RPCs, persisted in session summaries, validated to the provider's 4,000-character objective limit, and normalized to ADE's unlimited-budget policy by sending `tokenBudget: null` and clearing provider-reported budgets. `applyCodexEffectiveThreadState` accepts a `requestedCodexPolicy` option and uses `shouldPreserveRequestedCodexPolicy` to keep ADE-controlled picker selections authoritative when the lifecycle response echoes an older thread policy (prevents a manual Plan→Edit switch from snapping back); it also syncs the abstract `permissionMode` via `syncLegacyPermissionMode` after every policy application. Whenever an `updateSession` touches any permission/interaction/mode field, the service also emits a transient `session_meta_updated` chat event carrying the recomputed mode fields (`permissionMode`, `interactionMode`, `claudePermissionMode`, `codexApprovalPolicy`/`codexSandbox`/`codexConfigSource`, `opencodePermissionMode`, `droidPermissionMode`, `cursorModeId`, and the `cursorModeSnapshot`) so any other client viewing the same session — a desktop refreshing a session an iOS device just re-moded, or vice versa — updates its composer controls live. It is a direct state patch, emitted after the Cursor policy sync so `cursorModeSnapshot` reflects the recomputed mode, and is kept off the session-list refresh path. Builds ADE guidance from the active lane worktree so Agent Skill roots are lane-scoped in persistent system/developer prompts and provider fallback injection. `buildAgentRuntimeEnv(managed)` stamps every SDK-backed provider process with `ADE_CHAT_SESSION_ID`, `ADE_DEFAULT_ROLE=agent` (or `orchestrator` for a lead), `ADE_LANE_ID`, `ADE_PROJECT_ROOT`, and `ADE_WORKSPACE_ROOT`; the persistent guidance also names the concrete `--session ` argument for status commands so shared SDK servers do not depend on process-global env inheritance. `dismissPendingInputForSettlement` is the provider-neutral quieting boundary used by **Dismiss & settle**: it interrupts live Claude/Codex/OpenCode/Cursor/Droid turns best-effort, cancels local/provider waiters, removes Codex plan follow-ups, emits pending-input resolution, and persists an idle session before settle is written. When the session has Linear issues attached (`session_linear_issues`), `buildAgentRuntimeEnv` also materializes them into a per-session context file via `writeSessionLinearIssueContextFile` (`//linear-issues.json`, written atomically; stale files cleared when nothing is attached) and sets `ADE_LINEAR_ISSUE_IDS` (comma-joined identifiers) + `ADE_LINEAR_CONTEXT_FILE` so the agent reads its issue context without Linear credentials. Attaching a `linear_issue` context attachment at run time calls `laneService.attachLinearIssueToSession({ chatSessionId, issues, role: "worked", source: "chat_attach", includeInPr: true })` so the link is persisted even for standalone (laneless) chats; when the session has a lane it additionally runs `laneService.linkLinearIssues` for the lane/PR-card semantics. See [Linear integration](../linear-integration/README.md#session-scoped-issue-attachment-and-cli-context-injection). Claude SDK sessions also resolve the executable through `claudeCodeExecutable.ts` and pass `pathToClaudeCodeExecutable` so packaged builds can prefer the bundled native binary before PATH/auth fallbacks; interrupted Claude turns stop active subagents before emitting stopped `subagent_result`s, and every `subagent_result` is gated on a previously emitted `subagent_started` (tracked in `emittedSubagentStartIds`) so an interrupt can never emit a phantom stopped card for a subagent that never announced — terminal events clear both the taskId and agentId aliases. A plain Claude Code task run (`task_type` `other`, no agent metadata — e.g. "Re-run affected test files") is tracked for cleanup but never surfaces subagent rows. Claude resume paths run `claudeThinkingTranscriptRepair` before loading a transcript, and the runtime self-heals the same corruption after the Anthropic thinking-block 400 error. Full-auto plan acceptance emits the same plan-mode exit notice as the manual approval path so the renderer composer chip can update even when the session refresh races with compaction. Cursor SDK setup records interrupts that arrive while the worker is still being acquired, releases the acquired generation if setup loses the race, and suppresses false provider-health failures for user-initiated setup interrupts. Cursor provider slash commands use a dedicated discovery path (`cursorSlashCommandDiscovery`) instead of falling through to the generic filesystem-backed list. Claude query startup is single-flight: concurrent `ensureClaudeQuery` callers latch onto one in-flight `queryStartPromise`, and a per-runtime `queryGeneration` token aborts and reaps a start that a reset or interrupt superseded, so a resumed session never spawns twin subprocesses; both reset and interrupt reap the SDK subprocess through `claudeSubprocessReaper` because a closed `query()` still leaves a live `claude --resume` child. `run_in_background` shell tasks (SDK `task_type` `local_bash`/`background`) survive turn boundaries — the query stays alive across turns and delivers their real completion — so only interrupt, reset/dispose, or a host-restart rebind settle them as stopped; a reset that orphans still-open background tasks emits one `system_notice` that they were stopped without reporting completion, and background-task titles are sticky (the first spawn description is reused through the terminal row). A durable per-`(SDK message id, content index)` emitted-text record keeps a re-delivered assistant snapshot (after a stream-dedup reset from steer, message interleave, or idle handoff) from doubling the transcript. Claude `TaskCreate`/`TaskUpdate` tracking keys creates by tool-use id and remaps the harness's ordinal task id onto the Nth created task; an update for an id it cannot resolve or describe changes nothing rather than fabricating a todo row. `steer()` returns `AgentChatSteerResult` (`{ steerId, queued, reason?: "queue_full" }`); reasoning effort is normalized and applied at steer delivery, and an active Claude `interrupt-replace` uses SDK priority `now` without tearing down the query or its background work. When a spawned child chat ends, `reportChildSpawnEnded` reports its outcome to the spawner according to the child's `spawnKind` (see [Spawn types and completion reporting](#spawn-types-and-completion-reporting)); spawned agents also inherit `ADE_PARENT_CHAT_SESSION_ID` / `ADE_SPAWN_KIND` and a subagent self-report guidance line. Fork/import history seeding (`appendImportedChatEvents`) is chunked with event-loop yields, defers transcript flushes to chunk boundaries, and never publishes seeded historical envelopes to live event subscribers — readers load them via history APIs; live-publishing an entire source chat froze the app during fork handoff (ADE-122). The `chat.handoffSession` / `chat.prepareCrossMachineHandoff` runtime actions carry extended timeouts (120s daemon action, 150s IPC) because a brief handoff spans AI-brief generation plus first-message dispatch — the old 30s default fired a false timeout while the daemon-side handoff completed anyway. For orchestrator-lead sessions it builds the read-only capability services (`buildOrchestrationLeadReadServices` → `searchWorkspace` / `readLinearIssue` / `readPr` / `listProofArtifacts` / `mintDeeplink`), wiring each only when the backing service exists so a null service degrades to an omitted tool rather than a crash. Large service file. | -| `apps/desktop/src/main/services/chat/promptStashService.ts` | Runtime-owned create/list/delete contract for unsent desktop composer text. Preserves exact whitespace, rejects empty or over-200,000-character prompts, stores optional provider/model context, returns newest-first rows, and retains at most 20 entries. The PK-only `prompt_stashes` table is CRR-compatible; desktop clients use runtime routing, while session-bound agent action callers are denied because stash contents are private user drafts. | +| `apps/desktop/src/main/services/chat/promptStashService.ts` | Runtime-owned create/list/delete contract for unsent desktop composer text and images. Preserves exact whitespace, accepts attachment-only image stashes, rejects empty or over-200,000-character prompts and malformed attachment references, stores optional provider/model context, returns newest-first rows, and retains at most 20 entries. The PK-only `prompt_stashes` table is CRR-compatible, so text, metadata, image counts, and portable HTTP(S) image references converge through sync. Before committing local images, the composer copies them into the owning runtime; those bytes remain on that runtime. A different synced runtime withholds the machine-bound paths, reports the images as unavailable, and refuses a destructive text-only restore, while connected desktop clients routed to the origin runtime can preview and restore them. Live origin-runtime stash images are excluded from stale temporary-attachment cleanup. Session-bound agent action callers are denied because stash contents are private user drafts. | | `apps/desktop/src/main/services/chat/providerResumeClassifier.ts` | Classifies Codex resume failures without conflating missing threads with MCP/provider-environment or transient transport failures; rollout-file evidence keeps a locally known thread from being declared missing. | | `apps/desktop/src/renderer/components/chat/ChatContinuityRecoveryCard.tsx` | Renders the explicit continuity-recovery choices from a `system_notice`: retry the preserved thread, reconstruct from durable ADE history, or start a separate chat. | | `apps/desktop/src/main/services/chat/chatScheduledWorkScheduler.ts` | Runtime-owned durable mirror and wake coordinator for provider-neutral ADE action schedules plus Claude `ScheduleWakeup`, every successful `CronCreate`, and `/loop`. ADE's mirror is the delivery source of truth; Claude's native scheduler is an advisory latency path. The scheduler gives native Claude fire 90 seconds to claim a due record before ADE's timer backstops it. Every managed chat schedule that becomes due during an active Claude, Codex, Cursor, Droid, or OpenCode turn stays armed and retries in 20-second steps instead of entering that turn's disposable input queue; tracked CLI rows use the same defer loop until `ptyService` confirms a provider-specific composer boundary. Expiry remains authoritative during retries. Native no-id cron claims are limited to due CronCreate-owned rows, so an ambiguous provider event cannot consume a `ScheduleWakeup` or loop. The scheduler persists versioned records, optional provider ids, expiry/terminal timestamps, and per-chat pause state in the project SQLite `kv` store; restores and re-arms them on service start; coalesces overdue work to one late fire; and reports transitions back to `agentChatService`. Startup migration drops the pre-1.2.27 `cron-tool:` intent placeholders that Claude could never cancel, quarantines older active provider rows in a paused state for operator review, and bounds terminal history to the newest 200 rows or seven days. Uses injected time/timer/persistence adapters so restart, pause, collision, migration, expiry, and catch-up behavior can be tested without Electron. | diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index 8d458f716..f5d51fe46 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -18,8 +18,8 @@ subagents, computer use). The pane derives all visible state from the | `apps/desktop/src/renderer/lib/aiDiscoveryCache.ts` | Project-scoped AI integration-status and provider-model cache shared across renderer surfaces. `getAiStatusCached` uses a 10-second freshness window and deduplicates concurrent `ade.ai.getStatus` requests; cache update/invalidation events let open ModelPickers react without polling or mounting their own background refresh loops. | | `CrossMachineHandoffModal.tsx`, `crossMachineHandoffPresentation.tsx` | Modal state and user flow for **Send to machine**. It verifies a local source lane, follows live remote connection snapshots, lets the user pick brief or full-history fork (fork defaults on for fork-capable providers and constrains the model picker to the same provider), lets the user set the destination chat's model, reasoning effort, fast mode, and permission mode with the same shared pills the composer uses, handles existing-project versus confirmed-clone setup, offers a destination-run fast-forward when the target lane is clean and strictly behind the source commit, decodes destination responses at the renderer boundary, pins acceptance to the reviewed route kind, and exposes retryable source-marker failures after destination success. Source blockers render through `BlockedReasons` / `BlockedActionButton` instead of silently disabling Continue. The pure half — stage/mode types, `SourceCheck`, branch/route/repo-readiness copy, permission tone and icon maps, send-step labels, `CheckRow` — lives in `crossMachineHandoffPresentation.tsx` so it is assertable without mounting the stateful modal. Once destination acceptance is dispatched, a runtime timeout or connection interruption produces an amber unknown-outcome notice: the destination chat may still appear, the user should check that machine before retrying, and the modal never reports a truthful cancellation that the runtime did not perform. A fork that the destination can't accept (older ADE with no `forkHandoffSupport`, oversize history, or an unforkable provider file) surfaces a plain reason and a one-click **send as brief** that re-runs prepare + preflight; the insecure-route consent line is fork-aware (a fork discloses that the full history is sent exactly as recorded, a brief that only the summary is sent). | | `AgentChatMessageList.tsx` | Virtualized message list. The virtualizer is **hand-rolled**, not `@tanstack/react-virtual`: a `measuredHeights` row-key → height `Map` feeds top/bottom spacer divs around the rendered window, and each rendered row is wrapped in `MeasuredEventRow`, whose `ResizeObserver` reports its real height through `handleMeasure` → `reconcileMeasuredScrollTop` so a height correction above the viewport does not shift what the reader is looking at. Renders transcript rows and turn dividers, including a `Woke on schedule` divider before every synthetic scheduled turn and inline `SubagentSpawnCard` / `SubagentResultCard` / `BackgroundFinishChip` rows (from `SubagentActivityCards.tsx`) for real subagents and backgrounded shell commands, and accepts stable row-key jump requests from the while-you-were-away strip and the spawn/result jump affordances. Keeps sticky-bottom sessions pinned across streamed row growth and late virtual-height measurements. A Claude `queue_recovery: available` row renders one eight-second Undo card; later `restored`/`expired` rows settle the same recovery id so history replay cannot show a stale action. The last text block of a multi-block assistant turn exposes Copy turn, which joins only that turn's assistant text blocks with blank lines; legacy rows without a turn id and single-block turns keep only the normal block copy. Plan-approval rows with non-empty body text render a scrollable markdown block (capped at `360px`) beneath the header so the user can review plan content inline. Codex goal lifecycle rows use user-facing text such as `Goal set`, `Goal paused`, and `Goal cleared`. A stalled Codex turn renders a clickable Wait / Nudge / Retry / Resume recovery card wired to `agentChat.recoverCodexTurn`; terminal provider capacity/usage-limit errors render `ProviderFailureRecoveryCard` with same-thread retry and model-selection actions. User messages marked `metadata.hideFullPrompt` render and copy only their `displayText`, keeping internal handoff briefs out of the visible transcript details, and a handoff-brief user row shows a small brief chip. When a fork seeds pre-fork history into the new chat, the envelopes carry the `handoff_fork` provider origin and the list draws a single `Forked from the previous chat — full history above` divider (`computeForkHistoryDividerRowKey` pins it to the first live row after the seeded history) instead of one marker per seeded row. | -| `AgentChatComposer.tsx`, `ComposerPromptStash.tsx`, `ComposerSmartLinkMenu.tsx`, `smartLinkChipMark.ts` | Text input, attachments, model selector, compact title-only permission controls (per-provider `PermissionModePickerOption` tables fed into the shared `components/shared/PermissionModePicker`, which the composer owns the option data for but not the control), slash commands, desktop prompt stashes, smart-link chips (`smartLinkChipMark.ts` returns the inline `currentColor` SVG brand mark each chip renders), pending-input answering (including Codex MCP form/URL elicitations), voice-dictation target registration, and parallel model-slot controls. `ComposerPromptStash` keeps its command surface mounted even when the Appearance preference hides the bookmark, so Cmd/Ctrl+S remains available; it calls the owning runtime before clearing or consuming text and never stores attachments or machine-bound context. Completed URLs are non-editable inline chips whose `data-composer-chip-text` preserves the literal URL during serialization; clicking or keyboard-activating a chip opens the Copy link / Remove link menu, and character deletion removes the whole URL token. Every chip also carries a kind-naming `data-composer-chip` attribute, and a scoped `selectionchange` effect marks intersecting chips with `data-composer-chip-selected` so the native selection paints continuously across them (overlay styling lives in `apps/desktop/src/renderer/index.css`). During an active Claude turn, its split Send control selects without dispatching among inline, after-turn, and interrupt delivery; the primary button and Enter execute the selected mode. A separate Claude split Stop control selects **Stop & clear queue** or **Stop only**, persists that choice per chat, and dismisses its custom popover immediately after selection. Staged rows expose send-during-turn, interrupt, cancel, and Edit-back-to-composer actions. It forwards one-shot open requests to the shared ModelPicker so transcript recovery cards can open model selection without synthetic DOM events; the picker acknowledges each request so remounts do not reopen it. Launch-prompt clipboard reminder text is controlled by `launchPromptClipboardNoticeEnabled`, separate from the `launchPromptClipboardEnabled` copy behavior. For orchestration model-selection pending inputs it decodes the agent briefing metadata (`workDescription`, `filesHint`, `dependsOn`) before rendering the selection card. | -| `apps/desktop/src/main/services/chat/promptStashService.ts` | Runtime-backed prompt-stash persistence. Exact prompt text is stored in the PK-only, CRR-compatible `prompt_stashes` table, provider/model labels are optional, and newest-first retention is capped at 20 entries. | +| `AgentChatComposer.tsx`, `ComposerPromptStash.tsx`, `ComposerSmartLinkMenu.tsx`, `smartLinkChipMark.ts` | Text input, attachments, model selector, compact title-only permission controls (per-provider `PermissionModePickerOption` tables fed into the shared `components/shared/PermissionModePicker`, which the composer owns the option data for but not the control), slash commands, desktop prompt stashes, smart-link chips (`smartLinkChipMark.ts` returns the inline `currentColor` SVG brand mark each chip renders), pending-input answering (including Codex MCP form/URL elicitations), voice-dictation target registration, and parallel model-slot controls. `ComposerPromptStash` keeps its command surface mounted when the Appearance preference hides the bookmark, so Cmd/Ctrl+S remains available, but the visible bookmark stays out of the toolbar while both the composer and stash list are empty. Its menu is rendered in a viewport-clamped body portal with a bounded, scrolling list so composer overflow and short windows cannot crop it. A save first copies up to ten attached images into the owning project runtime, commits the text plus image references, then clears only the unchanged composer snapshot; restore reapplies both text and images before consuming the stash. The list renders an image thumbnail when the active runtime owns the bytes. On another synced runtime, the row retains its text and image count, labels the images as living on another machine, and refuses restore until the composer is connected to the origin runtime. Machine-bound context and non-image files are not stashed. Completed URLs are non-editable inline chips whose `data-composer-chip-text` preserves the literal URL during serialization; clicking or keyboard-activating a chip opens the Copy link / Remove link menu, and character deletion removes the whole URL token. Every chip also carries a kind-naming `data-composer-chip` attribute, and a scoped `selectionchange` effect marks intersecting chips with `data-composer-chip-selected` so the native selection paints continuously across them (overlay styling lives in `apps/desktop/src/renderer/index.css`). During an active Claude turn, its split Send control selects without dispatching among inline, after-turn, and interrupt delivery; the primary button and Enter execute the selected mode. A separate Claude split Stop control selects **Stop & clear queue** or **Stop only**, persists that choice per chat, and dismisses its custom popover immediately after selection. Staged rows expose send-during-turn, interrupt, cancel, and Edit-back-to-composer actions. It forwards one-shot open requests to the shared ModelPicker so transcript recovery cards can open model selection without synthetic DOM events; the picker acknowledges each request so remounts do not reopen it. Launch-prompt clipboard reminder text is controlled by `launchPromptClipboardNoticeEnabled`, separate from the `launchPromptClipboardEnabled` copy behavior. For orchestration model-selection pending inputs it decodes the agent briefing metadata (`workDescription`, `filesHint`, `dependsOn`) before rendering the selection card. | +| `apps/desktop/src/main/services/chat/promptStashService.ts` | Runtime-backed prompt-stash persistence. Exact prompt text, up to ten image references, their origin sync-site id, provider/model labels, and creation time are stored in the PK-only, CRR-compatible `prompt_stashes` table; newest-first retention is capped at 20 entries. Text and metadata converge across runtimes. HTTP(S) image references remain portable, while local-image bytes stay on the originating runtime: off-origin readers receive the image count but no absolute paths and cannot consume the stash. Origin-owned image files referenced by live stashes are protected from the normal seven-day temporary-attachment cleanup. | | `ProviderFailureRecoveryCard.tsx` | Friendly recovery surface for terminal provider capacity and usage-limit failures. Shows human-readable error identity and guidance, then offers **Retry turn** and **Choose model** only after the failed turn has released the composer. | | `chatTurnState.ts` | Pure turn-state helpers shared by live and hydration paths. Terminal transcript evidence beats a stale active session summary, and failed-turn retry resolves the associated non-steer user message even when the optimistic row has no provider turn id. | | `ChatActionsDrawerPanel.tsx`, `ChatSourcesPanel.tsx`, `chatSources.ts` | Chat Actions tab shell plus Codex Sources view. The source derivation deduplicates files, web queries/results, MCP apps/tools, and external resource URLs from transcript events; safe web rows open in ADE's browser. | From a8b8ef738dbe03f5a42180b679e9852c2eae29b1 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:23:15 -0400 Subject: [PATCH 2/6] =?UTF-8?q?ship:=20iteration=201=20=E2=80=94=20address?= =?UTF-8?q?=20CodeRabbit=20stash=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/chat/promptStashService.test.ts | 46 ++++++++++ .../main/services/chat/promptStashService.ts | 32 ++++--- .../chat/ComposerPromptStash.test.tsx | 91 +++++++++++++++++++ .../components/chat/ComposerPromptStash.tsx | 7 ++ apps/ios/ADE/Services/Database.swift | 4 +- apps/ios/ADETests/ADETests.swift | 10 ++ 6 files changed, 178 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/main/services/chat/promptStashService.test.ts b/apps/desktop/src/main/services/chat/promptStashService.test.ts index a715982ab..0cadc6546 100644 --- a/apps/desktop/src/main/services/chat/promptStashService.test.ts +++ b/apps/desktop/src/main/services/chat/promptStashService.test.ts @@ -84,6 +84,52 @@ describe("promptStashService", () => { expect(listPromptStashAttachmentPaths(db)).toEqual(new Set()); }); + it("normalizes site ids before deciding whether machine-bound images are local", () => { + const image = { path: "/source/.ade/attachments/design.png", type: "image" as const }; + const created = createPromptStash(db, { text: "Use this design", attachments: [image] }); + db.run( + "update prompt_stashes set attachment_origin_site_id = ? where id = ?", + [` \n${db.sync.getSiteId().toUpperCase()}\t `, created.id], + ); + + expect(listPromptStashes(db)).toEqual([ + expect.objectContaining({ + id: created.id, + attachments: [image], + attachmentCount: 1, + attachmentsAvailable: true, + }), + ]); + expect(listPromptStashAttachmentPaths(db)).toEqual(new Set([image.path])); + }); + + it("keeps portable image URLs while withholding cross-site image paths", () => { + const localImage = { path: "/source/.ade/attachments/design.png", type: "image" as const }; + const portableImage = { + path: "https://example.com/reference.png", + type: "image-url" as const, + url: "https://example.com/reference.png", + }; + const created = createPromptStash(db, { + text: "Compare these designs", + attachments: [localImage, portableImage], + }); + db.run( + "update prompt_stashes set attachment_origin_site_id = ? where id = ?", + ["different-runtime", created.id], + ); + + expect(listPromptStashes(db)).toEqual([ + expect.objectContaining({ + id: created.id, + attachments: [portableImage], + attachmentCount: 2, + attachmentsAvailable: false, + }), + ]); + expect(listPromptStashAttachmentPaths(db)).toEqual(new Set()); + }); + it("rejects empty, excessively large, and malformed stashes", () => { expect(() => createPromptStash(db, { text: " \n\t " })).toThrow("cannot be empty"); expect(() => createPromptStash(db, { diff --git a/apps/desktop/src/main/services/chat/promptStashService.ts b/apps/desktop/src/main/services/chat/promptStashService.ts index 6f5c71b49..3cf217825 100644 --- a/apps/desktop/src/main/services/chat/promptStashService.ts +++ b/apps/desktop/src/main/services/chat/promptStashService.ts @@ -81,17 +81,23 @@ function parseAttachments(json: string): AgentChatFileRef[] { type PromptStashSiteDb = Pick & Partial>; +function normalizeSiteId(value: unknown): string | null { + if (typeof value !== "string") return null; + const normalized = value.trim().toLowerCase(); + return normalized || null; +} + function currentSiteId(db: PromptStashSiteDb): string | null { try { - const siteId = db.sync?.getSiteId().trim().toLowerCase(); + const siteId = normalizeSiteId(db.sync?.getSiteId()); if (siteId) return siteId; } catch { // Fall through to the SQL read for narrow test/runtime adapters. } try { - return db.get<{ site_id: string }>( + return normalizeSiteId(db.get<{ site_id: string }>( "select lower(hex(crsql_site_id())) as site_id", - )?.site_id ?? null; + )?.site_id); } catch { return null; } @@ -99,15 +105,19 @@ function currentSiteId(db: PromptStashSiteDb): string | null { function fromRow(row: PromptStashRow, localSiteId: string | null): PromptStashEntry { const storedAttachments = parseAttachments(row.attachments_json); - const hasMachineBoundImages = storedAttachments.some((attachment) => attachment.type === "image"); - const attachmentsAvailable = !hasMachineBoundImages - || Boolean(localSiteId && row.attachment_origin_site_id === localSiteId); + const originMatches = Boolean( + localSiteId + && normalizeSiteId(row.attachment_origin_site_id) === localSiteId, + ); + const attachments = storedAttachments.filter((attachment) => ( + attachment.type !== "image" || originMatches + )); return { id: row.id, text: row.text, - attachments: attachmentsAvailable ? storedAttachments : [], + attachments, attachmentCount: storedAttachments.length, - attachmentsAvailable, + attachmentsAvailable: attachments.length === storedAttachments.length, provider: row.provider, modelId: row.model_id, createdAt: row.created_at, @@ -151,14 +161,14 @@ export function listPromptStashAttachmentPaths( ` select attachments_json, attachment_origin_site_id from prompt_stashes - where attachment_origin_site_id = ? `, - [localSiteId], ); return new Set(rows.flatMap((row) => ( - parseAttachments(row.attachments_json) + normalizeSiteId(row.attachment_origin_site_id) === localSiteId + ? parseAttachments(row.attachments_json) .filter((attachment) => attachment.type === "image") .map((attachment) => attachment.path) + : [] ))); } diff --git a/apps/desktop/src/renderer/components/chat/ComposerPromptStash.test.tsx b/apps/desktop/src/renderer/components/chat/ComposerPromptStash.test.tsx index 34c500fd2..226de9575 100644 --- a/apps/desktop/src/renderer/components/chat/ComposerPromptStash.test.tsx +++ b/apps/desktop/src/renderer/components/chat/ComposerPromptStash.test.tsx @@ -377,6 +377,97 @@ describe("ComposerPromptStash", () => { expect(menu.className).toContain("fixed"); }); + it("repositions the portal when asynchronous menu content changes its height", async () => { + let resizeCallback: ResizeObserverCallback | null = null; + const observedElements: Element[] = []; + const disconnected = vi.fn(); + const originalResizeObserver = globalThis.ResizeObserver; + const originalInnerHeight = window.innerHeight; + const originalInnerWidth = window.innerWidth; + class TestResizeObserver implements ResizeObserver { + constructor(callback: ResizeObserverCallback) { + resizeCallback = callback; + } + + observe(target: Element) { + observedElements.push(target); + } + + unobserve() {} + + disconnect() { + disconnected(); + } + } + Object.defineProperty(globalThis, "ResizeObserver", { + configurable: true, + value: TestResizeObserver, + }); + Object.defineProperty(window, "innerHeight", { configurable: true, value: 800 }); + Object.defineProperty(window, "innerWidth", { configurable: true, value: 1_000 }); + + try { + installBridge({ list: vi.fn().mockResolvedValue([savedEntry]) }); + const view = render( + , + ); + + const openButton = await screen.findByRole("button", { name: "Open 1 stashed prompt" }); + const anchor = view.container.firstElementChild as HTMLElement; + vi.spyOn(anchor, "getBoundingClientRect").mockReturnValue({ + bottom: 728, + height: 28, + left: 872, + right: 900, + top: 700, + width: 28, + x: 872, + y: 700, + toJSON: () => ({}), + }); + fireEvent.click(openButton); + + const menu = await screen.findByRole("dialog", { name: "Stashed prompts" }); + expect(observedElements).toContain(menu); + vi.spyOn(menu, "getBoundingClientRect").mockReturnValue({ + bottom: 300, + height: 300, + left: 0, + right: 380, + top: 0, + width: 380, + x: 0, + y: 0, + toJSON: () => ({}), + }); + + resizeCallback?.([], {} as ResizeObserver); + + await waitFor(() => expect(menu.style.top).toBe("390px")); + view.unmount(); + expect(disconnected).toHaveBeenCalledTimes(1); + } finally { + Object.defineProperty(globalThis, "ResizeObserver", { + configurable: true, + value: originalResizeObserver, + }); + Object.defineProperty(window, "innerHeight", { + configurable: true, + value: originalInnerHeight, + }); + Object.defineProperty(window, "innerWidth", { + configurable: true, + value: originalInnerWidth, + }); + } + }); + it("closes the stash menu without consuming an entry when the user starts a new draft", async () => { const remove = vi.fn().mockResolvedValue(true); installBridge({ diff --git a/apps/desktop/src/renderer/components/chat/ComposerPromptStash.tsx b/apps/desktop/src/renderer/components/chat/ComposerPromptStash.tsx index e65778178..9c4056618 100644 --- a/apps/desktop/src/renderer/components/chat/ComposerPromptStash.tsx +++ b/apps/desktop/src/renderer/components/chat/ComposerPromptStash.tsx @@ -290,7 +290,14 @@ export const ComposerPromptStash = forwardRef { + resizeObserver?.disconnect(); window.removeEventListener("resize", updatePosition); window.removeEventListener("scroll", updatePosition, true); }; diff --git a/apps/ios/ADE/Services/Database.swift b/apps/ios/ADE/Services/Database.swift index 3e6b4f2fe..f591761ff 100644 --- a/apps/ios/ADE/Services/Database.swift +++ b/apps/ios/ADE/Services/Database.swift @@ -3242,7 +3242,9 @@ final class DatabaseService { } do { - try run(sql) + // Keep bootstrap ADD COLUMN migrations on existing CRRs inside the same + // begin/commit alter path used by the rest of the database adapter. + try exec(sql) } catch { let lowered = sql.lowercased() let message = (error as NSError).localizedDescription.lowercased() diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 50cb4c5c6..64bde4d0e 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -7790,6 +7790,16 @@ final class ADETests: XCTestCase { XCTAssertEqual(promptChanges.first(where: { $0.cid == "attachments_json" })?.val, .string(attachmentJson)) XCTAssertEqual(promptChanges.first(where: { $0.cid == "attachment_origin_site_id" })?.val, .string(siteId)) + let updatedAttachmentJson = "[{\"path\":\"https://example.com/reference.png\",\"type\":\"image-url\",\"url\":\"https://example.com/reference.png\"}]" + try upgradedDatabase.executeSqlForTesting(""" + update prompt_stashes + set attachments_json = '\(updatedAttachmentJson)' + where id = '\(stashId)' + """) + let upgradedPromptChanges = upgradedDatabase.exportChangesSince(version: 0) + .filter { $0.table == "prompt_stashes" && $0.cid == "attachments_json" } + XCTAssertEqual(upgradedPromptChanges.last?.val, .string(updatedAttachmentJson)) + upgradedDatabase.close() } From 41198c5c71e56854dc8b6f057ce131fec0d7f73c Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:43:12 -0400 Subject: [PATCH 3/6] =?UTF-8?q?ship:=20iteration=202=20=E2=80=94=20fix=20t?= =?UTF-8?q?ypecheck=20and=20browser=20mock=20parity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/desktop/src/renderer/browserMock.test.ts | 63 +++++++++++++++++++ apps/desktop/src/renderer/browserMock.ts | 18 ++++-- .../chat/ComposerPromptStash.test.tsx | 6 +- 3 files changed, 81 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/src/renderer/browserMock.test.ts diff --git a/apps/desktop/src/renderer/browserMock.test.ts b/apps/desktop/src/renderer/browserMock.test.ts new file mode 100644 index 000000000..05e945bc7 --- /dev/null +++ b/apps/desktop/src/renderer/browserMock.test.ts @@ -0,0 +1,63 @@ +// @vitest-environment jsdom + +import { beforeAll, describe, expect, it, vi } from "vitest"; + +vi.mock("./browserRuntimeBridge", () => ({ + attachBrowserRuntimeBridge: vi.fn(async () => false), +})); + +beforeAll(async () => { + const browserWindow = window as unknown as { + ade?: unknown; + __adeBrowserMock?: boolean; + }; + delete browserWindow.ade; + delete browserWindow.__adeBrowserMock; + await import("./browserMock"); +}); + +describe("browserMock prompt stashes", () => { + it("round-trips image URL attachments through create, list, and delete", async () => { + const imageUrl = "https://example.com/reference.png"; + const created = await window.ade.agentChat.promptStashes.create({ + text: "Use this reference", + attachments: [{ path: imageUrl, type: "image-url", url: imageUrl }], + provider: "codex", + modelId: "openai/gpt-5.6-sol", + }); + + expect(created).toMatchObject({ + text: "Use this reference", + attachments: [{ path: imageUrl, type: "image-url", url: imageUrl }], + attachmentCount: 1, + attachmentsAvailable: true, + }); + await expect(window.ade.agentChat.promptStashes.list()).resolves.toContainEqual(created); + await expect(window.ade.agentChat.promptStashes.delete({ id: created.id })).resolves.toBe(true); + await expect(window.ade.agentChat.promptStashes.list()).resolves.not.toContainEqual(created); + }); + + it("returns valid image data and round-trips a saved local image attachment", async () => { + const { dataUrl } = await window.ade.agentChat.getImageDataUrl("/tmp/reference.png"); + expect(dataUrl).toMatch(/^data:image\/png;base64,[A-Za-z0-9+/]+=*$/); + + const saved = await window.ade.agentChat.saveTempAttachment({ + data: dataUrl.slice(dataUrl.indexOf(",") + 1), + filename: "reference.png", + }); + const created = await window.ade.agentChat.promptStashes.create({ + text: "", + attachments: [{ path: saved.path, type: "image" }], + }); + + await expect(window.ade.agentChat.promptStashes.list()).resolves.toContainEqual( + expect.objectContaining({ + id: created.id, + attachments: [{ path: saved.path, type: "image" }], + attachmentCount: 1, + attachmentsAvailable: true, + }), + ); + await window.ade.agentChat.promptStashes.delete({ id: created.id }); + }); +}); diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 652823bff..0c5bddaa7 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -47,6 +47,7 @@ import { type AgentChatResolveUnprocessedMessageArgs, type AgentChatResolveUnprocessedMessageResult, MAX_PROMPT_STASHES, + type PromptStashCreateArgs, type PromptStashEntry, type RemoteRuntimeActionRequest, } from "../shared/types"; @@ -86,6 +87,8 @@ const BROWSER_MOCK_PREVIEW_CAPABILITY_UNSUPPORTED = { error: "Browser preview cannot manage Xcode.", checkedAt: "1970-01-01T00:00:00.000Z", } as const; +const BROWSER_MOCK_IMAGE_DATA_URL = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/l5mS9QAAAABJRU5ErkJggg=="; const BUILTIN_MOCK_PROJECT = { id: "browser-mock", @@ -3244,7 +3247,7 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { hasClipboardImage: resolved(false), readClipboardImage: resolved(null), saveClipboardImageAttachment: resolved(null), - getImageDataUrl: resolvedArg({ dataUrl: "" }), + getImageDataUrl: resolvedArg({ dataUrl: BROWSER_MOCK_IMAGE_DATA_URL }), writeClipboardImage: resolvedArg(undefined), openPath: resolvedArg(undefined), openPathInEditor: resolvedArg(undefined), @@ -4857,11 +4860,18 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { set: resolvedArg(undefined), }, promptStashes: { - list: async () => [...browserMockPromptStashes], - create: async (args: { text: string; provider?: string | null; modelId?: string | null }) => { + list: async () => browserMockPromptStashes.map((entry) => ({ + ...entry, + attachments: entry.attachments?.map((attachment) => ({ ...attachment })), + })), + create: async (args: PromptStashCreateArgs) => { + const attachments = (args.attachments ?? []).map((attachment) => ({ ...attachment })); const entry: PromptStashEntry = { id: globalThis.crypto.randomUUID(), text: args.text, + attachments, + attachmentCount: attachments.length, + attachmentsAvailable: true, provider: args.provider ?? null, modelId: args.modelId ?? null, createdAt: new Date().toISOString(), @@ -5042,7 +5052,7 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { }, }), saveTempAttachment: resolvedArg({ path: "/tmp/browser-mock-attachment" }), - getImageDataUrl: resolvedArg({ dataUrl: "" }), + getImageDataUrl: resolvedArg({ dataUrl: BROWSER_MOCK_IMAGE_DATA_URL }), resolveSmartLinkPreview: async ({ url }: { url: string }) => deriveSmartLinkPreview(url), getEventHistory: async (arg: { sessionId: string; diff --git a/apps/desktop/src/renderer/components/chat/ComposerPromptStash.test.tsx b/apps/desktop/src/renderer/components/chat/ComposerPromptStash.test.tsx index 226de9575..21eadc151 100644 --- a/apps/desktop/src/renderer/components/chat/ComposerPromptStash.test.tsx +++ b/apps/desktop/src/renderer/components/chat/ComposerPromptStash.test.tsx @@ -378,7 +378,9 @@ describe("ComposerPromptStash", () => { }); it("repositions the portal when asynchronous menu content changes its height", async () => { - let resizeCallback: ResizeObserverCallback | null = null; + let resizeCallback: ResizeObserverCallback = () => { + throw new Error("ResizeObserver callback was not installed"); + }; const observedElements: Element[] = []; const disconnected = vi.fn(); const originalResizeObserver = globalThis.ResizeObserver; @@ -447,7 +449,7 @@ describe("ComposerPromptStash", () => { toJSON: () => ({}), }); - resizeCallback?.([], {} as ResizeObserver); + resizeCallback([], {} as ResizeObserver); await waitFor(() => expect(menu.style.top).toBe("390px")); view.unmount(); From edcc4bc5a71e2313bfe0940775354d120fb300b0 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:08:24 -0400 Subject: [PATCH 4/6] =?UTF-8?q?ship:=20iteration=203=20=E2=80=94=20pin=20s?= =?UTF-8?q?tash=20reads=20and=20prune=20synced=20overflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/chat/agentChatService.test.ts | 1 + .../main/services/chat/agentChatService.ts | 5 +- .../services/chat/promptStashService.test.ts | 74 +++++++++++++++++++ .../main/services/chat/promptStashService.ts | 40 ++++++---- apps/desktop/src/preload/global.d.ts | 5 +- apps/desktop/src/preload/preload.test.ts | 34 ++++++++- apps/desktop/src/preload/preload.ts | 7 +- .../chat/AgentChatComposer.test.tsx | 59 +++++++++++++++ .../components/chat/AgentChatComposer.tsx | 4 + .../components/chat/AgentChatPane.tsx | 1 + .../chat/ComposerPromptStash.test.tsx | 67 ++++++++++++++++- .../components/chat/ComposerPromptStash.tsx | 13 +++- 12 files changed, 283 insertions(+), 27 deletions(-) diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index e5952b165..07c866f89 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -17135,6 +17135,7 @@ describe("createAgentChatService", () => { db: { getJson: vi.fn(), setJson: vi.fn(), + run: vi.fn(), get: vi.fn().mockReturnValue({ site_id: "site-a" }), all: vi.fn().mockReturnValue([{ attachments_json: JSON.stringify([{ path: stashedImage, type: "image" }]), diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index eaa734946..ef5fe60b9 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -6579,7 +6579,7 @@ export function createAgentChatService(args: { projectConfigService: ReturnType; db?: ( Pick - & Partial> + & Partial> ) | null; aiIntegrationService: ReturnType; logger: Logger; @@ -42321,8 +42321,9 @@ export function createAgentChatService(args: { const protectedAttachmentPaths = promptStashDb && typeof promptStashDb.get === "function" && typeof promptStashDb.all === "function" + && typeof promptStashDb.run === "function" ? new Set(Array.from(listPromptStashAttachmentPaths( - promptStashDb as Pick & Partial>, + promptStashDb as Pick & Partial>, ), (filePath) => path.resolve(filePath))) : new Set(); const cleanupDir = (dirPath: string) => { diff --git a/apps/desktop/src/main/services/chat/promptStashService.test.ts b/apps/desktop/src/main/services/chat/promptStashService.test.ts index 0cadc6546..0e8779adb 100644 --- a/apps/desktop/src/main/services/chat/promptStashService.test.ts +++ b/apps/desktop/src/main/services/chat/promptStashService.test.ts @@ -22,6 +22,33 @@ function createLogger() { } as const; } +function insertSyncedPromptStash( + db: AdeDb, + entry: { + id: string; + createdAt: string; + attachmentPath?: string; + }, +): void { + db.run( + ` + insert into prompt_stashes( + id, text, attachments_json, attachment_origin_site_id, provider, model_id, created_at + ) + values (?, ?, ?, ?, null, null, ?) + `, + [ + entry.id, + entry.id, + JSON.stringify(entry.attachmentPath + ? [{ path: entry.attachmentPath, type: "image" }] + : []), + entry.attachmentPath ? db.sync.getSiteId() : null, + entry.createdAt, + ], + ); +} + describe("promptStashService", () => { let root: string; let db: AdeDb; @@ -160,6 +187,53 @@ describe("promptStashService", () => { expect(listed.map((entry) => entry.id)).not.toContain(created[2]?.id); }); + it("prunes synchronized overflow before returning a bounded list", () => { + const olderTimestamp = "2026-07-28T12:00:00.000Z"; + const newerTimestamp = "2026-07-28T12:00:01.000Z"; + for (let index = 0; index < MAX_PROMPT_STASHES + 3; index += 1) { + insertSyncedPromptStash(db, { + id: `synced-${String(index).padStart(2, "0")}`, + createdAt: index === 0 ? olderTimestamp : newerTimestamp, + }); + } + + const listed = listPromptStashes(db, 5); + const retainedIds = db.all<{ id: string }>( + "select id from prompt_stashes order by created_at desc, id desc", + ).map((row) => row.id); + + expect(listed.map((entry) => entry.id)).toEqual(retainedIds.slice(0, 5)); + expect(retainedIds).toHaveLength(MAX_PROMPT_STASHES); + expect(retainedIds).not.toContain("synced-00"); + expect(retainedIds).not.toContain("synced-01"); + expect(retainedIds).not.toContain("synced-02"); + }); + + it("prunes synchronized overflow before protecting live attachment paths", () => { + const inserted = Array.from( + { length: MAX_PROMPT_STASHES + 3 }, + (_, index) => ({ + id: `synced-image-${String(index).padStart(2, "0")}`, + createdAt: new Date(Date.parse("2026-07-28T12:00:00.000Z") + index).toISOString(), + attachmentPath: path.join(root, ".ade", "attachments", `image-${index}.png`), + }), + ); + inserted.forEach((entry) => insertSyncedPromptStash(db, entry)); + + const protectedPaths = listPromptStashAttachmentPaths(db); + const retainedIds = db.all<{ id: string }>( + "select id from prompt_stashes order by created_at desc, id desc", + ).map((row) => row.id); + + expect(retainedIds).toHaveLength(MAX_PROMPT_STASHES); + expect(protectedPaths).toEqual(new Set( + inserted.slice(-MAX_PROMPT_STASHES).map((entry) => entry.attachmentPath), + )); + expect(protectedPaths.has(inserted[0]!.attachmentPath)).toBe(false); + expect(protectedPaths.has(inserted[1]!.attachmentPath)).toBe(false); + expect(protectedPaths.has(inserted[2]!.attachmentPath)).toBe(false); + }); + it("deletes atomically and reports already-consumed stashes", () => { const created = createPromptStash(db, { text: "restore me" }); diff --git a/apps/desktop/src/main/services/chat/promptStashService.ts b/apps/desktop/src/main/services/chat/promptStashService.ts index 3cf217825..463c39f4d 100644 --- a/apps/desktop/src/main/services/chat/promptStashService.ts +++ b/apps/desktop/src/main/services/chat/promptStashService.ts @@ -135,10 +135,34 @@ function nextCreatedAt(db: AdeDb): string { )).toISOString(); } +type PromptStashRetentionDb = Pick; + +/** + * Keep retention convergent when CRR sync delivers rows created by another + * runtime. The deterministic created_at/id order matches local creation, and + * the single DELETE remains safe on partially converged replicas: adding more + * rows cannot promote an entry that was already outside the top N. + */ +function prunePromptStashRetention(db: PromptStashRetentionDb): void { + db.run( + ` + delete from prompt_stashes + where id in ( + select id + from prompt_stashes + order by created_at desc, id desc + limit -1 offset ? + ) + `, + [MAX_PROMPT_STASHES], + ); +} + export function listPromptStashes( db: AdeDb, limit = MAX_PROMPT_STASHES, ): PromptStashEntry[] { + prunePromptStashRetention(db); const normalizedLimit = Number.isFinite(limit) ? Math.floor(limit) : MAX_PROMPT_STASHES; const safeLimit = Math.max(1, Math.min(MAX_PROMPT_STASHES, normalizedLimit)); return db.all( @@ -153,8 +177,9 @@ export function listPromptStashes( } export function listPromptStashAttachmentPaths( - db: Pick & Partial>, + db: Pick & Partial>, ): Set { + prunePromptStashRetention(db); const localSiteId = currentSiteId(db); if (!localSiteId) return new Set(); const rows = db.all>( @@ -217,18 +242,7 @@ export function createPromptStash( ], ); - db.run( - ` - delete from prompt_stashes - where id in ( - select id - from prompt_stashes - order by created_at desc, id desc - limit -1 offset ? - ) - `, - [MAX_PROMPT_STASHES], - ); + prunePromptStashRetention(db); return entry; } diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 45b8f64b9..02d609e16 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -1725,7 +1725,10 @@ declare global { }, pin?: OpenProjectBinding | null, ) => Promise<{ path: string }>; - getImageDataUrl: (path: string) => Promise<{ dataUrl: string }>; + getImageDataUrl: ( + path: string, + pin?: OpenProjectBinding | null, + ) => Promise<{ dataUrl: string }>; resolveSmartLinkPreview: (args: { url: string }) => Promise; getEventHistory: ( args: { diff --git a/apps/desktop/src/preload/preload.test.ts b/apps/desktop/src/preload/preload.test.ts index 27efb649f..02ac47319 100644 --- a/apps/desktop/src/preload/preload.test.ts +++ b/apps/desktop/src/preload/preload.test.ts @@ -865,7 +865,7 @@ describe("preload OAuth bridge", () => { expect(invoke).not.toHaveBeenCalledWith(IPC.appOpenPathInEditor, expect.anything()); }); - it("routes chat image preview reads through the remote runtime for remote project paths", async () => { + it("routes chat image preview reads through the bound or explicitly pinned runtime", async () => { const binding = { kind: "remote", key: "remote:target-1:project-1", @@ -875,6 +875,15 @@ describe("preload OAuth bridge", () => { rootPath: "/remote/project", displayName: "Project", }; + const chatRuntimePin = { + kind: "remote", + key: "remote:target-2:project-2", + targetId: "target-2", + runtimeName: "Remote chat", + projectId: "project-2", + rootPath: "/remote/chat-project", + displayName: "Chat project", + }; const invoke = vi.fn(async (channel: string, payload?: unknown) => { if (channel === IPC.appGetWindowSession) { return { windowId: 1, project: null, binding }; @@ -911,9 +920,9 @@ describe("preload OAuth bridge", () => { await import("./preload"); const bridge = (globalThis as any).__adeBridge; - await expect(bridge.agentChat.getImageDataUrl("/remote/project/.ade/attachments/image.png")) - .resolves.toEqual({ dataUrl: "data:image/png;base64,REMOTE" }); - + await expect(bridge.agentChat.getImageDataUrl( + "/remote/project/.ade/attachments/image.png", + )).resolves.toEqual({ dataUrl: "data:image/png;base64,REMOTE" }); expect(invoke).toHaveBeenCalledWith(IPC.remoteRuntimeCallAction, { id: "target-1", projectId: "project-1", @@ -923,6 +932,23 @@ describe("preload OAuth bridge", () => { args: { path: "/remote/project/.ade/attachments/image.png" }, }, }); + invoke.mockClear(); + + await expect(bridge.agentChat.getImageDataUrl( + "/remote/chat-project/.ade/attachments/image.png", + chatRuntimePin, + )) + .resolves.toEqual({ dataUrl: "data:image/png;base64,REMOTE" }); + + expect(invoke).toHaveBeenCalledWith(IPC.remoteRuntimeCallAction, { + id: "target-2", + projectId: "project-2", + request: { + domain: "chat", + action: "getImageDataUrl", + args: { path: "/remote/chat-project/.ade/attachments/image.png" }, + }, + }); expect(invoke).not.toHaveBeenCalledWith(IPC.appGetImageDataUrl, expect.anything()); }); diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 2ea1fffb0..f9aeeb1d2 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -6387,8 +6387,11 @@ contextBridge.exposeInMainWorld("ade", { callPinnedOrBoundRuntimeActionOr(pin, "chat", "saveTempAttachment", { args }, () => ipcRenderer.invoke(IPC.agentChatSaveTempAttachment, args), ), - getImageDataUrl: async (path: string): Promise<{ dataUrl: string }> => - callProjectRuntimeActionOr("chat", "getImageDataUrl", { args: { path } }, () => + getImageDataUrl: async ( + path: string, + pin?: OpenProjectBinding | null, + ): Promise<{ dataUrl: string }> => + callPinnedOrBoundRuntimeActionOr(pin, "chat", "getImageDataUrl", { args: { path } }, () => ipcRenderer.invoke(IPC.appGetImageDataUrl, { path }), ), resolveSmartLinkPreview: async (args: { url: string }): Promise => diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index 619ac95d9..d1a9ae0d9 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -344,6 +344,65 @@ describe("AgentChatComposer", () => { expect(props.onDraftChange).toHaveBeenCalledWith(""); }); + it("reads a stashed source image through the selected chat runtime pin", async () => { + const chatRuntimePin = { + kind: "remote" as const, + key: "remote:source-machine:source-project", + targetId: "source-machine", + runtimeName: "Source Mac", + projectId: "source-project", + rootPath: "/remote/source-project", + displayName: "Source project", + }; + const sourceAttachment = { + path: "/remote/source-project/design.png", + type: "image" as const, + }; + const storedAttachment = { + path: "/bound-project/.ade/attachments/design.png", + type: "image" as const, + }; + const getImageDataUrl = vi.fn().mockResolvedValue({ + dataUrl: "data:image/png;base64,cHJldmlldw==", + }); + const saveTempAttachment = vi.fn().mockResolvedValue({ + path: storedAttachment.path, + }); + (window as any).ade = { + agentChat: { + promptStashes: { + list: vi.fn().mockResolvedValue([]), + create: vi.fn().mockResolvedValue({ + id: "stash-image", + text: "Need a steer message", + provider: "codex", + modelId: "openai/gpt-5.4", + attachments: [storedAttachment], + createdAt: "2026-07-28T12:00:00.000Z", + }), + delete: vi.fn().mockResolvedValue(true), + }, + getImageDataUrl, + saveTempAttachment, + }, + }; + + renderComposer({ + attachments: [sourceAttachment], + chatRuntimePin, + }); + fireEvent.click(screen.getByRole("button", { name: "Stash prompt" })); + + await waitFor(() => expect(getImageDataUrl).toHaveBeenCalledWith( + sourceAttachment.path, + chatRuntimePin, + )); + expect(saveTempAttachment).toHaveBeenCalledWith({ + data: "cHJldmlldw==", + filename: "design.png", + }); + }); + it("moves a queued steer message back to the composer for editing", () => { const onEditSteer = vi.fn(); const attachments = [{ path: "docs/queued.md", type: "file" as const }]; diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index 646676a68..a10e07481 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -1203,6 +1203,7 @@ export function AgentChatComposer({ draft, lastSentUserMessage = null, attachments, + chatRuntimePin = null, contextAttachments = [], allowAttachmentOnlySubmit = false, pinnedLinearIssue = null, @@ -1343,6 +1344,8 @@ export function AgentChatComposer({ /** Last message the user sent in this chat — recalled by ArrowUp on line 1. */ lastSentUserMessage?: string | null; attachments: AgentChatFileRef[]; + /** Explicit runtime owning attachments for the selected chat; null uses the bound project runtime. */ + chatRuntimePin?: OpenProjectBinding | null; contextAttachments?: AgentChatContextAttachment[]; allowAttachmentOnlySubmit?: boolean; pinnedLinearIssue?: LaneLinearIssue | null; @@ -4645,6 +4648,7 @@ export function AgentChatComposer({ ref={promptStashRef} draft={draft} attachments={attachments} + chatRuntimePin={chatRuntimePin} provider={sessionProvider} modelId={modelId} active={isActive} diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index bf1d3753d..afd171122 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -11724,6 +11724,7 @@ export function AgentChatPane({ draft={draft} lastSentUserMessage={lastSentUserMessage} attachments={attachments} + chatRuntimePin={chatRuntimePin} contextAttachments={contextAttachments} allowAttachmentOnlySubmit={workDraftKind === "cli"} pinnedLinearIssue={pinnedLinearIssue} diff --git a/apps/desktop/src/renderer/components/chat/ComposerPromptStash.test.tsx b/apps/desktop/src/renderer/components/chat/ComposerPromptStash.test.tsx index 21eadc151..47ab1291d 100644 --- a/apps/desktop/src/renderer/components/chat/ComposerPromptStash.test.tsx +++ b/apps/desktop/src/renderer/components/chat/ComposerPromptStash.test.tsx @@ -3,7 +3,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { createRef, forwardRef, type ComponentProps } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { PromptStashEntry } from "../../../shared/types"; +import type { OpenProjectBinding, PromptStashEntry } from "../../../shared/types"; import { ComposerPromptStash as ProductionComposerPromptStash, type ComposerPromptStashHandle, @@ -187,8 +187,17 @@ describe("ComposerPromptStash", () => { }); it("moves image attachments into a stash and restores their thumbnail and attachment", async () => { + const chatRuntimePin: OpenProjectBinding = { + kind: "remote", + key: "remote:source-machine:source-project", + targetId: "source-machine", + runtimeName: "Source Mac", + projectId: "source-project", + rootPath: "/remote/source-project", + displayName: "Source project", + }; const imageAttachment = { - path: "/Users/me/Desktop/design.png", + path: "/remote/source-project/design.png", type: "image" as const, }; const storedImageAttachment = { @@ -204,13 +213,21 @@ describe("ComposerPromptStash", () => { const saveTempAttachment = vi.fn().mockResolvedValue({ path: storedImageAttachment.path, }); - installBridge({ create, saveTempAttachment }); + const sourceImageRead = vi.fn().mockResolvedValue({ + dataUrl: "data:image/png;base64,cHJldmlldw==", + }); + installBridge({ + create, + getImageDataUrl: sourceImageRead, + saveTempAttachment, + }); const onDraftChange = vi.fn(); const onRemoveAttachment = vi.fn(); const saveView = render( { data: "cHJldmlldw==", filename: "design.png", }); + expect(sourceImageRead).toHaveBeenCalledWith( + imageAttachment.path, + chatRuntimePin, + ); expect(onDraftChange).toHaveBeenCalledWith(""); expect(onRemoveAttachment).toHaveBeenCalledWith(imageAttachment.path); saveView.unmount(); @@ -264,6 +285,46 @@ describe("ComposerPromptStash", () => { await waitFor(() => expect(remove).toHaveBeenCalledWith({ id: imageEntry.id })); }); + it("never falls back to this desktop for an image owned by a pinned remote chat", async () => { + const chatRuntimePin: OpenProjectBinding = { + kind: "remote", + key: "remote:source-machine:source-project", + targetId: "source-machine", + runtimeName: "Source Mac", + projectId: "source-project", + rootPath: "/remote/source-project", + displayName: "Source project", + }; + const runtimeRead = vi.fn().mockRejectedValue(new Error("source runtime unavailable")); + installBridge({ getImageDataUrl: runtimeRead }); + const localRead = vi.fn(); + (window as any).ade.app = { getImageDataUrl: localRead }; + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Stash prompt" })); + + expect((await screen.findByRole("alert")).textContent).toContain("source runtime unavailable"); + expect(runtimeRead).toHaveBeenCalledWith( + "/remote/source-project/design.png", + chatRuntimePin, + ); + expect(localRead).not.toHaveBeenCalled(); + }); + it("keeps the original image when an older runtime cannot confirm attachment persistence", async () => { const imageAttachment = { path: "/Users/me/Desktop/design.png", diff --git a/apps/desktop/src/renderer/components/chat/ComposerPromptStash.tsx b/apps/desktop/src/renderer/components/chat/ComposerPromptStash.tsx index 9c4056618..70f1cff7b 100644 --- a/apps/desktop/src/renderer/components/chat/ComposerPromptStash.tsx +++ b/apps/desktop/src/renderer/components/chat/ComposerPromptStash.tsx @@ -21,6 +21,7 @@ import { type AgentChatFileRef, MAX_PROMPT_STASH_ATTACHMENTS, MAX_PROMPT_STASHES, + type OpenProjectBinding, type PromptStashEntry, } from "../../../shared/types"; import { cn } from "../ui/cn"; @@ -154,6 +155,7 @@ function StashImageThumbnail({ attachment }: { attachment: AgentChatFileRef }) { export type ComposerPromptStashProps = { draft: string; attachments?: AgentChatFileRef[]; + chatRuntimePin?: OpenProjectBinding | null; provider?: string | null; modelId?: string | null; active: boolean; @@ -168,6 +170,7 @@ export type ComposerPromptStashProps = { export const ComposerPromptStash = forwardRef(function ComposerPromptStash({ draft, attachments = [], + chatRuntimePin = null, provider, modelId, active, @@ -361,8 +364,14 @@ export const ComposerPromptStash = forwardRef { if (operationInFlightRef.current) return; From e2058ba70bf934852356f13e01cfc4470b196f5c Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:35:19 -0400 Subject: [PATCH 5/6] =?UTF-8?q?ship:=20iteration=204=20=E2=80=94=20bound?= =?UTF-8?q?=20image-copy=20memory=20and=20restore=20CRR=20triggers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/desktop/src/renderer/browserMock.test.ts | 3 +- .../chat/AgentChatComposer.test.tsx | 41 ++++++--- .../chat/ComposerPromptStash.test.tsx | 89 ++++++++++++++++++- .../components/chat/ComposerPromptStash.tsx | 12 ++- apps/ios/ADE/Services/Database.swift | 19 +++- apps/ios/ADETests/ADETests.swift | 34 +++++++ 6 files changed, 177 insertions(+), 21 deletions(-) diff --git a/apps/desktop/src/renderer/browserMock.test.ts b/apps/desktop/src/renderer/browserMock.test.ts index 05e945bc7..178fd2211 100644 --- a/apps/desktop/src/renderer/browserMock.test.ts +++ b/apps/desktop/src/renderer/browserMock.test.ts @@ -58,6 +58,7 @@ describe("browserMock prompt stashes", () => { attachmentsAvailable: true, }), ); - await window.ade.agentChat.promptStashes.delete({ id: created.id }); + await expect(window.ade.agentChat.promptStashes.delete({ id: created.id })).resolves.toBe(true); + await expect(window.ade.agentChat.promptStashes.list()).resolves.not.toContainEqual(created); }); }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index d1a9ae0d9..19d0bda3c 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -344,7 +344,7 @@ describe("AgentChatComposer", () => { expect(props.onDraftChange).toHaveBeenCalledWith(""); }); - it("reads a stashed source image through the selected chat runtime pin", async () => { + it("reads a stashed source image through the selected runtime and saves the copy through the bound runtime", async () => { const chatRuntimePin = { kind: "remote" as const, key: "remote:source-machine:source-project", @@ -368,18 +368,19 @@ describe("AgentChatComposer", () => { const saveTempAttachment = vi.fn().mockResolvedValue({ path: storedAttachment.path, }); + const createPromptStash = vi.fn().mockResolvedValue({ + id: "stash-image", + text: "Need a steer message", + provider: "codex", + modelId: "openai/gpt-5.4", + attachments: [storedAttachment], + createdAt: "2026-07-28T12:00:00.000Z", + }); (window as any).ade = { agentChat: { promptStashes: { list: vi.fn().mockResolvedValue([]), - create: vi.fn().mockResolvedValue({ - id: "stash-image", - text: "Need a steer message", - provider: "codex", - modelId: "openai/gpt-5.4", - attachments: [storedAttachment], - createdAt: "2026-07-28T12:00:00.000Z", - }), + create: createPromptStash, delete: vi.fn().mockResolvedValue(true), }, getImageDataUrl, @@ -397,10 +398,24 @@ describe("AgentChatComposer", () => { sourceAttachment.path, chatRuntimePin, )); - expect(saveTempAttachment).toHaveBeenCalledWith({ - data: "cHJldmlldw==", - filename: "design.png", - }); + await waitFor(() => expect(createPromptStash).toHaveBeenCalledWith({ + text: "Need a steer message", + provider: "codex", + modelId: "openai/gpt-5.4", + attachments: [storedAttachment], + })); + expect(getImageDataUrl.mock.calls).toContainEqual([ + sourceAttachment.path, + chatRuntimePin, + ]); + // The source path belongs to the selected chat runtime, but the durable + // stash copy belongs to the bound project runtime that owns prompt stashes. + expect(saveTempAttachment.mock.calls).toEqual([[ + { + data: "cHJldmlldw==", + filename: "design.png", + }, + ]]); }); it("moves a queued steer message back to the composer for editing", () => { diff --git a/apps/desktop/src/renderer/components/chat/ComposerPromptStash.test.tsx b/apps/desktop/src/renderer/components/chat/ComposerPromptStash.test.tsx index 47ab1291d..f327c2fa5 100644 --- a/apps/desktop/src/renderer/components/chat/ComposerPromptStash.test.tsx +++ b/apps/desktop/src/renderer/components/chat/ComposerPromptStash.test.tsx @@ -1,6 +1,6 @@ /* @vitest-environment jsdom */ -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { createRef, forwardRef, type ComponentProps } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenProjectBinding, PromptStashEntry } from "../../../shared/types"; @@ -285,6 +285,93 @@ describe("ComposerPromptStash", () => { await waitFor(() => expect(remove).toHaveBeenCalledWith({ id: imageEntry.id })); }); + it("copies stashed images sequentially and preserves their composer order", async () => { + const sourceAttachments = [ + { path: "/Users/me/Desktop/first.png", type: "image" as const }, + { path: "/Users/me/Desktop/second.png", type: "image" as const }, + ]; + const storedAttachments = [ + { path: "/project/.ade/attachments/first.png", type: "image" as const }, + { path: "/project/.ade/attachments/second.png", type: "image" as const }, + ]; + let resolveFirstRead: ((result: { dataUrl: string }) => void) | undefined; + let resolveSecondRead: ((result: { dataUrl: string }) => void) | undefined; + let resolveFirstSave: ((result: { path: string }) => void) | undefined; + let resolveSecondSave: ((result: { path: string }) => void) | undefined; + const getImageDataUrl = vi.fn() + .mockImplementationOnce(() => new Promise<{ dataUrl: string }>((resolve) => { + resolveFirstRead = resolve; + })) + .mockImplementationOnce(() => new Promise<{ dataUrl: string }>((resolve) => { + resolveSecondRead = resolve; + })); + const saveTempAttachment = vi.fn() + .mockImplementationOnce(() => new Promise<{ path: string }>((resolve) => { + resolveFirstSave = resolve; + })) + .mockImplementationOnce(() => new Promise<{ path: string }>((resolve) => { + resolveSecondSave = resolve; + })); + const create = vi.fn().mockResolvedValue({ + ...savedEntry, + attachments: storedAttachments, + }); + installBridge({ create, getImageDataUrl, saveTempAttachment }); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Stash prompt" })); + + await waitFor(() => expect(getImageDataUrl).toHaveBeenCalledTimes(1)); + expect(getImageDataUrl).toHaveBeenNthCalledWith(1, sourceAttachments[0]!.path, null); + expect(saveTempAttachment).not.toHaveBeenCalled(); + + await act(async () => { + resolveFirstRead?.({ dataUrl: "data:image/png;base64,Zmlyc3Q=" }); + }); + await waitFor(() => expect(saveTempAttachment).toHaveBeenCalledTimes(1)); + expect(saveTempAttachment).toHaveBeenNthCalledWith(1, { + data: "Zmlyc3Q=", + filename: "first.png", + }); + expect(getImageDataUrl).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveFirstSave?.({ path: storedAttachments[0]!.path }); + }); + await waitFor(() => expect(getImageDataUrl).toHaveBeenCalledTimes(2)); + expect(getImageDataUrl).toHaveBeenNthCalledWith(2, sourceAttachments[1]!.path, null); + expect(saveTempAttachment).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveSecondRead?.({ dataUrl: "data:image/png;base64,c2Vjb25k" }); + }); + await waitFor(() => expect(saveTempAttachment).toHaveBeenCalledTimes(2)); + expect(saveTempAttachment).toHaveBeenNthCalledWith(2, { + data: "c2Vjb25k", + filename: "second.png", + }); + expect(create).not.toHaveBeenCalled(); + + await act(async () => { + resolveSecondSave?.({ path: storedAttachments[1]!.path }); + }); + await waitFor(() => expect(create).toHaveBeenCalledWith({ + text: "Keep these images ordered", + attachments: storedAttachments, + provider: undefined, + modelId: undefined, + })); + }); + it("never falls back to this desktop for an image owned by a pinned remote chat", async () => { const chatRuntimePin: OpenProjectBinding = { kind: "remote", diff --git a/apps/desktop/src/renderer/components/chat/ComposerPromptStash.tsx b/apps/desktop/src/renderer/components/chat/ComposerPromptStash.tsx index 70f1cff7b..0a994fd88 100644 --- a/apps/desktop/src/renderer/components/chat/ComposerPromptStash.tsx +++ b/apps/desktop/src/renderer/components/chat/ComposerPromptStash.tsx @@ -360,8 +360,12 @@ export const ComposerPromptStash = forwardRef => { - if (attachment.type === "image-url") return attachment; + const storedAttachments: AgentChatFileRef[] = []; + for (const attachment of savedAttachments) { + if (attachment.type === "image-url") { + storedAttachments.push(attachment); + continue; + } let dataUrl: string; try { dataUrl = (await window.ade.agentChat.getImageDataUrl( @@ -380,8 +384,8 @@ export const ComposerPromptStash = forwardRef Date: Tue, 28 Jul 2026 17:05:48 -0400 Subject: [PATCH 6/6] =?UTF-8?q?ship:=20iteration=205=20=E2=80=94=20pin=20p?= =?UTF-8?q?rompt=20stash=20runtime=20ownership?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/desktop/src/preload/global.d.ts | 14 +- apps/desktop/src/preload/preload.test.ts | 144 +++++++++++++ apps/desktop/src/preload/preload.ts | 23 +- apps/desktop/src/renderer/browserMock.ts | 7 +- .../chat/AgentChatComposer.test.tsx | 23 +- .../components/chat/AgentChatComposer.tsx | 8 +- .../components/chat/AgentChatPane.test.tsx | 24 +++ .../components/chat/AgentChatPane.tsx | 2 +- .../chat/ComposerPromptStash.test.tsx | 198 +++++++++++++++--- .../components/chat/ComposerPromptStash.tsx | 128 ++++++++--- 10 files changed, 485 insertions(+), 86 deletions(-) diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 02d609e16..826bdbf98 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -1698,9 +1698,17 @@ declare global { pin?: OpenProjectBinding | null, ) => Promise; promptStashes: { - list: () => Promise; - create: (args: PromptStashCreateArgs) => Promise; - delete: (args: PromptStashDeleteArgs) => Promise; + list: ( + pin?: OpenProjectBinding | null, + ) => Promise; + create: ( + args: PromptStashCreateArgs, + pin?: OpenProjectBinding | null, + ) => Promise; + delete: ( + args: PromptStashDeleteArgs, + pin?: OpenProjectBinding | null, + ) => Promise; }; getTurnFileDiff: ( args: AgentChatGetTurnFileDiffArgs, diff --git a/apps/desktop/src/preload/preload.test.ts b/apps/desktop/src/preload/preload.test.ts index 02ac47319..f4a4c12b6 100644 --- a/apps/desktop/src/preload/preload.test.ts +++ b/apps/desktop/src/preload/preload.test.ts @@ -2579,6 +2579,24 @@ describe("preload OAuth bridge", () => { statusHints: {}, }; } + if (request?.domain === "chat" && request.action === "createPromptStash") { + return { + ok: true, + domain: "chat", + action: "createPromptStash", + result: promptStashes[0], + statusHints: {}, + }; + } + if (request?.domain === "chat" && request.action === "deletePromptStash") { + return { + ok: true, + domain: "chat", + action: "deletePromptStash", + result: true, + statusHints: {}, + }; + } } return undefined; }); @@ -2605,6 +2623,12 @@ describe("preload OAuth bridge", () => { await expect(bridge.sessions.getDelta("session-1")).resolves.toEqual(delta); await expect(bridge.computerUse.readArtifactPreview({ uri: ".ade/artifacts/proof.png" })).resolves.toBe(preview); await expect(bridge.agentChat.promptStashes.list()).resolves.toEqual(promptStashes); + await expect(bridge.agentChat.promptStashes.create({ + text: "Fix the parser", + })).resolves.toEqual(promptStashes[0]); + await expect(bridge.agentChat.promptStashes.delete({ + id: "stash-1", + })).resolves.toBe(true); expect(invoke).toHaveBeenCalledWith(IPC.remoteRuntimeCallAction, { id: "target-1", @@ -2623,6 +2647,24 @@ describe("preload OAuth bridge", () => { action: "listPromptStashes", }, }); + expect(invoke).toHaveBeenCalledWith(IPC.remoteRuntimeCallAction, { + id: "target-1", + projectId: "project-1", + request: { + domain: "chat", + action: "createPromptStash", + args: { text: "Fix the parser" }, + }, + }); + expect(invoke).toHaveBeenCalledWith(IPC.remoteRuntimeCallAction, { + id: "target-1", + projectId: "project-1", + request: { + domain: "chat", + action: "deletePromptStash", + args: { id: "stash-1" }, + }, + }); expect(invoke).toHaveBeenCalledWith(IPC.remoteRuntimeCallAction, { id: "target-1", projectId: "project-1", @@ -2635,6 +2677,108 @@ describe("preload OAuth bridge", () => { expect(invoke).not.toHaveBeenCalledWith(IPC.sessionsGetDelta, { sessionId: "session-1" }); expect(invoke).not.toHaveBeenCalledWith(IPC.computerUseReadArtifactPreview, { uri: ".ade/artifacts/proof.png" }); expect(invoke).not.toHaveBeenCalledWith(IPC.agentChatPromptStashesList); + expect(invoke).not.toHaveBeenCalledWith(IPC.agentChatPromptStashesCreate, expect.anything()); + expect(invoke).not.toHaveBeenCalledWith(IPC.agentChatPromptStashesDelete, expect.anything()); + }); + + it("routes prompt-stash list, create, and delete through their explicit captured binding", async () => { + const activeBinding = { + kind: "local", + key: "local:/active", + rootPath: "/active", + displayName: "Active", + }; + const capturedBinding = { + kind: "remote", + key: "remote:stash-owner:stash-project", + targetId: "stash-owner", + runtimeName: "Stash owner", + projectId: "stash-project", + rootPath: "/remote/stash-project", + displayName: "Stash project", + }; + const created = { + id: "stash-1", + text: "Keep this owner", + provider: "codex", + modelId: "openai/gpt-5.4", + createdAt: "2026-07-28T12:00:00.000Z", + }; + const invoke = vi.fn(async (channel: string, payload?: unknown) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: null, binding: activeBinding }; + } + if (channel === IPC.remoteRuntimeCallAction) { + const request = (payload as { + request?: { domain?: string; action?: string }; + } | undefined)?.request; + if (request?.action === "listPromptStashes") return { result: [created] }; + if (request?.action === "createPromptStash") return { result: created }; + if (request?.action === "deletePromptStash") return { result: true }; + } + throw new Error(`unexpected IPC: ${channel}`); + }); + const exposeInMainWorld = vi.fn((name: string, value: unknown) => { + (globalThis as any).__bridgeName = name; + (globalThis as any).__adeBridge = value; + }); + + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { + invoke, + on: vi.fn(), + removeListener: vi.fn(), + }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + + await import("./preload"); + + const bridge = (globalThis as any).__adeBridge; + await expect(bridge.agentChat.promptStashes.list(capturedBinding)).resolves.toEqual([created]); + await expect(bridge.agentChat.promptStashes.create( + { text: "Keep this owner" }, + capturedBinding, + )).resolves.toEqual(created); + await expect(bridge.agentChat.promptStashes.delete( + { id: created.id }, + capturedBinding, + )).resolves.toBe(true); + + expect(invoke).toHaveBeenCalledWith(IPC.remoteRuntimeCallAction, { + id: capturedBinding.targetId, + projectId: capturedBinding.projectId, + request: { + domain: "chat", + action: "listPromptStashes", + }, + }); + expect(invoke).toHaveBeenCalledWith(IPC.remoteRuntimeCallAction, { + id: capturedBinding.targetId, + projectId: capturedBinding.projectId, + request: { + domain: "chat", + action: "createPromptStash", + args: { text: "Keep this owner" }, + }, + }); + expect(invoke).toHaveBeenCalledWith(IPC.remoteRuntimeCallAction, { + id: capturedBinding.targetId, + projectId: capturedBinding.projectId, + request: { + domain: "chat", + action: "deletePromptStash", + args: { id: created.id }, + }, + }); + expect(invoke).not.toHaveBeenCalledWith(IPC.agentChatPromptStashesList); + expect(invoke).not.toHaveBeenCalledWith(IPC.agentChatPromptStashesCreate, expect.anything()); + expect(invoke).not.toHaveBeenCalledWith(IPC.agentChatPromptStashesDelete, expect.anything()); }); // The action registry answers lifecycle mutations with an `{ ok, sessionId, diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index f9aeeb1d2..98335ba12 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -6323,22 +6323,33 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.invoke(IPC.agentChatFileSearch, args), ), promptStashes: { - list: async (): Promise => - callProjectRuntimeActionOr( + list: async ( + pin?: OpenProjectBinding | null, + ): Promise => + callPinnedOrBoundRuntimeActionOr( + pin, "chat", "listPromptStashes", {}, () => ipcRenderer.invoke(IPC.agentChatPromptStashesList), ), - create: async (args: PromptStashCreateArgs): Promise => - callProjectRuntimeActionOr( + create: async ( + args: PromptStashCreateArgs, + pin?: OpenProjectBinding | null, + ): Promise => + callPinnedOrBoundRuntimeActionOr( + pin, "chat", "createPromptStash", { args }, () => ipcRenderer.invoke(IPC.agentChatPromptStashesCreate, args), ), - delete: async (args: PromptStashDeleteArgs): Promise => - callProjectRuntimeActionOr( + delete: async ( + args: PromptStashDeleteArgs, + pin?: OpenProjectBinding | null, + ): Promise => + callPinnedOrBoundRuntimeActionOr( + pin, "chat", "deletePromptStash", { args }, diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 0c5bddaa7..db48b458d 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -43,6 +43,7 @@ import { type AgentChatRecoverTurnResult, type AgentChatPrepareCrossMachineHandoffArgs, type AgentChatInterruptResult, + type OpenProjectBinding, type AgentChatRestoreCancelledQueueResult, type AgentChatResolveUnprocessedMessageArgs, type AgentChatResolveUnprocessedMessageResult, @@ -4860,11 +4861,11 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { set: resolvedArg(undefined), }, promptStashes: { - list: async () => browserMockPromptStashes.map((entry) => ({ + list: async (_pin?: OpenProjectBinding | null) => browserMockPromptStashes.map((entry) => ({ ...entry, attachments: entry.attachments?.map((attachment) => ({ ...attachment })), })), - create: async (args: PromptStashCreateArgs) => { + create: async (args: PromptStashCreateArgs, _pin?: OpenProjectBinding | null) => { const attachments = (args.attachments ?? []).map((attachment) => ({ ...attachment })); const entry: PromptStashEntry = { id: globalThis.crypto.randomUUID(), @@ -4880,7 +4881,7 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { browserMockPromptStashes.splice(MAX_PROMPT_STASHES); return entry; }, - delete: async ({ id }: { id: string }) => { + delete: async ({ id }: { id: string }, _pin?: OpenProjectBinding | null) => { const index = browserMockPromptStashes.findIndex((entry) => entry.id === id); if (index < 0) return false; browserMockPromptStashes.splice(index, 1); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index 19d0bda3c..c1aea985f 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -3,7 +3,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen, waitFor, within, type RenderResult } from "@testing-library/react"; import type { ComponentProps } from "react"; -import type { IosElementContextItem, NormalizedLinearIssue } from "../../../shared/types"; +import type { + IosElementContextItem, + NormalizedLinearIssue, + OpenProjectBinding, +} from "../../../shared/types"; import { AgentChatComposer } from "./AgentChatComposer"; import { useAppStore } from "../../state/appStore"; @@ -340,12 +344,12 @@ describe("AgentChatComposer", () => { text: "Need a steer message", provider: "codex", modelId: "openai/gpt-5.4", - })); + }, null)); expect(props.onDraftChange).toHaveBeenCalledWith(""); }); - it("reads a stashed source image through the selected runtime and saves the copy through the bound runtime", async () => { - const chatRuntimePin = { + it("threads the effective composer binding through the complete image stash operation", async () => { + const composerMachineBinding: OpenProjectBinding = { kind: "remote" as const, key: "remote:source-machine:source-project", targetId: "source-machine", @@ -390,31 +394,30 @@ describe("AgentChatComposer", () => { renderComposer({ attachments: [sourceAttachment], - chatRuntimePin, + composerMachineBinding, }); fireEvent.click(screen.getByRole("button", { name: "Stash prompt" })); await waitFor(() => expect(getImageDataUrl).toHaveBeenCalledWith( sourceAttachment.path, - chatRuntimePin, + composerMachineBinding, )); await waitFor(() => expect(createPromptStash).toHaveBeenCalledWith({ text: "Need a steer message", provider: "codex", modelId: "openai/gpt-5.4", attachments: [storedAttachment], - })); + }, composerMachineBinding)); expect(getImageDataUrl.mock.calls).toContainEqual([ sourceAttachment.path, - chatRuntimePin, + composerMachineBinding, ]); - // The source path belongs to the selected chat runtime, but the durable - // stash copy belongs to the bound project runtime that owns prompt stashes. expect(saveTempAttachment.mock.calls).toEqual([[ { data: "cHJldmlldw==", filename: "design.png", }, + composerMachineBinding, ]]); }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index a10e07481..a20de22f5 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -1203,7 +1203,7 @@ export function AgentChatComposer({ draft, lastSentUserMessage = null, attachments, - chatRuntimePin = null, + composerMachineBinding = null, contextAttachments = [], allowAttachmentOnlySubmit = false, pinnedLinearIssue = null, @@ -1344,8 +1344,8 @@ export function AgentChatComposer({ /** Last message the user sent in this chat — recalled by ArrowUp on line 1. */ lastSentUserMessage?: string | null; attachments: AgentChatFileRef[]; - /** Explicit runtime owning attachments for the selected chat; null uses the bound project runtime. */ - chatRuntimePin?: OpenProjectBinding | null; + /** Effective runtime owning this composer and its prompt stashes. */ + composerMachineBinding?: OpenProjectBinding | null; contextAttachments?: AgentChatContextAttachment[]; allowAttachmentOnlySubmit?: boolean; pinnedLinearIssue?: LaneLinearIssue | null; @@ -4648,7 +4648,7 @@ export function AgentChatComposer({ ref={promptStashRef} draft={draft} attachments={attachments} - chatRuntimePin={chatRuntimePin} + composerMachineBinding={composerMachineBinding} provider={sessionProvider} modelId={modelId} active={isActive} diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index 45abde608..e978f986f 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -684,6 +684,17 @@ function installAdeMocks(options?: { setGoalStatus: setCodexGoalStatus, }, fileSearch: vi.fn().mockResolvedValue([]), + promptStashes: { + list: vi.fn().mockResolvedValue([]), + create: vi.fn().mockResolvedValue({ + id: "stash-1", + text: "saved", + provider: "codex", + modelId: "openai/gpt-5.4", + createdAt: "2026-07-28T12:00:00.000Z", + }), + delete: vi.fn().mockResolvedValue(true), + }, create, delete: deleteChat, dispose: vi.fn().mockResolvedValue(undefined), @@ -8955,6 +8966,19 @@ describe("AgentChatPane per-chat runtime routing", () => { expect(useAppStore.getState().projectBinding).toEqual(machineA); }); + it("passes the effective remote project binding to prompt stashes when the chat pin is null", async () => { + bindWindowToMachineB(); + const session = buildSession("chat-on-b", { laneId: "lane-b", title: "Remote-bound chat" }); + installAdeMocks({ sessions: [session], eventHistory: emptyHistory("chat-on-b") }); + + renderPane(session); + + await waitFor(() => expect(window.ade.agentChat.promptStashes.list).toHaveBeenCalledWith( + machineB, + )); + expect(useAppStore.getState().projectBinding).toEqual(machineB); + }); + it("routes a prop-driven incoming local chat independently of the outgoing remote selection", async () => { bindWindowToMachineA(); const outgoing = buildSession("chat-on-b", { laneId: "lane-b", title: "Outgoing remote chat" }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index afd171122..ceb7ac326 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -11724,7 +11724,7 @@ export function AgentChatPane({ draft={draft} lastSentUserMessage={lastSentUserMessage} attachments={attachments} - chatRuntimePin={chatRuntimePin} + composerMachineBinding={composerMachineBinding} contextAttachments={contextAttachments} allowAttachmentOnlySubmit={workDraftKind === "cli"} pinnedLinearIssue={pinnedLinearIssue} diff --git a/apps/desktop/src/renderer/components/chat/ComposerPromptStash.test.tsx b/apps/desktop/src/renderer/components/chat/ComposerPromptStash.test.tsx index f327c2fa5..ba181527d 100644 --- a/apps/desktop/src/renderer/components/chat/ComposerPromptStash.test.tsx +++ b/apps/desktop/src/renderer/components/chat/ComposerPromptStash.test.tsx @@ -135,7 +135,7 @@ describe("ComposerPromptStash", () => { text: "Fix the parser", provider: "codex", modelId: "openai/gpt-5.4", - })); + }, null)); expect(onDraftChange).toHaveBeenCalledWith(""); expect(bridge.list).toHaveBeenCalledTimes(1); }); @@ -181,13 +181,48 @@ describe("ComposerPromptStash", () => { fireEvent.click(await screen.findByRole("button", { name: "Open 1 stashed prompt" })); fireEvent.click(await screen.findByRole("button", { name: /Fix the parser/i })); - await waitFor(() => expect(remove).toHaveBeenCalledWith({ id: "stash-1" })); + await waitFor(() => expect(remove).toHaveBeenCalledWith({ id: "stash-1" }, null)); expect(onDraftChange).toHaveBeenCalledWith("Fix the parser"); expect(screen.queryByText("Stashed prompts")).toBeNull(); }); + it("deletes a listed stash through the binding that loaded it", async () => { + const ownerBinding: OpenProjectBinding = { + kind: "remote", + key: "remote:stash-owner:stash-project", + targetId: "stash-owner", + runtimeName: "Stash owner", + projectId: "stash-project", + rootPath: "/remote/stash-project", + displayName: "Stash project", + }; + const remove = vi.fn().mockResolvedValue(true); + installBridge({ + list: vi.fn().mockResolvedValue([savedEntry]), + delete: remove, + }); + render( + , + ); + + fireEvent.click(await screen.findByRole("button", { name: "Open 1 stashed prompt" })); + fireEvent.click(screen.getByRole("button", { name: "Delete stashed prompt" })); + + await waitFor(() => expect(remove).toHaveBeenCalledWith( + { id: savedEntry.id }, + ownerBinding, + )); + }); + it("moves image attachments into a stash and restores their thumbnail and attachment", async () => { - const chatRuntimePin: OpenProjectBinding = { + const composerMachineBinding: OpenProjectBinding = { kind: "remote", key: "remote:source-machine:source-project", targetId: "source-machine", @@ -227,7 +262,7 @@ describe("ComposerPromptStash", () => { { attachments: [storedImageAttachment], provider: undefined, modelId: undefined, - })); + }, composerMachineBinding)); expect(saveTempAttachment).toHaveBeenCalledWith({ data: "cHJldmlldw==", filename: "design.png", - }); + }, composerMachineBinding); expect(sourceImageRead).toHaveBeenCalledWith( imageAttachment.path, - chatRuntimePin, + composerMachineBinding, ); expect(onDraftChange).toHaveBeenCalledWith(""); expect(onRemoveAttachment).toHaveBeenCalledWith(imageAttachment.path); @@ -268,6 +303,7 @@ describe("ComposerPromptStash", () => { render( { ); fireEvent.click(await screen.findByRole("button", { name: "Open 1 stashed prompt" })); - await waitFor(() => expect(getImageDataUrl).toHaveBeenCalledWith(storedImageAttachment.path)); + await waitFor(() => expect(getImageDataUrl).toHaveBeenCalledWith( + storedImageAttachment.path, + composerMachineBinding, + )); expect(document.querySelector("[data-prompt-stash-menu] img")?.getAttribute("src")) .toBe("data:image/png;base64,cHJldmlldw=="); fireEvent.click(screen.getByRole("button", { name: /Use this design/i })); expect(onAddAttachment).toHaveBeenCalledWith(storedImageAttachment); - await waitFor(() => expect(remove).toHaveBeenCalledWith({ id: imageEntry.id })); + await waitFor(() => expect(remove).toHaveBeenCalledWith( + { id: imageEntry.id }, + composerMachineBinding, + )); }); - it("copies stashed images sequentially and preserves their composer order", async () => { + it("pins a sequential image copy to its captured owner when the active binding switches", async () => { + const originalBinding: OpenProjectBinding = { + kind: "remote", + key: "remote:source-machine:source-project", + targetId: "source-machine", + runtimeName: "Source Mac", + projectId: "source-project", + rootPath: "/remote/source-project", + displayName: "Source project", + }; + const switchedBinding: OpenProjectBinding = { + kind: "remote", + key: "remote:other-machine:other-project", + targetId: "other-machine", + runtimeName: "Other Mac", + projectId: "other-project", + rootPath: "/remote/other-project", + displayName: "Other project", + }; const sourceAttachments = [ { path: "/Users/me/Desktop/first.png", type: "image" as const }, { path: "/Users/me/Desktop/second.png", type: "image" as const }, @@ -317,23 +377,41 @@ describe("ComposerPromptStash", () => { attachments: storedAttachments, }); installBridge({ create, getImageDataUrl, saveTempAttachment }); - render( + const onDraftChange = vi.fn(); + const view = render( , ); fireEvent.click(screen.getByRole("button", { name: "Stash prompt" })); await waitFor(() => expect(getImageDataUrl).toHaveBeenCalledTimes(1)); - expect(getImageDataUrl).toHaveBeenNthCalledWith(1, sourceAttachments[0]!.path, null); + expect(getImageDataUrl).toHaveBeenNthCalledWith( + 1, + sourceAttachments[0]!.path, + originalBinding, + ); expect(saveTempAttachment).not.toHaveBeenCalled(); + view.rerender( + , + ); + await act(async () => { resolveFirstRead?.({ dataUrl: "data:image/png;base64,Zmlyc3Q=" }); }); @@ -341,14 +419,18 @@ describe("ComposerPromptStash", () => { expect(saveTempAttachment).toHaveBeenNthCalledWith(1, { data: "Zmlyc3Q=", filename: "first.png", - }); + }, originalBinding); expect(getImageDataUrl).toHaveBeenCalledTimes(1); await act(async () => { resolveFirstSave?.({ path: storedAttachments[0]!.path }); }); await waitFor(() => expect(getImageDataUrl).toHaveBeenCalledTimes(2)); - expect(getImageDataUrl).toHaveBeenNthCalledWith(2, sourceAttachments[1]!.path, null); + expect(getImageDataUrl).toHaveBeenNthCalledWith( + 2, + sourceAttachments[1]!.path, + originalBinding, + ); expect(saveTempAttachment).toHaveBeenCalledTimes(1); await act(async () => { @@ -358,7 +440,7 @@ describe("ComposerPromptStash", () => { expect(saveTempAttachment).toHaveBeenNthCalledWith(2, { data: "c2Vjb25k", filename: "second.png", - }); + }, originalBinding); expect(create).not.toHaveBeenCalled(); await act(async () => { @@ -369,11 +451,12 @@ describe("ComposerPromptStash", () => { attachments: storedAttachments, provider: undefined, modelId: undefined, - })); + }, originalBinding)); + expect(onDraftChange).not.toHaveBeenCalled(); }); - it("never falls back to this desktop for an image owned by a pinned remote chat", async () => { - const chatRuntimePin: OpenProjectBinding = { + it("never falls back to this desktop for an image owned by the effective remote binding", async () => { + const composerMachineBinding: OpenProjectBinding = { kind: "remote", key: "remote:source-machine:source-project", targetId: "source-machine", @@ -394,7 +477,7 @@ describe("ComposerPromptStash", () => { path: "/remote/source-project/design.png", type: "image", }]} - chatRuntimePin={chatRuntimePin} + composerMachineBinding={composerMachineBinding} active buttonVisible shortcutLabel="⌘+S" @@ -407,12 +490,73 @@ describe("ComposerPromptStash", () => { expect((await screen.findByRole("alert")).textContent).toContain("source runtime unavailable"); expect(runtimeRead).toHaveBeenCalledWith( "/remote/source-project/design.png", - chatRuntimePin, + composerMachineBinding, ); expect(localRead).not.toHaveBeenCalled(); }); + it("allows the captured local owner to use the Electron image fallback", async () => { + const localBinding: OpenProjectBinding = { + kind: "local", + key: "local:/project", + rootPath: "/project", + displayName: "Project", + }; + const sourceAttachment = { + path: "/Users/me/Desktop/design.png", + type: "image" as const, + }; + const storedAttachment = { + path: "/project/.ade/attachments/design.png", + type: "image" as const, + }; + const runtimeRead = vi.fn().mockRejectedValue(new Error("local runtime unavailable")); + const localRead = vi.fn().mockResolvedValue({ + dataUrl: "data:image/png;base64,cHJldmlldw==", + }); + const saveTempAttachment = vi.fn().mockResolvedValue({ path: storedAttachment.path }); + const create = vi.fn().mockResolvedValue({ + ...savedEntry, + attachments: [storedAttachment], + }); + installBridge({ create, getImageDataUrl: runtimeRead, saveTempAttachment }); + (window as any).ade.app = { getImageDataUrl: localRead }; + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Stash prompt" })); + + await waitFor(() => expect(create).toHaveBeenCalledWith({ + text: "Keep the local image", + attachments: [storedAttachment], + provider: undefined, + modelId: undefined, + }, localBinding)); + expect(runtimeRead).toHaveBeenCalledWith(sourceAttachment.path, localBinding); + expect(localRead).toHaveBeenCalledWith(sourceAttachment.path); + expect(saveTempAttachment).toHaveBeenCalledWith({ + data: "cHJldmlldw==", + filename: "design.png", + }, localBinding); + }); + it("keeps the original image when an older runtime cannot confirm attachment persistence", async () => { + const localBinding: OpenProjectBinding = { + kind: "local", + key: "local:/project", + rootPath: "/project", + displayName: "Project", + }; const imageAttachment = { path: "/Users/me/Desktop/design.png", type: "image" as const, @@ -425,6 +569,7 @@ describe("ComposerPromptStash", () => { { fireEvent.click(screen.getByRole("button", { name: "Stash prompt" })); await waitFor(() => expect(view.container.querySelector(".animate-spin")).toBeNull()); expect(create).toHaveBeenCalledTimes(1); - expect(bridge.delete).toHaveBeenCalledWith({ id: savedEntry.id }); + expect(bridge.delete).toHaveBeenCalledWith({ id: savedEntry.id }, localBinding); expect(onDraftChange).not.toHaveBeenCalled(); expect(onRemoveAttachment).not.toHaveBeenCalled(); }); @@ -675,7 +820,7 @@ describe("ComposerPromptStash", () => { text: "Hidden button prompt", provider: undefined, modelId: undefined, - })); + }, null)); expect(onDraftChange).toHaveBeenCalledWith(""); expect(screen.queryByRole("button", { name: "Stash prompt" })).toBeNull(); }); @@ -741,7 +886,10 @@ describe("ComposerPromptStash", () => { resolveCreate?.(savedEntry); await waitFor(() => expect(view.container.querySelector(".animate-spin")).toBeNull()); - expect(create).toHaveBeenCalledWith(expect.objectContaining({ text: "Save this version" })); + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ text: "Save this version" }), + null, + ); expect(onDraftChange).not.toHaveBeenCalled(); }); @@ -831,7 +979,7 @@ describe("ComposerPromptStash", () => { resolveDelete?.(true); await waitFor(() => expect(view.container.querySelector(".animate-spin")).toBeNull()); - expect(remove).toHaveBeenCalledWith({ id: "stash-1" }); + expect(remove).toHaveBeenCalledWith({ id: "stash-1" }, null); expect(onDraftChange).toHaveBeenCalledTimes(1); }); }); diff --git a/apps/desktop/src/renderer/components/chat/ComposerPromptStash.tsx b/apps/desktop/src/renderer/components/chat/ComposerPromptStash.tsx index 0a994fd88..ace82a5c2 100644 --- a/apps/desktop/src/renderer/components/chat/ComposerPromptStash.tsx +++ b/apps/desktop/src/renderer/components/chat/ComposerPromptStash.tsx @@ -110,12 +110,19 @@ function stashAttachmentsUnavailable(entry: PromptStashEntry): boolean { return entry.attachmentsAvailable === false && stashAttachmentCount(entry) > 0; } -function StashImageThumbnail({ attachment }: { attachment: AgentChatFileRef }) { +function StashImageThumbnail({ + attachment, + composerMachineBinding, +}: { + attachment: AgentChatFileRef; + composerMachineBinding: OpenProjectBinding | null; +}) { const directUrl = attachment.type === "image-url" ? attachment.url : null; const [src, setSrc] = useState(directUrl); const [failed, setFailed] = useState(false); useEffect(() => { + const capturedBinding = composerMachineBinding; let cancelled = false; setSrc(directUrl); setFailed(false); @@ -125,7 +132,7 @@ function StashImageThumbnail({ attachment }: { attachment: AgentChatFileRef }) { setFailed(true); return () => { cancelled = true; }; } - void readImage(attachment.path) + void readImage(attachment.path, capturedBinding) .then(({ dataUrl }) => { if (!cancelled) setSrc(dataUrl); }) @@ -133,7 +140,7 @@ function StashImageThumbnail({ attachment }: { attachment: AgentChatFileRef }) { if (!cancelled) setFailed(true); }); return () => { cancelled = true; }; - }, [attachment, directUrl]); + }, [attachment, composerMachineBinding, directUrl]); return ( @@ -155,7 +162,7 @@ function StashImageThumbnail({ attachment }: { attachment: AgentChatFileRef }) { export type ComposerPromptStashProps = { draft: string; attachments?: AgentChatFileRef[]; - chatRuntimePin?: OpenProjectBinding | null; + composerMachineBinding?: OpenProjectBinding | null; provider?: string | null; modelId?: string | null; active: boolean; @@ -170,7 +177,7 @@ export type ComposerPromptStashProps = { export const ComposerPromptStash = forwardRef(function ComposerPromptStash({ draft, attachments = [], - chatRuntimePin = null, + composerMachineBinding = null, provider, modelId, active, @@ -189,7 +196,15 @@ export const ComposerPromptStash = forwardRef([]); + const latestComposerMachineBindingRef = useRef(composerMachineBinding); + latestComposerMachineBindingRef.current = composerMachineBinding; + const [stashSnapshot, setStashSnapshot] = useState<{ + entries: PromptStashEntry[]; + ownerBinding: OpenProjectBinding | null; + }>({ + entries: [], + ownerBinding: null, + }); const [menuOpen, setMenuOpen] = useState(false); const [highlightedId, setHighlightedId] = useState(null); const [busy, setBusy] = useState(false); @@ -202,6 +217,10 @@ export const ComposerPromptStash = forwardRef attachments.filter(isStashableAttachment), [attachments], ); + const currentBindingKey = composerMachineBinding?.key ?? null; + const entriesOwnerBinding = stashSnapshot.ownerBinding; + const entriesOwnerBindingKey = entriesOwnerBinding?.key ?? null; + const entries = entriesOwnerBindingKey === currentBindingKey ? stashSnapshot.entries : []; const hasComposerContent = draft.trim().length > 0 || stashableComposerAttachments.length > 0; const renderButton = buttonVisible && (hasComposerContent || entries.length > 0); const attachmentSignature = attachments.map((attachment) => ( @@ -213,12 +232,20 @@ export const ComposerPromptStash = forwardRef { + const refresh = useCallback(async ( + bindingOverride?: OpenProjectBinding | null, + ) => { + const capturedBinding = bindingOverride === undefined + ? composerMachineBinding + : bindingOverride; const sequence = ++refreshSequenceRef.current; try { - const next = await window.ade.agentChat.promptStashes.list(); + const next = await window.ade.agentChat.promptStashes.list(capturedBinding); if (sequence !== refreshSequenceRef.current) return; - setEntries(next); + setStashSnapshot({ + entries: next, + ownerBinding: capturedBinding, + }); setHighlightedId((current) => ( current && next.some((entry) => entry.id === current) ? current @@ -229,11 +256,11 @@ export const ComposerPromptStash = forwardRef { - if (active) void refresh(); - }, [active, refresh]); + if (active) void refresh(composerMachineBinding); + }, [active, composerMachineBinding, refresh]); useEffect(() => { if (!saveReceiptVisible) return; @@ -341,6 +368,7 @@ export const ComposerPromptStash = forwardRef { if (disabled || operationInFlightRef.current) return; + const operationBinding = composerMachineBinding; const savedText = latestDraftRef.current; const savedComposerAttachments = [...latestAttachmentsRef.current]; const savedAttachments = savedComposerAttachments.filter(isStashableAttachment); @@ -351,7 +379,7 @@ export const ComposerPromptStash = forwardRef 0) { const confirmedAttachments = stashAttachments(created); const runtimeConfirmedImages = storedAttachments.every((stored) => ( @@ -399,7 +427,7 @@ export const ComposerPromptStash = forwardRef [ - created, - ...current.filter((entry) => entry.id !== created.id), - ].slice(0, MAX_PROMPT_STASHES)); + const operationBindingKey = operationBinding?.key ?? null; + if ((latestComposerMachineBindingRef.current?.key ?? null) === operationBindingKey) { + setStashSnapshot((current) => ({ + entries: [ + created, + ...((current.ownerBinding?.key ?? null) === operationBindingKey + ? current.entries.filter((entry) => entry.id !== created.id) + : []), + ].slice(0, MAX_PROMPT_STASHES), + ownerBinding: operationBinding, + })); + } setHighlightedId(created.id); setSaveReceiptKey((current) => current + 1); setSaveReceiptVisible(true); @@ -418,7 +454,8 @@ export const ComposerPromptStash = forwardRef ( Boolean(savedComposerAttachments[index] && sameAttachment(current, savedComposerAttachments[index]!)) @@ -436,10 +473,11 @@ export const ComposerPromptStash = forwardRef { if (operationInFlightRef.current) return; + const operationBinding = entriesOwnerBinding; if (stashAttachmentsUnavailable(entry)) { setError("These images live on the machine where this prompt was stashed. Connect to that machine to restore it."); return; @@ -452,7 +490,15 @@ export const ComposerPromptStash = forwardRef current.filter((candidate) => candidate.id !== entry.id)); + const operationBindingKey = operationBinding?.key ?? null; + setStashSnapshot((current) => ( + (current.ownerBinding?.key ?? null) === operationBindingKey + ? { + ...current, + entries: current.entries.filter((candidate) => candidate.id !== entry.id), + } + : current + )); setHighlightedId(null); setMenuOpen(false); // Put the saved text into the composer before waiting on a remote delete. @@ -464,9 +510,11 @@ export const ComposerPromptStash = forwardRef { if (operationInFlightRef.current) return; + const operationBinding = entriesOwnerBinding; operationInFlightRef.current = true; refreshSequenceRef.current += 1; setBusy(true); setError(null); try { - await window.ade.agentChat.promptStashes.delete({ id: entry.id }); - setEntries((current) => current.filter((candidate) => candidate.id !== entry.id)); + await window.ade.agentChat.promptStashes.delete({ id: entry.id }, operationBinding); + const operationBindingKey = operationBinding?.key ?? null; + setStashSnapshot((current) => ( + (current.ownerBinding?.key ?? null) === operationBindingKey + ? { + ...current, + entries: current.entries.filter((candidate) => candidate.id !== entry.id), + } + : current + )); setHighlightedId((current) => current === entry.id ? null : current); } catch (deleteError) { setError(deleteError instanceof Error ? deleteError.message : "Could not delete this prompt."); @@ -494,7 +551,7 @@ export const ComposerPromptStash = forwardRef setHighlightedId(entry.id)} > {imageAttachment ? ( - + ) : attachmentCount ? ( {attachmentsUnavailable ? : }