diff --git a/packages/app/src/attachments/composer-workspace-attachments.tsx b/packages/app/src/attachments/composer-workspace-attachments.tsx index afe357cdc..83a013738 100644 --- a/packages/app/src/attachments/composer-workspace-attachments.tsx +++ b/packages/app/src/attachments/composer-workspace-attachments.tsx @@ -9,8 +9,12 @@ import type { } from "@/attachments/types"; import { AttachmentPill } from "@/components/attachment-pill"; import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store"; +import { + isWorkspaceAttachment, + userAttachmentsOnly, + workspaceAttachmentToSubmitAttachment, +} from "@/attachments/workspace-attachment-utils"; import { ICON_SIZE, type Theme } from "@/styles/theme"; -import type { AgentAttachment } from "@server/shared/messages"; import { useClearReviewDraft } from "@/review/store"; interface WorkspaceAttachmentBindingInput { @@ -69,31 +73,6 @@ function getAttachmentKey(attachment: WorkspaceComposerAttachment): string { }); } -function isWorkspaceAttachment( - attachment: ComposerAttachment | undefined, -): attachment is WorkspaceComposerAttachment { - return attachment?.kind === "review" || attachment?.kind === "browser_element"; -} - -function userAttachmentsOnly(attachments: readonly ComposerAttachment[]): UserComposerAttachment[] { - return attachments.filter( - (attachment): attachment is UserComposerAttachment => - attachment.kind !== "review" && attachment.kind !== "browser_element", - ); -} - -function toSubmitAttachment(attachment: ComposerAttachment): AgentAttachment | null { - if (attachment.kind === "browser_element") { - return { - type: "text", - mimeType: "text/plain", - title: `Browser element · ${attachment.attachment.tag}`, - text: attachment.attachment.formatted, - }; - } - return attachment.kind === "review" ? attachment.attachment : null; -} - function renderPill(args: RenderWorkspaceAttachmentPillArgs): ReactElement { return ( + attachment.kind !== "review" && attachment.kind !== "browser_element", + ); +} + +export function workspaceAttachmentToSubmitAttachment( + attachment: ComposerAttachment, +): AgentAttachment | null { + if (attachment.kind === "browser_element") { + return { + type: "text", + mimeType: "text/plain", + title: `Browser element · ${attachment.attachment.tag}`, + text: attachment.attachment.formatted, + }; + } + return attachment.kind === "review" ? attachment.attachment : null; +} diff --git a/packages/app/src/components/composer-actions.test.ts b/packages/app/src/components/composer-actions.test.ts new file mode 100644 index 000000000..d6fd2bea6 --- /dev/null +++ b/packages/app/src/components/composer-actions.test.ts @@ -0,0 +1,715 @@ +import { describe, expect, it } from "vitest"; +import type { AgentAttachment, GitHubSearchItem } from "@server/shared/messages"; +import type { + AttachmentMetadata, + ComposerAttachment, + UserComposerAttachment, + WorkspaceComposerAttachment, +} from "@/attachments/types"; +import type { StreamItem } from "@/types/stream"; +import { + cancelComposerAgent, + dispatchComposerAgentMessage, + editQueuedComposerMessage, + findGithubItemByOption, + isAttachmentSelectedForGithubItem, + openComposerAttachment, + pickAndPersistImages, + queueComposerMessage, + removeComposerAttachmentAtIndex, + sendQueuedComposerMessageNow, + toggleGithubAttachment, + type AgentStreamWriter, + type AttachmentPersister, + type ComposerCancelClient, + type ComposerSendClient, + type QueueWriter, + type QueuedComposerMessage, +} from "./composer-actions"; + +const imageMetadata: AttachmentMetadata = { + id: "img-1", + mimeType: "image/png", + storageType: "web-indexeddb", + storageKey: "img-1", + fileName: "img-1.png", + byteSize: 42, + createdAt: 1, +}; + +const issueItem: GitHubSearchItem = { + kind: "issue", + number: 101, + title: "Fix composer attachments", + url: "https://github.com/acme/paseo/issues/101", + state: "open", + body: "Issue body", + labels: ["composer"], + baseRefName: null, + headRefName: null, +}; + +const prItem: GitHubSearchItem = { + kind: "pr", + number: 202, + title: "Refactor composer attachments", + url: "https://github.com/acme/paseo/pull/202", + state: "open", + body: "PR body", + labels: ["composer"], + baseRefName: "main", + headRefName: "composer-attachments", +}; + +function imageWithId(id: string): AttachmentMetadata { + return { ...imageMetadata, id, storageKey: id, fileName: `${id}.png` }; +} + +function reviewWorkspaceAttachment(body: string): WorkspaceComposerAttachment { + const attachment: Extract = { + type: "review", + mimeType: "application/paseo-review", + cwd: "/repo", + mode: "uncommitted", + baseRef: null, + comments: [ + { + filePath: "src/example.ts", + side: "new", + lineNumber: 41, + body, + context: { + hunkHeader: "@@ -40,2 +40,2 @@", + targetLine: { + oldLineNumber: null, + newLineNumber: 41, + type: "add", + content: "const value = newValue;", + }, + lines: [ + { + oldLineNumber: null, + newLineNumber: 41, + type: "add", + content: "const value = newValue;", + }, + ], + }, + }, + ], + }; + return { + kind: "review", + reviewDraftKey: `review:${body}`, + commentCount: 1, + attachment, + }; +} + +function browserElementWorkspaceAttachment(): Extract< + WorkspaceComposerAttachment, + { kind: "browser_element" } +> { + return { + kind: "browser_element", + attachment: { + url: "https://example.com/page", + selector: "button.primary", + tag: "button", + text: "Save", + outerHTML: '', + computedStyles: { display: "flex" }, + boundingRect: { x: 1, y: 2, width: 80, height: 32 }, + reactSource: null, + parentChain: ["form.settings"], + children: [], + formatted: 'button.primary', + }, + }; +} + +function createFakePersister(): AttachmentPersister & { + blobCalls: Array<{ blob: Blob; mimeType: string; fileName: string | null }>; + fileUriCalls: Array<{ uri: string; mimeType: string; fileName: string | null }>; + deletedBatches: AttachmentMetadata[][]; +} { + const blobCalls: Array<{ blob: Blob; mimeType: string; fileName: string | null }> = []; + const fileUriCalls: Array<{ uri: string; mimeType: string; fileName: string | null }> = []; + const deletedBatches: AttachmentMetadata[][] = []; + return { + blobCalls, + fileUriCalls, + deletedBatches, + persistFromBlob: async ({ blob, mimeType, fileName }) => { + blobCalls.push({ blob, mimeType, fileName }); + return { ...imageMetadata, id: `blob-${blobCalls.length}` }; + }, + persistFromFileUri: async ({ uri, mimeType, fileName }) => { + fileUriCalls.push({ uri, mimeType, fileName }); + return { ...imageMetadata, id: `uri-${fileUriCalls.length}` }; + }, + deleteAttachments: (metadata) => { + deletedBatches.push(metadata); + }, + }; +} + +interface FakeSendCall { + agentId: string; + text: string; + options: { + messageId: string; + images: Array<{ data: string; mimeType: string }>; + attachments: AgentAttachment[]; + }; +} + +function createFakeSendClient( + options: { rejection?: Error } = {}, +): ComposerSendClient & { calls: FakeSendCall[] } { + const calls: FakeSendCall[] = []; + return { + calls, + sendAgentMessage: async (agentId, text, opts) => { + calls.push({ agentId, text, options: opts }); + if (options.rejection) { + throw options.rejection; + } + }, + }; +} + +interface FakeStream extends AgentStreamWriter { + head: Map; + tail: Map; +} + +function createFakeStream(initialHead: Map = new Map()): FakeStream { + const fake: FakeStream = { + head: new Map(initialHead), + tail: new Map(), + getHead: (agentId) => fake.head.get(agentId), + setHead: (updater) => { + fake.head = updater(fake.head); + }, + setTail: (updater) => { + fake.tail = updater(fake.tail); + }, + }; + return fake; +} + +function createFakeQueue( + initial: Map = new Map(), +): QueueWriter & { state: Map } { + const fake: QueueWriter & { state: Map } = { + state: new Map(initial), + read: (agentId) => fake.state.get(agentId) ?? [], + write: (updater) => { + fake.state = updater(fake.state); + }, + }; + return fake; +} + +const passthroughEncodeImages = async (images: AttachmentMetadata[]) => + images.map((image) => ({ data: image.id, mimeType: image.mimeType })); + +describe("cancelComposerAgent", () => { + function baseInput(): { + client: ComposerCancelClient & { canceledIds: string[] }; + agentId: string; + isAgentRunning: boolean; + isCancellingAgent: boolean; + isConnected: boolean; + } { + const canceledIds: string[] = []; + return { + client: { + canceledIds, + cancelAgent: async (id) => { + canceledIds.push(id); + }, + }, + agentId: "agent", + isAgentRunning: true, + isCancellingAgent: false, + isConnected: true, + }; + } + + it("issues a cancel and reports true when the agent is running, connected, and not already canceling", () => { + const input = baseInput(); + const result = cancelComposerAgent(input); + expect(result).toBe(true); + expect(input.client.canceledIds).toEqual(["agent"]); + }); + + it("does nothing when the agent is not running", () => { + const input = baseInput(); + const result = cancelComposerAgent({ ...input, isAgentRunning: false }); + expect(result).toBe(false); + expect(input.client.canceledIds).toEqual([]); + }); + + it("does nothing when the agent is already being canceled", () => { + const input = baseInput(); + const result = cancelComposerAgent({ ...input, isCancellingAgent: true }); + expect(result).toBe(false); + expect(input.client.canceledIds).toEqual([]); + }); + + it("does nothing when disconnected or the client is null", () => { + const input = baseInput(); + expect(cancelComposerAgent({ ...input, isConnected: false })).toBe(false); + expect(cancelComposerAgent({ ...input, client: null })).toBe(false); + expect(input.client.canceledIds).toEqual([]); + }); +}); + +describe("pickAndPersistImages", () => { + it("returns [] when the picker yields nothing", async () => { + const persister = createFakePersister(); + const result = await pickAndPersistImages({ + pickImages: async () => null, + persister, + }); + expect(result).toEqual([]); + expect(persister.blobCalls).toEqual([]); + expect(persister.fileUriCalls).toEqual([]); + }); + + it("persists blob sources via persistFromBlob with the picked mime type and file name", async () => { + const persister = createFakePersister(); + const blob = new Blob(["image"]); + const result = await pickAndPersistImages({ + pickImages: async () => [ + { source: { kind: "blob", blob }, mimeType: "image/png", fileName: "img-1.png" }, + ], + persister, + }); + expect(persister.blobCalls).toEqual([{ blob, mimeType: "image/png", fileName: "img-1.png" }]); + expect(result.map((m) => m.id)).toEqual(["blob-1"]); + }); + + it("persists file_uri sources via persistFromFileUri", async () => { + const persister = createFakePersister(); + const result = await pickAndPersistImages({ + pickImages: async () => [ + { source: { kind: "file_uri", uri: "/tmp/x.jpg" }, mimeType: null, fileName: null }, + ], + persister, + }); + expect(persister.fileUriCalls).toEqual([ + { uri: "/tmp/x.jpg", mimeType: "image/jpeg", fileName: null }, + ]); + expect(result).toHaveLength(1); + }); +}); + +describe("dispatchComposerAgentMessage", () => { + it("sends text + image data + structured attachments and appends user_message to the tail when head is empty", async () => { + const client = createFakeSendClient(); + const stream = createFakeStream(); + const image = imageWithId("img-2"); + + await dispatchComposerAgentMessage({ + client, + agentId: "agent", + text: "send attachments", + attachments: [ + { kind: "image", metadata: image }, + { kind: "github_pr", item: prItem }, + ], + encodeImages: passthroughEncodeImages, + stream, + }); + + expect(client.calls).toHaveLength(1); + const [call] = client.calls; + expect(call.agentId).toBe("agent"); + expect(call.text).toBe("send attachments"); + expect(call.options.images).toEqual([{ data: image.id, mimeType: image.mimeType }]); + expect(call.options.attachments).toEqual([ + { + type: "github_pr", + mimeType: "application/github-pr", + number: 202, + title: "Refactor composer attachments", + url: "https://github.com/acme/paseo/pull/202", + body: "PR body", + baseRefName: "main", + headRefName: "composer-attachments", + }, + ]); + + expect(stream.head.get("agent")).toBeUndefined(); + const tail = stream.tail.get("agent"); + expect(tail).toHaveLength(1); + const userMessage = tail?.[0] as Extract; + expect(userMessage.kind).toBe("user_message"); + expect(userMessage.text).toBe("send attachments"); + expect(userMessage.images).toEqual([image]); + expect(userMessage.attachments).toEqual(call.options.attachments); + expect(userMessage.id).toBe(call.options.messageId); + }); + + it("appends to the existing head when one is present", async () => { + const existingItem: StreamItem = { + kind: "user_message", + id: "prior", + text: "prior", + timestamp: new Date(0), + }; + const stream = createFakeStream(new Map([["agent", [existingItem]]])); + const client = createFakeSendClient(); + + await dispatchComposerAgentMessage({ + client, + agentId: "agent", + text: "next message", + attachments: [], + encodeImages: passthroughEncodeImages, + stream, + }); + + expect(stream.head.get("agent")).toHaveLength(2); + expect(stream.tail.get("agent")).toBeUndefined(); + }); + + it("submits empty wire arrays when no attachments are provided", async () => { + const client = createFakeSendClient(); + const stream = createFakeStream(); + + await dispatchComposerAgentMessage({ + client, + agentId: "agent", + text: "plain message", + attachments: [], + encodeImages: passthroughEncodeImages, + stream, + }); + + expect(client.calls[0]?.options).toMatchObject({ + images: [], + attachments: [], + }); + }); + + it("serializes workspace review attachments through the structured attachment path", async () => { + const client = createFakeSendClient(); + const stream = createFakeStream(); + const review = reviewWorkspaceAttachment("Please simplify this."); + + await dispatchComposerAgentMessage({ + client, + agentId: "agent", + text: "review this", + attachments: [review], + encodeImages: passthroughEncodeImages, + stream, + }); + + expect(client.calls[0]?.options.attachments).toEqual([review.attachment]); + expect(client.calls[0]?.options.images).toEqual([]); + }); + + it("serializes browser_element workspace attachments as text attachments at the wire boundary", async () => { + const client = createFakeSendClient(); + const stream = createFakeStream(); + const browserElement = browserElementWorkspaceAttachment(); + + await dispatchComposerAgentMessage({ + client, + agentId: "agent", + text: "inspect element", + attachments: [browserElement], + encodeImages: passthroughEncodeImages, + stream, + }); + + expect(client.calls[0]?.options.attachments).toEqual([ + { + type: "text", + mimeType: "text/plain", + title: "Browser element · button", + text: browserElement.attachment.formatted, + }, + ]); + }); +}); + +describe("queueComposerMessage", () => { + it("queues a trimmed message under the agent id and returns the new entry", () => { + const queue = createFakeQueue(); + const result = queueComposerMessage({ + agentId: "agent", + text: " draft ", + attachments: [], + queue, + }); + + expect(result.queued?.text).toBe("draft"); + expect(queue.state.get("agent")).toEqual([ + { id: result.queued?.id, text: "draft", attachments: [] }, + ]); + }); + + it("does not queue an empty message with no attachments", () => { + const queue = createFakeQueue(); + const result = queueComposerMessage({ + agentId: "agent", + text: " ", + attachments: [], + queue, + }); + expect(result.queued).toBeNull(); + expect(queue.state.get("agent")).toBeUndefined(); + }); + + it("captures workspace review attachments at queue time alongside user attachments", () => { + const queue = createFakeQueue(); + const review = reviewWorkspaceAttachment("Initial queued review."); + const image = imageWithId("img-queue"); + queueComposerMessage({ + agentId: "agent", + text: "queue this", + attachments: [{ kind: "image", metadata: image }, review], + queue, + }); + + expect(queue.state.get("agent")?.[0]?.attachments).toEqual([ + { kind: "image", metadata: image }, + review, + ]); + }); +}); + +describe("editQueuedComposerMessage", () => { + it("returns null and leaves the queue untouched when the message id is missing", () => { + const queue = createFakeQueue( + new Map([["agent", [{ id: "other", text: "other", attachments: [] }]]]), + ); + const result = editQueuedComposerMessage({ agentId: "agent", messageId: "missing", queue }); + expect(result).toBeNull(); + expect(queue.state.get("agent")).toHaveLength(1); + }); + + it("returns the text and only user attachments, removing the queued entry", () => { + const review = reviewWorkspaceAttachment("Queued snapshot."); + const image = imageWithId("img-queued-edit"); + const queue = createFakeQueue( + new Map([ + [ + "agent", + [ + { + id: "msg-1", + text: "queued draft", + attachments: [{ kind: "image", metadata: image }, review], + }, + ], + ], + ]), + ); + + const result = editQueuedComposerMessage({ agentId: "agent", messageId: "msg-1", queue }); + expect(result).toEqual({ + text: "queued draft", + attachments: [{ kind: "image", metadata: image }], + }); + expect(queue.state.get("agent")).toEqual([]); + }); +}); + +describe("sendQueuedComposerMessageNow", () => { + it("returns missing without submitting when the message id is gone", async () => { + const queue = createFakeQueue(); + const submitted: Array<{ text: string; attachments: ComposerAttachment[] }> = []; + const result = await sendQueuedComposerMessageNow({ + agentId: "agent", + messageId: "msg-1", + queue, + submitMessage: async (input) => { + submitted.push(input); + }, + }); + expect(result).toEqual({ status: "missing" }); + expect(submitted).toEqual([]); + }); + + it("removes the queued entry and submits its text + attachments", async () => { + const review = reviewWorkspaceAttachment("Queued for send."); + const queue = createFakeQueue( + new Map([["agent", [{ id: "msg-1", text: "send me", attachments: [review] }]]]), + ); + const submitted: Array<{ text: string; attachments: ComposerAttachment[] }> = []; + const result = await sendQueuedComposerMessageNow({ + agentId: "agent", + messageId: "msg-1", + queue, + submitMessage: async (input) => { + submitted.push(input); + }, + }); + expect(result).toEqual({ status: "submitted" }); + expect(queue.state.get("agent")).toEqual([]); + expect(submitted).toEqual([{ text: "send me", attachments: [review] }]); + }); + + it("restores the queued entry to the front and surfaces the error message on failure", async () => { + const queue = createFakeQueue( + new Map([ + [ + "agent", + [ + { id: "msg-1", text: "first", attachments: [] }, + { id: "msg-2", text: "second", attachments: [] }, + ], + ], + ]), + ); + const result = await sendQueuedComposerMessageNow({ + agentId: "agent", + messageId: "msg-1", + queue, + submitMessage: async () => { + throw new Error("network down"); + }, + }); + expect(result).toEqual({ status: "failed", errorMessage: "network down" }); + const state = queue.state.get("agent"); + expect(state?.map((m) => m.id)).toEqual(["msg-1", "msg-2"]); + }); +}); + +describe("removeComposerAttachmentAtIndex", () => { + it("removes an image attachment and asks the persister to delete the underlying metadata", () => { + const image = imageWithId("img-remove"); + const persister = createFakePersister(); + const next = removeComposerAttachmentAtIndex({ + attachments: [{ kind: "image", metadata: image }] satisfies UserComposerAttachment[], + index: 0, + deleteAttachments: persister.deleteAttachments, + }); + expect(next).toEqual([]); + expect(persister.deletedBatches).toEqual([[image]]); + }); + + it("removes a github attachment without scheduling any storage deletes", () => { + const persister = createFakePersister(); + const next = removeComposerAttachmentAtIndex({ + attachments: [ + { kind: "github_issue", item: issueItem }, + { kind: "github_pr", item: prItem }, + ] satisfies UserComposerAttachment[], + index: 0, + deleteAttachments: persister.deleteAttachments, + }); + expect(next).toEqual([{ kind: "github_pr", item: prItem }]); + expect(persister.deletedBatches).toEqual([]); + }); +}); + +describe("openComposerAttachment", () => { + it("opens the lightbox for image attachments", () => { + const image = imageWithId("img-body"); + const lightboxCalls: AttachmentMetadata[] = []; + const externalUrlCalls: string[] = []; + openComposerAttachment({ + attachment: { kind: "image", metadata: image }, + setLightboxMetadata: (metadata) => { + lightboxCalls.push(metadata); + }, + openWorkspaceAttachment: () => false, + openExternalUrl: (url) => { + externalUrlCalls.push(url); + }, + }); + expect(lightboxCalls).toEqual([image]); + expect(externalUrlCalls).toEqual([]); + }); + + it("delegates workspace review attachments to the workspace opener", () => { + const review = reviewWorkspaceAttachment("Open me."); + const workspaceCalls: ComposerAttachment[] = []; + openComposerAttachment({ + attachment: review, + setLightboxMetadata: () => { + throw new Error("unexpected lightbox call"); + }, + openWorkspaceAttachment: ({ attachment }) => { + workspaceCalls.push(attachment); + return true; + }, + openExternalUrl: () => { + throw new Error("unexpected external url call"); + }, + }); + expect(workspaceCalls).toEqual([review]); + }); + + it("opens GitHub item URLs through the external url opener", () => { + const externalUrlCalls: string[] = []; + openComposerAttachment({ + attachment: { kind: "github_issue", item: issueItem }, + setLightboxMetadata: () => { + throw new Error("unexpected lightbox call"); + }, + openWorkspaceAttachment: () => false, + openExternalUrl: (url) => { + externalUrlCalls.push(url); + }, + }); + expect(externalUrlCalls).toEqual([issueItem.url]); + }); +}); + +describe("toggleGithubAttachment", () => { + it("appends a GitHub issue when not already attached", () => { + const next = toggleGithubAttachment([], issueItem); + expect(next).toEqual([{ kind: "github_issue", item: issueItem }]); + }); + + it("appends a GitHub PR when not already attached", () => { + const next = toggleGithubAttachment([], prItem); + expect(next).toEqual([{ kind: "github_pr", item: prItem }]); + }); + + it("removes an existing GitHub item with the same kind+number", () => { + const next = toggleGithubAttachment([{ kind: "github_issue", item: issueItem }], issueItem); + expect(next).toEqual([]); + }); + + it("does not affect other items with different kind or number", () => { + const start: UserComposerAttachment[] = [ + { kind: "github_issue", item: issueItem }, + { kind: "github_pr", item: prItem }, + ]; + const otherIssue: GitHubSearchItem = { ...issueItem, number: 999 }; + const next = toggleGithubAttachment(start, otherIssue); + expect(next).toEqual([ + { kind: "github_issue", item: issueItem }, + { kind: "github_pr", item: prItem }, + { kind: "github_issue", item: otherIssue }, + ]); + }); +}); + +describe("findGithubItemByOption / isAttachmentSelectedForGithubItem", () => { + it("locates items via their composite kind:number id", () => { + expect(findGithubItemByOption([issueItem, prItem], "issue:101")).toBe(issueItem); + expect(findGithubItemByOption([issueItem, prItem], "pr:202")).toBe(prItem); + expect(findGithubItemByOption([issueItem], "pr:404")).toBeUndefined(); + }); + + it("recognizes when an attachment list already contains a matching GitHub item", () => { + const attachments: ComposerAttachment[] = [ + { kind: "image", metadata: imageWithId("img-x") }, + { kind: "github_issue", item: issueItem }, + reviewWorkspaceAttachment("ignored"), + ]; + expect(isAttachmentSelectedForGithubItem(attachments, issueItem)).toBe(true); + expect(isAttachmentSelectedForGithubItem(attachments, prItem)).toBe(false); + }); +}); diff --git a/packages/app/src/components/composer-actions.ts b/packages/app/src/components/composer-actions.ts new file mode 100644 index 000000000..83cf606d0 --- /dev/null +++ b/packages/app/src/components/composer-actions.ts @@ -0,0 +1,325 @@ +import type { GitHubSearchItem } from "@server/shared/messages"; +import type { + AttachmentMetadata, + ComposerAttachment, + UserComposerAttachment, +} from "@/attachments/types"; +import { + isWorkspaceAttachment, + userAttachmentsOnly, +} from "@/attachments/workspace-attachment-utils"; +import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments"; +import { generateMessageId, type StreamItem } from "@/types/stream"; +import type { PickedImageAttachmentInput } from "@/hooks/image-attachment-picker"; + +export interface QueuedComposerMessage { + id: string; + text: string; + attachments: ComposerAttachment[]; +} + +export interface AttachmentPersister { + persistFromBlob: (input: { + blob: Blob; + mimeType: string; + fileName: string | null; + }) => Promise; + persistFromFileUri: (input: { + uri: string; + mimeType: string; + fileName: string | null; + }) => Promise; + deleteAttachments: (metadata: AttachmentMetadata[]) => Promise | void; +} + +export interface ComposerSendClient { + sendAgentMessage: ( + agentId: string, + text: string, + options: { + messageId: string; + images: Array<{ data: string; mimeType: string }>; + attachments: ReturnType["attachments"]; + }, + ) => Promise; +} + +export interface ComposerCancelClient { + cancelAgent: (agentId: string) => Promise | void; +} + +export interface AgentStreamWriter { + getHead: (agentId: string) => StreamItem[] | undefined; + setHead: (updater: (prev: Map) => Map) => void; + setTail: (updater: (prev: Map) => Map) => void; +} + +export interface QueueWriter { + read: (agentId: string) => QueuedComposerMessage[]; + write: ( + updater: (prev: Map) => Map, + ) => void; +} + +export async function pickAndPersistImages(input: { + pickImages: () => Promise; + persister: Pick; +}): Promise { + const result = await input.pickImages(); + if (!result?.length) return []; + return await Promise.all( + result.map(async (picked) => { + const fileName = picked.fileName ?? null; + const mimeType = picked.mimeType || "image/jpeg"; + if (picked.source.kind === "blob") { + return await input.persister.persistFromBlob({ + blob: picked.source.blob, + mimeType, + fileName, + }); + } + return await input.persister.persistFromFileUri({ + uri: picked.source.uri, + mimeType, + fileName, + }); + }), + ); +} + +export function removeComposerAttachmentAtIndex(input: { + attachments: T[]; + index: number; + deleteAttachments: AttachmentPersister["deleteAttachments"]; +}): T[] { + const removed = input.attachments[input.index]; + if (removed?.kind === "image") { + void input.deleteAttachments([removed.metadata]); + } + return input.attachments.filter((_, i) => i !== input.index); +} + +export interface CancelComposerAgentInput { + client: ComposerCancelClient | null; + agentId: string; + isAgentRunning: boolean; + isCancellingAgent: boolean; + isConnected: boolean; +} + +export function cancelComposerAgent(input: CancelComposerAgentInput): boolean { + if (!input.isAgentRunning || input.isCancellingAgent) return false; + if (!input.isConnected || !input.client) return false; + void input.client.cancelAgent(input.agentId); + return true; +} + +export interface DispatchComposerAgentMessageInput { + client: ComposerSendClient; + agentId: string; + text: string; + attachments: ComposerAttachment[]; + encodeImages: ( + images: AttachmentMetadata[], + ) => Promise | undefined>; + stream: AgentStreamWriter; +} + +export async function dispatchComposerAgentMessage( + input: DispatchComposerAgentMessageInput, +): Promise { + const wirePayload = splitComposerAttachmentsForSubmit(input.attachments); + const messageId = generateMessageId(); + const userMessage: StreamItem = { + kind: "user_message", + id: messageId, + text: input.text, + timestamp: new Date(), + ...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}), + ...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}), + }; + appendUserMessageToStream(input.agentId, userMessage, input.stream); + const imagesData = await input.encodeImages(wirePayload.images); + await input.client.sendAgentMessage(input.agentId, input.text, { + messageId, + images: imagesData ?? [], + attachments: wirePayload.attachments, + }); +} + +function appendUserMessageToStream( + agentId: string, + userMessage: StreamItem, + stream: AgentStreamWriter, +): void { + const head = stream.getHead(agentId); + if (head && head.length > 0) { + stream.setHead((prev) => { + const next = new Map(prev); + next.set(agentId, [...(prev.get(agentId) ?? []), userMessage]); + return next; + }); + return; + } + stream.setTail((prev) => { + const next = new Map(prev); + next.set(agentId, [...(prev.get(agentId) ?? []), userMessage]); + return next; + }); +} + +export interface QueueComposerMessageInput { + agentId: string; + text: string; + attachments: ComposerAttachment[]; + queue: QueueWriter; +} + +export interface QueueComposerMessageResult { + queued: QueuedComposerMessage | null; +} + +export function queueComposerMessage(input: QueueComposerMessageInput): QueueComposerMessageResult { + const trimmed = input.text.trim(); + if (!trimmed && input.attachments.length === 0) { + return { queued: null }; + } + const item: QueuedComposerMessage = { + id: generateMessageId(), + text: trimmed, + attachments: input.attachments, + }; + input.queue.write((prev) => { + const next = new Map(prev); + next.set(input.agentId, [...(prev.get(input.agentId) ?? []), item]); + return next; + }); + return { queued: item }; +} + +export interface EditQueuedComposerMessageInput { + agentId: string; + messageId: string; + queue: QueueWriter; +} + +export interface EditQueuedComposerMessageResult { + text: string; + attachments: UserComposerAttachment[]; +} + +export function editQueuedComposerMessage( + input: EditQueuedComposerMessageInput, +): EditQueuedComposerMessageResult | null { + const item = input.queue.read(input.agentId).find((q) => q.id === input.messageId); + if (!item) return null; + input.queue.write((prev) => { + const next = new Map(prev); + next.set( + input.agentId, + (prev.get(input.agentId) ?? []).filter((q) => q.id !== input.messageId), + ); + return next; + }); + return { + text: item.text, + attachments: userAttachmentsOnly(item.attachments), + }; +} + +export interface SendQueuedComposerMessageNowInput { + agentId: string; + messageId: string; + queue: QueueWriter; + submitMessage: (input: { text: string; attachments: ComposerAttachment[] }) => Promise; +} + +export type SendQueuedComposerMessageNowResult = + | { status: "missing" } + | { status: "submitted" } + | { status: "failed"; errorMessage: string }; + +export async function sendQueuedComposerMessageNow( + input: SendQueuedComposerMessageNowInput, +): Promise { + const item = input.queue.read(input.agentId).find((q) => q.id === input.messageId); + if (!item) return { status: "missing" }; + input.queue.write((prev) => { + const next = new Map(prev); + next.set( + input.agentId, + (prev.get(input.agentId) ?? []).filter((q) => q.id !== input.messageId), + ); + return next; + }); + try { + await input.submitMessage({ text: item.text, attachments: item.attachments }); + return { status: "submitted" }; + } catch (error) { + input.queue.write((prev) => { + const next = new Map(prev); + next.set(input.agentId, [item, ...(prev.get(input.agentId) ?? [])]); + return next; + }); + return { + status: "failed", + errorMessage: error instanceof Error ? error.message : "Failed to send message", + }; + } +} + +export interface OpenComposerAttachmentInput { + attachment: ComposerAttachment; + setLightboxMetadata: (metadata: AttachmentMetadata) => void; + openWorkspaceAttachment: (input: { attachment: ComposerAttachment }) => boolean; + openExternalUrl: (url: string) => void; +} + +export function openComposerAttachment(input: OpenComposerAttachmentInput): void { + if (input.attachment.kind === "image") { + input.setLightboxMetadata(input.attachment.metadata); + return; + } + if (isWorkspaceAttachment(input.attachment)) { + input.openWorkspaceAttachment({ attachment: input.attachment }); + return; + } + input.openExternalUrl(input.attachment.item.url); +} + +export function buildGithubAttachment(item: GitHubSearchItem): UserComposerAttachment { + return item.kind === "pr" ? { kind: "github_pr", item } : { kind: "github_issue", item }; +} + +export function toggleGithubAttachment( + current: UserComposerAttachment[], + item: GitHubSearchItem, +): UserComposerAttachment[] { + const matches = (attachment: UserComposerAttachment) => + attachment.kind !== "image" && + attachment.item.kind === item.kind && + attachment.item.number === item.number; + if (current.some(matches)) { + return current.filter((attachment) => !matches(attachment)); + } + return [...current, buildGithubAttachment(item)]; +} + +export function findGithubItemByOption( + items: readonly GitHubSearchItem[], + optionId: string, +): GitHubSearchItem | undefined { + return items.find((candidate) => `${candidate.kind}:${candidate.number}` === optionId); +} + +export function isAttachmentSelectedForGithubItem( + current: readonly ComposerAttachment[], + item: GitHubSearchItem, +): boolean { + return userAttachmentsOnly(current).some( + (attachment) => + attachment.kind !== "image" && + attachment.item.kind === item.kind && + attachment.item.number === item.number, + ); +} diff --git a/packages/app/src/components/composer-attachments.ts b/packages/app/src/components/composer-attachments.ts index 2521696fb..e3279d25d 100644 --- a/packages/app/src/components/composer-attachments.ts +++ b/packages/app/src/components/composer-attachments.ts @@ -1,5 +1,8 @@ import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types"; -import { composerWorkspaceAttachment } from "@/attachments/composer-workspace-attachments"; +import { + isWorkspaceAttachment, + workspaceAttachmentToSubmitAttachment, +} from "@/attachments/workspace-attachment-utils"; import type { AgentAttachment } from "@server/shared/messages"; import { buildGitHubAttachmentFromSearchItem } from "@/utils/review-attachments"; @@ -18,8 +21,8 @@ export function splitComposerAttachmentsForSubmit(attachments: ComposerAttachmen continue; } - if (composerWorkspaceAttachment.is(attachment)) { - const workspaceAttachment = composerWorkspaceAttachment.toSubmitAttachment(attachment); + if (isWorkspaceAttachment(attachment)) { + const workspaceAttachment = workspaceAttachmentToSubmitAttachment(attachment); if (workspaceAttachment) { reviewAttachments.push(workspaceAttachment); } diff --git a/packages/app/src/components/composer.test.tsx b/packages/app/src/components/composer.test.tsx deleted file mode 100644 index 6a421294f..000000000 --- a/packages/app/src/components/composer.test.tsx +++ /dev/null @@ -1,1476 +0,0 @@ -import React, { useState } from "react"; -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { JSDOM } from "jsdom"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { - AttachmentMetadata, - ComposerAttachment, - UserComposerAttachment, -} from "@/attachments/types"; -import { composerWorkspaceAttachment } from "@/attachments/composer-workspace-attachments"; -import type { AgentAttachment, GitHubSearchItem } from "@server/shared/messages"; -import { Composer } from "./composer"; -import { splitComposerAttachmentsForSubmit } from "./composer-attachments"; -import { addReviewDraftComment, getReviewDraftComments, resetReviewDraftStore } from "@/review"; - -const keyboardActionHandlerMock = vi.hoisted(() => vi.fn()); - -const { - theme, - imageMetadata, - issueItem, - prItem, - mockClient, - pickImagesMock, - persistAttachmentFromBlobMock, - deleteAttachmentsMock, - encodeImagesMock, - openExternalUrlMock, - mockSessionState, - setAgentStreamTailMock, - setAgentStreamHeadMock, - setQueuedMessagesMock, - agentDirectoryStatusMock, - appSendBehavior, -} = vi.hoisted(() => { - const hoistedTheme = { - spacing: { 1: 4, 2: 8, 3: 12, 4: 16, 6: 24, 8: 32 }, - iconSize: { sm: 14, md: 18, lg: 22 }, - borderWidth: { 1: 1 }, - borderRadius: { full: 999, md: 6, lg: 8, "2xl": 16 }, - fontSize: { xs: 11, sm: 13, base: 15, lg: 18 }, - fontWeight: { normal: "400", medium: "500" }, - lineHeight: { diff: 18 }, - opacity: { 50: 0.5 }, - shadow: { md: {} }, - colors: { - surface0: "#000", - surface1: "#111", - surface2: "#222", - surface3: "#333", - surface4: "#888", - foreground: "#fff", - foregroundMuted: "#aaa", - popoverForeground: "#fff", - border: "#555", - borderAccent: "#444", - accent: "#0a84ff", - accentForeground: "#fff", - destructive: "#ff453a", - palette: { - green: { 500: "#30d158", 600: "#24b14c", 800: "#126024" }, - red: { 500: "#ff453a", 600: "#d92d20" }, - zinc: { 600: "#52525b" }, - }, - }, - }; - - const hoistedImageMetadata: AttachmentMetadata = { - id: "img-1", - mimeType: "image/png", - storageType: "web-indexeddb", - storageKey: "img-1", - fileName: "img-1.png", - byteSize: 42, - createdAt: 1, - }; - - const hoistedIssueItem: GitHubSearchItem = { - kind: "issue", - number: 101, - title: "Fix composer attachments", - url: "https://github.com/acme/paseo/issues/101", - state: "open", - body: "Issue body", - labels: ["composer"], - baseRefName: null, - headRefName: null, - }; - - const hoistedPrItem: GitHubSearchItem = { - kind: "pr", - number: 202, - title: "Refactor composer attachments", - url: "https://github.com/acme/paseo/pull/202", - state: "open", - body: "PR body", - labels: ["composer"], - baseRefName: "main", - headRefName: "composer-attachments", - }; - - const hoistedMockClient = { - isConnected: true, - searchGitHub: vi.fn(async () => ({ items: [hoistedIssueItem, hoistedPrItem] })), - sendAgentMessage: vi.fn(async () => {}), - cancelAgent: vi.fn(async () => {}), - }; - - const hoistedSetQueuedMessagesMock = vi.fn( - (serverId: string, updater: (prev: Map) => Map) => { - const session = hoistedMockSessionState.sessions[serverId]; - session.queuedMessages = updater(session.queuedMessages); - }, - ); - const hoistedMockSessionState: { - sessions: Record< - string, - { - agents: Map; - serverInfo: { - serverId: string; - hostname: string | null; - version: string | null; - capabilities?: { - voice?: { - dictation: { enabled: boolean; reason: string }; - voice: { enabled: boolean; reason: string }; - }; - }; - } | null; - queuedMessages: Map; - agentStreamHead: Map; - agentStreamTail: Map; - } - >; - setQueuedMessages: ReturnType; - setAgentStreamTail?: ReturnType; - setAgentStreamHead?: ReturnType; - } = { - sessions: { - server: { - agents: new Map([["agent", { status: "idle", lastUsage: null }]]), - serverInfo: { - serverId: "server", - hostname: "test", - version: "0.0.0", - capabilities: { - voice: { - dictation: { enabled: true, reason: "" }, - voice: { enabled: true, reason: "" }, - }, - }, - }, - queuedMessages: new Map(), - agentStreamHead: new Map(), - agentStreamTail: new Map(), - }, - }, - setQueuedMessages: hoistedSetQueuedMessagesMock, - }; - const hoistedSetAgentStreamTailMock = vi.fn( - (serverId: string, updater: (prev: Map) => Map) => { - const session = hoistedMockSessionState.sessions[serverId]; - session.agentStreamTail = updater(session.agentStreamTail); - }, - ); - const hoistedSetAgentStreamHeadMock = vi.fn( - (serverId: string, updater: (prev: Map) => Map) => { - const session = hoistedMockSessionState.sessions[serverId]; - session.agentStreamHead = updater(session.agentStreamHead); - }, - ); - hoistedMockSessionState.setAgentStreamTail = hoistedSetAgentStreamTailMock; - hoistedMockSessionState.setAgentStreamHead = hoistedSetAgentStreamHeadMock; - const hoistedAgentDirectoryStatusMock = vi.fn(() => "ready"); - const hoistedAppSendBehavior = { current: "interrupt" as "interrupt" | "queue" }; - - return { - theme: hoistedTheme, - imageMetadata: hoistedImageMetadata, - issueItem: hoistedIssueItem, - prItem: hoistedPrItem, - mockClient: hoistedMockClient, - pickImagesMock: vi.fn(), - persistAttachmentFromBlobMock: vi.fn(async () => hoistedImageMetadata), - deleteAttachmentsMock: vi.fn(async () => {}), - encodeImagesMock: vi.fn(async (images: AttachmentMetadata[]) => images), - openExternalUrlMock: vi.fn(async () => {}), - mockSessionState: hoistedMockSessionState, - setAgentStreamTailMock: hoistedSetAgentStreamTailMock, - setAgentStreamHeadMock: hoistedSetAgentStreamHeadMock, - setQueuedMessagesMock: hoistedSetQueuedMessagesMock, - agentDirectoryStatusMock: hoistedAgentDirectoryStatusMock, - appSendBehavior: hoistedAppSendBehavior, - }; -}); - -vi.mock("react-native-unistyles", () => ({ - StyleSheet: { - create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory), - }, - withUnistyles: (component: T) => component, - useUnistyles: () => ({ theme }), -})); - -vi.mock("@/constants/platform", () => ({ - isWeb: true, - isNative: false, -})); - -vi.mock("@/constants/layout", () => ({ - FOOTER_HEIGHT: 72, - MAX_CONTENT_WIDTH: 900, - useIsCompactFormFactor: () => false, -})); - -vi.mock("lucide-react-native", () => { - const createIcon = (name: string) => (props: Record) => - React.createElement("span", { ...props, "data-icon": name }); - return { - ArrowUp: createIcon("ArrowUp"), - CornerDownLeft: createIcon("CornerDownLeft"), - Square: createIcon("Square"), - Pencil: createIcon("Pencil"), - AudioLines: createIcon("AudioLines"), - CircleDot: createIcon("CircleDot"), - MousePointer2: createIcon("MousePointer2"), - GitPullRequest: createIcon("GitPullRequest"), - MessageSquareCode: createIcon("MessageSquareCode"), - X: createIcon("X"), - Mic: createIcon("Mic"), - MicOff: createIcon("MicOff"), - Plus: createIcon("Plus"), - Paperclip: createIcon("Paperclip"), - Github: createIcon("Github"), - }; -}); - -vi.mock("react-native-reanimated", () => ({ - default: { - View: "div", - }, - Keyframe: class Keyframe { - duration() { - return this; - } - withCallback() { - return this; - } - }, - runOnJS: (fn: (...args: unknown[]) => unknown) => fn, - useSharedValue: (value: unknown) => ({ value }), - useAnimatedStyle: (factory: () => unknown) => factory(), - withTiming: (value: unknown) => value, -})); - -vi.mock("react-native-safe-area-context", () => ({ - useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }), -})); - -vi.mock("@/runtime/host-runtime", () => ({ - useHostRuntimeClient: () => mockClient, - useHostRuntimeIsConnected: () => true, - useHostRuntimeAgentDirectoryStatus: () => agentDirectoryStatusMock(), -})); - -vi.mock("@/stores/session-store", () => { - const useSessionStore = (selector: (state: typeof mockSessionState) => unknown) => - selector(mockSessionState); - useSessionStore.getState = () => mockSessionState; - return { useSessionStore }; -}); - -vi.mock("@/hooks/use-image-attachment-picker", () => ({ - useImageAttachmentPicker: () => ({ pickImages: pickImagesMock }), -})); - -vi.mock("@/attachments/service", () => ({ - persistAttachmentFromBlob: persistAttachmentFromBlobMock, - persistAttachmentFromFileUri: vi.fn(async () => imageMetadata), - deleteAttachments: deleteAttachmentsMock, -})); - -vi.mock("@/attachments/use-attachment-preview-url", () => ({ - useAttachmentPreviewUrl: () => "blob:preview", -})); - -vi.mock("expo-image", () => ({ - Image: (props: Record) => { - const source = props.source as { uri?: string } | string | undefined; - const uri = typeof source === "string" ? source : source?.uri; - return React.createElement("div", { - "data-testid": props.testID, - "data-source": uri, - role: "img", - }); - }, -})); - -vi.mock("react-native", async () => { - const actual = await vi.importActual>("react-native"); - const Modal = ({ visible = true, children }: { visible?: boolean; children?: React.ReactNode }) => - visible ? React.createElement("div", { "data-testid": "lightbox-modal" }, children) : null; - return { ...actual, Modal }; -}); - -vi.mock("@/utils/encode-images", () => ({ - encodeImages: encodeImagesMock, -})); - -vi.mock("@/utils/open-external-url", () => ({ - openExternalUrl: openExternalUrlMock, -})); - -vi.mock("@/hooks/use-settings", () => ({ - useAppSettings: () => ({ settings: { sendBehavior: appSendBehavior.current } }), -})); - -vi.mock("@/hooks/use-agent-autocomplete", () => ({ - useAgentAutocomplete: () => ({ - isVisible: false, - options: [], - selectedIndex: -1, - isLoading: false, - errorMessage: null, - loadingText: "", - emptyText: "", - onSelectOption: vi.fn(), - onKeyPress: () => false, - }), -})); - -vi.mock("@/hooks/use-shortcut-keys", () => ({ - useShortcutKeys: () => null, -})); - -vi.mock("@/hooks/use-keyboard-action-handler", () => ({ - useKeyboardActionHandler: (input: unknown) => { - keyboardActionHandlerMock(input); - }, -})); - -vi.mock("@/hooks/use-keyboard-shift-style", () => ({ - useKeyboardShiftStyle: () => ({ style: {} }), -})); - -vi.mock("@/contexts/voice-context", () => ({ - useVoiceOptional: () => null, -})); - -vi.mock("@/contexts/toast-context", () => ({ - useToast: () => ({ error: vi.fn() }), -})); - -vi.mock("@/components/agent-status-bar", () => ({ - AgentStatusBar: () => null, - DraftAgentStatusBar: () => null, -})); - -vi.mock("@/components/context-window-meter", () => ({ - ContextWindowMeter: () => null, -})); - -vi.mock("@/components/composer.status-controls", () => ({ - resolveStatusControlMode: () => "agent", -})); - -vi.mock("@/components/ui/autocomplete", () => ({ - Autocomplete: () => null, -})); - -vi.mock("@/components/use-web-scrollbar", () => ({ - useWebElementScrollbar: () => null, -})); - -vi.mock("@/hooks/use-web-scrollbar-style", () => ({ - useWebScrollbarStyle: () => undefined, -})); - -vi.mock("@/hooks/use-dictation", () => ({ - useDictation: () => ({ - isRecording: false, - isProcessing: false, - partialTranscript: "", - volume: 0, - duration: 0, - error: null, - status: "idle", - startDictation: vi.fn(), - cancelDictation: vi.fn(), - confirmDictation: vi.fn(), - retryFailedDictation: vi.fn(), - discardFailedDictation: vi.fn(), - }), -})); - -vi.mock("@/utils/server-info-capabilities", () => ({ - getVoiceReadinessState: ({ - serverInfo, - mode, - }: { - serverInfo: { - capabilities?: { - voice?: { - dictation?: { enabled: boolean; reason: string }; - voice?: { enabled: boolean; reason: string }; - }; - }; - } | null; - mode: "dictation" | "voice"; - }) => serverInfo?.capabilities?.voice?.[mode] ?? null, - resolveVoiceUnavailableMessage: () => null, -})); - -vi.mock("@/components/ui/shortcut", () => ({ - Shortcut: () => null, -})); - -vi.mock("@/components/ui/tooltip", () => ({ - Tooltip: ({ children }: { children: React.ReactNode }) => children, - TooltipTrigger: ({ - asChild, - children, - onPress, - accessibilityLabel, - disabled, - }: { - asChild?: boolean; - children: React.ReactNode | ((state: { hovered: boolean }) => React.ReactNode); - onPress?: () => void; - accessibilityLabel?: string; - disabled?: boolean; - }) => - asChild ? ( - children - ) : ( - - ), - TooltipContent: ({ children }: { children: React.ReactNode }) => children, -})); - -vi.mock("@/components/ui/dropdown-menu", () => { - const DropdownContext = React.createContext<{ - open: boolean; - setOpen: (open: boolean) => void; - } | null>(null); - - return { - DropdownMenu: ({ children }: { children: React.ReactNode }) => { - const [open, setOpen] = React.useState(false); - const contextValue = React.useMemo(() => ({ open, setOpen }), [open]); - return {children}; - }, - DropdownMenuTrigger: ({ - children, - testID, - accessibilityLabel, - disabled, - }: { - children: - | React.ReactNode - | ((state: { hovered: boolean; pressed: boolean; open: boolean }) => React.ReactNode); - testID?: string; - accessibilityLabel?: string; - disabled?: boolean; - }) => { - const menu = React.useContext(DropdownContext); - const handleClick = React.useCallback(() => menu?.setOpen(true), [menu]); - return ( - - ); - }, - DropdownMenuContent: ({ children, testID }: { children: React.ReactNode; testID?: string }) => { - const menu = React.useContext(DropdownContext); - return menu?.open ?
{children}
: null; - }, - DropdownMenuItem: ({ - children, - onSelect, - testID, - disabled, - }: { - children: React.ReactNode; - onSelect?: () => void; - testID?: string; - disabled?: boolean; - }) => ( - - ), - }; -}); - -vi.mock("@/components/ui/combobox", () => ({ - Combobox: ({ - open, - options, - renderOption, - anchorRef, - }: { - open?: boolean; - options: Array<{ id: string; label: string; description?: string }>; - renderOption?: (input: { - option: { id: string; label: string; description?: string }; - selected: boolean; - active: boolean; - onPress: () => void; - }) => React.ReactElement; - anchorRef: React.RefObject; - }) => - open ? ( -
- {options.map((option) => - renderOption ? ( - renderOption({ option, selected: false, active: false, onPress: vi.fn() }) - ) : ( - - ), - )} -
- ) : null, - ComboboxItem: ({ - label, - selected, - onPress, - testID, - }: { - label: string; - selected?: boolean; - onPress: () => void; - testID?: string; - }) => ( - - ), -})); - -vi.mock("./dictation-controls", () => ({ - DictationOverlay: () => null, -})); - -vi.mock("./realtime-voice-overlay", () => ({ - RealtimeVoiceOverlay: () => null, -})); - -let root: Root | null = null; -let container: HTMLElement | null = null; -let queryClient: QueryClient | null = null; -let latestAttachments: ComposerAttachment[] = []; -let workspaceBindingRenderCount = 0; - -type ReviewComposerAttachment = Extract; -type ReviewAttachment = Extract; -type BrowserElementComposerAttachment = Extract; - -function reviewAttachment(body: string): ReviewAttachment { - return { - type: "review", - mimeType: "application/paseo-review", - cwd: "/repo", - mode: "uncommitted", - baseRef: null, - comments: [ - { - filePath: "src/example.ts", - side: "new", - lineNumber: 41, - body, - context: { - hunkHeader: "@@ -40,2 +40,2 @@", - targetLine: { - oldLineNumber: null, - newLineNumber: 41, - type: "add", - content: "const value = newValue;", - }, - lines: [ - { - oldLineNumber: null, - newLineNumber: 41, - type: "add", - content: "const value = newValue;", - }, - ], - }, - }, - ], - }; -} - -function reviewComposerAttachment(body: string): ReviewComposerAttachment { - return { - kind: "review", - reviewDraftKey: `review:${body}`, - commentCount: 1, - attachment: reviewAttachment(body), - }; -} - -function browserElementComposerAttachment(): BrowserElementComposerAttachment { - return { - kind: "browser_element", - attachment: { - url: "https://example.com/page", - selector: "button.primary", - tag: "button", - text: "Save", - outerHTML: '', - computedStyles: { display: "flex" }, - boundingRect: { x: 1, y: 2, width: 80, height: 32 }, - reactSource: { - fileName: "src/save-button.tsx", - lineNumber: 12, - columnNumber: 3, - componentName: "SaveButton", - }, - parentChain: ["form.settings"], - children: [], - formatted: 'button.primary', - }, - }; -} - -function cloneReviewComposerAttachment( - attachment: ReviewComposerAttachment, -): ReviewComposerAttachment { - return { - ...attachment, - attachment: { - ...attachment.attachment, - comments: attachment.attachment.comments.map((comment) => ({ - ...comment, - context: { - ...comment.context, - targetLine: { ...comment.context.targetLine }, - lines: comment.context.lines.map((line) => ({ ...line })), - }, - })), - }, - }; -} - -function seedReviewDraft(key: string) { - addReviewDraftComment({ - key, - comment: { - id: `${key}:comment`, - filePath: "src/example.ts", - side: "new", - lineNumber: 41, - body: "Please simplify this.", - createdAt: "2026-04-21T00:00:00.000Z", - updatedAt: "2026-04-21T00:00:00.000Z", - }, - }); -} - -beforeEach(() => { - const dom = new JSDOM("", { - url: "http://localhost", - }); - vi.stubGlobal("React", React); - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - vi.stubGlobal("window", dom.window); - vi.stubGlobal("document", dom.window.document); - vi.stubGlobal("HTMLElement", dom.window.HTMLElement); - vi.stubGlobal("Node", dom.window.Node); - vi.stubGlobal("navigator", dom.window.navigator); - vi.stubGlobal("Blob", dom.window.Blob); - Object.assign(dom.window.HTMLElement.prototype, { - attachEvent: vi.fn(), - detachEvent: vi.fn(), - }); - vi.stubGlobal("localStorage", dom.window.localStorage); - - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - latestAttachments = []; - mockClient.searchGitHub.mockClear(); - mockClient.sendAgentMessage.mockClear(); - mockClient.cancelAgent.mockClear(); - pickImagesMock.mockReset(); - persistAttachmentFromBlobMock.mockClear(); - deleteAttachmentsMock.mockClear(); - encodeImagesMock.mockClear(); - openExternalUrlMock.mockClear(); - setAgentStreamTailMock.mockClear(); - setAgentStreamHeadMock.mockClear(); - setQueuedMessagesMock.mockClear(); - keyboardActionHandlerMock.mockClear(); - agentDirectoryStatusMock.mockReset(); - agentDirectoryStatusMock.mockReturnValue("ready"); - appSendBehavior.current = "interrupt"; - workspaceBindingRenderCount = 0; - mockSessionState.sessions.server.serverInfo = { - serverId: "server", - hostname: "test", - version: "0.0.0", - capabilities: { - voice: { - dictation: { enabled: true, reason: "" }, - voice: { enabled: true, reason: "" }, - }, - }, - }; - mockSessionState.sessions.server.agents = new Map([ - ["agent", { status: "idle", lastUsage: null }], - ]); - mockSessionState.sessions.server.agentStreamHead = new Map(); - mockSessionState.sessions.server.agentStreamTail = new Map(); - mockSessionState.sessions.server.queuedMessages = new Map(); - mockSessionState.sessions.server.agents = new Map([ - ["agent", { status: "idle", lastUsage: null }], - ]); - resetReviewDraftStore(); -}); - -afterEach(() => { - if (root) { - act(() => { - root?.unmount(); - }); - } - queryClient?.clear(); - root = null; - container = null; - queryClient = null; - vi.unstubAllGlobals(); -}); - -function imageAttachment(id: string): AttachmentMetadata { - return { - ...imageMetadata, - id, - storageKey: id, - fileName: `${id}.png`, - }; -} - -function ComposerHarness({ - initialText = "", - initialAttachments = [], - workspaceAttachment = null, - isSubmitLoading = false, - submitBehavior, -}: { - initialText?: string; - initialAttachments?: UserComposerAttachment[]; - workspaceAttachment?: ReviewComposerAttachment | null; - isSubmitLoading?: boolean; - submitBehavior?: "clear" | "preserve-and-lock"; -}) { - const [text, setText] = useState(initialText); - const [attachments, setAttachments] = useState(initialAttachments); - const workspaceAttachments = React.useMemo( - () => (workspaceAttachment ? [workspaceAttachment] : []), - [workspaceAttachment], - ); - latestAttachments = attachments; - - const handleChangeAttachments = React.useCallback( - ( - updater: - | UserComposerAttachment[] - | ((current: UserComposerAttachment[]) => UserComposerAttachment[]), - ) => { - setAttachments((current) => { - const next = typeof updater === "function" ? updater(current) : updater; - latestAttachments = next; - return next; - }); - }, - [], - ); - - return ( - - - - ); -} - -function renderComposer( - input: { - initialText?: string; - initialAttachments?: UserComposerAttachment[]; - workspaceAttachment?: ReviewComposerAttachment | null; - isSubmitLoading?: boolean; - submitBehavior?: "clear" | "preserve-and-lock"; - } = {}, -) { - act(() => { - root?.render(); - }); -} - -function WorkspaceAttachmentBindingHarness({ - workspaceAttachment, -}: { - workspaceAttachment: ReviewComposerAttachment; -}) { - workspaceBindingRenderCount += 1; - const { selectedAttachments } = composerWorkspaceAttachment.useBinding({ - normalAttachments: [], - workspaceAttachments: [workspaceAttachment], - }); - - return
{selectedAttachments.length}
; -} - -function click(element: Element) { - act(() => { - element.dispatchEvent(new window.MouseEvent("click", { bubbles: true })); - }); -} - -async function flushAsyncWork() { - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - await new Promise((resolve) => setTimeout(resolve, 0)); - }); -} - -async function findByTestId(testID: string): Promise { - for (let attempt = 0; attempt < 10; attempt += 1) { - await flushAsyncWork(); - const element = queryByTestId(testID); - if (element) { - return element; - } - } - throw new Error(`Missing element with testID ${testID}`); -} - -function queryByTestId(testID: string): HTMLElement | null { - return document.querySelector(`[data-testid="${testID}"]`); -} - -function queryAllAttachmentMenuItems(): NodeListOf { - return document.querySelectorAll('[data-testid^="message-input-attachment-menu-item-"]'); -} - -function dispatchAgentInterrupt() { - act(() => { - const registeredHandler = keyboardActionHandlerMock.mock.calls.at(-1)?.[0]; - registeredHandler?.handle({ id: "agent.interrupt", scope: "global" }); - }); -} - -describe("Composer keyboard shortcuts", () => { - it("interrupts a running agent without clearing a filled draft", async () => { - mockSessionState.sessions.server.agents = new Map([ - ["agent", { status: "running", lastUsage: null }], - ]); - - renderComposer({ initialText: "keep this prompt" }); - await flushAsyncWork(); - - dispatchAgentInterrupt(); - - expect(mockClient.cancelAgent).toHaveBeenCalledWith("agent"); - expect(document.querySelector('[aria-label="Message agent..."]')).toHaveProperty( - "value", - "keep this prompt", - ); - }); - - it("interrupts a running agent when the message input is unfocused", async () => { - mockSessionState.sessions.server.agents = new Map([ - ["agent", { status: "running", lastUsage: null }], - ]); - - renderComposer(); - await flushAsyncWork(); - - const input = document.querySelector('[aria-label="Message agent..."]') as HTMLElement | null; - input?.blur(); - dispatchAgentInterrupt(); - - expect(mockClient.cancelAgent).toHaveBeenCalledWith("agent"); - }); - - it("does not interrupt when the agent is idle", () => { - renderComposer(); - - dispatchAgentInterrupt(); - - expect(mockClient.cancelAgent).not.toHaveBeenCalled(); - }); -}); - -describe("Composer attachments", () => { - it("opens a Plus menu with image and GitHub attachment actions", () => { - renderComposer(); - - click(queryByTestId("message-input-attach-button")!); - - expect(queryAllAttachmentMenuItems()).toHaveLength(2); - expect(queryByTestId("message-input-attachment-menu-item-image")?.textContent).toBe( - "Add image", - ); - expect(queryByTestId("message-input-attachment-menu-item-github")?.textContent).toBe( - "Add issue or PR", - ); - }); - - it("adds a picked image as a unified composer attachment and renders a pill", async () => { - pickImagesMock.mockResolvedValue([ - { - source: { kind: "blob", blob: new Blob(["image"]) }, - mimeType: "image/png", - fileName: "img-1.png", - }, - ]); - renderComposer(); - - click(queryByTestId("message-input-attach-button")!); - click(queryByTestId("message-input-attachment-menu-item-image")!); - await flushAsyncWork(); - - expect(persistAttachmentFromBlobMock).toHaveBeenCalledWith({ - blob: expect.any(Blob), - mimeType: "image/png", - fileName: "img-1.png", - }); - expect(latestAttachments).toEqual([{ kind: "image", metadata: imageMetadata }]); - expect(queryByTestId("composer-image-attachment-pill")).not.toBeNull(); - }); - - it("opens the GitHub combobox from the Plus menu anchored at the Plus button", async () => { - renderComposer(); - - click(queryByTestId("message-input-attach-button")!); - click(queryByTestId("message-input-attachment-menu-item-github")!); - - const combobox = await findByTestId("composer-github-combobox"); - expect(combobox.dataset.anchor).toBe("attached"); - }); - - it("lazily searches GitHub only after the GitHub combobox opens", async () => { - renderComposer(); - await flushAsyncWork(); - - expect(mockClient.searchGitHub).not.toHaveBeenCalled(); - - click(queryByTestId("message-input-attach-button")!); - click(queryByTestId("message-input-attachment-menu-item-github")!); - await findByTestId("composer-github-combobox"); - - expect(mockClient.searchGitHub).toHaveBeenCalledWith({ - cwd: "/repo", - query: "", - limit: 20, - }); - }); - - it("closes the GitHub combobox after selecting an item", async () => { - renderComposer(); - - click(queryByTestId("message-input-attach-button")!); - click(queryByTestId("message-input-attachment-menu-item-github")!); - const issueOption = await findByTestId("composer-github-option-issue:101"); - - click(issueOption); - - expect(latestAttachments).toEqual([{ kind: "github_issue", item: issueItem }]); - expect(queryByTestId("composer-github-combobox")).toBeNull(); - }); - - it("toggles GitHub search items into and out of unified composer attachments", async () => { - renderComposer(); - - click(queryByTestId("message-input-attach-button")!); - click(queryByTestId("message-input-attachment-menu-item-github")!); - const issueOption = await findByTestId("composer-github-option-issue:101"); - - click(issueOption); - expect(latestAttachments).toEqual([{ kind: "github_issue", item: issueItem }]); - expect(queryByTestId("composer-github-attachment-pill")).not.toBeNull(); - expect(queryByTestId("composer-github-combobox")).toBeNull(); - - click(queryByTestId("message-input-attach-button")!); - click(queryByTestId("message-input-attachment-menu-item-github")!); - const selectedIssueOption = await findByTestId("composer-github-option-issue:101"); - click(selectedIssueOption); - expect(latestAttachments).toEqual([]); - expect(queryByTestId("composer-github-combobox")).toBeNull(); - }); - - it("submits mixed composer attachments as the expected wire images and attachments", async () => { - const image = imageAttachment("img-2"); - renderComposer({ - initialText: "send attachments", - initialAttachments: [ - { kind: "image", metadata: image }, - { kind: "github_pr", item: prItem }, - ], - }); - - click(document.querySelector('[aria-label="Send message"]')!); - await flushAsyncWork(); - - expect(mockClient.sendAgentMessage).toHaveBeenCalledWith( - "agent", - "send attachments", - expect.objectContaining({ - images: [image], - attachments: [ - { - type: "github_pr", - mimeType: "application/github-pr", - number: 202, - title: "Refactor composer attachments", - url: "https://github.com/acme/paseo/pull/202", - body: "PR body", - baseRefName: "main", - headRefName: "composer-attachments", - }, - ], - }), - ); - }); - - it("serializes workspace review attachments through the structured attachment path", async () => { - const review = reviewComposerAttachment("Please simplify this."); - expect(splitComposerAttachmentsForSubmit([review])).toEqual({ - images: [], - attachments: [review.attachment], - }); - }); - - it("serializes browser element workspace attachments as generic text attachments", async () => { - const browserElement = browserElementComposerAttachment(); - expect(splitComposerAttachmentsForSubmit([browserElement])).toEqual({ - images: [], - attachments: [ - { - type: "text", - mimeType: "text/plain", - title: "Browser element · button", - text: browserElement.attachment.formatted, - }, - ], - }); - }); - - it("does not enqueue redundant binding renders for equivalent workspace attachments", async () => { - const review = reviewComposerAttachment("Stable workspace review."); - - act(() => { - root?.render(); - }); - await flushAsyncWork(); - - expect(workspaceBindingRenderCount).toBe(1); - expect(queryByTestId("workspace-binding-count")?.textContent).toBe("1"); - - act(() => { - root?.render( - , - ); - }); - await flushAsyncWork(); - - expect(workspaceBindingRenderCount).toBe(2); - expect(queryByTestId("workspace-binding-count")?.textContent).toBe("1"); - }); - - it("renders and submits a workspace review attachment pill", async () => { - const review = reviewComposerAttachment("Please simplify this."); - renderComposer({ - initialText: "review this", - workspaceAttachment: review, - }); - - expect(queryByTestId("composer-review-attachment-pill")?.textContent).toContain("Review"); - expect(queryByTestId("composer-review-attachment-pill")?.textContent).toContain("1 comment"); - - click(document.querySelector('[aria-label="Send message"]')!); - await flushAsyncWork(); - - expect(mockClient.sendAgentMessage).toHaveBeenCalledWith( - "agent", - "review this", - expect.objectContaining({ - attachments: [review.attachment], - }), - ); - }); - - it("clears the included workspace review draft after a successful submit", async () => { - const review = reviewComposerAttachment("Clear submitted review draft."); - seedReviewDraft(review.reviewDraftKey); - renderComposer({ - initialText: "review this", - workspaceAttachment: review, - }); - - click(document.querySelector('[aria-label="Send message"]')!); - await flushAsyncWork(); - - expect(getReviewDraftComments(review.reviewDraftKey)).toBeUndefined(); - }); - - it("restores only normal attachments when a submit with a workspace review fails", async () => { - const image = imageAttachment("img-failure"); - const review = reviewComposerAttachment("This should be sent but not persisted."); - seedReviewDraft(review.reviewDraftKey); - mockClient.sendAgentMessage.mockRejectedValueOnce(new Error("network down")); - renderComposer({ - initialText: "review this", - initialAttachments: [{ kind: "image", metadata: image }], - workspaceAttachment: review, - }); - - click(document.querySelector('[aria-label="Send message"]')!); - await flushAsyncWork(); - - expect(mockClient.sendAgentMessage).toHaveBeenCalledWith( - "agent", - "review this", - expect.objectContaining({ - attachments: [review.attachment], - }), - ); - expect(latestAttachments).toEqual([{ kind: "image", metadata: image }]); - expect(getReviewDraftComments(review.reviewDraftKey)).toHaveLength(1); - }); - - it("clears workspace review suppression after a send lifecycle", async () => { - const review = reviewComposerAttachment("Keep this available for the next message."); - renderComposer({ - initialText: "send without review", - workspaceAttachment: review, - }); - - click(document.querySelector('[aria-label="Remove review attachment"]')!); - expect(queryByTestId("composer-review-attachment-pill")).toBeNull(); - - click(document.querySelector('[aria-label="Send message"]')!); - await flushAsyncWork(); - - expect(mockClient.sendAgentMessage).toHaveBeenCalledWith( - "agent", - "send without review", - expect.objectContaining({ - attachments: [], - }), - ); - expect(queryByTestId("composer-review-attachment-pill")).not.toBeNull(); - }); - - it("keeps workspace review suppressed after a failed send", async () => { - const review = reviewComposerAttachment("Do not send this on retry."); - mockClient.sendAgentMessage.mockRejectedValueOnce(new Error("network down")); - renderComposer({ - initialText: "retry without review", - workspaceAttachment: review, - }); - - click(document.querySelector('[aria-label="Remove review attachment"]')!); - expect(queryByTestId("composer-review-attachment-pill")).toBeNull(); - - click(document.querySelector('[aria-label="Send message"]')!); - await flushAsyncWork(); - - expect(mockClient.sendAgentMessage).toHaveBeenNthCalledWith( - 1, - "agent", - "retry without review", - expect.objectContaining({ - attachments: [], - }), - ); - expect(queryByTestId("composer-review-attachment-pill")).toBeNull(); - - click(document.querySelector('[aria-label="Send message"]')!); - await flushAsyncWork(); - - expect(mockClient.sendAgentMessage).toHaveBeenNthCalledWith( - 2, - "agent", - "retry without review", - expect.objectContaining({ - attachments: [], - }), - ); - }); - - it("captures workspace review attachments at queue time", async () => { - appSendBehavior.current = "queue"; - mockSessionState.sessions.server.agents.set("agent", { status: "running", lastUsage: null }); - const initialReview = reviewComposerAttachment("Initial queued review."); - const editedReview = reviewComposerAttachment("Edited after queue."); - renderComposer({ - initialText: "queue this", - workspaceAttachment: initialReview, - }); - - click(document.querySelector('[aria-label="Queue message"]')!); - await flushAsyncWork(); - renderComposer({ - initialText: "", - workspaceAttachment: editedReview, - }); - - const queued = mockSessionState.sessions.server.queuedMessages.get("agent") as Array<{ - attachments: ComposerAttachment[]; - }>; - expect(queued[0]?.attachments).toEqual([initialReview]); - }); - - it("clears the included workspace review draft after queueing", async () => { - appSendBehavior.current = "queue"; - mockSessionState.sessions.server.agents.set("agent", { status: "running", lastUsage: null }); - const review = reviewComposerAttachment("Clear queued review draft."); - seedReviewDraft(review.reviewDraftKey); - renderComposer({ - initialText: "queue this", - workspaceAttachment: review, - }); - - click(document.querySelector('[aria-label="Queue message"]')!); - await flushAsyncWork(); - - expect(getReviewDraftComments(review.reviewDraftKey)).toBeUndefined(); - }); - - it("clears workspace review suppression after queueing a message", async () => { - appSendBehavior.current = "queue"; - mockSessionState.sessions.server.agents.set("agent", { status: "running", lastUsage: null }); - const review = reviewComposerAttachment("Queue without this review first."); - renderComposer({ - initialText: "queue without review", - workspaceAttachment: review, - }); - - click(document.querySelector('[aria-label="Remove review attachment"]')!); - expect(queryByTestId("composer-review-attachment-pill")).toBeNull(); - - click(document.querySelector('[aria-label="Queue message"]')!); - await flushAsyncWork(); - - const queued = mockSessionState.sessions.server.queuedMessages.get("agent") as Array<{ - attachments: ComposerAttachment[]; - }>; - expect(queued[0]?.attachments).toEqual([]); - expect(queryByTestId("composer-review-attachment-pill")).not.toBeNull(); - }); - - it("does not restore queued workspace review attachments into live draft attachments when editing", async () => { - appSendBehavior.current = "queue"; - mockSessionState.sessions.server.agents.set("agent", { status: "running", lastUsage: null }); - const image = imageAttachment("img-queued-edit"); - const review = reviewComposerAttachment("Queued snapshot."); - renderComposer({ - initialText: "queue this", - initialAttachments: [{ kind: "image", metadata: image }], - workspaceAttachment: review, - }); - - click(document.querySelector('[aria-label="Queue message"]')!); - await flushAsyncWork(); - expect( - ( - mockSessionState.sessions.server.queuedMessages.get("agent") as Array<{ - attachments: ComposerAttachment[]; - }> - )[0]?.attachments, - ).toEqual([{ kind: "image", metadata: image }, review]); - - click(document.querySelector('[aria-label="Edit queued message"]')!); - - expect(latestAttachments).toEqual([{ kind: "image", metadata: image }]); - }); - - it("submits empty wire arrays when there are no composer attachments", async () => { - renderComposer({ initialText: "plain message" }); - - click(document.querySelector('[aria-label="Send message"]')!); - await flushAsyncWork(); - - expect(mockClient.sendAgentMessage).toHaveBeenCalledWith( - "agent", - "plain message", - expect.objectContaining({ - images: [], - attachments: [], - }), - ); - }); - - it("removes the image attachment when its pill X button is pressed", () => { - const image = imageAttachment("img-remove"); - renderComposer({ initialAttachments: [{ kind: "image", metadata: image }] }); - - const removeButton = document.querySelector('[aria-label="Remove image attachment"]'); - expect(removeButton).not.toBeNull(); - click(removeButton!); - - expect(latestAttachments).toEqual([]); - expect(deleteAttachmentsMock).toHaveBeenCalledWith([image]); - }); - - it("removes a GitHub attachment when its pill X button is pressed", () => { - renderComposer({ initialAttachments: [{ kind: "github_issue", item: issueItem }] }); - - const removeButton = document.querySelector(`[aria-label="Remove issue #${issueItem.number}"]`); - expect(removeButton).not.toBeNull(); - click(removeButton!); - - expect(latestAttachments).toEqual([]); - }); - - it("opens the GitHub issue URL when the pill body is pressed", () => { - renderComposer({ initialAttachments: [{ kind: "github_issue", item: issueItem }] }); - - click(queryByTestId("composer-github-attachment-pill")!); - - expect(openExternalUrlMock).toHaveBeenCalledWith(issueItem.url); - expect(latestAttachments).toEqual([{ kind: "github_issue", item: issueItem }]); - }); - - it("opens the GitHub PR URL when the pill body is pressed", () => { - renderComposer({ initialAttachments: [{ kind: "github_pr", item: prItem }] }); - - click(queryByTestId("composer-github-attachment-pill")!); - - expect(openExternalUrlMock).toHaveBeenCalledWith(prItem.url); - expect(latestAttachments).toEqual([{ kind: "github_pr", item: prItem }]); - }); - - it("opens the image lightbox when the image pill body is pressed", () => { - const image = imageAttachment("img-body"); - renderComposer({ initialAttachments: [{ kind: "image", metadata: image }] }); - - expect(queryByTestId("attachment-lightbox-image")).toBeNull(); - - click(queryByTestId("composer-image-attachment-pill")!); - - expect(queryByTestId("attachment-lightbox-image")).not.toBeNull(); - expect(openExternalUrlMock).not.toHaveBeenCalled(); - expect(latestAttachments).toEqual([{ kind: "image", metadata: image }]); - }); - - it("enables dictation from server capabilities before the agent directory finishes loading", () => { - agentDirectoryStatusMock.mockReturnValue("initial_loading"); - - renderComposer(); - - expect(document.querySelector('[aria-label="Start dictation"]')).toHaveProperty( - "disabled", - false, - ); - }); - - it("locks the preserved draft while submit loading", () => { - renderComposer({ - initialText: "keep this prompt", - initialAttachments: [{ kind: "github_pr", item: prItem }], - isSubmitLoading: true, - submitBehavior: "preserve-and-lock", - }); - - const textInput = document.querySelector('[aria-label="Message agent..."]'); - const attachButton = queryByTestId("message-input-attach-button"); - const pill = queryByTestId("composer-github-attachment-pill"); - const removeButton = document.querySelector(`[aria-label="Remove PR #${prItem.number}"]`); - - expect(textInput).toHaveProperty("readOnly", true); - expect(textInput).toHaveProperty("value", "keep this prompt"); - expect(attachButton).toHaveProperty("disabled", true); - expect(pill).not.toBeNull(); - expect(removeButton).not.toBeNull(); - - click(pill!); - click(removeButton!); - - expect(openExternalUrlMock).not.toHaveBeenCalled(); - expect(latestAttachments).toEqual([{ kind: "github_pr", item: prItem }]); - }); - - it("closes the image lightbox when its close button is pressed", () => { - const image = imageAttachment("img-close"); - renderComposer({ initialAttachments: [{ kind: "image", metadata: image }] }); - - click(queryByTestId("composer-image-attachment-pill")!); - expect(queryByTestId("attachment-lightbox-image")).not.toBeNull(); - - const closeButton = document.querySelector( - '[aria-label="Close image"][data-testid="attachment-lightbox-close"]', - ); - expect(closeButton).not.toBeNull(); - click(closeButton!); - - expect(queryByTestId("attachment-lightbox-image")).toBeNull(); - }); - - it("splits mixed composer attachments only at the submit wire boundary", () => { - const image = imageAttachment("img-3"); - - expect( - splitComposerAttachmentsForSubmit([ - { kind: "image", metadata: image }, - { kind: "github_issue", item: issueItem }, - { kind: "github_pr", item: prItem }, - ]), - ).toEqual({ - images: [image], - attachments: [ - { - type: "github_issue", - mimeType: "application/github-issue", - number: 101, - title: "Fix composer attachments", - url: "https://github.com/acme/paseo/issues/101", - body: "Issue body", - }, - { - type: "github_pr", - mimeType: "application/github-pr", - number: 202, - title: "Refactor composer attachments", - url: "https://github.com/acme/paseo/pull/202", - body: "PR body", - baseRefName: "main", - headRefName: "composer-attachments", - }, - ], - }); - }); -}); diff --git a/packages/app/src/components/composer.tsx b/packages/app/src/components/composer.tsx index b0fed211a..3ff9900db 100644 --- a/packages/app/src/components/composer.tsx +++ b/packages/app/src/components/composer.tsx @@ -23,7 +23,6 @@ import { import Animated from "react-native-reanimated"; import { useQuery } from "@tanstack/react-query"; import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout"; -import { generateMessageId, type StreamItem } from "@/types/stream"; import { AgentStatusBar, DraftAgentStatusBar, @@ -31,7 +30,6 @@ import { } from "./agent-status-bar"; import { ContextWindowMeter } from "./context-window-meter"; import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker"; -import type { PickedImageAttachmentInput } from "@/hooks/image-attachment-picker"; import { useSessionStore } from "@/stores/session-store"; import { MessageInput, @@ -44,6 +42,22 @@ import { ICON_SIZE, type Theme } from "@/styles/theme"; import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query"; import { encodeImages } from "@/utils/encode-images"; import { focusWithRetries } from "@/utils/web-focus"; +import { + cancelComposerAgent, + dispatchComposerAgentMessage, + editQueuedComposerMessage, + findGithubItemByOption, + isAttachmentSelectedForGithubItem, + openComposerAttachment, + pickAndPersistImages, + queueComposerMessage, + removeComposerAttachmentAtIndex, + sendQueuedComposerMessageNow, + toggleGithubAttachment, + type AgentStreamWriter, + type QueueWriter, + type QueuedComposerMessage, +} from "@/components/composer-actions"; import { useVoiceOptional } from "@/contexts/voice-context"; import { useToast } from "@/contexts/toast-context"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; @@ -79,17 +93,12 @@ import type { import { composerWorkspaceAttachment } from "@/attachments/composer-workspace-attachments"; import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; -import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments"; import { AttachmentPill } from "@/components/attachment-pill"; import { AttachmentLightbox } from "@/components/attachment-lightbox"; import { openExternalUrl } from "@/utils/open-external-url"; import { useIsDictationReady } from "@/hooks/use-is-dictation-ready"; -interface QueuedMessage { - id: string; - text: string; - attachments: ComposerAttachment[]; -} +type QueuedMessage = QueuedComposerMessage; type AttachmentListUpdater = | UserComposerAttachment[] @@ -128,37 +137,6 @@ function resolveMessagePlaceholder(isDesktopWebBreakpoint: boolean): string { return isDesktopWebBreakpoint ? DESKTOP_MESSAGE_PLACEHOLDER : MOBILE_MESSAGE_PLACEHOLDER; } -async function pickAndPersistImages( - pickImages: () => Promise, -): Promise { - const result = await pickImages(); - if (!result?.length) return []; - return await Promise.all( - result.map(async (pickedImage) => { - if (pickedImage.source.kind === "blob") { - return await persistAttachmentFromBlob({ - blob: pickedImage.source.blob, - mimeType: pickedImage.mimeType || "image/jpeg", - fileName: pickedImage.fileName ?? null, - }); - } - return await persistAttachmentFromFileUri({ - uri: pickedImage.source.uri, - mimeType: pickedImage.mimeType || "image/jpeg", - fileName: pickedImage.fileName ?? null, - }); - }), - ); -} - -function removeAttachmentAtIndex(prev: T[], index: number): T[] { - const removed = prev[index]; - if (removed?.kind === "image") { - void deleteAttachments([removed.metadata]); - } - return prev.filter((_, i) => i !== index); -} - function buildCancelButtonStyle(isConnected: boolean, isCancellingAgent: boolean): object[] { const disabled = !isConnected || isCancellingAgent ? styles.buttonDisabled : undefined; return [styles.cancelButton, disabled].filter((value): value is object => Boolean(value)); @@ -237,45 +215,6 @@ function renderLeftContent(args: RenderLeftContentArgs): ReactElement { return ; } -function findGithubItemByOption( - items: readonly GitHubSearchItem[], - optionId: string, -): GitHubSearchItem | undefined { - return items.find((candidate) => `${candidate.kind}:${candidate.number}` === optionId); -} - -function isAttachmentSelectedForGithubItem( - current: readonly ComposerAttachment[], - item: GitHubSearchItem, -): boolean { - return composerWorkspaceAttachment - .userAttachmentsOnly(current) - .some( - (attachment) => - attachment.kind !== "image" && - attachment.item.kind === item.kind && - attachment.item.number === item.number, - ); -} - -function buildGithubAttachment(item: GitHubSearchItem): UserComposerAttachment { - return item.kind === "pr" ? { kind: "github_pr", item } : { kind: "github_issue", item }; -} - -function toggleGithubAttachment( - current: UserComposerAttachment[], - item: GitHubSearchItem, -): UserComposerAttachment[] { - const matches = (attachment: UserComposerAttachment) => - attachment.kind !== "image" && - attachment.item.kind === item.kind && - attachment.item.number === item.number; - if (current.some(matches)) { - return current.filter((attachment) => !matches(attachment)); - } - return [...current, buildGithubAttachment(item)]; -} - interface RenderAttachmentPreviewListArgs { selectedAttachments: ComposerAttachment[]; isComposerLocked: boolean; @@ -416,104 +355,6 @@ function attemptStartRealtimeVoice(args: AttemptStartRealtimeVoiceArgs): void { }); } -interface DispatchAgentMessageSendArgs { - client: NonNullable>; - serverId: string; - targetAgentId: string; - text: string; - sendAttachments: ComposerAttachment[]; - setAgentStreamHead: ReturnType["setAgentStreamHead"]; - setAgentStreamTail: ReturnType["setAgentStreamTail"]; -} - -function appendUserMessageToStream( - args: DispatchAgentMessageSendArgs & { userMessage: StreamItem }, -): void { - const { serverId, targetAgentId, userMessage, setAgentStreamHead, setAgentStreamTail } = args; - const currentHead = useSessionStore - .getState() - .sessions[serverId]?.agentStreamHead?.get(targetAgentId); - if (currentHead && currentHead.length > 0) { - setAgentStreamHead(serverId, (prev) => { - const head = prev.get(targetAgentId) || []; - const updated = new Map(prev); - updated.set(targetAgentId, [...head, userMessage]); - return updated; - }); - return; - } - setAgentStreamTail(serverId, (prev) => { - const currentStream = prev.get(targetAgentId) || []; - const updated = new Map(prev); - updated.set(targetAgentId, [...currentStream, userMessage]); - return updated; - }); -} - -async function dispatchAgentMessageSend(args: DispatchAgentMessageSendArgs): Promise { - const { client, targetAgentId, text, sendAttachments } = args; - const wirePayload = splitComposerAttachmentsForSubmit(sendAttachments); - const clientMessageId = generateMessageId(); - const userMessage: StreamItem = { - kind: "user_message", - id: clientMessageId, - text, - timestamp: new Date(), - ...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}), - ...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}), - }; - appendUserMessageToStream({ ...args, userMessage }); - const imagesData = await encodeImages(wirePayload.images); - await client.sendAgentMessage(targetAgentId, text, { - messageId: clientMessageId, - images: imagesData ?? [], - attachments: wirePayload.attachments, - }); -} - -function openComposerAttachment( - attachment: ComposerAttachment, - setLightboxMetadata: (metadata: AttachmentMetadata) => void, - openWorkspaceAttachment: (input: { attachment: ComposerAttachment }) => boolean, -): void { - if (attachment.kind === "image") { - setLightboxMetadata(attachment.metadata); - return; - } - if (composerWorkspaceAttachment.is(attachment)) { - openWorkspaceAttachment({ attachment }); - return; - } - void openExternalUrl(attachment.item.url); -} - -interface CancelRunningAgentArgs { - isAgentRunning: boolean; - isCancellingAgent: boolean; - isConnected: boolean; - client: ReturnType; - agentIdRef: { current: string }; - setIsCancellingAgent: (value: boolean) => void; - messageInputRef: { current: MessageInputRef | null }; -} - -function cancelRunningAgent(args: CancelRunningAgentArgs): void { - const { - isAgentRunning, - isCancellingAgent, - isConnected, - client, - agentIdRef, - setIsCancellingAgent, - messageInputRef, - } = args; - if (!isAgentRunning || isCancellingAgent) return; - if (!isConnected || !client) return; - setIsCancellingAgent(true); - void client.cancelAgent(agentIdRef.current); - messageInputRef.current?.focus(); -} - function focusMessageInputWithPlatformStrategy(messageInputRef: { current: MessageInputRef | null; }): void { @@ -1152,14 +993,18 @@ export function Composer({ if (!client) { throw new Error("Host is not connected"); } - await dispatchAgentMessageSend({ + const stream: AgentStreamWriter = { + getHead: (id) => useSessionStore.getState().sessions[serverId]?.agentStreamHead?.get(id), + setHead: (updater) => setAgentStreamHead(serverId, updater), + setTail: (updater) => setAgentStreamTail(serverId, updater), + }; + await dispatchComposerAgentMessage({ client, - serverId, - targetAgentId, + agentId: targetAgentId, text, - sendAttachments, - setAgentStreamHead, - setAgentStreamTail, + attachments: sendAttachments, + encodeImages, + stream, }); onAttentionPromptSend?.(); }; @@ -1172,33 +1017,23 @@ export function Composer({ const isAgentRunning = agentState.status === "running"; const hasAgent = agentState.status !== null; - const updateQueue = useCallback( - (updater: (current: QueuedMessage[]) => QueuedMessage[]) => { - setQueuedMessages(serverId, (prev: Map) => { - const next = new Map(prev); - next.set(agentId, updater(prev.get(agentId) ?? [])); - return next; - }); - }, - [agentId, serverId, setQueuedMessages], + const queueWriter = useMemo( + () => ({ + read: (id) => useSessionStore.getState().sessions[serverId]?.queuedMessages?.get(id) ?? [], + write: (updater) => setQueuedMessages(serverId, updater), + }), + [serverId, setQueuedMessages], ); const queueMessage = useCallback( (queuedMessage: string, queuedAttachments: ComposerAttachment[]) => { - const trimmedMessage = queuedMessage.trim(); - if (!trimmedMessage && queuedAttachments.length === 0) return; - - const newItem = { - id: generateMessageId(), - text: trimmedMessage, + const result = queueComposerMessage({ + agentId, + text: queuedMessage, attachments: queuedAttachments, - }; - - setQueuedMessages(serverId, (prev: Map) => { - const next = new Map(prev); - next.set(agentId, [...(prev.get(agentId) ?? []), newItem]); - return next; + queue: queueWriter, }); + if (!result.queued) return; setUserInput(""); setSelectedAttachments([]); @@ -1208,9 +1043,8 @@ export function Composer({ [ agentId, clearSentAttachments, + queueWriter, resetSuppression, - serverId, - setQueuedMessages, setSelectedAttachments, setUserInput, ], @@ -1284,7 +1118,15 @@ export function Composer({ ); const handlePickImage = useCallback(async () => { - const newImages = await pickAndPersistImages(pickImages); + const newImages = await pickAndPersistImages({ + pickImages, + persister: { + persistFromBlob: ({ blob, mimeType, fileName }) => + persistAttachmentFromBlob({ blob, mimeType, fileName }), + persistFromFileUri: ({ uri, mimeType, fileName }) => + persistAttachmentFromFileUri({ uri, mimeType, fileName }), + }, + }); if (newImages.length === 0) return; addImages(newImages); }, [addImages, pickImages]); @@ -1298,14 +1140,23 @@ export function Composer({ if (didRemoveWorkspaceAttachment) { return; } - setSelectedAttachments((prev) => removeAttachmentAtIndex(prev, index)); + setSelectedAttachments((prev) => + removeComposerAttachmentAtIndex({ attachments: prev, index, deleteAttachments }), + ); }, [removeAttachment, selectedAttachments, setSelectedAttachments], ); const handleOpenAttachment = useCallback( (attachment: ComposerAttachment) => { - openComposerAttachment(attachment, setLightboxMetadata, openAttachment); + openComposerAttachment({ + attachment, + setLightboxMetadata, + openWorkspaceAttachment: openAttachment, + openExternalUrl: (url) => { + void openExternalUrl(url); + }, + }); }, [openAttachment], ); @@ -1317,15 +1168,16 @@ export function Composer({ }, [isAgentRunning, isConnected]); const handleCancelAgent = useCallback(() => { - cancelRunningAgent({ + const didCancel = cancelComposerAgent({ + client, + agentId: agentIdRef.current, isAgentRunning, isCancellingAgent, isConnected, - client, - agentIdRef, - setIsCancellingAgent, - messageInputRef, }); + if (!didCancel) return; + setIsCancellingAgent(true); + messageInputRef.current?.focus(); }, [client, isAgentRunning, isCancellingAgent, isConnected]); const focusMessageInputForKeyboardAction = useCallback(() => { @@ -1391,33 +1243,34 @@ export function Composer({ const handleEditQueuedMessage = useCallback( (id: string) => { - const item = queuedMessages.find((q) => q.id === id); - if (!item) return; - - updateQueue((current) => current.filter((q) => q.id !== id)); - setUserInput(item.text); - setSelectedAttachments(composerWorkspaceAttachment.userAttachmentsOnly(item.attachments)); + const result = editQueuedComposerMessage({ + agentId, + messageId: id, + queue: queueWriter, + }); + if (!result) return; + setUserInput(result.text); + setSelectedAttachments(result.attachments); }, - [queuedMessages, setSelectedAttachments, setUserInput, updateQueue], + [agentId, queueWriter, setSelectedAttachments, setUserInput], ); const handleSendQueuedNow = useCallback( async (id: string) => { - const item = queuedMessages.find((q) => q.id === id); - if (!item) return; if (!sendAgentMessageRef.current && !onSubmitMessageRef.current) return; - - updateQueue((current) => current.filter((q) => q.id !== id)); - // Reuse the regular send path; server-side send atomically interrupts any active run. - try { - await submitMessage(item.text, item.attachments); - } catch (error) { - updateQueue((current) => [item, ...current]); - setSendError(error instanceof Error ? error.message : "Failed to send message"); + const result = await sendQueuedComposerMessageNow({ + agentId, + messageId: id, + queue: queueWriter, + submitMessage: ({ text, attachments: queuedAttachments }) => + submitMessage(text, queuedAttachments), + }); + if (result.status === "failed") { + setSendError(result.errorMessage); } }, - [queuedMessages, submitMessage, updateQueue], + [agentId, queueWriter, submitMessage], ); const handleQueue = useCallback(