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.
This commit is contained in:
Mohamed Boudra
2026-04-17 11:36:08 +07:00
parent 11524dc349
commit 3838a8b969
25 changed files with 2994 additions and 735 deletions
+7
View File
@@ -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 }
@@ -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");
});
@@ -1,32 +1,32 @@
export type AgentInputSubmitResult = "noop" | "queued" | "submitted" | "failed";
export interface AgentInputSubmitActionInput<TImage> {
export interface AgentInputSubmitActionInput<TAttachment> {
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<void>;
queueMessage: (input: { message: string; attachments: TAttachment[] }) => void;
submitMessage: (input: { message: string; attachments: TAttachment[] }) => Promise<void>;
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<TImage>(
input: AgentInputSubmitActionInput<TImage>,
export async function submitAgentInput<TAttachment>(
input: AgentInputSubmitActionInput<TAttachment>,
): Promise<AgentInputSubmitResult> {
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<TImage>(
}
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";
@@ -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<string, unknown>) =>
React.createElement("span", { ...props, "data-icon": name });
return {
X: createIcon("X"),
};
});
vi.mock("expo-image", () => ({
Image: (props: Record<string, unknown>) => {
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<Record<string, unknown>>("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("<!doctype html><html><body></body></html>");
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(<AttachmentLightbox metadata={null} onClose={vi.fn()} />);
expect(queryByTestId("attachment-lightbox-backdrop")).toBeNull();
expect(queryByTestId("attachment-lightbox-image")).toBeNull();
});
it("renders the image when metadata is provided", () => {
render(<AttachmentLightbox metadata={imageMetadata} onClose={vi.fn()} />);
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(<AttachmentLightbox metadata={imageMetadata} onClose={vi.fn()} />);
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(<AttachmentLightbox metadata={imageMetadata} onClose={onClose} />);
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(<AttachmentLightbox metadata={imageMetadata} onClose={onClose} />);
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(<AttachmentLightbox metadata={imageMetadata} onClose={vi.fn()} />);
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(<AttachmentLightbox metadata={imageMetadata} onClose={onClose} />);
act(() => {
window.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Escape" }));
});
expect(onClose).toHaveBeenCalledTimes(1);
});
});
@@ -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 (
<Modal transparent animationType="fade" statusBarTranslucent visible onRequestClose={onClose}>
<View style={styles.root}>
<Pressable
testID="attachment-lightbox-backdrop"
accessibilityRole="button"
accessibilityLabel="Dismiss image"
onPress={onClose}
style={styles.backdrop}
/>
<View style={styles.contentLayer}>
<View style={styles.imageArea}>
{hasError ? (
<Text style={styles.errorText}>Couldn't load image</Text>
) : (
<Pressable onPress={() => {}} style={styles.imagePressable}>
<ExpoImage
testID="attachment-lightbox-image"
source={{ uri: url }}
contentFit="contain"
onError={() => setErrored(true)}
style={imageFillStyle}
/>
</Pressable>
)}
</View>
<Pressable
testID="attachment-lightbox-close"
accessibilityRole="button"
accessibilityLabel="Close image"
hitSlop={8}
onPress={onClose}
style={[
styles.closeButton,
{
top: insets.top + theme.spacing[3],
right: insets.right + theme.spacing[3],
},
]}
>
<X size={16} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
</View>
</Modal>
);
}
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,
},
}));
@@ -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 (
<View style={styles.wrapper}>
<Pressable
testID={testID}
onPress={onOpen}
accessibilityRole="button"
accessibilityLabel={openAccessibilityLabel}
style={styles.body}
>
{children}
</Pressable>
<Pressable
onPress={onRemove}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={removeAccessibilityLabel}
style={styles.closeButton}
>
<X size={12} color={theme.colors.foregroundMuted} />
</Pressable>
</View>
);
}
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,
},
}));
@@ -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,
};
}
@@ -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<string, { status: string; lastUsage: null }>;
queuedMessages: Map<string, unknown[]>;
agentStreamHead: Map<string, unknown[]>;
agentStreamTail: Map<string, unknown[]>;
}
>;
setQueuedMessages: ReturnType<typeof vi.fn>;
setAgentStreamTail?: ReturnType<typeof vi.fn>;
setAgentStreamHead?: ReturnType<typeof vi.fn>;
} = {
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<string, unknown[]>) => Map<string, unknown[]>) => {
const session = mockSessionState.sessions[serverId];
session.agentStreamTail = updater(session.agentStreamTail);
},
);
const setAgentStreamHeadMock = vi.fn(
(serverId: string, updater: (prev: Map<string, unknown[]>) => Map<string, unknown[]>) => {
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<string, unknown>) =>
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<string, unknown>) => {
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<Record<string, unknown>>("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}</>
) : (
<button type="button" aria-label={accessibilityLabel} disabled={disabled} onClick={onPress}>
{typeof children === "function" ? children({ hovered: false }) : children}
</button>
),
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 (
<DropdownContext.Provider value={{ open, setOpen }}>{children}</DropdownContext.Provider>
);
},
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 (
<button
type="button"
data-testid={testID}
aria-label={accessibilityLabel}
disabled={disabled}
onClick={() => menu?.setOpen(true)}
>
{typeof children === "function"
? children({ hovered: false, pressed: false, open: menu?.open ?? false })
: children}
</button>
);
},
DropdownMenuContent: ({ children, testID }: { children: React.ReactNode; testID?: string }) => {
const menu = React.useContext(DropdownContext);
return menu?.open ? <div data-testid={testID}>{children}</div> : null;
},
DropdownMenuItem: ({
children,
onSelect,
testID,
disabled,
}: {
children: React.ReactNode;
onSelect?: () => void;
testID?: string;
disabled?: boolean;
}) => (
<button type="button" data-testid={testID} disabled={disabled} onClick={onSelect}>
{children}
</button>
),
};
});
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<unknown>;
}) =>
open ? (
<div
data-testid="composer-github-combobox"
data-anchor={anchorRef.current ? "attached" : "missing"}
>
{options.map((option) =>
renderOption ? (
renderOption({ option, selected: false, active: false, onPress: vi.fn() })
) : (
<button type="button" key={option.id}>
{option.label}
</button>
),
)}
</div>
) : null,
ComboboxItem: ({
label,
selected,
onPress,
testID,
}: {
label: string;
selected?: boolean;
onPress: () => void;
testID?: string;
}) => (
<button
type="button"
data-testid={testID}
data-selected={selected ? "true" : "false"}
onClick={onPress}
>
{label}
</button>
),
}));
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("<!doctype html><html><body></body></html>");
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 (
<QueryClientProvider client={queryClient!}>
<Composer
agentId="agent"
serverId="server"
isPaneFocused
value={text}
onChangeText={setText}
attachments={attachments}
onChangeAttachments={(updater) => {
setAttachments((current) => {
const next = typeof updater === "function" ? updater(current) : updater;
latestAttachments = next;
return next;
});
}}
cwd="/repo"
clearDraft={vi.fn()}
/>
</QueryClientProvider>
);
}
function renderComposer(
input: { initialText?: string; initialAttachments?: ComposerAttachment[] } = {},
) {
act(() => {
root?.render(<ComposerHarness {...input} />);
});
}
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<HTMLElement> {
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<HTMLElement> {
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",
},
],
});
});
});
+316 -53
View File
@@ -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 <View style={styles.imageThumbnailPlaceholder} />;
}
return <Image source={{ uri }} style={styles.imageThumbnail} />;
}
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<string | null>(null);
const [isMessageInputFocused, setIsMessageInputFocused] = useState(false);
const [isGithubPickerOpen, setIsGithubPickerOpen] = useState(false);
const [githubSearchQuery, setGithubSearchQuery] = useState("");
const [lightboxMetadata, setLightboxMetadata] = useState<AttachmentMetadata | null>(null);
const attachButtonRef = useRef<View | null>(null);
const messageInputRef = useRef<MessageInputRef>(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<void>) | null
((agentId: string, text: string, attachments: ComposerAttachment[]) => Promise<void>) | 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<string, QueuedMessage[]>) => {
@@ -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({
</View>
);
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<AttachmentMenuItem[]>(
() => [
{
id: "image",
label: "Add image",
icon: <Paperclip size={theme.iconSize.md} color={theme.colors.foregroundMuted} />,
onSelect: () => {
void handlePickImage();
},
},
{
id: "github",
label: "Add issue or PR",
icon: <Github size={theme.iconSize.md} color={theme.colors.foregroundMuted} />,
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 ? (
<DraftAgentStatusBar {...statusControls} />
@@ -697,6 +813,7 @@ export function Composer({
<Animated.View
style={[styles.container, { paddingBottom: insets.bottom }, keyboardAnimatedStyle]}
>
<AttachmentLightbox metadata={lightboxMetadata} onClose={() => setLightboxMetadata(null)} />
{/* Input area */}
<View style={styles.inputAreaContainer}>
<View style={styles.inputAreaContent}>
@@ -745,6 +862,59 @@ export function Composer({
</View>
)}
{selectedAttachments.length > 0 ? (
<View style={styles.attachmentPreviewContainer} testID="composer-attachment-pills">
{selectedAttachments.map((attachment, index) => {
if (attachment.kind === "image") {
return (
<AttachmentPill
key={`${attachment.metadata.id}-${index}`}
testID="composer-image-attachment-pill"
onOpen={() => handleOpenAttachment(attachment)}
onRemove={() => handleRemoveAttachment(index)}
openAccessibilityLabel="Open image attachment"
removeAccessibilityLabel="Remove image attachment"
>
<ImageAttachmentThumbnail image={attachment.metadata} />
</AttachmentPill>
);
}
const item = attachment.item;
const kindLabel = item.kind === "pr" ? "PR" : "issue";
return (
<AttachmentPill
key={`${item.kind}:${item.number}`}
testID="composer-github-attachment-pill"
onOpen={() => handleOpenAttachment(attachment)}
onRemove={() => handleRemoveAttachment(index)}
openAccessibilityLabel={`Open ${kindLabel} #${item.number}`}
removeAccessibilityLabel={`Remove ${kindLabel} #${item.number}`}
>
<View style={styles.githubPillBody}>
<View style={styles.githubPillIcon}>
{item.kind === "pr" ? (
<GitPullRequest
size={theme.iconSize.sm}
color={theme.colors.foregroundMuted}
/>
) : (
<CircleDot
size={theme.iconSize.sm}
color={theme.colors.foregroundMuted}
/>
)}
</View>
<Text style={styles.githubPillText} numberOfLines={1}>
#{item.number} {item.title}
</Text>
</View>
</AttachmentPill>
);
})}
</View>
) : null}
{/* MessageInput handles everything: text, dictation, attachments, all buttons */}
<MessageInput
ref={messageInputRef}
@@ -756,10 +926,13 @@ export function Composer({
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
isSubmitDisabled={isProcessing || isSubmitLoading}
isSubmitLoading={isProcessing || isSubmitLoading}
images={selectedImages}
onPickImages={handlePickImage}
attachments={selectedAttachments}
cwd={cwd}
attachmentMenuItems={attachmentMenuItems}
onAttachButtonRef={(node) => {
attachButtonRef.current = node;
}}
onAddImages={addImages}
onRemoveImage={handleRemoveImage}
client={client}
isReadyForDictation={isDictationReady}
placeholder={messagePlaceholder}
@@ -789,6 +962,60 @@ export function Composer({
onHeightChange={onComposerHeightChange}
inputWrapperStyle={inputWrapperStyle}
/>
<Combobox
options={githubSearchOptions}
value=""
onSelect={() => {}}
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 <View key={option.id} />;
}
const selected = selectedAttachments.some(
(attachment) =>
attachment.kind !== "image" &&
attachment.item.kind === item.kind &&
attachment.item.number === item.number,
);
return (
<ComboboxItem
key={option.id}
testID={`composer-github-option-${option.id}`}
label={option.label}
selected={selected}
active={active}
onPress={() => handleToggleGithubItem(item)}
leadingSlot={
item.kind === "pr" ? (
<GitPullRequest
size={theme.iconSize.sm}
color={theme.colors.foregroundMuted}
/>
) : (
<CircleDot size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
)
}
/>
);
}}
/>
</View>
</View>
</View>
@@ -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",
@@ -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<string, unknown>) =>
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<string, unknown> }) => 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<string, any>) =>
asChild ? (
<>{children}</>
) : (
<button type="button" aria-label={props.accessibilityLabel}>
{typeof children === "function" ? children({ hovered: false }) : children}
</button>
),
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;
}) => (
<button type="button" data-testid={testID} aria-label={accessibilityLabel}>
{typeof children === "function"
? children({ hovered: false, pressed: false, open: false })
: children}
</button>
),
DropdownMenuContent: ({ children, testID }: { children: React.ReactNode; testID?: string }) => (
<div data-testid={testID}>{children}</div>
),
DropdownMenuItem: ({
children,
onSelect,
testID,
disabled,
}: {
children: React.ReactNode;
onSelect?: () => void;
testID?: string;
disabled?: boolean;
}) => (
<button type="button" data-testid={testID} disabled={disabled} onClick={onSelect}>
{children}
</button>
),
}));
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("<!doctype html><html><body></body></html>");
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(
<MessageInput
value=""
onChangeText={vi.fn()}
onSubmit={vi.fn()}
attachments={[]}
cwd="/repo"
attachmentMenuItems={menuItems}
client={{ isConnected: true } as never}
isAgentRunning
onQueue={vi.fn()}
/>,
);
});
}
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<HTMLElement> {
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);
});
});
+84 -152
View File
@@ -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 <View style={styles.imageThumbnailPlaceholder} />;
}
return <Image source={{ uri }} style={styles.imageThumbnail} />;
}
export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(function MessageInput(
{
value,
@@ -212,10 +219,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(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<MessageInputRef, MessageInputProps>(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<MessageInputRef, MessageInputProps>(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<MessageInputRef, MessageInputProps>(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<MessageInputRef, MessageInputProps>(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<MessageInputRef, MessageInputProps>(funct
}, [
allowEmptySubmit,
value,
images,
attachments,
cwd,
onSubmit,
isAgentRunning,
onHeightChange,
@@ -605,17 +614,18 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(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<MessageInputRef, MessageInputProps>(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<MessageInputRef, MessageInputProps>(funct
ref={inputWrapperRef}
style={[styles.inputWrapper, inputWrapperStyle, inputAnimatedStyle]}
>
{/* Image preview pills */}
{hasImages && (
<View style={styles.imagePreviewContainer} testID="message-input-image-preview">
{images.map((image, index) => (
<Pressable
key={`${image.id}-${index}`}
testID="message-input-image-pill"
style={styles.imagePill}
onPress={onRemoveImage ? () => onRemoveImage(index) : undefined}
>
{({ hovered }) => (
<>
<ImageAttachmentThumbnail image={image} />
{onRemoveImage && (
<View
style={[
styles.removeImageButton,
(hovered || !isWeb) && styles.removeImageButtonVisible,
]}
>
<X size={theme.iconSize.md} color="white" />
</View>
)}
</>
)}
</Pressable>
))}
</View>
)}
{/* Text input */}
<View style={styles.textInputScrollWrapper}>
<TextInput
@@ -1071,14 +1051,14 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
<View style={styles.buttonRow}>
{/* Left: attachment button + leftContent slot */}
<View style={styles.leftButtonGroup}>
{onPickImages && (
<DropdownMenu>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<Pressable
onPress={onPickImages}
<DropdownMenuTrigger
disabled={!isConnected || disabled}
accessibilityLabel="Attach images"
accessibilityLabel="Add attachment"
accessibilityRole="button"
testID="message-input-attach-button"
style={({ hovered }) => [
styles.attachButton,
hovered && styles.iconButtonHovered,
@@ -1086,18 +1066,43 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
]}
>
{({ hovered }) => (
<Paperclip
size={buttonIconSize}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
<View
ref={onAttachButtonRef}
collapsable={false}
style={styles.attachButtonAnchor}
>
<Plus
size={buttonIconSize}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
</View>
)}
</Pressable>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>Attach images</Text>
<Text style={styles.tooltipText}>Add attachment</Text>
</TooltipContent>
</Tooltip>
)}
<DropdownMenuContent
side="top"
align="start"
offset={8}
minWidth={220}
testID="message-input-attachment-menu"
>
{attachmentMenuItems.map((item) => (
<DropdownMenuItem
key={item.id}
testID={`message-input-attachment-menu-item-${item.id}`}
disabled={item.disabled}
onSelect={item.onSelect}
leading={item.icon ?? null}
>
{item.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{leftContent}
</View>
@@ -1164,31 +1169,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
</TooltipContent>
</Tooltip>
{rightContent}
{hasSendableContent && isAgentRunning && onQueue && !defaultActionQueues && (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleAlternateSendAction}
disabled={!isConnected || disabled}
accessibilityLabel="Queue message"
accessibilityRole="button"
style={({ hovered }) => [
styles.queueButton,
hovered && styles.iconButtonHovered,
(!isConnected || disabled) && styles.buttonDisabled,
]}
>
<Plus size={buttonIconSize} color="white" />
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Queue</Text>
{queueKeys ? (
<Shortcut chord={queueKeys} style={styles.tooltipShortcut} />
) : null}
</View>
</TooltipContent>
</Tooltip>
)}
{shouldShowSendButton && (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
@@ -1276,52 +1256,6 @@ const styles = StyleSheet.create(((theme: any) => ({
}
: {}),
},
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,
+8 -2
View File
@@ -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<View | null>;
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" ? (
<ScrollView
@@ -13,6 +13,7 @@ import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { encodeImages } from "@/utils/encode-images";
import { toErrorMessage } from "@/utils/error-messages";
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import { requireWorkspaceExecutionAuthority } from "@/utils/workspace-execution";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
@@ -47,6 +48,7 @@ export function WorkspaceSetupDialog() {
const isConnected = useHostRuntimeIsConnected(serverId);
const chatDraft = useAgentInputDraft({
draftKey: `workspace-setup:${serverId}:${sourceDirectory}`,
initialCwd: sourceDirectory,
composer: {
initialServerId: serverId || null,
initialValues: workspace?.workspaceDirectory
@@ -105,47 +107,54 @@ export function WorkspaceSetupDialog() {
return client;
}, [client, isConnected]);
const ensureWorkspace = useCallback(async () => {
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}
@@ -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);
@@ -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<string, string>(),
@@ -92,6 +92,7 @@ vi.mock("./use-agent-form-state", () => ({
}));
let useAgentInputDraft: typeof import("./use-agent-input-draft").useAgentInputDraft;
type DraftRecordForTest = ReturnType<typeof useDraftStore.getState>["drafts"][string];
beforeAll(async () => {
const storage = new Map<string, string>();
@@ -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<typeof useAgentInputDraft> | 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<typeof useAgentInputDraft> | 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<typeof useAgentInputDraft> {
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(
<QueryClientProvider client={queryClient}>
<Probe />
</QueryClientProvider>,
);
});
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<typeof useAgentInputDraft> | 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<typeof useAgentInputDraft> {
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(
<QueryClientProvider client={queryClient}>
<Probe />
</QueryClientProvider>,
);
});
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<typeof useAgentInputDraft> | null = null;
function getLatest(): ReturnType<typeof useAgentInputDraft> {
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(
<QueryClientProvider client={queryClient}>
<Probe />
</QueryClientProvider>,
);
});
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<typeof useAgentInputDraft> | null = null;
function getLatest(): ReturnType<typeof useAgentInputDraft> {
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(
<QueryClientProvider client={queryClient}>
<Probe />
</QueryClientProvider>,
);
});
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<typeof useAgentInputDraft> | null = null;
const image: AttachmentMetadata = {
id: "next-image",
mimeType: "image/png",
storageType: "web-indexeddb",
storageKey: "attachments/next-image",
createdAt: 11,
};
function getLatest(): ReturnType<typeof useAgentInputDraft> {
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(
<QueryClientProvider client={queryClient}>
<Probe />
</QueryClientProvider>,
);
});
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<typeof useAgentInputDraft> | null = null;
const image: AttachmentMetadata = {
id: "clear-image",
mimeType: "image/png",
storageType: "web-indexeddb",
storageKey: "attachments/clear-image",
createdAt: 12,
};
function getLatest(): ReturnType<typeof useAgentInputDraft> {
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(
<QueryClientProvider client={queryClient}>
<Probe />
</QueryClientProvider>,
);
});
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: "" },
});
});
});
+52 -34
View File
@@ -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<AttachmentMetadata[]>([]);
const [attachments, setAttachmentsState] = useState<ComposerAttachment[]>([]);
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,
@@ -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<TCreateResult> = {
type SubmitContext = {
text: string;
images?: UserMessageImageAttachment[];
attachments: ComposerAttachment[];
cwd: string;
};
type CreateRequestContext = {
attempt: CreateAttempt;
text: string;
images?: UserMessageImageAttachment[];
attachments?: AgentAttachment[];
cwd: string;
};
interface UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult> {
@@ -137,12 +143,14 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
}, [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<TDraftAgent, TCreateResult>({
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<TDraftAgent, TCreateResult>({
...(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<TDraftAgent, TCreateResult>({
attempt,
text: trimmedPrompt,
images,
attachments: wirePayload.attachments,
cwd,
});
if (createResult.agentId) {
+10 -9
View File
@@ -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}
@@ -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}
+168 -251
View File
@@ -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<string | null>(null);
const [branchPickerOpen, setBranchPickerOpen] = useState(false);
const branchAnchorRef = useRef<View>(null);
const [selectedGithubItem, setSelectedGithubItem] = useState<GitHubSearchItem | null>(null);
const [githubPickerOpen, setGithubPickerOpen] = useState(false);
const [githubSearchQuery, setGithubSearchQuery] = useState("");
const githubAnchorRef = useRef<View>(null);
const [checkout, setCheckout] = useState<Checkout | null>(null);
const [checkoutPickerOpen, setCheckoutPickerOpen] = useState(false);
const [pickerAction, setPickerAction] = useState<Checkout["action"]>("branch-off");
const checkoutAnchorRef = useRef<View>(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<Checkout["action"]>[] = 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({
/>
<View style={styles.optionsRow}>
<View>
<Pressable
ref={branchAnchorRef}
onPress={() => setBranchPickerOpen(true)}
style={({ pressed, hovered }) => [
styles.badge,
hovered && styles.badgeHovered,
pressed && styles.badgePressed,
]}
accessibilityRole="button"
accessibilityLabel="Branch"
>
<GitBranch size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
<Text style={styles.badgeText} numberOfLines={1}>
{selectedBranch ?? currentBranch ?? "main"}
</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
<Tooltip>
<TooltipTrigger asChild triggerRefProp="ref">
<Pressable
ref={checkoutAnchorRef}
onPress={openCheckoutPicker}
style={({ pressed, hovered }) => [
styles.badge,
hovered && styles.badgeHovered,
pressed && styles.badgePressed,
]}
accessibilityRole="button"
accessibilityLabel="Checkout ref"
>
<View style={styles.badgeIconBox}>
<GitBranch size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</View>
<Text style={styles.badgeText} numberOfLines={1}>
{effectiveCheckout ? formatCheckoutBadge(effectiveCheckout) : "main"}
</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>Choose where to start from</Text>
</TooltipContent>
</Tooltip>
<Combobox
options={branchOptions}
value={selectedBranch ?? currentBranch ?? ""}
onSelect={(id) => 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 }) => (
<ComboboxItem
key={option.id}
label={option.label}
selected={selected}
active={active}
onPress={onPress}
leadingSlot={
<GitBranch size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
}
/>
)}
/>
</View>
<View>
<Pressable
ref={githubAnchorRef}
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 ? (
<>
<View style={styles.badgeIcon}>
{selectedGithubItem.kind === "pr" ? (
<GitPullRequest
size={theme.iconSize.md}
color={theme.colors.foregroundMuted}
/>
) : (
<CircleDot size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
)}
</View>
<Text style={styles.badgeText} numberOfLines={1}>
#{selectedGithubItem.number} {selectedGithubItem.title}
</Text>
<Pressable
onPress={() => openExternalUrl(selectedGithubItem.url)}
accessibilityLabel="Open on GitHub"
hitSlop={4}
>
{({ hovered }) => (
<ExternalLink
size={theme.iconSize.sm}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
</>
) : (
<>
<View style={styles.badgeIcon}>
<GitHubIcon size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</View>
<Text style={styles.badgeText} numberOfLines={1}>
Issue or PR
</Text>
</>
)}
</Pressable>
<Combobox
options={githubSearchOptions}
value={
selectedGithubItem
? `${selectedGithubItem.kind}:${selectedGithubItem.number}`
: ""
}
onSelect={handleSelectGithubItem}
searchable
searchPlaceholder="Search issues and PRs..."
title="GitHub"
open={githubPickerOpen}
onOpenChange={(open) => {
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={
<View style={styles.checkoutPickerHeader}>
<SegmentedControl
size="sm"
options={checkoutModeOptions}
value={pickerAction}
onValueChange={setPickerAction}
/>
</View>
}
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 <View key={option.id} />;
return (
<ComboboxItem
key={option.id}
label={option.label}
label={refLabel(ref)}
selected={selected}
active={active}
onPress={onPress}
leadingSlot={
item?.kind === "pr" ? (
<GitPullRequest
<View style={styles.rowIconBox}>
<GitBranch
size={theme.iconSize.sm}
color={theme.colors.foregroundMuted}
/>
) : (
<CircleDot
size={theme.iconSize.sm}
color={theme.colors.foregroundMuted}
/>
)
}
trailingSlot={
item?.url ? (
<Pressable
onPress={() => openExternalUrl(item.url)}
hitSlop={8}
accessibilityLabel="Open on GitHub"
>
{({ hovered }) => (
<ExternalLink
size={theme.iconSize.sm}
color={
hovered ? theme.colors.foreground : theme.colors.foregroundMuted
}
/>
)}
</Pressable>
) : undefined
</View>
}
/>
);
@@ -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",
},
}));
@@ -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}
@@ -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,
@@ -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);
});
+137 -106
View File
@@ -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<DraftInput | undefined>;
hydrateDraftInput: (input: {
draftKey: string;
initialCwd?: string;
}) => Promise<DraftInput | undefined>;
saveDraftInput: (input: { draftKey: string; draft: DraftInput }) => void;
markDraftLifecycle: (input: { draftKey: string; lifecycle: DraftLifecycleState }) => void;
clearDraftInput: (input: {
@@ -64,20 +68,15 @@ const draftGenerations = new Map<string, number>();
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<string, unknown>;
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<string, unknown>;
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<st
if (draftRecord.lifecycle !== "active") {
continue;
}
for (const image of draftRecord.input.images) {
if (isAttachmentMetadata(image)) {
referencedIds.add(image.id);
if (!isCanonicalDraftInput(draftRecord.input)) {
continue;
}
for (const attachment of draftRecord.input.attachments) {
if (attachment.kind === "image") {
referencedIds.add(attachment.metadata.id);
}
}
}
const modalRecord = state.createModalDraft;
if (modalRecord?.lifecycle === "active") {
for (const image of modalRecord.input.images) {
if (isAttachmentMetadata(image)) {
referencedIds.add(image.id);
if (modalRecord?.lifecycle === "active" && isCanonicalDraftInput(modalRecord.input)) {
for (const attachment of modalRecord.input.attachments) {
if (attachment.kind === "image") {
referencedIds.add(attachment.metadata.id);
}
}
}
@@ -209,7 +260,7 @@ function applyClearDraftRecord(input: {
return {
...input.record,
input: { text: "", images: [] },
input: { text: "", attachments: [], cwd: "" },
lifecycle: input.lifecycle,
updatedAt: input.nowMs,
version: input.record.version + 1,
@@ -250,8 +301,10 @@ async function runAttachmentGc(): Promise<void> {
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<void> {
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<CanonicalDraftInput> {
const rawInput =
input.rawInput && typeof input.rawInput === "object"
? (input.rawInput as Record<string, unknown>)
: {};
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<DraftStoreState> {
const input = (state ?? {}) as {
drafts?: Record<string, unknown>;
createModalDraft?: unknown;
@@ -373,82 +453,33 @@ function migratePersistedState(state: unknown): DraftStoreState {
continue;
}
const parsed = rawRecord as Record<string, unknown>;
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<string, unknown>;
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<string, unknown>;
if (record.input && typeof record.input === "object") {
const recordInput = record.input as Record<string, unknown>;
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<DraftStore>()(
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<DraftStore>()(
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<DraftStore>()(
);
export const __draftStoreTestUtils = {
migrateDraftInput,
migratePersistedState,
normalizeAttachmentMetadata,
pruneFinalizedDraftRecords,
+8 -5
View File
@@ -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<string, AgentFileExplorerState>;
// Queued messages
queuedMessages: Map<string, Array<{ id: string; text: string; images?: AttachmentMetadata[] }>>;
queuedMessages: Map<
string,
Array<{ id: string; text: string; attachments: ComposerAttachment[] }>
>;
}
// Global store state
@@ -379,10 +382,10 @@ interface SessionStoreActions {
setQueuedMessages: (
serverId: string,
value:
| Map<string, Array<{ id: string; text: string; images?: AttachmentMetadata[] }>>
| Map<string, Array<{ id: string; text: string; attachments: ComposerAttachment[] }>>
| ((
prev: Map<string, Array<{ id: string; text: string; images?: AttachmentMetadata[] }>>,
) => Map<string, Array<{ id: string; text: string; images?: AttachmentMetadata[] }>>),
prev: Map<string, Array<{ id: string; text: string; attachments: ComposerAttachment[] }>>,
) => Map<string, Array<{ id: string; text: string; attachments: ComposerAttachment[] }>>),
) => void;
// Hydration