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:
@@ -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",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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: "" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user