From 3838a8b969d87fae95e3461bab62c482704b4daf Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 17 Apr 2026 11:36:08 +0700 Subject: [PATCH] feat(app): unify composer attachments with pill + lightbox redesign Images and GitHub issues/PRs now live in one draft-store list (ComposerAttachment union) with cwd persisted on the draft. Paperclip becomes a + drop-up menu; queue button removed. Each pill uses a shared AttachmentPill wrapper with a corner-X that mirrors the auto-update toast spec. Pill body opens the attachment: GitHub via openExternalUrl, images via a new fullscreen AttachmentLightbox. GitHub picker auto-closes on select. --- packages/app/src/attachments/types.ts | 7 + .../src/components/agent-input-submit.test.ts | 38 +- .../app/src/components/agent-input-submit.ts | 28 +- .../components/attachment-lightbox.test.tsx | 223 +++++ .../src/components/attachment-lightbox.tsx | 151 +++ .../app/src/components/attachment-pill.tsx | 72 ++ .../src/components/composer-attachments.ts | 30 + packages/app/src/components/composer.test.tsx | 887 ++++++++++++++++++ packages/app/src/components/composer.tsx | 369 ++++++-- .../app/src/components/message-input.test.tsx | 305 ++++++ packages/app/src/components/message-input.tsx | 236 ++--- packages/app/src/components/ui/combobox.tsx | 10 +- .../src/components/workspace-setup-dialog.tsx | 96 +- packages/app/src/contexts/session-context.tsx | 9 +- .../hooks/use-agent-input-draft.live.test.tsx | 367 +++++++- .../app/src/hooks/use-agent-input-draft.ts | 86 +- .../src/hooks/use-draft-agent-create-flow.ts | 28 +- packages/app/src/panels/agent-panel.tsx | 19 +- .../src/screens/agent/draft-agent-screen.tsx | 9 +- .../app/src/screens/new-workspace-screen.tsx | 419 ++++----- .../workspace/workspace-draft-agent-tab.tsx | 9 +- .../src/stores/draft-store.lifecycle.test.ts | 24 +- .../src/stores/draft-store.migration.test.ts | 51 +- packages/app/src/stores/draft-store.ts | 243 ++--- packages/app/src/stores/session-store.ts | 13 +- 25 files changed, 2994 insertions(+), 735 deletions(-) create mode 100644 packages/app/src/components/attachment-lightbox.test.tsx create mode 100644 packages/app/src/components/attachment-lightbox.tsx create mode 100644 packages/app/src/components/attachment-pill.tsx create mode 100644 packages/app/src/components/composer-attachments.ts create mode 100644 packages/app/src/components/composer.test.tsx create mode 100644 packages/app/src/components/message-input.test.tsx diff --git a/packages/app/src/attachments/types.ts b/packages/app/src/attachments/types.ts index 9d4bf0718..a859212f3 100644 --- a/packages/app/src/attachments/types.ts +++ b/packages/app/src/attachments/types.ts @@ -1,3 +1,5 @@ +import type { GitHubSearchItem } from "@server/shared/messages"; + export type AttachmentStorageType = "web-indexeddb" | "desktop-file" | "native-file"; export interface AttachmentMetadata { @@ -15,6 +17,11 @@ export interface AttachmentMetadata { createdAt: number; } +export type ComposerAttachment = + | { kind: "image"; metadata: AttachmentMetadata } + | { kind: "github_issue"; item: GitHubSearchItem } + | { kind: "github_pr"; item: GitHubSearchItem }; + export type AttachmentDataSource = | { kind: "blob"; blob: Blob } | { kind: "data_url"; dataUrl: string } diff --git a/packages/app/src/components/agent-input-submit.test.ts b/packages/app/src/components/agent-input-submit.test.ts index 65fa5c30c..c01390ce9 100644 --- a/packages/app/src/components/agent-input-submit.test.ts +++ b/packages/app/src/components/agent-input-submit.test.ts @@ -24,19 +24,20 @@ describe("submitAgentInput", () => { }); const clearDraft = vi.fn(); const setUserInput = vi.fn(); - const setSelectedImages = vi.fn(); + const setAttachments = vi.fn(); const setSendError = vi.fn(); const setIsProcessing = vi.fn(); const submitPromise = submitAgentInput({ message: " hello world ", + attachments: [], isAgentRunning: false, canSubmit: true, queueMessage, submitMessage, clearDraft, setUserInput, - setSelectedImages, + setAttachments, setSendError, setIsProcessing, }); @@ -44,10 +45,10 @@ describe("submitAgentInput", () => { expect(queueMessage).not.toHaveBeenCalled(); expect(submitMessage).toHaveBeenCalledWith({ message: "hello world", - imageAttachments: undefined, + attachments: [], }); expect(setUserInput).toHaveBeenCalledWith(""); - expect(setSelectedImages).toHaveBeenCalledWith([]); + expect(setAttachments).toHaveBeenCalledWith([]); expect(setSendError).toHaveBeenCalledWith(null); expect(setIsProcessing).toHaveBeenCalledWith(true); expect(clearDraft).not.toHaveBeenCalled(); @@ -63,21 +64,21 @@ describe("submitAgentInput", () => { const submitMessage = vi.fn(); const clearDraft = vi.fn(); const setUserInput = vi.fn(); - const setSelectedImages = vi.fn(); + const setAttachments = vi.fn(); const setSendError = vi.fn(); const setIsProcessing = vi.fn(); await expect( submitAgentInput({ message: " queued message ", - imageAttachments: [{ id: "img-1" }], + attachments: [{ id: "img-1" }], isAgentRunning: true, canSubmit: true, queueMessage, submitMessage, clearDraft, setUserInput, - setSelectedImages, + setAttachments, setSendError, setIsProcessing, }), @@ -85,11 +86,11 @@ describe("submitAgentInput", () => { expect(queueMessage).toHaveBeenCalledWith({ message: "queued message", - imageAttachments: [{ id: "img-1" }], + attachments: [{ id: "img-1" }], }); expect(submitMessage).not.toHaveBeenCalled(); expect(setUserInput).toHaveBeenCalledWith(""); - expect(setSelectedImages).toHaveBeenCalledWith([]); + expect(setAttachments).toHaveBeenCalledWith([]); expect(setSendError).not.toHaveBeenCalled(); expect(setIsProcessing).not.toHaveBeenCalled(); expect(clearDraft).not.toHaveBeenCalled(); @@ -103,23 +104,23 @@ describe("submitAgentInput", () => { }); const clearDraft = vi.fn(); const setUserInput = vi.fn(); - const setSelectedImages = vi.fn(); + const setAttachments = vi.fn(); const setSendError = vi.fn(); const setIsProcessing = vi.fn(); const onSubmitError = vi.fn(); - const imageAttachments = [{ id: "img-1" }]; + const attachments = [{ id: "img-1" }]; await expect( submitAgentInput({ message: " hello world ", - imageAttachments, + attachments, isAgentRunning: false, canSubmit: true, queueMessage, submitMessage, clearDraft, setUserInput, - setSelectedImages, + setAttachments, setSendError, setIsProcessing, onSubmitError, @@ -129,8 +130,8 @@ describe("submitAgentInput", () => { expect(onSubmitError).toHaveBeenCalledWith(submitError); expect(setUserInput).toHaveBeenNthCalledWith(1, ""); expect(setUserInput).toHaveBeenNthCalledWith(2, "hello world"); - expect(setSelectedImages).toHaveBeenNthCalledWith(1, []); - expect(setSelectedImages).toHaveBeenNthCalledWith(2, imageAttachments); + expect(setAttachments).toHaveBeenNthCalledWith(1, []); + expect(setAttachments).toHaveBeenNthCalledWith(2, attachments); expect(setSendError).toHaveBeenNthCalledWith(1, null); expect(setSendError).toHaveBeenNthCalledWith(2, "No host selected"); expect(setIsProcessing).toHaveBeenNthCalledWith(1, true); @@ -143,13 +144,14 @@ describe("submitAgentInput", () => { const submitMessage = vi.fn(async () => {}); const clearDraft = vi.fn(); const setUserInput = vi.fn(); - const setSelectedImages = vi.fn(); + const setAttachments = vi.fn(); const setSendError = vi.fn(); const setIsProcessing = vi.fn(); await expect( submitAgentInput({ message: " ", + attachments: [], allowEmptySubmit: true, isAgentRunning: false, canSubmit: true, @@ -157,7 +159,7 @@ describe("submitAgentInput", () => { submitMessage, clearDraft, setUserInput, - setSelectedImages, + setAttachments, setSendError, setIsProcessing, }), @@ -166,7 +168,7 @@ describe("submitAgentInput", () => { expect(queueMessage).not.toHaveBeenCalled(); expect(submitMessage).toHaveBeenCalledWith({ message: "", - imageAttachments: undefined, + attachments: [], }); expect(clearDraft).toHaveBeenCalledWith("sent"); }); diff --git a/packages/app/src/components/agent-input-submit.ts b/packages/app/src/components/agent-input-submit.ts index 73f143cf9..f8667107f 100644 --- a/packages/app/src/components/agent-input-submit.ts +++ b/packages/app/src/components/agent-input-submit.ts @@ -1,32 +1,32 @@ export type AgentInputSubmitResult = "noop" | "queued" | "submitted" | "failed"; -export interface AgentInputSubmitActionInput { +export interface AgentInputSubmitActionInput { message: string; - imageAttachments?: TImage[]; + attachments: TAttachment[]; hasExternalContent?: boolean; allowEmptySubmit?: boolean; forceSend?: boolean; isAgentRunning: boolean; canSubmit: boolean; - queueMessage: (input: { message: string; imageAttachments?: TImage[] }) => void; - submitMessage: (input: { message: string; imageAttachments?: TImage[] }) => Promise; + queueMessage: (input: { message: string; attachments: TAttachment[] }) => void; + submitMessage: (input: { message: string; attachments: TAttachment[] }) => Promise; clearDraft: (lifecycle: "sent" | "abandoned") => void; setUserInput: (text: string) => void; - setSelectedImages: (images: TImage[]) => void; + setAttachments: (attachments: TAttachment[]) => void; setSendError: (message: string | null) => void; setIsProcessing: (isProcessing: boolean) => void; onSubmitError?: (error: unknown) => void; } -export async function submitAgentInput( - input: AgentInputSubmitActionInput, +export async function submitAgentInput( + input: AgentInputSubmitActionInput, ): Promise { const trimmedMessage = input.message.trim(); - const imageAttachments = input.imageAttachments; + const attachments = input.attachments; if ( !trimmedMessage && - !imageAttachments?.length && + attachments.length === 0 && !input.hasExternalContent && !input.allowEmptySubmit ) { @@ -38,27 +38,27 @@ export async function submitAgentInput( } if (input.isAgentRunning && !input.forceSend) { - input.queueMessage({ message: trimmedMessage, imageAttachments }); + input.queueMessage({ message: trimmedMessage, attachments }); input.setUserInput(""); - input.setSelectedImages([]); + input.setAttachments([]); return "queued"; } // Clear immediately so optimistic stream updates and composer state stay in sync. input.setUserInput(""); - input.setSelectedImages([]); + input.setAttachments([]); input.setSendError(null); input.setIsProcessing(true); try { - await input.submitMessage({ message: trimmedMessage, imageAttachments }); + await input.submitMessage({ message: trimmedMessage, attachments }); input.clearDraft("sent"); input.setIsProcessing(false); return "submitted"; } catch (error) { input.onSubmitError?.(error); input.setUserInput(trimmedMessage); - input.setSelectedImages(imageAttachments ?? []); + input.setAttachments(attachments); input.setSendError(error instanceof Error ? error.message : "Failed to send message"); input.setIsProcessing(false); return "failed"; diff --git a/packages/app/src/components/attachment-lightbox.test.tsx b/packages/app/src/components/attachment-lightbox.test.tsx new file mode 100644 index 000000000..15ff85ac6 --- /dev/null +++ b/packages/app/src/components/attachment-lightbox.test.tsx @@ -0,0 +1,223 @@ +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { JSDOM } from "jsdom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AttachmentMetadata } from "@/attachments/types"; +import { AttachmentLightbox } from "./attachment-lightbox"; + +const { theme, imageMetadata, useAttachmentPreviewUrlMock } = vi.hoisted(() => { + const theme = { + spacing: { 1: 4, 2: 8, 3: 12, 4: 16 }, + iconSize: { sm: 14, md: 18, lg: 22 }, + borderWidth: { 1: 1 }, + borderRadius: { full: 999, md: 6, lg: 8 }, + fontSize: { xs: 11, sm: 13, base: 15 }, + fontWeight: { normal: "400" }, + colors: { + surface1: "#111", + surface2: "#222", + foreground: "#fff", + foregroundMuted: "#aaa", + border: "#555", + borderAccent: "#444", + }, + }; + + const imageMetadata: AttachmentMetadata = { + id: "img-1", + mimeType: "image/png", + storageType: "web-indexeddb", + storageKey: "img-1", + fileName: "img-1.png", + byteSize: 42, + createdAt: 1, + }; + + return { + theme, + imageMetadata, + useAttachmentPreviewUrlMock: vi.fn<(metadata: AttachmentMetadata | null) => string | null>( + () => "blob:preview", + ), + }; +}); + +vi.mock("react-native-unistyles", () => ({ + StyleSheet: { + create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory), + }, + useUnistyles: () => ({ theme }), +})); + +vi.mock("@/constants/platform", () => ({ + isWeb: true, + isNative: false, +})); + +vi.mock("react-native-safe-area-context", () => ({ + useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }), +})); + +vi.mock("lucide-react-native", () => { + const createIcon = (name: string) => (props: Record) => + React.createElement("span", { ...props, "data-icon": name }); + return { + X: createIcon("X"), + }; +}); + +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, + "data-style": JSON.stringify(props.style ?? null), + 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("@/attachments/use-attachment-preview-url", () => ({ + useAttachmentPreviewUrl: (metadata: AttachmentMetadata | null) => + useAttachmentPreviewUrlMock(metadata), +})); + +let root: Root | null = null; +let container: HTMLElement | null = null; + +beforeEach(() => { + const dom = new JSDOM(""); + 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); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + useAttachmentPreviewUrlMock.mockReturnValue("blob:preview"); +}); + +afterEach(() => { + if (root) { + act(() => { + root?.unmount(); + }); + } + root = null; + container = null; + vi.unstubAllGlobals(); +}); + +function render(element: React.ReactElement) { + act(() => { + root?.render(element); + }); +} + +function click(element: Element) { + act(() => { + element.dispatchEvent(new window.MouseEvent("click", { bubbles: true })); + }); +} + +function queryByTestId(testID: string): HTMLElement | null { + return document.querySelector(`[data-testid="${testID}"]`); +} + +describe("AttachmentLightbox", () => { + it("renders nothing when metadata is null", () => { + render(); + + expect(queryByTestId("attachment-lightbox-backdrop")).toBeNull(); + expect(queryByTestId("attachment-lightbox-image")).toBeNull(); + }); + + it("renders the image when metadata is provided", () => { + render(); + + const image = queryByTestId("attachment-lightbox-image"); + expect(image).not.toBeNull(); + expect(image?.getAttribute("data-source")).toBe("blob:preview"); + }); + + it("fills its parent via absolute positioning so expo-image does not collapse to 0px", () => { + render(); + + const image = queryByTestId("attachment-lightbox-image"); + const style = JSON.parse(image?.getAttribute("data-style") ?? "null") as { + position?: string; + top?: number; + left?: number; + right?: number; + bottom?: number; + } | null; + expect(style?.position).toBe("absolute"); + expect(style?.top).toBe(0); + expect(style?.left).toBe(0); + expect(style?.right).toBe(0); + expect(style?.bottom).toBe(0); + }); + + it("calls onClose when the backdrop is pressed", () => { + const onClose = vi.fn(); + render(); + + const backdrop = queryByTestId("attachment-lightbox-backdrop"); + expect(backdrop).not.toBeNull(); + click(backdrop!); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("calls onClose when the close button is pressed", () => { + const onClose = vi.fn(); + render(); + + const closeButton = document.querySelector( + '[aria-label="Close image"][data-testid="attachment-lightbox-close"]', + ); + expect(closeButton).not.toBeNull(); + click(closeButton!); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("shows error text when the preview URL resolves to null", () => { + useAttachmentPreviewUrlMock.mockReturnValue(null); + render(); + + expect(queryByTestId("attachment-lightbox-image")).toBeNull(); + expect(document.body.textContent ?? "").toContain("Couldn't load image"); + }); + + it("closes on Escape key on web", () => { + const onClose = vi.fn(); + render(); + + act(() => { + window.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Escape" })); + }); + + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/app/src/components/attachment-lightbox.tsx b/packages/app/src/components/attachment-lightbox.tsx new file mode 100644 index 000000000..dc67f40dc --- /dev/null +++ b/packages/app/src/components/attachment-lightbox.tsx @@ -0,0 +1,151 @@ +import { useEffect, useState } from "react"; +import { Modal, Pressable, Text, View } from "react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { Image as ExpoImage } from "expo-image"; +import { X } from "lucide-react-native"; +import type { AttachmentMetadata } from "@/attachments/types"; +import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; +import { isWeb } from "@/constants/platform"; + +interface AttachmentLightboxProps { + metadata: AttachmentMetadata | null; + onClose: () => void; +} + +export function AttachmentLightbox({ metadata, onClose }: AttachmentLightboxProps) { + const { theme } = useUnistyles(); + const insets = useSafeAreaInsets(); + const url = useAttachmentPreviewUrl(metadata); + const [errored, setErrored] = useState(false); + + useEffect(() => { + setErrored(false); + }, [metadata?.id]); + + useEffect(() => { + if (!isWeb || !metadata) return; + function handleKeydown(event: KeyboardEvent) { + if (event.key === "Escape") { + onClose(); + } + } + window.addEventListener("keydown", handleKeydown); + return () => { + window.removeEventListener("keydown", handleKeydown); + }; + }, [metadata, onClose]); + + if (!metadata) { + return null; + } + + const hasError = errored || !url; + + return ( + + + + + + {hasError ? ( + Couldn't load image + ) : ( + {}} style={styles.imagePressable}> + setErrored(true)} + style={imageFillStyle} + /> + + )} + + + + + + + + ); +} + +const imageFillStyle = { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, +} as const; + +const styles = StyleSheet.create((theme) => ({ + root: { + flex: 1, + }, + backdrop: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: "rgba(0,0,0,0.9)", + }, + contentLayer: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + pointerEvents: "box-none", + }, + imageArea: { + flex: 1, + alignItems: "center", + justifyContent: "center", + padding: theme.spacing[4], + pointerEvents: "box-none", + }, + imagePressable: { + flex: 1, + width: "100%", + alignSelf: "center", + maxWidth: 960, + maxHeight: 640, + }, + errorText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, + closeButton: { + position: "absolute", + width: 32, + height: 32, + borderRadius: 16, + backgroundColor: theme.colors.surface2, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + alignItems: "center", + justifyContent: "center", + zIndex: 1, + }, +})); diff --git a/packages/app/src/components/attachment-pill.tsx b/packages/app/src/components/attachment-pill.tsx new file mode 100644 index 000000000..28b38c077 --- /dev/null +++ b/packages/app/src/components/attachment-pill.tsx @@ -0,0 +1,72 @@ +import type { ReactNode } from "react"; +import { Pressable, View } from "react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { X } from "lucide-react-native"; + +interface AttachmentPillProps { + onOpen: () => void; + onRemove: () => void; + openAccessibilityLabel: string; + removeAccessibilityLabel: string; + testID?: string; + children: ReactNode; +} + +export function AttachmentPill({ + onOpen, + onRemove, + openAccessibilityLabel, + removeAccessibilityLabel, + testID, + children, +}: AttachmentPillProps) { + const { theme } = useUnistyles(); + return ( + + + {children} + + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + wrapper: { + position: "relative", + }, + body: { + borderRadius: theme.borderRadius.md, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.borderAccent, + overflow: "hidden", + }, + closeButton: { + position: "absolute", + top: -8, + left: -8, + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: theme.colors.surface2, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + alignItems: "center", + justifyContent: "center", + zIndex: 1, + }, +})); diff --git a/packages/app/src/components/composer-attachments.ts b/packages/app/src/components/composer-attachments.ts new file mode 100644 index 000000000..8159b84dd --- /dev/null +++ b/packages/app/src/components/composer-attachments.ts @@ -0,0 +1,30 @@ +import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types"; +import type { AgentAttachment } from "@server/shared/messages"; +import { buildGitHubAttachmentFromSearchItem } from "@/utils/review-attachments"; + +export type ImageAttachment = AttachmentMetadata; + +export function splitComposerAttachmentsForSubmit(attachments: ComposerAttachment[]): { + images: ImageAttachment[]; + attachments: AgentAttachment[]; +} { + const images: ImageAttachment[] = []; + const reviewAttachments: AgentAttachment[] = []; + + for (const attachment of attachments) { + if (attachment.kind === "image") { + images.push(attachment.metadata); + continue; + } + + const reviewAttachment = buildGitHubAttachmentFromSearchItem(attachment.item); + if (reviewAttachment) { + reviewAttachments.push(reviewAttachment); + } + } + + return { + images, + attachments: reviewAttachments, + }; +} diff --git a/packages/app/src/components/composer.test.tsx b/packages/app/src/components/composer.test.tsx new file mode 100644 index 000000000..1aa672b47 --- /dev/null +++ b/packages/app/src/components/composer.test.tsx @@ -0,0 +1,887 @@ +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 } from "@/attachments/types"; +import type { GitHubSearchItem } from "@server/shared/messages"; +import { Composer } from "./composer"; +import { splitComposerAttachmentsForSubmit } from "./composer-attachments"; + +const { + theme, + imageMetadata, + issueItem, + prItem, + mockClient, + pickImagesMock, + persistAttachmentFromBlobMock, + deleteAttachmentsMock, + encodeImagesMock, + openExternalUrlMock, + mockSessionState, + setAgentStreamTailMock, + setAgentStreamHeadMock, + setQueuedMessagesMock, +} = vi.hoisted(() => { + const theme = { + 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" }, + 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 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", + }; + + const mockClient = { + isConnected: true, + searchGitHub: vi.fn(async () => ({ items: [issueItem, prItem] })), + sendAgentMessage: vi.fn(async () => {}), + cancelAgent: vi.fn(async () => {}), + }; + + const setQueuedMessagesMock = vi.fn(); + const mockSessionState: { + sessions: Record< + string, + { + agents: Map; + queuedMessages: Map; + agentStreamHead: Map; + agentStreamTail: Map; + } + >; + setQueuedMessages: ReturnType; + setAgentStreamTail?: ReturnType; + setAgentStreamHead?: ReturnType; + } = { + sessions: { + server: { + agents: new Map([["agent", { status: "idle", lastUsage: null }]]), + queuedMessages: new Map(), + agentStreamHead: new Map(), + agentStreamTail: new Map(), + }, + }, + setQueuedMessages: setQueuedMessagesMock, + }; + const setAgentStreamTailMock = vi.fn( + (serverId: string, updater: (prev: Map) => Map) => { + const session = mockSessionState.sessions[serverId]; + session.agentStreamTail = updater(session.agentStreamTail); + }, + ); + const setAgentStreamHeadMock = vi.fn( + (serverId: string, updater: (prev: Map) => Map) => { + const session = mockSessionState.sessions[serverId]; + session.agentStreamHead = updater(session.agentStreamHead); + }, + ); + mockSessionState.setAgentStreamTail = setAgentStreamTailMock; + mockSessionState.setAgentStreamHead = setAgentStreamHeadMock; + + return { + theme, + imageMetadata, + issueItem, + prItem, + mockClient, + pickImagesMock: vi.fn(), + persistAttachmentFromBlobMock: vi.fn(async () => imageMetadata), + deleteAttachmentsMock: vi.fn(async () => {}), + encodeImagesMock: vi.fn(async (images: AttachmentMetadata[]) => images), + openExternalUrlMock: vi.fn(async () => {}), + mockSessionState, + setAgentStreamTailMock, + setAgentStreamHeadMock, + setQueuedMessagesMock, + }; +}); + +vi.mock("react-native-unistyles", () => ({ + StyleSheet: { + create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory), + }, + 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"), + Square: createIcon("Square"), + Pencil: createIcon("Pencil"), + AudioLines: createIcon("AudioLines"), + CircleDot: createIcon("CircleDot"), + GitPullRequest: createIcon("GitPullRequest"), + 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: () => "ready", +})); + +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: "interrupt" } }), +})); + +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: () => {}, +})); + +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("@/utils/scroll-jank-investigation", () => ({ + markScrollInvestigationRender: vi.fn(), + markScrollInvestigationEvent: 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", () => ({ + 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); + 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); + 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[] = []; + +beforeEach(() => { + const dom = new JSDOM(""); + 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); + + 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(); + mockSessionState.sessions.server.agentStreamHead = new Map(); + mockSessionState.sessions.server.agentStreamTail = new Map(); + mockSessionState.sessions.server.queuedMessages = new Map(); +}); + +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 = [], +}: { + initialText?: string; + initialAttachments?: ComposerAttachment[]; +}) { + const [text, setText] = useState(initialText); + const [attachments, setAttachments] = useState(initialAttachments); + latestAttachments = attachments; + + return ( + + { + setAttachments((current) => { + const next = typeof updater === "function" ? updater(current) : updater; + latestAttachments = next; + return next; + }); + }} + cwd="/repo" + clearDraft={vi.fn()} + /> + + ); +} + +function renderComposer( + input: { initialText?: string; initialAttachments?: ComposerAttachment[] } = {}, +) { + act(() => { + root?.render(); + }); +} + +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-"]'); +} + +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("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("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("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 d7ff5f402..fc78ae875 100644 --- a/packages/app/src/components/composer.tsx +++ b/packages/app/src/components/composer.tsx @@ -1,11 +1,21 @@ -import { View, Pressable, Text, ActivityIndicator } from "react-native"; -import { useState, useEffect, useRef, useCallback } from "react"; +import { View, Pressable, Text, ActivityIndicator, Image } from "react-native"; +import { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useShallow } from "zustand/shallow"; -import { ArrowUp, Square, Pencil, AudioLines } from "lucide-react-native"; +import { + ArrowUp, + Square, + Pencil, + AudioLines, + CircleDot, + GitPullRequest, + Github, + Paperclip, +} from "lucide-react-native"; import Animated from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useQuery } from "@tanstack/react-query"; import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout"; import { generateMessageId, type StreamItem } from "@/types/stream"; import { @@ -21,6 +31,7 @@ import { type MessagePayload, type ImageAttachment, type MessageInputRef, + type AttachmentMenuItem, } from "./message-input"; import { Theme } from "@/styles/theme"; import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query"; @@ -51,14 +62,32 @@ import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispat import { submitAgentInput } from "@/components/agent-input-submit"; import { useAppSettings } from "@/hooks/use-settings"; import { isWeb, isNative } from "@/constants/platform"; +import type { GitHubSearchItem } from "@server/shared/messages"; +import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types"; +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"; type QueuedMessage = { id: string; text: string; - images?: ImageAttachment[]; + attachments: ComposerAttachment[]; }; -type ImageListUpdater = ImageAttachment[] | ((prev: ImageAttachment[]) => ImageAttachment[]); +type AttachmentListUpdater = + | ComposerAttachment[] + | ((prev: ComposerAttachment[]) => ComposerAttachment[]); + +function ImageAttachmentThumbnail({ image }: { image: ImageAttachment }) { + const uri = useAttachmentPreviewUrl(image); + if (!uri) { + return ; + } + return ; +} interface ComposerProps { agentId: string; @@ -77,8 +106,9 @@ interface ComposerProps { blurOnSubmit?: boolean; value: string; onChangeText: (text: string) => void; - images: ImageAttachment[]; - onChangeImages: (updater: ImageListUpdater) => void; + attachments: ComposerAttachment[]; + onChangeAttachments: (updater: AttachmentListUpdater) => void; + cwd: string; clearDraft: (lifecycle: "sent" | "abandoned") => void; /** When true, auto-focuses the text input on web. */ autoFocus?: boolean; @@ -115,8 +145,9 @@ export function Composer({ blurOnSubmit = false, value, onChangeText, - images, - onChangeImages, + attachments, + onChangeAttachments, + cwd, clearDraft, autoFocus = false, onAddImages, @@ -175,13 +206,17 @@ export function Composer({ : MOBILE_MESSAGE_PLACEHOLDER; const userInput = value; const setUserInput = onChangeText; - const selectedImages = images; - const setSelectedImages = onChangeImages; + const selectedAttachments = attachments; + const setSelectedAttachments = onChangeAttachments; const [cursorIndex, setCursorIndex] = useState(0); const [isProcessing, setIsProcessing] = useState(false); const [isCancellingAgent, setIsCancellingAgent] = useState(false); const [sendError, setSendError] = useState(null); const [isMessageInputFocused, setIsMessageInputFocused] = useState(false); + const [isGithubPickerOpen, setIsGithubPickerOpen] = useState(false); + const [githubSearchQuery, setGithubSearchQuery] = useState(""); + const [lightboxMetadata, setLightboxMetadata] = useState(null); + const attachButtonRef = useRef(null); const messageInputRef = useRef(null); const keyboardHandlerIdRef = useRef( `message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}`, @@ -213,16 +248,19 @@ export function Composer({ const { pickImages } = useImageAttachmentPicker(); const agentIdRef = useRef(agentId); const sendAgentMessageRef = useRef< - ((agentId: string, text: string, images?: ImageAttachment[]) => Promise) | null + ((agentId: string, text: string, attachments: ComposerAttachment[]) => Promise) | null >(null); const onSubmitMessageRef = useRef(onSubmitMessage); // Expose addImages function to parent for drag-and-drop support const addImages = useCallback( (images: ImageAttachment[]) => { - setSelectedImages((prev) => [...prev, ...images]); + setSelectedAttachments((prev) => [ + ...prev, + ...images.map((metadata) => ({ kind: "image" as const, metadata })), + ]); }, - [setSelectedImages], + [setSelectedAttachments], ); useEffect(() => { @@ -245,18 +283,18 @@ export function Composer({ }, [focusInput, onFocusInput]); const submitMessage = useCallback( - async (text: string, images?: ImageAttachment[]) => { + async (text: string, attachments: ComposerAttachment[]) => { onMessageSent?.(); if (onSubmitMessageRef.current) { - await onSubmitMessageRef.current({ text, images }); + await onSubmitMessageRef.current({ text, attachments, cwd }); return; } if (!sendAgentMessageRef.current) { throw new Error("Host is not connected"); } - await sendAgentMessageRef.current(agentIdRef.current, text, images); + await sendAgentMessageRef.current(agentIdRef.current, text, attachments); }, - [onMessageSent], + [cwd, onMessageSent], ); useEffect(() => { @@ -267,19 +305,20 @@ export function Composer({ sendAgentMessageRef.current = async ( agentId: string, text: string, - images?: ImageAttachment[], + attachments: ComposerAttachment[], ) => { if (!client) { throw new Error("Host is not connected"); } + const wirePayload = splitComposerAttachmentsForSubmit(attachments); const clientMessageId = generateMessageId(); const userMessage: StreamItem = { kind: "user_message", id: clientMessageId, text, timestamp: new Date(), - ...(images && images.length > 0 ? { images } : {}), + ...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}), }; // Append to head if streaming (keeps the user message with the current @@ -303,10 +342,11 @@ export function Composer({ return updated; }); } - const imagesData = await encodeImages(images); + const imagesData = await encodeImages(wirePayload.images); await client.sendAgentMessage(agentId, text, { messageId: clientMessageId, - ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), + images: imagesData ?? [], + attachments: wirePayload.attachments, }); onAttentionPromptSend?.(); }; @@ -330,14 +370,14 @@ export function Composer({ [agentId, serverId, setQueuedMessages], ); - function queueMessage(message: string, imageAttachments?: ImageAttachment[]) { + function queueMessage(message: string, attachments: ComposerAttachment[]) { const trimmedMessage = message.trim(); - if (!trimmedMessage && !imageAttachments?.length) return; + if (!trimmedMessage && attachments.length === 0) return; const newItem = { id: generateMessageId(), text: trimmedMessage, - images: imageAttachments, + attachments, }; setQueuedMessages(serverId, (prev: Map) => { @@ -347,17 +387,17 @@ export function Composer({ }); setUserInput(""); - setSelectedImages([]); + setSelectedAttachments([]); } async function sendMessageWithContent( message: string, - imageAttachments?: ImageAttachment[], + attachments: ComposerAttachment[], forceSend?: boolean, ) { await submitAgentInput({ message, - imageAttachments, + attachments, hasExternalContent, allowEmptySubmit, forceSend, @@ -365,16 +405,16 @@ export function Composer({ // Parent-managed submits are still valid submit paths even when the // transport is disconnected, because the parent decides the failure mode. canSubmit: Boolean(sendAgentMessageRef.current || onSubmitMessageRef.current), - queueMessage: ({ message, imageAttachments }) => { - queueMessage(message, imageAttachments); + queueMessage: ({ message, attachments }) => { + queueMessage(message, attachments); }, - submitMessage: async ({ message, imageAttachments }) => { - await submitMessage(message, imageAttachments); + submitMessage: async ({ message, attachments }) => { + await submitMessage(message, attachments); }, clearDraft, setUserInput, - setSelectedImages: (images) => { - setSelectedImages(images); + setAttachments: (nextAttachments) => { + setSelectedAttachments(nextAttachments); }, setSendError, setIsProcessing, @@ -388,10 +428,10 @@ export function Composer({ if (blurOnSubmit) { messageInputRef.current?.blur(); } - void sendMessageWithContent(payload.text, payload.images, payload.forceSend); + void sendMessageWithContent(payload.text, payload.attachments, payload.forceSend); } - async function handlePickImage() { + const handlePickImage = useCallback(async () => { const result = await pickImages(); if (!result?.length) { return; @@ -414,19 +454,27 @@ export function Composer({ }); }), ); - setSelectedImages((prev) => [...prev, ...newImages]); - } + addImages(newImages); + }, [addImages, pickImages]); - function handleRemoveImage(index: number) { - setSelectedImages((prev) => { + function handleRemoveAttachment(index: number) { + setSelectedAttachments((prev) => { const removed = prev[index]; - if (removed) { - void deleteAttachments([removed]); + if (removed?.kind === "image") { + void deleteAttachments([removed.metadata]); } return prev.filter((_, i) => i !== index); }); } + function handleOpenAttachment(attachment: ComposerAttachment) { + if (attachment.kind === "image") { + setLightboxMetadata(attachment.metadata); + return; + } + void openExternalUrl(attachment.item.url); + } + useEffect(() => { if (!isAgentRunning || !isConnected) { setIsCancellingAgent(false); @@ -459,16 +507,12 @@ export function Composer({ }, }); return true; - case "message-input.send": - return messageInputRef.current?.runKeyboardAction("send") ?? false; case "message-input.dictation-toggle": messageInputRef.current?.runKeyboardAction("dictation-toggle"); return true; case "message-input.dictation-cancel": messageInputRef.current?.runKeyboardAction("dictation-cancel"); return true; - case "message-input.dictation-confirm": - return messageInputRef.current?.runKeyboardAction("dictation-confirm") ?? false; case "message-input.voice-toggle": messageInputRef.current?.runKeyboardAction("voice-toggle"); return true; @@ -543,7 +587,7 @@ export function Composer({ updateQueue((current) => current.filter((q) => q.id !== id)); setUserInput(item.text); - setSelectedImages(item.images ?? []); + setSelectedAttachments(item.attachments); } async function handleSendQueuedNow(id: string) { @@ -555,7 +599,7 @@ export function Composer({ // Reuse the regular send path; server-side send atomically interrupts any active run. try { - await submitMessage(item.text, item.images); + await submitMessage(item.text, item.attachments); } catch (error) { updateQueue((current) => [item, ...current]); setSendError(error instanceof Error ? error.message : "Failed to send message"); @@ -563,10 +607,10 @@ export function Composer({ } const handleQueue = useCallback((payload: MessagePayload) => { - queueMessage(payload.text, payload.images); + queueMessage(payload.text, payload.attachments); }, []); - const hasSendableContent = userInput.trim().length > 0 || selectedImages.length > 0; + const hasSendableContent = userInput.trim().length > 0 || selectedAttachments.length > 0; // Handle keyboard navigation for command autocomplete and stop action. const handleCommandKeyPress = useCallback( @@ -686,6 +730,78 @@ export function Composer({ ); + const githubSearchQueryTrimmed = githubSearchQuery.trim(); + const githubSearchResultsQuery = useQuery({ + queryKey: ["composer-github-search", serverId, cwd, githubSearchQueryTrimmed], + queryFn: async () => { + if (!client) { + throw new Error("Host is not connected"); + } + return client.searchGitHub({ + cwd, + query: githubSearchQueryTrimmed, + limit: 20, + }); + }, + enabled: isConnected && !!client && cwd.trim().length > 0, + staleTime: 30_000, + }); + + const githubSearchItems = githubSearchResultsQuery.data?.items ?? []; + const githubSearchOptions: ComboboxOption[] = useMemo( + () => + githubSearchItems.map((item) => ({ + id: `${item.kind}:${item.number}`, + label: `#${item.number} ${item.title}`, + description: githubSearchQueryTrimmed, + })), + [githubSearchItems, githubSearchQueryTrimmed], + ); + + const attachmentMenuItems = useMemo( + () => [ + { + id: "image", + label: "Add image", + icon: , + onSelect: () => { + void handlePickImage(); + }, + }, + { + id: "github", + label: "Add issue or PR", + icon: , + onSelect: () => { + setIsGithubPickerOpen(true); + }, + }, + ], + [handlePickImage, theme.colors.foregroundMuted, theme.iconSize.md], + ); + + const handleToggleGithubItem = useCallback( + (item: GitHubSearchItem) => { + setSelectedAttachments((current) => { + const matches = (attachment: ComposerAttachment) => + attachment.kind !== "image" && + attachment.item.kind === item.kind && + attachment.item.number === item.number; + + if (current.some(matches)) { + return current.filter((attachment) => !matches(attachment)); + } + + const nextAttachment: ComposerAttachment = + item.kind === "pr" ? { kind: "github_pr", item } : { kind: "github_issue", item }; + return [...current, nextAttachment]; + }); + setIsGithubPickerOpen(false); + setGithubSearchQuery(""); + }, + [setSelectedAttachments, setGithubSearchQuery, setIsGithubPickerOpen], + ); + const leftContent = resolveStatusControlMode(statusControls) === "draft" && statusControls ? ( @@ -697,6 +813,7 @@ export function Composer({ + setLightboxMetadata(null)} /> {/* Input area */} @@ -745,6 +862,59 @@ export function Composer({ )} + {selectedAttachments.length > 0 ? ( + + {selectedAttachments.map((attachment, index) => { + if (attachment.kind === "image") { + return ( + handleOpenAttachment(attachment)} + onRemove={() => handleRemoveAttachment(index)} + openAccessibilityLabel="Open image attachment" + removeAccessibilityLabel="Remove image attachment" + > + + + ); + } + + const item = attachment.item; + const kindLabel = item.kind === "pr" ? "PR" : "issue"; + return ( + handleOpenAttachment(attachment)} + onRemove={() => handleRemoveAttachment(index)} + openAccessibilityLabel={`Open ${kindLabel} #${item.number}`} + removeAccessibilityLabel={`Remove ${kindLabel} #${item.number}`} + > + + + {item.kind === "pr" ? ( + + ) : ( + + )} + + + #{item.number} {item.title} + + + + ); + })} + + ) : null} + {/* MessageInput handles everything: text, dictation, attachments, all buttons */} { + attachButtonRef.current = node; + }} onAddImages={addImages} - onRemoveImage={handleRemoveImage} client={client} isReadyForDictation={isDictationReady} placeholder={messagePlaceholder} @@ -789,6 +962,60 @@ export function Composer({ onHeightChange={onComposerHeightChange} inputWrapperStyle={inputWrapperStyle} /> + {}} + keepOpenOnSelect + searchable + searchPlaceholder="Search issues and PRs..." + title="Attach issue or PR" + open={isGithubPickerOpen} + onOpenChange={(open) => { + setIsGithubPickerOpen(open); + if (!open) { + setGithubSearchQuery(""); + } + }} + onSearchQueryChange={setGithubSearchQuery} + desktopPlacement="top-start" + anchorRef={attachButtonRef} + emptyText={githubSearchResultsQuery.isFetching ? "Searching..." : "No results found."} + renderOption={({ option, active }) => { + const item = githubSearchItems.find((candidate) => { + return `${candidate.kind}:${candidate.number}` === option.id; + }); + if (!item) { + return ; + } + const selected = selectedAttachments.some( + (attachment) => + attachment.kind !== "image" && + attachment.item.kind === item.kind && + attachment.item.number === item.number, + ); + return ( + handleToggleGithubItem(item)} + leadingSlot={ + item.kind === "pr" ? ( + + ) : ( + + ) + } + /> + ); + }} + /> @@ -824,6 +1051,7 @@ const styles = StyleSheet.create(((theme: Theme) => ({ messageInputContainer: { position: "relative", width: "100%", + gap: theme.spacing[3], }, autocompletePopover: { position: "absolute", @@ -867,6 +1095,41 @@ const styles = StyleSheet.create(((theme: Theme) => ({ iconButtonHovered: { backgroundColor: theme.colors.surface2, }, + attachmentPreviewContainer: { + flexDirection: "row", + gap: theme.spacing[2], + flexWrap: "wrap", + }, + imageThumbnail: { + width: 48, + height: 48, + }, + imageThumbnailPlaceholder: { + width: 48, + height: 48, + backgroundColor: theme.colors.surface2, + }, + githubPillBody: { + minHeight: 48, + maxWidth: 260, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + backgroundColor: theme.colors.surface1, + }, + githubPillIcon: { + width: 18, + alignItems: "center", + justifyContent: "center", + }, + githubPillText: { + minWidth: 0, + flexShrink: 1, + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + }, tooltipRow: { flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/components/message-input.test.tsx b/packages/app/src/components/message-input.test.tsx new file mode 100644 index 000000000..ba7473b69 --- /dev/null +++ b/packages/app/src/components/message-input.test.tsx @@ -0,0 +1,305 @@ +import React from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { JSDOM } from "jsdom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { MessageInput, type AttachmentMenuItem } from "./message-input"; + +const { theme } = vi.hoisted(() => ({ + theme: { + spacing: { 1: 4, 2: 8, 3: 12, 4: 16 }, + 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 }, + fontWeight: { normal: "400" }, + shadow: { md: {} }, + colors: { + surface1: "#111", + surface2: "#222", + surface3: "#333", + surface4: "#888", + foreground: "#fff", + foregroundMuted: "#aaa", + popoverForeground: "#fff", + borderAccent: "#444", + accent: "#0a84ff", + accentForeground: "#fff", + destructive: "#ff453a", + palette: { + green: { 500: "#30d158" }, + }, + }, + }, +})); + +vi.mock("react-native-unistyles", () => ({ + StyleSheet: { + create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory), + }, + useUnistyles: () => ({ theme }), +})); + +vi.mock("@/constants/platform", () => ({ + isWeb: false, + isNative: true, +})); + +vi.mock("lucide-react-native", () => { + const createIcon = (name: string) => (props: Record) => + React.createElement("span", { ...props, "data-icon": name }); + return { + Mic: createIcon("Mic"), + MicOff: createIcon("MicOff"), + ArrowUp: createIcon("ArrowUp"), + Plus: createIcon("Plus"), + Square: createIcon("Square"), + }; +}); + +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("@/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("@/stores/session-store", () => ({ + useSessionStore: (selector: (state: { sessions: Record }) => unknown) => + selector({ sessions: {} }), +})); + +vi.mock("@/contexts/voice-context", () => ({ + useVoiceOptional: () => null, +})); + +vi.mock("@/contexts/toast-context", () => ({ + useToast: () => ({ error: vi.fn() }), +})); + +vi.mock("@/utils/server-info-capabilities", () => ({ + resolveVoiceUnavailableMessage: () => null, +})); + +vi.mock("@/components/use-web-scrollbar", () => ({ + useWebElementScrollbar: () => null, +})); + +vi.mock( + "@/hooks/use-web-scrollbar-style", + () => ({ + useWebScrollbarStyle: () => undefined, + }), + // @ts-expect-error Vitest accepts virtual mocks at runtime; the app's types omit this overload. + { virtual: true }, +); + +vi.mock("@/hooks/use-shortcut-keys", () => ({ + useShortcutKeys: () => null, +})); + +vi.mock("@/components/ui/shortcut", () => ({ + Shortcut: () => null, +})); + +vi.mock("@/components/ui/tooltip", () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ + asChild, + children, + ...props + }: { + asChild?: boolean; + children: React.ReactNode | ((state: { hovered: boolean }) => React.ReactNode); + } & Record) => + asChild ? ( + <>{children} + ) : ( + + ), + TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +vi.mock("@/components/ui/dropdown-menu", () => ({ + DropdownMenu: ({ children }: { children: React.ReactNode }) => <>{children}, + DropdownMenuTrigger: ({ + children, + testID, + accessibilityLabel, + }: { + children: + | React.ReactNode + | ((state: { hovered: boolean; pressed: boolean; open: boolean }) => React.ReactNode); + testID?: string; + accessibilityLabel?: string; + }) => ( + + ), + DropdownMenuContent: ({ children, testID }: { children: React.ReactNode; testID?: string }) => ( +
{children}
+ ), + DropdownMenuItem: ({ + children, + onSelect, + testID, + disabled, + }: { + children: React.ReactNode; + onSelect?: () => void; + testID?: string; + disabled?: boolean; + }) => ( + + ), +})); + +vi.mock("./dictation-controls", () => ({ + DictationOverlay: () => null, +})); + +vi.mock("./realtime-voice-overlay", () => ({ + RealtimeVoiceOverlay: () => null, +})); + +let root: Root | null = null; +let container: HTMLElement | null = null; + +beforeEach(() => { + const dom = new JSDOM(""); + 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); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + if (root) { + act(() => { + root?.unmount(); + }); + } + root = null; + container = null; + vi.unstubAllGlobals(); +}); + +function renderMessageInput(menuItems: AttachmentMenuItem[]) { + act(() => { + root?.render( + , + ); + }); +} + +function click(element: Element) { + act(() => { + element.dispatchEvent(new window.MouseEvent("click", { bubbles: true })); + }); +} + +function queryByTestId(testID: string): HTMLElement | null { + return document.querySelector(`[data-testid="${testID}"]`); +} + +function queryAllByAriaLabel(label: string): NodeListOf { + return document.querySelectorAll(`[aria-label="${label}"]`); +} + +describe("MessageInput attachments", () => { + it("renders the Plus attachment button and opens a menu with two attachment items", () => { + const menuItems: AttachmentMenuItem[] = [ + { id: "image", label: "Add image", onSelect: vi.fn() }, + { id: "github", label: "Add issue or PR", onSelect: vi.fn() }, + ]; + + renderMessageInput(menuItems); + + expect(document.querySelectorAll('[data-icon="Plus"]')).toHaveLength(1); + + const attachButton = queryByTestId("message-input-attach-button"); + expect(attachButton).not.toBeNull(); + click(attachButton!); + + expect(queryByTestId("message-input-attachment-menu-item-image")).not.toBeNull(); + expect(queryByTestId("message-input-attachment-menu-item-github")).not.toBeNull(); + expect( + document.querySelectorAll('[data-testid^="message-input-attachment-menu-item-"]'), + ).toHaveLength(2); + }); + + it("selecting Attach image invokes the supplied image attachment action", () => { + const attachImage = vi.fn(); + + renderMessageInput([ + { id: "image", label: "Attach image", onSelect: attachImage }, + { id: "github", label: "Attach GitHub issue or PR", onSelect: vi.fn() }, + ]); + + click(queryByTestId("message-input-attach-button")!); + click(queryByTestId("message-input-attachment-menu-item-image")!); + + expect(attachImage).toHaveBeenCalledTimes(1); + }); + + it("does not render the old queue button", () => { + renderMessageInput([ + { id: "image", label: "Add image", onSelect: vi.fn() }, + { id: "github", label: "Add issue or PR", onSelect: vi.fn() }, + ]); + + expect(queryAllByAriaLabel("Queue message")).toHaveLength(0); + }); +}); diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index 3f82fd226..029e5e737 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -8,7 +8,6 @@ import { TextInputContentSizeChangeEventData, TextInputKeyPressEventData, TextInputSelectionChangeEventData, - Image, BackHandler, } from "react-native"; import { @@ -21,7 +20,7 @@ import { forwardRef, } from "react"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { Mic, MicOff, ArrowUp, Paperclip, Plus, X, Square } from "lucide-react-native"; +import { Mic, MicOff, ArrowUp, Plus, Square } from "lucide-react-native"; import Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated"; import { useDictation } from "@/hooks/use-dictation"; import { DictationOverlay } from "./dictation-controls"; @@ -36,10 +35,16 @@ import { filesToImageAttachments, } from "@/utils/image-attachments-from-files"; import type { AttachmentMetadata } from "@/attachments/types"; -import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; +import type { ComposerAttachment } from "@/attachments/types"; import { focusWithRetries } from "@/utils/web-focus"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Shortcut } from "@/components/ui/shortcut"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { useWebElementScrollbar } from "@/components/use-web-scrollbar"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { formatShortcut } from "@/utils/format-shortcut"; @@ -56,11 +61,20 @@ export type ImageAttachment = AttachmentMetadata; export interface MessagePayload { text: string; - images?: ImageAttachment[]; + attachments: ComposerAttachment[]; + cwd: string; /** When true, bypasses queue and sends immediately even if agent is running */ forceSend?: boolean; } +export interface AttachmentMenuItem { + id: string; + label: string; + onSelect: () => void; + disabled?: boolean; + icon?: React.ReactElement | null; +} + export interface MessageInputProps { value: string; onChangeText: (text: string) => void; @@ -73,10 +87,11 @@ export interface MessageInputProps { submitButtonAccessibilityLabel?: string; isSubmitDisabled?: boolean; isSubmitLoading?: boolean; - images?: ImageAttachment[]; - onPickImages?: () => void; + attachments: ComposerAttachment[]; + cwd: string; + attachmentMenuItems: AttachmentMenuItem[]; + onAttachButtonRef?: (node: View | null) => void; onAddImages?: (images: ImageAttachment[]) => void; - onRemoveImage?: (index: number) => void; client: DaemonClient | null; /** Dictation start gate from host runtime (socket connected + directory ready). */ isReadyForDictation?: boolean; @@ -194,14 +209,6 @@ function getScrollableAncestorChain(element: HTMLElement | null): string[] { return results; } -function ImageAttachmentThumbnail({ image }: { image: ImageAttachment }) { - const uri = useAttachmentPreviewUrl(image); - if (!uri) { - return ; - } - return ; -} - export const MessageInput = forwardRef(function MessageInput( { value, @@ -212,10 +219,11 @@ export const MessageInput = forwardRef(funct submitButtonAccessibilityLabel, isSubmitDisabled = false, isSubmitLoading = false, - images = [], - onPickImages, + attachments, + cwd, + attachmentMenuItems, + onAttachButtonRef, onAddImages, - onRemoveImage, client, isReadyForDictation, placeholder = "Message...", @@ -249,7 +257,6 @@ export const MessageInput = forwardRef(funct const sendKeys = useShortcutKeys("message-input-send"); const voiceMuteToggleKeys = useShortcutKeys("voice-mute-toggle"); const dictationToggleKeys = useShortcutKeys("dictation-toggle"); - const queueKeys = useShortcutKeys("message-input-queue"); const focusInputKeys = useShortcutKeys("focus-message-input"); const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT); const [isInputFocused, setIsInputFocused] = useState(false); @@ -376,15 +383,15 @@ export const MessageInput = forwardRef(funct sendAfterTranscriptRef.current = false; if (shouldAutoSend) { - const imageAttachments = images.length > 0 ? images : undefined; // Respect send behavior setting: when "queue", dictation queues too. if (defaultSendBehavior === "queue" && isAgentRunning && onQueue) { - onQueue({ text: nextValue, images: imageAttachments }); + onQueue({ text: nextValue, attachments, cwd }); onChangeText(""); } else { onSubmit({ text: nextValue, - images: imageAttachments, + attachments, + cwd, forceSend: isAgentRunning || undefined, }); } @@ -398,7 +405,7 @@ export const MessageInput = forwardRef(funct }); } }, - [onChangeText, onSubmit, onQueue, images, isAgentRunning, defaultSendBehavior], + [onChangeText, onSubmit, onQueue, attachments, cwd, isAgentRunning, defaultSendBehavior], ); const handleDictationError = useCallback( @@ -582,10 +589,11 @@ export const MessageInput = forwardRef(funct const handleSendMessage = useCallback(() => { const trimmed = value.trim(); - if (!trimmed && images.length === 0 && !hasExternalContent && !allowEmptySubmit) return; + if (!trimmed && attachments.length === 0 && !hasExternalContent && !allowEmptySubmit) return; const payload = { text: trimmed, - images: images.length > 0 ? images : undefined, + attachments, + cwd, forceSend: isAgentRunning || undefined, }; onSubmit(payload); @@ -595,7 +603,8 @@ export const MessageInput = forwardRef(funct }, [ allowEmptySubmit, value, - images, + attachments, + cwd, onSubmit, isAgentRunning, onHeightChange, @@ -605,17 +614,18 @@ export const MessageInput = forwardRef(funct const handleQueueMessage = useCallback(() => { if (!onQueue) return; const trimmed = value.trim(); - if (!trimmed && images.length === 0) return; + if (!trimmed && attachments.length === 0) return; const payload = { text: trimmed, - images: images.length > 0 ? images : undefined, + attachments, + cwd, }; onQueue(payload); onChangeText(""); inputHeightRef.current = MIN_INPUT_HEIGHT; setInputHeight(MIN_INPUT_HEIGHT); onHeightChange?.(MIN_INPUT_HEIGHT); - }, [value, images, onQueue, onChangeText, onHeightChange]); + }, [value, attachments, cwd, onQueue, onChangeText, onHeightChange]); // Default send action: respects the sendBehavior setting. // When "interrupt" (default), primary action sends immediately (interrupts). @@ -950,8 +960,8 @@ export const MessageInput = forwardRef(funct handleDefaultSendAction(); } - const hasImages = images.length > 0; - const hasRealContent = value.trim().length > 0 || hasImages; + const hasAttachments = attachments.length > 0; + const hasRealContent = value.trim().length > 0 || hasAttachments; const hasSendableContent = hasRealContent || hasExternalContent; const shouldShowSendButton = hasSendableContent || allowEmptySubmit || isSubmitLoading; const canPressLoadingButton = isSubmitLoading && typeof onSubmitLoadingPress === "function"; @@ -989,36 +999,6 @@ export const MessageInput = forwardRef(funct ref={inputWrapperRef} style={[styles.inputWrapper, inputWrapperStyle, inputAnimatedStyle]} > - {/* Image preview pills */} - {hasImages && ( - - {images.map((image, index) => ( - onRemoveImage(index) : undefined} - > - {({ hovered }) => ( - <> - - {onRemoveImage && ( - - - - )} - - )} - - ))} - - )} - {/* Text input */} (funct {/* Left: attachment button + leftContent slot */} - {onPickImages && ( + - [ styles.attachButton, hovered && styles.iconButtonHovered, @@ -1086,18 +1066,43 @@ export const MessageInput = forwardRef(funct ]} > {({ hovered }) => ( - + + + )} - + - Attach images + Add attachment - )} + + {attachmentMenuItems.map((item) => ( + + {item.label} + + ))} + + {leftContent} @@ -1164,31 +1169,6 @@ export const MessageInput = forwardRef(funct {rightContent} - {hasSendableContent && isAgentRunning && onQueue && !defaultActionQueues && ( - - [ - styles.queueButton, - hovered && styles.iconButtonHovered, - (!isConnected || disabled) && styles.buttonDisabled, - ]} - > - - - - - Queue - {queueKeys ? ( - - ) : null} - - - - )} {shouldShowSendButton && ( ({ } : {}), }, - imagePreviewContainer: { - flexDirection: "row", - gap: theme.spacing[2], - flexWrap: "wrap", - }, - imagePill: { - position: "relative", - borderRadius: theme.borderRadius.md, - borderWidth: 1, - borderColor: theme.colors.borderAccent, - overflow: "hidden", - ...(isWeb - ? { - cursor: "pointer", - } - : {}), - }, - imageThumbnail: { - width: 48, - height: 48, - }, - imageThumbnailPlaceholder: { - width: 48, - height: 48, - backgroundColor: theme.colors.surface2, - }, - removeImageButton: { - position: "absolute", - top: 0, - left: 0, - right: 0, - bottom: 0, - alignItems: "center", - justifyContent: "center", - backgroundColor: "rgba(0, 0, 0, 0.5)", - opacity: 0, - ...(isWeb - ? { - transitionProperty: "opacity", - transitionDuration: "150ms", - } - : {}), - }, - removeImageButtonVisible: { - opacity: 1, - }, textInputScrollWrapper: { position: "relative", }, @@ -1359,7 +1293,7 @@ const styles = StyleSheet.create(((theme: any) => ({ flexGrow: 1, flexDirection: "row", alignItems: "flex-end", - gap: theme.spacing[1], + gap: theme.spacing[0], }, rightButtonGroup: { flexShrink: 0, @@ -1374,6 +1308,12 @@ const styles = StyleSheet.create(((theme: any) => ({ alignItems: "center", justifyContent: "center", }, + attachButtonAnchor: { + width: 28, + height: 28, + alignItems: "center", + justifyContent: "center", + }, voiceButton: { width: 28, height: 28, @@ -1384,14 +1324,6 @@ const styles = StyleSheet.create(((theme: any) => ({ voiceButtonRecording: { backgroundColor: theme.colors.destructive, }, - queueButton: { - width: 28, - height: 28, - borderRadius: theme.borderRadius.full, - backgroundColor: theme.colors.surface1, - alignItems: "center", - justifyContent: "center", - }, sendButton: { width: 28, height: 28, diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 470184158..7a15e50ac 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -81,6 +81,8 @@ export interface ComboboxProps { desktopFixedHeight?: number; /** Content rendered above the scroll area on desktop (sticky header). */ stickyHeader?: ReactNode; + /** When true, selecting an option does not close the picker (multi-select mode). */ + keepOpenOnSelect?: boolean; anchorRef: React.RefObject; children?: ReactNode; } @@ -262,6 +264,7 @@ export function Combobox({ desktopMinWidth, desktopFixedHeight, stickyHeader, + keepOpenOnSelect = false, anchorRef, children, }: ComboboxProps): ReactElement { @@ -587,9 +590,11 @@ export function Combobox({ const handleSelect = useCallback( (id: string) => { onSelect(id); - handleClose(); + if (!keepOpenOnSelect) { + handleClose(); + } }, - [handleClose, onSelect], + [handleClose, keepOpenOnSelect, onSelect], ); const handleSubmitSearch = useCallback(() => { @@ -779,6 +784,7 @@ export function Combobox({ ) : ( <> + {stickyHeader} {searchable ? searchInput : null} {effectiveOptionsPosition === "above-search" ? ( { - if (!pendingWorkspaceSetup) { - throw new Error("No workspace setup is pending"); - } + const ensureWorkspace = useCallback( + async (input: { cwd: string; attachments: MessagePayload["attachments"] }) => { + if (!pendingWorkspaceSetup) { + throw new Error("No workspace setup is pending"); + } - if (createdWorkspace) { - return createdWorkspace; - } + if (createdWorkspace) { + return createdWorkspace; + } - const connectedClient = withConnectedClient(); - const payload = - pendingWorkspaceSetup.creationMethod === "create_worktree" - ? await connectedClient.createPaseoWorktree({ - cwd: pendingWorkspaceSetup.sourceDirectory, - worktreeSlug: createNameId(), - }) - : await connectedClient.openProject(pendingWorkspaceSetup.sourceDirectory); + const connectedClient = withConnectedClient(); + const wirePayload = splitComposerAttachmentsForSubmit(input.attachments); + const payload = + pendingWorkspaceSetup.creationMethod === "create_worktree" + ? await connectedClient.createPaseoWorktree({ + cwd: input.cwd, + worktreeSlug: createNameId(), + ...(wirePayload.attachments.length > 0 + ? { attachments: wirePayload.attachments } + : {}), + }) + : await connectedClient.openProject(input.cwd); - if (payload.error || !payload.workspace) { - throw new Error( - payload.error ?? - (pendingWorkspaceSetup.creationMethod === "create_worktree" - ? "Failed to create worktree" - : "Failed to open project"), - ); - } + if (payload.error || !payload.workspace) { + throw new Error( + payload.error ?? + (pendingWorkspaceSetup.creationMethod === "create_worktree" + ? "Failed to create worktree" + : "Failed to open project"), + ); + } - const normalizedWorkspace = normalizeWorkspaceDescriptor(payload.workspace); - mergeWorkspaces(pendingWorkspaceSetup.serverId, [normalizedWorkspace]); - if (pendingWorkspaceSetup.creationMethod === "open_project") { - setHasHydratedWorkspaces(pendingWorkspaceSetup.serverId, true); - } - setCreatedWorkspace(normalizedWorkspace); - return normalizedWorkspace; - }, [ - createdWorkspace, - mergeWorkspaces, - pendingWorkspaceSetup, - setHasHydratedWorkspaces, - withConnectedClient, - ]); + const normalizedWorkspace = normalizeWorkspaceDescriptor(payload.workspace); + mergeWorkspaces(pendingWorkspaceSetup.serverId, [normalizedWorkspace]); + if (pendingWorkspaceSetup.creationMethod === "open_project") { + setHasHydratedWorkspaces(pendingWorkspaceSetup.serverId, true); + } + setCreatedWorkspace(normalizedWorkspace); + return normalizedWorkspace; + }, + [ + createdWorkspace, + mergeWorkspaces, + pendingWorkspaceSetup, + setHasHydratedWorkspaces, + withConnectedClient, + ], + ); const getIsStillActive = useCallback(() => { const current = useWorkspaceSetupStore.getState().pendingWorkspaceSetup; @@ -161,17 +170,18 @@ export function WorkspaceSetupDialog() { ]); const handleCreateChatAgent = useCallback( - async ({ text, images }: MessagePayload) => { + async ({ text, attachments, cwd }: MessagePayload) => { try { setPendingAction("chat"); setErrorMessage(null); - const workspace = await ensureWorkspace(); + const workspace = await ensureWorkspace({ cwd, attachments }); const connectedClient = withConnectedClient(); if (!composerState) { throw new Error("Workspace setup composer state is required"); } - const encodedImages = await encodeImages(images); + const wirePayload = splitComposerAttachmentsForSubmit(attachments); + const encodedImages = await encodeImages(wirePayload.images); const workspaceDirectory = requireWorkspaceExecutionAuthority({ workspace, }).workspaceDirectory; @@ -188,6 +198,7 @@ export function WorkspaceSetupDialog() { : {}), ...(text.trim() ? { initialPrompt: text.trim() } : {}), ...(encodedImages && encodedImages.length > 0 ? { images: encodedImages } : {}), + ...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}), }); if (!getIsStillActive()) { @@ -285,8 +296,9 @@ export function WorkspaceSetupDialog() { blurOnSubmit={true} value={chatDraft.text} onChangeText={chatDraft.setText} - images={chatDraft.images} - onChangeImages={chatDraft.setImages} + attachments={chatDraft.attachments} + onChangeAttachments={chatDraft.setAttachments} + cwd={chatDraft.cwd} clearDraft={chatDraft.clear} autoFocus commandDraftConfig={composerState?.commandDraftConfig} diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index a63de277d..c53d084ee 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -53,6 +53,7 @@ import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agen import { resolveProjectPlacement } from "@/utils/project-placement"; import { buildDraftStoreKey } from "@/stores/draft-keys"; import type { AttachmentMetadata } from "@/attachments/types"; +import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments"; import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking"; import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts"; import { isNative } from "@/constants/platform"; @@ -476,7 +477,13 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider if (queue && queue.length > 0) { const [next, ...rest] = queue; if (sendAgentMessageRef.current) { - void sendAgentMessageRef.current(agent.id, next.text, next.images); + const wirePayload = splitComposerAttachmentsForSubmit(next.attachments); + void sendAgentMessageRef.current( + agent.id, + next.text, + wirePayload.images, + wirePayload.attachments, + ); } setQueuedMessages(serverId, (prev) => { const updated = new Map(prev); diff --git a/packages/app/src/hooks/use-agent-input-draft.live.test.tsx b/packages/app/src/hooks/use-agent-input-draft.live.test.tsx index e94c6ec16..9d2edbd51 100644 --- a/packages/app/src/hooks/use-agent-input-draft.live.test.tsx +++ b/packages/app/src/hooks/use-agent-input-draft.live.test.tsx @@ -4,7 +4,7 @@ import { JSDOM } from "jsdom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { useDraftStore } from "@/stores/draft-store"; -import type { AttachmentMetadata } from "@/attachments/types"; +import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types"; const { asyncStorage } = vi.hoisted(() => ({ asyncStorage: new Map(), @@ -92,6 +92,7 @@ vi.mock("./use-agent-form-state", () => ({ })); let useAgentInputDraft: typeof import("./use-agent-input-draft").useAgentInputDraft; +type DraftRecordForTest = ReturnType["drafts"][string]; beforeAll(async () => { const storage = new Map(); @@ -137,7 +138,7 @@ describe("useAgentInputDraft live contract", () => { useDraftStore.setState({ drafts: {}, createModalDraft: null }); }); - it("hydrates persisted text and images and returns draft-mode composer state for a caller-provided key", async () => { + it("hydrates persisted text and attachments and returns draft-mode composer state for a caller-provided key", async () => { let latest: ReturnType | null = null; const image: AttachmentMetadata = { id: "attachment-1", @@ -194,7 +195,7 @@ describe("useAgentInputDraft live contract", () => { await act(async () => { getLatest().setText("hello world"); - getLatest().setImages([image]); + getLatest().setAttachments([{ kind: "image", metadata: image }]); }); await act(async () => { @@ -211,7 +212,357 @@ describe("useAgentInputDraft live contract", () => { }); expect(getLatest().text).toBe("hello world"); - expect(getLatest().images).toEqual([image]); + expect(getLatest().attachments).toEqual([{ kind: "image", metadata: image }]); + }); + + it("migrates legacy image drafts to image attachments on hydration", async () => { + let latest: ReturnType | null = null; + const image: AttachmentMetadata = { + id: "legacy-image", + mimeType: "image/png", + storageType: "web-indexeddb", + storageKey: "attachments/legacy-image", + createdAt: 10, + fileName: "legacy.png", + byteSize: 512, + }; + + function getLatest(): ReturnType { + if (!latest) { + throw new Error("Expected hook result"); + } + return latest; + } + + useDraftStore.setState({ + drafts: { + "draft:legacy": { + input: { + text: "legacy text", + images: [image], + }, + lifecycle: "active", + updatedAt: Date.now(), + version: 1, + } as unknown as DraftRecordForTest, + }, + createModalDraft: null, + }); + + function Probe() { + latest = useAgentInputDraft({ draftKey: "draft:legacy", initialCwd: "/repo" }); + return null; + } + + const queryClient = new QueryClient(); + const container = document.getElementById("root"); + if (!container) { + throw new Error("Missing root container"); + } + + const root = createRoot(container); + await act(async () => { + root.render( + + + , + ); + }); + + expect(getLatest().text).toBe("legacy text"); + expect(getLatest().attachments).toEqual([{ kind: "image", metadata: image }]); + expect(getLatest().cwd).toBe("/repo"); + expect(useDraftStore.getState().drafts["draft:legacy"]?.input).toEqual({ + text: "legacy text", + attachments: [{ kind: "image", metadata: image }], + cwd: "/repo", + }); + }); + + it("round-trips persisted attachments and cwd unchanged", async () => { + let latest: ReturnType | null = null; + const githubIssue: ComposerAttachment = { + kind: "github_issue", + item: { + kind: "issue", + number: 42, + title: "Unify attachments", + url: "https://github.com/paseo/paseo/issues/42", + state: "open", + body: "body", + labels: ["composer"], + }, + }; + + function getLatest(): ReturnType { + if (!latest) { + throw new Error("Expected hook result"); + } + return latest; + } + + useDraftStore.setState({ + drafts: { + "draft:new-shape": { + input: { + text: "new text", + attachments: [githubIssue], + cwd: "/persisted", + }, + lifecycle: "active", + updatedAt: Date.now(), + version: 1, + }, + }, + createModalDraft: null, + }); + + function Probe() { + latest = useAgentInputDraft({ draftKey: "draft:new-shape", initialCwd: "/initial" }); + return null; + } + + const queryClient = new QueryClient(); + const container = document.getElementById("root"); + if (!container) { + throw new Error("Missing root container"); + } + + const root = createRoot(container); + await act(async () => { + root.render( + + + , + ); + }); + + expect(getLatest().text).toBe("new text"); + expect(getLatest().attachments).toEqual([githubIssue]); + expect(getLatest().cwd).toBe("/persisted"); + + await act(async () => { + root.unmount(); + }); + + expect(useDraftStore.getState().drafts["draft:new-shape"]?.input).toEqual({ + text: "new text", + attachments: [githubIssue], + cwd: "/persisted", + }); + }); + + it("seeds cwd from initialCwd when persisted cwd is missing", async () => { + let latest: ReturnType | null = null; + + function getLatest(): ReturnType { + if (!latest) { + throw new Error("Expected hook result"); + } + return latest; + } + + useDraftStore.setState({ + drafts: { + "draft:seed-cwd": { + input: { + text: "seed", + attachments: [], + }, + lifecycle: "active", + updatedAt: Date.now(), + version: 1, + } as unknown as DraftRecordForTest, + }, + createModalDraft: null, + }); + + function Probe() { + latest = useAgentInputDraft({ draftKey: "draft:seed-cwd", initialCwd: "/initial" }); + return null; + } + + const queryClient = new QueryClient(); + const container = document.getElementById("root"); + if (!container) { + throw new Error("Missing root container"); + } + + const root = createRoot(container); + await act(async () => { + root.render( + + + , + ); + }); + + expect(getLatest().cwd).toBe("/initial"); + expect(useDraftStore.getState().drafts["draft:seed-cwd"]?.input).toEqual({ + text: "seed", + attachments: [], + cwd: "/initial", + }); + }); + + it("seeds cwd as empty string when persisted cwd and initialCwd are missing", async () => { + let latest: ReturnType | null = null; + + function getLatest(): ReturnType { + if (!latest) { + throw new Error("Expected hook result"); + } + return latest; + } + + useDraftStore.setState({ + drafts: { + "draft:empty-cwd": { + input: { + text: "seed", + attachments: [], + }, + lifecycle: "active", + updatedAt: Date.now(), + version: 1, + } as unknown as DraftRecordForTest, + }, + createModalDraft: null, + }); + + function Probe() { + latest = useAgentInputDraft({ draftKey: "draft:empty-cwd" }); + return null; + } + + const queryClient = new QueryClient(); + const container = document.getElementById("root"); + if (!container) { + throw new Error("Missing root container"); + } + + const root = createRoot(container); + await act(async () => { + root.render( + + + , + ); + }); + + expect(getLatest().cwd).toBe(""); + expect(useDraftStore.getState().drafts["draft:empty-cwd"]?.input).toEqual({ + text: "seed", + attachments: [], + cwd: "", + }); + }); + + it("updates and persists attachments through setAttachments", async () => { + let latest: ReturnType | null = null; + const image: AttachmentMetadata = { + id: "next-image", + mimeType: "image/png", + storageType: "web-indexeddb", + storageKey: "attachments/next-image", + createdAt: 11, + }; + + function getLatest(): ReturnType { + if (!latest) { + throw new Error("Expected hook result"); + } + return latest; + } + + function Probe() { + latest = useAgentInputDraft({ draftKey: "draft:attachments", initialCwd: "/repo" }); + return null; + } + + const queryClient = new QueryClient(); + const container = document.getElementById("root"); + if (!container) { + throw new Error("Missing root container"); + } + + const root = createRoot(container); + await act(async () => { + root.render( + + + , + ); + }); + + await act(async () => { + getLatest().setText("with attachment"); + getLatest().setAttachments([{ kind: "image", metadata: image }]); + }); + + expect(getLatest().attachments).toEqual([{ kind: "image", metadata: image }]); + expect(useDraftStore.getState().drafts["draft:attachments"]?.input).toEqual({ + text: "with attachment", + attachments: [{ kind: "image", metadata: image }], + cwd: "/repo", + }); + }); + + it("clear resets text, attachments, and cwd", async () => { + let latest: ReturnType | null = null; + const image: AttachmentMetadata = { + id: "clear-image", + mimeType: "image/png", + storageType: "web-indexeddb", + storageKey: "attachments/clear-image", + createdAt: 12, + }; + + function getLatest(): ReturnType { + if (!latest) { + throw new Error("Expected hook result"); + } + return latest; + } + + function Probe() { + latest = useAgentInputDraft({ draftKey: "draft:clear", initialCwd: "/repo" }); + return null; + } + + const queryClient = new QueryClient(); + const container = document.getElementById("root"); + if (!container) { + throw new Error("Missing root container"); + } + + const root = createRoot(container); + await act(async () => { + root.render( + + + , + ); + }); + + await act(async () => { + getLatest().setText("queued message"); + getLatest().setAttachments([{ kind: "image", metadata: image }]); + getLatest().setCwd("/changed"); + }); + + await act(async () => { + getLatest().clear("sent"); + }); + + expect(getLatest().text).toBe(""); + expect(getLatest().attachments).toEqual([]); + expect(getLatest().cwd).toBe(""); + expect(useDraftStore.getState().drafts["draft:clear"]?.input).toEqual({ + text: "", + attachments: [], + cwd: "", + }); }); it("clears drafts with sent and abandoned lifecycle tombstones", async () => { @@ -253,7 +604,7 @@ describe("useAgentInputDraft live contract", () => { await act(async () => { getLatest().setText("queued message"); - getLatest().setImages([sentImage]); + getLatest().setAttachments([{ kind: "image", metadata: sentImage }]); }); await act(async () => { @@ -261,10 +612,10 @@ describe("useAgentInputDraft live contract", () => { }); expect(getLatest().text).toBe(""); - expect(getLatest().images).toEqual([]); + expect(getLatest().attachments).toEqual([]); expect(useDraftStore.getState().drafts["draft:lifecycle"]).toMatchObject({ lifecycle: "sent", - input: { text: "", images: [] }, + input: { text: "", attachments: [], cwd: "" }, }); await act(async () => { @@ -277,7 +628,7 @@ describe("useAgentInputDraft live contract", () => { expect(useDraftStore.getState().drafts["draft:lifecycle"]).toMatchObject({ lifecycle: "abandoned", - input: { text: "", images: [] }, + input: { text: "", attachments: [], cwd: "" }, }); }); }); diff --git a/packages/app/src/hooks/use-agent-input-draft.ts b/packages/app/src/hooks/use-agent-input-draft.ts index fe500724e..e8f710315 100644 --- a/packages/app/src/hooks/use-agent-input-draft.ts +++ b/packages/app/src/hooks/use-agent-input-draft.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { AttachmentMetadata } from "@/attachments/types"; +import type { ComposerAttachment } from "@/attachments/types"; import type { DraftAgentStatusBarProps } from "@/components/agent-status-bar"; import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query"; import { @@ -11,7 +11,9 @@ import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features"; import { useDraftStore } from "@/stores/draft-store"; import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types"; -type ImageUpdater = AttachmentMetadata[] | ((prev: AttachmentMetadata[]) => AttachmentMetadata[]); +type AttachmentUpdater = + | ComposerAttachment[] + | ((prev: ComposerAttachment[]) => ComposerAttachment[]); type AgentInputDraftComposerOptions = { initialServerId: string | null; @@ -29,6 +31,7 @@ type DraftKeyInput = string | ((context: DraftKeyContext) => string); type UseAgentInputDraftInput = { draftKey: DraftKeyInput; + initialCwd?: string; composer?: AgentInputDraftComposerOptions; }; @@ -44,33 +47,36 @@ type DraftComposerState = UseAgentFormStateResult & { interface AgentInputDraft { text: string; setText: (text: string) => void; - images: AttachmentMetadata[]; - setImages: (updater: ImageUpdater) => void; + attachments: ComposerAttachment[]; + setAttachments: (updater: AttachmentUpdater) => void; + cwd: string; + setCwd: (cwd: string) => void; clear: (lifecycle: "sent" | "abandoned") => void; isHydrated: boolean; composerState: DraftComposerState | null; } -function hasDraftContent(input: { text: string; images: AttachmentMetadata[] }): boolean { - return input.text.trim().length > 0 || input.images.length > 0; +function hasDraftContent(input: { + text: string; + attachments: ComposerAttachment[]; + cwd: string; +}): boolean { + return ( + input.text.trim().length > 0 || input.attachments.length > 0 || input.cwd.trim().length > 0 + ); } -function areImagesEqual(input: { - left: AttachmentMetadata[]; - right: AttachmentMetadata[]; +function areAttachmentsEqual(input: { + left: ComposerAttachment[]; + right: ComposerAttachment[]; }): boolean { if (input.left.length !== input.right.length) { return false; } - return input.left.every((image, index) => { + return input.left.every((attachment, index) => { const other = input.right[index]; - return ( - image.id === other?.id && - image.mimeType === other?.mimeType && - image.storageType === other?.storageType && - image.storageKey === other?.storageKey - ); + return JSON.stringify(attachment) === JSON.stringify(other); }); } @@ -183,15 +189,16 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr [formState.selectedServerId, input.draftKey], ); const [text, setText] = useState(""); - const [images, setImagesState] = useState([]); + const [attachments, setAttachmentsState] = useState([]); + const [cwd, setCwd] = useState(input.initialCwd ?? ""); const [isHydrated, setIsHydrated] = useState(false); const draftGenerationRef = useRef(0); const hydratedGenerationRef = useRef(0); - const setImages = useCallback((updater: ImageUpdater) => { - setImagesState((previousImages) => { + const setAttachments = useCallback((updater: AttachmentUpdater) => { + setAttachmentsState((previousAttachments) => { if (typeof updater === "function") { - return updater(previousImages); + return updater(previousAttachments); } return updater; }); @@ -207,7 +214,8 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr hydratedGenerationRef.current = generation; setText(""); - setImagesState([]); + setAttachmentsState([]); + setCwd(""); setIsHydrated(true); }, [draftKey], @@ -220,13 +228,17 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr hydratedGenerationRef.current = 0; setText(""); - setImagesState([]); + setAttachmentsState([]); + setCwd(input.initialCwd ?? ""); setIsHydrated(false); let cancelled = false; void (async () => { - const draft = await store.hydrateDraftInput(draftKey); + const draft = await store.hydrateDraftInput({ + draftKey, + initialCwd: input.initialCwd, + }); if (cancelled) { return; } @@ -236,7 +248,8 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr if (draft) { setText(draft.text); - setImagesState(draft.images); + setAttachmentsState(draft.attachments); + setCwd(draft.cwd); } hydratedGenerationRef.current = generation; @@ -246,7 +259,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr return () => { cancelled = true; }; - }, [draftKey]); + }, [draftKey, input.initialCwd]); useEffect(() => { const currentGeneration = draftGenerationRef.current; @@ -268,16 +281,18 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr const existing = store.getDraftInput(draftKey); const isSameDraft = - existing?.text === text && - areImagesEqual({ - left: existing?.images ?? [], - right: images, + existing !== undefined && + existing.text === text && + existing.cwd === cwd && + areAttachmentsEqual({ + left: existing.attachments, + right: attachments, }); if (isSameDraft) { return; } - if (!hasDraftContent({ text, images })) { + if (!hasDraftContent({ text, attachments, cwd })) { if (existing) { store.clearDraftInput({ draftKey, lifecycle: "abandoned" }); } @@ -288,10 +303,11 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr draftKey, draft: { text, - images, + attachments, + cwd, }, }); - }, [draftKey, images, text]); + }, [attachments, cwd, draftKey, text]); const lockedWorkingDir = composerOptions?.lockedWorkingDir?.trim() ?? ""; useEffect(() => { @@ -395,8 +411,10 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr return { text, setText, - images, - setImages, + attachments, + setAttachments, + cwd, + setCwd, clear, isHydrated, composerState, diff --git a/packages/app/src/hooks/use-draft-agent-create-flow.ts b/packages/app/src/hooks/use-draft-agent-create-flow.ts index 190d92e41..a346818bd 100644 --- a/packages/app/src/hooks/use-draft-agent-create-flow.ts +++ b/packages/app/src/hooks/use-draft-agent-create-flow.ts @@ -1,10 +1,13 @@ import { useCallback, useMemo, useReducer } from "react"; +import type { ComposerAttachment } from "@/attachments/types"; +import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments"; import { useCreateFlowStore } from "@/stores/create-flow-store"; import { generateMessageId, type StreamItem, type UserMessageImageAttachment, } from "@/types/stream"; +import type { AgentAttachment } from "@server/shared/messages"; const EMPTY_STREAM_ITEMS: StreamItem[] = []; @@ -60,13 +63,16 @@ type CreateRequestResult = { type SubmitContext = { text: string; - images?: UserMessageImageAttachment[]; + attachments: ComposerAttachment[]; + cwd: string; }; type CreateRequestContext = { attempt: CreateAttempt; text: string; images?: UserMessageImageAttachment[]; + attachments?: AgentAttachment[]; + cwd: string; }; interface UseDraftAgentCreateFlowOptions { @@ -137,12 +143,14 @@ export function useDraftAgentCreateFlow({ }, [buildDraftAgent, machine]); const handleCreateFromInput = useCallback( - async ({ text, images }: SubmitContext) => { + async ({ text, attachments, cwd }: SubmitContext) => { if (isSubmitting) { throw new Error("Already loading"); } dispatch({ type: "DRAFT_SET_ERROR", message: "" }); + const wirePayload = splitComposerAttachmentsForSubmit(attachments); + const images = wirePayload.images; const trimmedPrompt = text.trim(); if (!trimmedPrompt && !allowEmptyText) { @@ -151,7 +159,11 @@ export function useDraftAgentCreateFlow({ throw error; } - const validationError = validateBeforeSubmit?.({ text: trimmedPrompt, images }); + const validationError = validateBeforeSubmit?.({ + text: trimmedPrompt, + attachments, + cwd, + }); if (validationError) { const error = new Error(validationError); dispatch({ type: "DRAFT_SET_ERROR", message: validationError }); @@ -182,7 +194,13 @@ export function useDraftAgentCreateFlow({ ...(attempt.images && attempt.images.length > 0 ? { images: attempt.images } : {}), }); - onBeforeSubmit?.({ attempt, text: trimmedPrompt, images }); + onBeforeSubmit?.({ + attempt, + text: trimmedPrompt, + images, + attachments: wirePayload.attachments, + cwd, + }); dispatch({ type: "SUBMIT", attempt }); onCreateStart?.(); @@ -191,6 +209,8 @@ export function useDraftAgentCreateFlow({ attempt, text: trimmedPrompt, images, + attachments: wirePayload.attachments, + cwd, }); if (createResult.agentId) { diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 02045f1c0..b0dee6e34 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -432,13 +432,6 @@ function ChatAgentContent({ routeKey: string; reason: "initial-entry" | "resume"; } | null>(null); - const agentInputDraft = useAgentInputDraft({ - draftKey: buildDraftStoreKey({ - serverId, - agentId: agentId ?? "__pending__", - }), - }); - const handleFilesDropped = useCallback((files: ImageAttachment[]) => { addImagesRef.current?.(files); }, []); @@ -462,6 +455,13 @@ function ChatAgentContent({ }; }), ); + const agentInputDraft = useAgentInputDraft({ + draftKey: buildDraftStoreKey({ + serverId, + agentId: agentId ?? "__pending__", + }), + initialCwd: agentState.cwd ?? "", + }); const projectPlacement = useStoreWithEqualityFn( useSessionStore, (state) => @@ -968,8 +968,9 @@ function ChatAgentContent({ isPaneFocused={isPaneFocused} value={agentInputDraft.text} onChangeText={agentInputDraft.setText} - images={agentInputDraft.images} - onChangeImages={agentInputDraft.setImages} + attachments={agentInputDraft.attachments} + onChangeAttachments={agentInputDraft.setAttachments} + cwd={agentInputDraft.cwd} clearDraft={agentInputDraft.clear} autoFocus={isPaneFocused} isSubmitLoading={showPendingCreateSubmitLoading} diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index 80ca73cb1..da72b5ce8 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -208,6 +208,7 @@ function DraftAgentScreenContent({ agentId: draftAgentIdRef.current, draftId: draftIdRef.current, }), + initialCwd: resolvedWorkingDir ?? "", composer: { initialServerId: resolvedServerId ?? null, initialValues, @@ -850,7 +851,7 @@ function DraftAgentScreenContent({ labels: {}, }; }, - createRequest: async ({ attempt, text, images }) => { + createRequest: async ({ attempt, text, images, attachments }) => { const trimmedPath = workingDir.trim(); const resolvedWorkingDir = isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : trimmedPath; @@ -896,6 +897,7 @@ function DraftAgentScreenContent({ initialPrompt: text, clientMessageId: attempt.clientMessageId, ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), + ...(attachments && attachments.length > 0 ? { attachments } : {}), git: gitOptions, }); @@ -1201,8 +1203,9 @@ function DraftAgentScreenContent({ blurOnSubmit={true} value={draftInput.text} onChangeText={draftInput.setText} - images={draftInput.images} - onChangeImages={draftInput.setImages} + attachments={draftInput.attachments} + onChangeAttachments={draftInput.setAttachments} + cwd={draftInput.cwd} clearDraft={draftInput.clear} autoFocus={!isSubmitting} onAddImages={handleAddImagesCallback} diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index e65f455f9..bc3ff3bde 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -3,17 +3,14 @@ import { Pressable, Text, View } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { createNameId } from "mnemonic-id"; import { useQuery } from "@tanstack/react-query"; -import { - CircleDot, - ChevronDown, - ExternalLink, - GitBranch, - GitPullRequest, -} from "lucide-react-native"; -import { GitHubIcon } from "@/components/icons/github-icon"; +import { ChevronDown, GitBranch } from "lucide-react-native"; import { Composer } from "@/components/composer"; +import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments"; import { Combobox, ComboboxItem } from "@/components/ui/combobox"; import type { ComboboxOption as ComboboxOptionType } from "@/components/ui/combobox"; +import { SegmentedControl } from "@/components/ui/segmented-control"; +import type { SegmentedControlOption } from "@/components/ui/segmented-control"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { SidebarMenuToggle } from "@/components/headers/menu-header"; import { ScreenHeader } from "@/components/headers/screen-header"; @@ -30,12 +27,10 @@ import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session- import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { encodeImages } from "@/utils/encode-images"; import { toErrorMessage } from "@/utils/error-messages"; -import { openExternalUrl } from "@/utils/open-external-url"; -import { buildGitHubAttachmentFromSearchItem } from "@/utils/review-attachments"; import { requireWorkspaceExecutionAuthority } from "@/utils/workspace-execution"; import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation"; import type { ImageAttachment, MessagePayload } from "@/components/message-input"; -import type { GitHubSearchItem } from "@server/shared/messages"; +import type { AgentAttachment } from "@server/shared/messages"; interface NewWorkspaceScreenProps { serverId: string; @@ -43,6 +38,26 @@ interface NewWorkspaceScreenProps { displayName?: string; } +type BaseRef = { name: string }; + +interface Checkout { + action: "checkout" | "branch-off"; + ref: BaseRef; +} + +function refId(ref: BaseRef): string { + return `branch:${ref.name}`; +} + +function refLabel(ref: BaseRef): string { + return ref.name; +} + +function formatCheckoutBadge(c: Checkout): string { + const label = c.ref.name; + return c.action === "branch-off" ? `new branch off ${label}` : label; +} + export function NewWorkspaceScreen({ serverId, sourceDirectory, @@ -57,13 +72,10 @@ export function NewWorkspaceScreen({ typeof normalizeWorkspaceDescriptor > | null>(null); const [pendingAction, setPendingAction] = useState<"chat" | null>(null); - const [selectedBranch, setSelectedBranch] = useState(null); - const [branchPickerOpen, setBranchPickerOpen] = useState(false); - const branchAnchorRef = useRef(null); - const [selectedGithubItem, setSelectedGithubItem] = useState(null); - const [githubPickerOpen, setGithubPickerOpen] = useState(false); - const [githubSearchQuery, setGithubSearchQuery] = useState(""); - const githubAnchorRef = useRef(null); + const [checkout, setCheckout] = useState(null); + const [checkoutPickerOpen, setCheckoutPickerOpen] = useState(false); + const [pickerAction, setPickerAction] = useState("branch-off"); + const checkoutAnchorRef = useRef(null); const displayName = displayNameProp?.trim() ?? ""; const workspace = createdWorkspace; @@ -71,6 +83,7 @@ export function NewWorkspaceScreen({ const isConnected = useHostRuntimeIsConnected(serverId); const chatDraft = useAgentInputDraft({ draftKey: `new-workspace:${serverId}:${sourceDirectory}`, + initialCwd: sourceDirectory, composer: { initialServerId: serverId || null, initialValues: workspace?.workspaceDirectory @@ -110,89 +123,74 @@ export function NewWorkspaceScreen({ enabled: isConnected && !!client, }); - const githubSearchQuery_trimmed = githubSearchQuery.trim(); - const githubSearchResultsQuery = useQuery({ - queryKey: ["github-search", serverId, sourceDirectory, githubSearchQuery_trimmed], - queryFn: async () => { - const connectedClient = withConnectedClient(); - return connectedClient.searchGitHub({ - cwd: sourceDirectory, - query: githubSearchQuery_trimmed, - limit: 20, - }); - }, - enabled: isConnected && !!client, - staleTime: 30_000, - }); - - const githubSearchOptions: ComboboxOptionType[] = useMemo(() => { - const items = githubSearchResultsQuery.data?.items ?? []; - return items.map((item) => ({ - id: `${item.kind}:${item.number}`, - label: `#${item.number} ${item.title}`, - // Include search query so the Combobox's client-side filter doesn't - // discard server-searched results that match on body but not title. - description: githubSearchQuery_trimmed, - })); - }, [githubSearchResultsQuery.data?.items, githubSearchQuery_trimmed]); - - const handleSelectGithubItem = useCallback( - (id: string) => { - const items = githubSearchResultsQuery.data?.items ?? []; - const [kind, numberStr] = id.split(":"); - const item = items.find((i) => i.kind === kind && i.number === Number(numberStr)); - setSelectedGithubItem(item ?? null); - setGithubPickerOpen(false); - setGithubSearchQuery(""); - }, - [githubSearchResultsQuery.data?.items], - ); - - const branchOptions: ComboboxOptionType[] = useMemo( - () => - (branchSuggestionsQuery.data?.branches ?? []).map((branch) => ({ - id: branch, - label: branch, - })), + const checkoutRefs: BaseRef[] = useMemo( + () => (branchSuggestionsQuery.data?.branches ?? []).map((name): BaseRef => ({ name })), [branchSuggestionsQuery.data?.branches], ); - const ensureWorkspace = useCallback(async () => { - if (createdWorkspace) { - return createdWorkspace; - } + const checkoutOptions: ComboboxOptionType[] = useMemo( + () => + checkoutRefs.map((ref) => ({ + id: refId(ref), + label: refLabel(ref), + })), + [checkoutRefs], + ); - const connectedClient = withConnectedClient(); - const reviewAttachment = buildGitHubAttachmentFromSearchItem(selectedGithubItem); - const payload = await connectedClient.createPaseoWorktree({ - cwd: sourceDirectory, - worktreeSlug: createNameId(), - ...(reviewAttachment ? { attachments: [reviewAttachment] } : {}), - }); + const effectiveCheckout: Checkout | null = + checkout ?? (currentBranch ? { action: "branch-off", ref: { name: currentBranch } } : null); - if (payload.error || !payload.workspace) { - throw new Error(payload.error ?? "Failed to create worktree"); - } + const commitCheckout = useCallback((ref: BaseRef, action: Checkout["action"]) => { + setCheckout({ action, ref }); + setCheckoutPickerOpen(false); + }, []); - const normalizedWorkspace = normalizeWorkspaceDescriptor(payload.workspace); - mergeWorkspaces(serverId, [normalizedWorkspace]); - setCreatedWorkspace(normalizedWorkspace); - return normalizedWorkspace; - }, [ - createdWorkspace, - mergeWorkspaces, - serverId, - selectedGithubItem, - sourceDirectory, - withConnectedClient, - ]); + const openCheckoutPicker = useCallback(() => { + setPickerAction(effectiveCheckout?.action ?? "branch-off"); + setCheckoutPickerOpen(true); + }, [effectiveCheckout]); + + const checkoutModeOptions: SegmentedControlOption[] = useMemo( + () => [ + { value: "checkout", label: "Check out" }, + { value: "branch-off", label: "Branch off" }, + ], + [], + ); + + const ensureWorkspace = useCallback( + async (input: { cwd: string; attachments: AgentAttachment[] }) => { + if (createdWorkspace) { + return createdWorkspace; + } + + const connectedClient = withConnectedClient(); + const payload = await connectedClient.createPaseoWorktree({ + cwd: input.cwd, + worktreeSlug: createNameId(), + ...(input.attachments.length > 0 ? { attachments: input.attachments } : {}), + }); + + if (payload.error || !payload.workspace) { + throw new Error(payload.error ?? "Failed to create worktree"); + } + + const normalizedWorkspace = normalizeWorkspaceDescriptor(payload.workspace); + mergeWorkspaces(serverId, [normalizedWorkspace]); + setCreatedWorkspace(normalizedWorkspace); + return normalizedWorkspace; + }, + [createdWorkspace, mergeWorkspaces, serverId, withConnectedClient], + ); const handleCreateChatAgent = useCallback( - async ({ text, images }: MessagePayload) => { + async ({ text, attachments, cwd }: MessagePayload) => { try { setPendingAction("chat"); setErrorMessage(null); - const workspace = await ensureWorkspace(); + const { images, attachments: reviewAttachments } = + splitComposerAttachmentsForSubmit(attachments); + const workspace = await ensureWorkspace({ cwd, attachments: reviewAttachments }); const connectedClient = withConnectedClient(); if (!composerState) { throw new Error("Composer state is required"); @@ -200,7 +198,6 @@ export function NewWorkspaceScreen({ const initialPrompt = text.trim(); const encodedImages = await encodeImages(images); - const reviewAttachment = buildGitHubAttachmentFromSearchItem(selectedGithubItem); const workspaceDirectory = requireWorkspaceExecutionAuthority({ workspace, }).workspaceDirectory; @@ -217,7 +214,7 @@ export function NewWorkspaceScreen({ : {}), ...(initialPrompt ? { initialPrompt } : {}), ...(encodedImages && encodedImages.length > 0 ? { images: encodedImages } : {}), - ...(reviewAttachment ? { attachments: [reviewAttachment] } : {}), + ...(reviewAttachments.length > 0 ? { attachments: reviewAttachments } : {}), }); setAgents(serverId, (previous) => { @@ -239,15 +236,7 @@ export function NewWorkspaceScreen({ setPendingAction(null); } }, - [ - composerState, - ensureWorkspace, - selectedGithubItem, - serverId, - setAgents, - toast, - withConnectedClient, - ], + [composerState, ensureWorkspace, serverId, setAgents, toast, withConnectedClient], ); const workspaceTitle = @@ -289,15 +278,15 @@ export function NewWorkspaceScreen({ serverId={serverId} isPaneFocused={true} onSubmitMessage={handleCreateChatAgent} - hasExternalContent={selectedGithubItem !== null} allowEmptySubmit={true} submitButtonAccessibilityLabel="Create" isSubmitLoading={pendingAction === "chat"} blurOnSubmit={true} value={chatDraft.text} onChangeText={chatDraft.setText} - images={chatDraft.images} - onChangeImages={chatDraft.setImages} + attachments={chatDraft.attachments} + onChangeAttachments={chatDraft.setAttachments} + cwd={chatDraft.cwd} clearDraft={() => { // No-op: screen navigates away on success, text should stay for retry on error }} @@ -315,166 +304,74 @@ export function NewWorkspaceScreen({ /> - setBranchPickerOpen(true)} - style={({ pressed, hovered }) => [ - styles.badge, - hovered && styles.badgeHovered, - pressed && styles.badgePressed, - ]} - accessibilityRole="button" - accessibilityLabel="Branch" - > - - - {selectedBranch ?? currentBranch ?? "main"} - - - + + + [ + styles.badge, + hovered && styles.badgeHovered, + pressed && styles.badgePressed, + ]} + accessibilityRole="button" + accessibilityLabel="Checkout ref" + > + + + + + {effectiveCheckout ? formatCheckoutBadge(effectiveCheckout) : "main"} + + + + + + Choose where to start from + + setSelectedBranch(id)} + options={checkoutOptions} + value={effectiveCheckout ? refId(effectiveCheckout.ref) : ""} + onSelect={(id) => { + const ref = checkoutRefs.find((r) => refId(r) === id); + if (ref) commitCheckout(ref, pickerAction); + }} searchable searchPlaceholder="Search branches" - title="Branch" - open={branchPickerOpen} - onOpenChange={setBranchPickerOpen} + title="Start from" + open={checkoutPickerOpen} + onOpenChange={setCheckoutPickerOpen} desktopPlacement="bottom-start" - anchorRef={branchAnchorRef} - renderOption={({ option, selected, active, onPress }) => ( - - } - /> - )} - /> - - - setGithubPickerOpen(true)} - style={({ pressed, hovered }) => [ - styles.badge, - hovered && styles.badgeHovered, - pressed && styles.badgePressed, - ]} - accessibilityRole="button" - accessibilityLabel={ - selectedGithubItem - ? `#${selectedGithubItem.number} ${selectedGithubItem.title}` - : "Add GitHub issue or PR" - } - > - {selectedGithubItem ? ( - <> - - {selectedGithubItem.kind === "pr" ? ( - - ) : ( - - )} - - - #{selectedGithubItem.number} {selectedGithubItem.title} - - openExternalUrl(selectedGithubItem.url)} - accessibilityLabel="Open on GitHub" - hitSlop={4} - > - {({ hovered }) => ( - - )} - - - ) : ( - <> - - - - - Issue or PR - - - )} - - { - setGithubPickerOpen(open); - if (!open) setGithubSearchQuery(""); - }} - onSearchQueryChange={setGithubSearchQuery} - desktopPlacement="bottom-start" - anchorRef={githubAnchorRef} - emptyText={ - githubSearchResultsQuery.isFetching ? "Searching..." : "No results found." + anchorRef={checkoutAnchorRef} + emptyText="No branches." + stickyHeader={ + + + } renderOption={({ option, selected, active, onPress }) => { - const item = (githubSearchResultsQuery.data?.items ?? []).find( - (i) => `${i.kind}:${i.number}` === option.id, - ); + const ref = checkoutRefs.find((r) => refId(r) === option.id); + if (!ref) return ; return ( + - ) : ( - - ) - } - trailingSlot={ - item?.url ? ( - openExternalUrl(item.url)} - hitSlop={8} - accessibilityLabel="Open on GitHub" - > - {({ hovered }) => ( - - )} - - ) : undefined + } /> ); @@ -561,12 +458,32 @@ const styles = StyleSheet.create((theme) => ({ badgePressed: { backgroundColor: theme.colors.surface0, }, - badgeIcon: { - flexShrink: 0, - }, badgeText: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, flexShrink: 1, }, + tooltipText: { + fontSize: theme.fontSize.sm, + color: theme.colors.popoverForeground, + }, + checkoutPickerHeader: { + paddingHorizontal: theme.spacing[2], + paddingTop: theme.spacing[2], + paddingBottom: theme.spacing[1], + alignItems: "flex-start", + }, + badgeIconBox: { + width: theme.iconSize.md, + height: theme.iconSize.md, + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + }, + rowIconBox: { + width: theme.iconSize.md, + height: theme.iconSize.md, + alignItems: "center", + justifyContent: "center", + }, })); diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx index 9d16a2ae5..23afbe233 100644 --- a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx +++ b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx @@ -66,6 +66,7 @@ export function WorkspaceDraftAgentTab({ ); const draftInput = useAgentInputDraft({ draftKey: draftStoreKey, + initialCwd: workspaceDirectory ?? "", composer: { initialServerId: serverId, initialValues: workspaceDirectory ? { workingDir: workspaceDirectory } : undefined, @@ -148,7 +149,7 @@ export function WorkspaceDraftAgentTab({ labels: {}, }; }, - createRequest: async ({ attempt, text, images }) => { + createRequest: async ({ attempt, text, images, attachments }) => { invariant(workspaceDirectory, "Workspace directory is required"); invariant(workspaceExecutionAuthority, "Workspace authority is required"); if (!client) { @@ -173,6 +174,7 @@ export function WorkspaceDraftAgentTab({ ...(text ? { initialPrompt: text } : {}), clientMessageId: attempt.clientMessageId, ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), + ...(attachments && attachments.length > 0 ? { attachments } : {}), }); return { @@ -291,8 +293,9 @@ export function WorkspaceDraftAgentTab({ blurOnSubmit={true} value={draftInput.text} onChangeText={draftInput.setText} - images={draftInput.images} - onChangeImages={draftInput.setImages} + attachments={draftInput.attachments} + onChangeAttachments={draftInput.setAttachments} + cwd={draftInput.cwd} clearDraft={draftInput.clear} autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })} onAddImages={handleAddImagesCallback} diff --git a/packages/app/src/stores/draft-store.lifecycle.test.ts b/packages/app/src/stores/draft-store.lifecycle.test.ts index 201801fd9..6d841ff31 100644 --- a/packages/app/src/stores/draft-store.lifecycle.test.ts +++ b/packages/app/src/stores/draft-store.lifecycle.test.ts @@ -7,19 +7,19 @@ describe("draft-store lifecycle", () => { const nowMs = 1_000_000; const drafts = { oldSent: { - input: { text: "", images: [] }, + input: { text: "", attachments: [], cwd: "" }, lifecycle: "sent" as const, updatedAt: 0, version: 2, }, recentAbandoned: { - input: { text: "", images: [] }, + input: { text: "", attachments: [], cwd: "" }, lifecycle: "abandoned" as const, updatedAt: nowMs + 2 * 60 * 1000, version: 2, }, active: { - input: { text: "a", images: [] }, + input: { text: "a", attachments: [], cwd: "/repo" }, lifecycle: "active" as const, updatedAt: 0, version: 1, @@ -41,15 +41,19 @@ describe("draft-store lifecycle", () => { record: { input: { text: "hello", - images: [ + attachments: [ { - id: "att-1", - mimeType: "image/jpeg", - storageType: "web-indexeddb", - storageKey: "att-1", - createdAt: 1, + kind: "image", + metadata: { + id: "att-1", + mimeType: "image/jpeg", + storageType: "web-indexeddb", + storageKey: "att-1", + createdAt: 1, + }, }, ], + cwd: "/repo", }, lifecycle: "active", updatedAt: 1, @@ -60,7 +64,7 @@ describe("draft-store lifecycle", () => { }); expect(cleared).toEqual({ - input: { text: "", images: [] }, + input: { text: "", attachments: [], cwd: "" }, lifecycle: "sent", updatedAt: 2, version: 2, diff --git a/packages/app/src/stores/draft-store.migration.test.ts b/packages/app/src/stores/draft-store.migration.test.ts index cecaf9620..4770ae77c 100644 --- a/packages/app/src/stores/draft-store.migration.test.ts +++ b/packages/app/src/stores/draft-store.migration.test.ts @@ -3,8 +3,8 @@ import { describe, expect, it } from "vitest"; import { __draftStoreTestUtils } from "./draft-store"; describe("draft-store migration", () => { - it("normalizes attachment metadata and strips persisted preview URLs", () => { - const migrated = __draftStoreTestUtils.migratePersistedState({ + it("normalizes legacy image metadata into image attachments and strips persisted preview URLs", async () => { + const migrated = await __draftStoreTestUtils.migratePersistedState({ drafts: { "agent:server:agent": { input: { @@ -28,32 +28,43 @@ describe("draft-store migration", () => { createModalDraft: null, }); - expect(migrated.drafts["agent:server:agent"]?.input.images).toEqual([ - { - id: "att-1", - mimeType: "image/png", - storageType: "desktop-file", - storageKey: "/tmp/att-1.png", - createdAt: 1700000000000, - }, - ]); + expect(migrated.drafts["agent:server:agent"]?.input).toEqual({ + text: "hello", + attachments: [ + { + kind: "image", + metadata: { + id: "att-1", + mimeType: "image/png", + storageType: "desktop-file", + storageKey: "/tmp/att-1.png", + createdAt: 1700000000000, + }, + }, + ], + cwd: "", + }); }); - it("is idempotent for already-migrated shapes", () => { + it("is idempotent for already-migrated shapes", async () => { const original = { drafts: { "agent:server:agent": { input: { text: "hello", - images: [ + attachments: [ { - id: "att-1", - mimeType: "image/jpeg", - storageType: "web-indexeddb", - storageKey: "att-1", - createdAt: 1700000000000, + kind: "image", + metadata: { + id: "att-1", + mimeType: "image/jpeg", + storageType: "web-indexeddb", + storageKey: "att-1", + createdAt: 1700000000000, + }, }, ], + cwd: "/repo", }, lifecycle: "active", updatedAt: 1700000000001, @@ -63,8 +74,8 @@ describe("draft-store migration", () => { createModalDraft: null, }; - const once = __draftStoreTestUtils.migratePersistedState(original); - const twice = __draftStoreTestUtils.migratePersistedState(once); + const once = await __draftStoreTestUtils.migratePersistedState(original); + const twice = await __draftStoreTestUtils.migratePersistedState(once); expect(twice).toEqual(once); }); diff --git a/packages/app/src/stores/draft-store.ts b/packages/app/src/stores/draft-store.ts index a87681c28..410f112fa 100644 --- a/packages/app/src/stores/draft-store.ts +++ b/packages/app/src/stores/draft-store.ts @@ -1,7 +1,8 @@ import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import AsyncStorage from "@react-native-async-storage/async-storage"; -import type { AttachmentMetadata } from "@/attachments/types"; +import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types"; +import { GitHubSearchItemSchema } from "@server/shared/messages"; import { garbageCollectAttachments, persistAttachmentFromDataUrl, @@ -22,16 +23,16 @@ type PersistedDraftImage = AttachmentMetadata | LegacyDraftImage; export interface DraftInput { text: string; - images: AttachmentMetadata[]; + attachments: ComposerAttachment[]; + cwd: string; } export type DraftLifecycleState = "active" | "abandoned" | "sent"; +type CanonicalDraftInput = DraftInput; + interface DraftRecord { - input: { - text: string; - images: PersistedDraftImage[]; - }; + input: CanonicalDraftInput; lifecycle: DraftLifecycleState; updatedAt: number; version: number; @@ -44,7 +45,10 @@ interface DraftStoreState { interface DraftStoreActions { getDraftInput: (draftKey: string) => DraftInput | undefined; - hydrateDraftInput: (draftKey: string) => Promise; + hydrateDraftInput: (input: { + draftKey: string; + initialCwd?: string; + }) => Promise; saveDraftInput: (input: { draftKey: string; draft: DraftInput }) => void; markDraftLifecycle: (input: { draftKey: string; lifecycle: DraftLifecycleState }) => void; clearDraftInput: (input: { @@ -64,20 +68,15 @@ const draftGenerations = new Map(); let gcScheduled = false; function createDraftRecord(input: { - draft: { text: string; images: PersistedDraftImage[] }; + draft: DraftInput; lifecycle: DraftLifecycleState; previousVersion?: number; }): DraftRecord { - const normalizedImages = input.draft.images.map((image) => { - if (isAttachmentMetadata(image)) { - return normalizeAttachmentMetadata(image); - } - return image; - }); return { input: { text: input.draft.text, - images: normalizedImages, + attachments: input.draft.attachments.map(normalizeComposerAttachment), + cwd: input.draft.cwd, }, lifecycle: input.lifecycle, updatedAt: Date.now(), @@ -136,8 +135,56 @@ function normalizePersistedImage(value: unknown): PersistedDraftImage | null { return null; } -function hasLegacyImages(images: readonly PersistedDraftImage[]): boolean { - return images.some((entry) => !isAttachmentMetadata(entry)); +function isComposerAttachment(value: unknown): value is ComposerAttachment { + if (!value || typeof value !== "object") { + return false; + } + const record = value as Record; + if (record.kind === "image") { + const metadata = record.metadata; + return isAttachmentMetadata(metadata); + } + if (record.kind !== "github_issue" && record.kind !== "github_pr") { + return false; + } + return GitHubSearchItemSchema.safeParse(record.item).success; +} + +function normalizeComposerAttachment(attachment: ComposerAttachment): ComposerAttachment { + if (attachment.kind === "image") { + return { + kind: "image", + metadata: normalizeAttachmentMetadata(attachment.metadata), + }; + } + return attachment; +} + +function normalizePersistedComposerAttachment(value: unknown): ComposerAttachment | null { + if (!isComposerAttachment(value)) { + return null; + } + return normalizeComposerAttachment(value); +} + +function legacyImagesToAttachments(images: readonly AttachmentMetadata[]): ComposerAttachment[] { + return images.map((metadata) => ({ + kind: "image", + metadata, + })); +} + +function isCanonicalDraftInput(value: unknown): value is CanonicalDraftInput { + if (!value || typeof value !== "object") { + return false; + } + const input = value as Record; + return ( + typeof input.text === "string" && + Array.isArray(input.attachments) && + input.attachments.every(isComposerAttachment) && + typeof input.cwd === "string" + ); } function toDraftInputIfReady(record: DraftRecord | null | undefined): DraftInput | undefined { @@ -147,12 +194,13 @@ function toDraftInputIfReady(record: DraftRecord | null | undefined): DraftInput if (record.lifecycle !== "active") { return undefined; } - if (hasLegacyImages(record.input.images)) { + if (!isCanonicalDraftInput(record.input)) { return undefined; } return { text: record.input.text, - images: (record.input.images as AttachmentMetadata[]).map(normalizeAttachmentMetadata), + attachments: record.input.attachments.map(normalizeComposerAttachment), + cwd: record.input.cwd, }; } @@ -163,18 +211,21 @@ function collectReferencedAttachmentIdsFromState(state: DraftStoreState): Set { for (const session of Object.values(sessions)) { for (const queue of session.queuedMessages.values()) { for (const queuedMessage of queue) { - for (const image of queuedMessage.images ?? []) { - referencedIds.add(image.id); + for (const attachment of queuedMessage.attachments) { + if (attachment.kind === "image") { + referencedIds.add(attachment.metadata.id); + } } } } @@ -305,12 +358,12 @@ function scheduleAttachmentGc(): void { async function migrateAllLegacyDrafts(): Promise { const state = useDraftStore.getState(); const keys = Object.entries(state.drafts) - .filter(([, record]) => hasLegacyImages(record.input.images)) + .filter(([, record]) => record.lifecycle === "active" && !isCanonicalDraftInput(record.input)) .map(([draftKey]) => draftKey); for (const draftKey of keys) { try { - await state.hydrateDraftInput(draftKey); + await state.hydrateDraftInput({ draftKey }); } catch (error) { console.warn("[DraftStore] Failed to migrate draft during startup", { draftKey, @@ -361,7 +414,34 @@ async function migrateLegacyImages( return migrated.filter((entry): entry is AttachmentMetadata => entry !== null); } -function migratePersistedState(state: unknown): DraftStoreState { +async function migrateDraftInput(input: { + rawInput: unknown; + initialCwd?: string; +}): Promise { + const rawInput = + input.rawInput && typeof input.rawInput === "object" + ? (input.rawInput as Record) + : {}; + const attachments = Array.isArray(rawInput.attachments) + ? rawInput.attachments + .map((entry) => normalizePersistedComposerAttachment(entry)) + .filter((entry): entry is ComposerAttachment => entry !== null) + : []; + const legacyImages = Array.isArray(rawInput.images) + ? rawInput.images + .map((entry) => normalizePersistedImage(entry)) + .filter((entry): entry is PersistedDraftImage => entry !== null) + : []; + const migratedImages = await migrateLegacyImages(legacyImages); + + return { + text: typeof rawInput.text === "string" ? rawInput.text : "", + attachments: [...attachments, ...legacyImagesToAttachments(migratedImages)], + cwd: typeof rawInput.cwd === "string" ? rawInput.cwd : (input.initialCwd ?? ""), + }; +} + +async function migratePersistedState(state: unknown): Promise { const input = (state ?? {}) as { drafts?: Record; createModalDraft?: unknown; @@ -373,82 +453,33 @@ function migratePersistedState(state: unknown): DraftStoreState { continue; } const parsed = rawRecord as Record; + const rawInput = + "input" in parsed && parsed.input && typeof parsed.input === "object" ? parsed.input : parsed; - if ("input" in parsed && parsed.input && typeof parsed.input === "object") { - const recordInput = parsed.input as Record; - nextDrafts[draftKey] = { - input: { - text: typeof recordInput.text === "string" ? recordInput.text : "", - images: Array.isArray(recordInput.images) - ? recordInput.images - .map((entry) => normalizePersistedImage(entry)) - .filter((entry): entry is PersistedDraftImage => entry !== null) - : [], - }, - lifecycle: - parsed.lifecycle === "sent" || parsed.lifecycle === "abandoned" - ? (parsed.lifecycle as DraftLifecycleState) - : "active", - updatedAt: typeof parsed.updatedAt === "number" ? parsed.updatedAt : Date.now(), - version: typeof parsed.version === "number" ? parsed.version : 1, - }; - continue; - } - - // Legacy shape: { text, images }. - const legacy = parsed as { text?: unknown; images?: unknown }; nextDrafts[draftKey] = { - input: { - text: typeof legacy.text === "string" ? legacy.text : "", - images: Array.isArray(legacy.images) - ? legacy.images - .map((entry) => normalizePersistedImage(entry)) - .filter((entry): entry is PersistedDraftImage => entry !== null) - : [], - }, - lifecycle: "active", - updatedAt: Date.now(), - version: 1, + input: await migrateDraftInput({ rawInput }), + lifecycle: + parsed.lifecycle === "sent" || parsed.lifecycle === "abandoned" + ? (parsed.lifecycle as DraftLifecycleState) + : "active", + updatedAt: typeof parsed.updatedAt === "number" ? parsed.updatedAt : Date.now(), + version: typeof parsed.version === "number" ? parsed.version : 1, }; } let createModalDraft: DraftRecord | null = null; if (input.createModalDraft && typeof input.createModalDraft === "object") { const record = input.createModalDraft as Record; - if (record.input && typeof record.input === "object") { - const recordInput = record.input as Record; - createModalDraft = { - input: { - text: typeof recordInput.text === "string" ? recordInput.text : "", - images: Array.isArray(recordInput.images) - ? recordInput.images - .map((entry) => normalizePersistedImage(entry)) - .filter((entry): entry is PersistedDraftImage => entry !== null) - : [], - }, - lifecycle: - record.lifecycle === "sent" || record.lifecycle === "abandoned" - ? (record.lifecycle as DraftLifecycleState) - : "active", - updatedAt: typeof record.updatedAt === "number" ? record.updatedAt : Date.now(), - version: typeof record.version === "number" ? record.version : 1, - }; - } else { - const legacy = record as { text?: unknown; images?: unknown }; - createModalDraft = { - input: { - text: typeof legacy.text === "string" ? legacy.text : "", - images: Array.isArray(legacy.images) - ? legacy.images - .map((entry) => normalizePersistedImage(entry)) - .filter((entry): entry is PersistedDraftImage => entry !== null) - : [], - }, - lifecycle: "active", - updatedAt: Date.now(), - version: 1, - }; - } + const rawInput = record.input && typeof record.input === "object" ? record.input : record; + createModalDraft = { + input: await migrateDraftInput({ rawInput }), + lifecycle: + record.lifecycle === "sent" || record.lifecycle === "abandoned" + ? (record.lifecycle as DraftLifecycleState) + : "active", + updatedAt: typeof record.updatedAt === "number" ? record.updatedAt : Date.now(), + version: typeof record.version === "number" ? record.version : 1, + }; } return { @@ -468,7 +499,7 @@ export const useDraftStore = create()( return toDraftInputIfReady(record); }, - hydrateDraftInput: async (draftKey) => { + hydrateDraftInput: async ({ draftKey, initialCwd }) => { const current = get().drafts[draftKey]; if (!current) { return undefined; @@ -481,11 +512,10 @@ export const useDraftStore = create()( return ready; } - const migratedImages = await migrateLegacyImages(current.input.images); - const migratedDraft: DraftInput = { - text: current.input.text, - images: migratedImages, - }; + const migratedDraft = await migrateDraftInput({ + rawInput: current.input, + initialCwd, + }); set((state) => { const existing = state.drafts[draftKey]; @@ -627,6 +657,7 @@ export const useDraftStore = create()( ); export const __draftStoreTestUtils = { + migrateDraftInput, migratePersistedState, normalizeAttachmentMetadata, pruneFinalizedDraftRecords, diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index e4242cb31..f259adb30 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -4,7 +4,7 @@ import type { DaemonClient } from "@server/client/daemon-client"; import type { AgentDirectoryEntry } from "@/types/agent-directory"; import type { StreamItem } from "@/types/stream"; import type { PendingPermission } from "@/types/shared"; -import type { AttachmentMetadata } from "@/attachments/types"; +import type { ComposerAttachment } from "@/attachments/types"; import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle"; import type { AgentPermissionResponse, @@ -263,7 +263,10 @@ export interface SessionState { fileExplorer: Map; // Queued messages - queuedMessages: Map>; + queuedMessages: Map< + string, + Array<{ id: string; text: string; attachments: ComposerAttachment[] }> + >; } // Global store state @@ -379,10 +382,10 @@ interface SessionStoreActions { setQueuedMessages: ( serverId: string, value: - | Map> + | Map> | (( - prev: Map>, - ) => Map>), + prev: Map>, + ) => Map>), ) => void; // Hydration