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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user