mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
refactor(app): extract composer-actions module with pure tests
Move composer cancel/queue/attachment/dispatch orchestration out of composer.tsx into a React-free actions module. The module takes its dependencies (send client, queue writer, stream writer, attachment persister, image picker) as injected ports, so the new composer-actions.test.ts can drive every action with inline fakes — zero vi.mock of internal modules, zero JSDOM, zero React. The old composer.test.tsx (heavy mocked component test) is removed and replaced with composer-actions.test.ts (31 unit tests). Also extract isWorkspaceAttachment / userAttachmentsOnly / workspaceAttachmentToSubmitAttachment from composer-workspace-attachments.tsx into a sibling .ts so the actions module (and composer-attachments.ts) can use them without dragging React/RN/lucide into a pure test graph.
This commit is contained in:
@@ -9,8 +9,12 @@ import type {
|
||||
} from "@/attachments/types";
|
||||
import { AttachmentPill } from "@/components/attachment-pill";
|
||||
import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store";
|
||||
import {
|
||||
isWorkspaceAttachment,
|
||||
userAttachmentsOnly,
|
||||
workspaceAttachmentToSubmitAttachment,
|
||||
} from "@/attachments/workspace-attachment-utils";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import type { AgentAttachment } from "@server/shared/messages";
|
||||
import { useClearReviewDraft } from "@/review/store";
|
||||
|
||||
interface WorkspaceAttachmentBindingInput {
|
||||
@@ -69,31 +73,6 @@ function getAttachmentKey(attachment: WorkspaceComposerAttachment): string {
|
||||
});
|
||||
}
|
||||
|
||||
function isWorkspaceAttachment(
|
||||
attachment: ComposerAttachment | undefined,
|
||||
): attachment is WorkspaceComposerAttachment {
|
||||
return attachment?.kind === "review" || attachment?.kind === "browser_element";
|
||||
}
|
||||
|
||||
function userAttachmentsOnly(attachments: readonly ComposerAttachment[]): UserComposerAttachment[] {
|
||||
return attachments.filter(
|
||||
(attachment): attachment is UserComposerAttachment =>
|
||||
attachment.kind !== "review" && attachment.kind !== "browser_element",
|
||||
);
|
||||
}
|
||||
|
||||
function toSubmitAttachment(attachment: ComposerAttachment): AgentAttachment | null {
|
||||
if (attachment.kind === "browser_element") {
|
||||
return {
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
title: `Browser element · ${attachment.attachment.tag}`,
|
||||
text: attachment.attachment.formatted,
|
||||
};
|
||||
}
|
||||
return attachment.kind === "review" ? attachment.attachment : null;
|
||||
}
|
||||
|
||||
function renderPill(args: RenderWorkspaceAttachmentPillArgs): ReactElement {
|
||||
return (
|
||||
<WorkspaceAttachmentPill
|
||||
@@ -303,7 +282,7 @@ function WorkspaceAttachmentPill({
|
||||
export const composerWorkspaceAttachment = {
|
||||
is: isWorkspaceAttachment,
|
||||
renderPill,
|
||||
toSubmitAttachment,
|
||||
toSubmitAttachment: workspaceAttachmentToSubmitAttachment,
|
||||
userAttachmentsOnly,
|
||||
useBinding: useWorkspaceAttachmentBinding,
|
||||
};
|
||||
|
||||
35
packages/app/src/attachments/workspace-attachment-utils.ts
Normal file
35
packages/app/src/attachments/workspace-attachment-utils.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type {
|
||||
ComposerAttachment,
|
||||
UserComposerAttachment,
|
||||
WorkspaceComposerAttachment,
|
||||
} from "@/attachments/types";
|
||||
import type { AgentAttachment } from "@server/shared/messages";
|
||||
|
||||
export function isWorkspaceAttachment(
|
||||
attachment: ComposerAttachment | undefined,
|
||||
): attachment is WorkspaceComposerAttachment {
|
||||
return attachment?.kind === "review" || attachment?.kind === "browser_element";
|
||||
}
|
||||
|
||||
export function userAttachmentsOnly(
|
||||
attachments: readonly ComposerAttachment[],
|
||||
): UserComposerAttachment[] {
|
||||
return attachments.filter(
|
||||
(attachment): attachment is UserComposerAttachment =>
|
||||
attachment.kind !== "review" && attachment.kind !== "browser_element",
|
||||
);
|
||||
}
|
||||
|
||||
export function workspaceAttachmentToSubmitAttachment(
|
||||
attachment: ComposerAttachment,
|
||||
): AgentAttachment | null {
|
||||
if (attachment.kind === "browser_element") {
|
||||
return {
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
title: `Browser element · ${attachment.attachment.tag}`,
|
||||
text: attachment.attachment.formatted,
|
||||
};
|
||||
}
|
||||
return attachment.kind === "review" ? attachment.attachment : null;
|
||||
}
|
||||
715
packages/app/src/components/composer-actions.test.ts
Normal file
715
packages/app/src/components/composer-actions.test.ts
Normal file
@@ -0,0 +1,715 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentAttachment, GitHubSearchItem } from "@server/shared/messages";
|
||||
import type {
|
||||
AttachmentMetadata,
|
||||
ComposerAttachment,
|
||||
UserComposerAttachment,
|
||||
WorkspaceComposerAttachment,
|
||||
} from "@/attachments/types";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import {
|
||||
cancelComposerAgent,
|
||||
dispatchComposerAgentMessage,
|
||||
editQueuedComposerMessage,
|
||||
findGithubItemByOption,
|
||||
isAttachmentSelectedForGithubItem,
|
||||
openComposerAttachment,
|
||||
pickAndPersistImages,
|
||||
queueComposerMessage,
|
||||
removeComposerAttachmentAtIndex,
|
||||
sendQueuedComposerMessageNow,
|
||||
toggleGithubAttachment,
|
||||
type AgentStreamWriter,
|
||||
type AttachmentPersister,
|
||||
type ComposerCancelClient,
|
||||
type ComposerSendClient,
|
||||
type QueueWriter,
|
||||
type QueuedComposerMessage,
|
||||
} from "./composer-actions";
|
||||
|
||||
const imageMetadata: AttachmentMetadata = {
|
||||
id: "img-1",
|
||||
mimeType: "image/png",
|
||||
storageType: "web-indexeddb",
|
||||
storageKey: "img-1",
|
||||
fileName: "img-1.png",
|
||||
byteSize: 42,
|
||||
createdAt: 1,
|
||||
};
|
||||
|
||||
const issueItem: GitHubSearchItem = {
|
||||
kind: "issue",
|
||||
number: 101,
|
||||
title: "Fix composer attachments",
|
||||
url: "https://github.com/acme/paseo/issues/101",
|
||||
state: "open",
|
||||
body: "Issue body",
|
||||
labels: ["composer"],
|
||||
baseRefName: null,
|
||||
headRefName: null,
|
||||
};
|
||||
|
||||
const prItem: GitHubSearchItem = {
|
||||
kind: "pr",
|
||||
number: 202,
|
||||
title: "Refactor composer attachments",
|
||||
url: "https://github.com/acme/paseo/pull/202",
|
||||
state: "open",
|
||||
body: "PR body",
|
||||
labels: ["composer"],
|
||||
baseRefName: "main",
|
||||
headRefName: "composer-attachments",
|
||||
};
|
||||
|
||||
function imageWithId(id: string): AttachmentMetadata {
|
||||
return { ...imageMetadata, id, storageKey: id, fileName: `${id}.png` };
|
||||
}
|
||||
|
||||
function reviewWorkspaceAttachment(body: string): WorkspaceComposerAttachment {
|
||||
const attachment: Extract<AgentAttachment, { type: "review" }> = {
|
||||
type: "review",
|
||||
mimeType: "application/paseo-review",
|
||||
cwd: "/repo",
|
||||
mode: "uncommitted",
|
||||
baseRef: null,
|
||||
comments: [
|
||||
{
|
||||
filePath: "src/example.ts",
|
||||
side: "new",
|
||||
lineNumber: 41,
|
||||
body,
|
||||
context: {
|
||||
hunkHeader: "@@ -40,2 +40,2 @@",
|
||||
targetLine: {
|
||||
oldLineNumber: null,
|
||||
newLineNumber: 41,
|
||||
type: "add",
|
||||
content: "const value = newValue;",
|
||||
},
|
||||
lines: [
|
||||
{
|
||||
oldLineNumber: null,
|
||||
newLineNumber: 41,
|
||||
type: "add",
|
||||
content: "const value = newValue;",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
return {
|
||||
kind: "review",
|
||||
reviewDraftKey: `review:${body}`,
|
||||
commentCount: 1,
|
||||
attachment,
|
||||
};
|
||||
}
|
||||
|
||||
function browserElementWorkspaceAttachment(): Extract<
|
||||
WorkspaceComposerAttachment,
|
||||
{ kind: "browser_element" }
|
||||
> {
|
||||
return {
|
||||
kind: "browser_element",
|
||||
attachment: {
|
||||
url: "https://example.com/page",
|
||||
selector: "button.primary",
|
||||
tag: "button",
|
||||
text: "Save",
|
||||
outerHTML: '<button class="primary">Save</button>',
|
||||
computedStyles: { display: "flex" },
|
||||
boundingRect: { x: 1, y: 2, width: 80, height: 32 },
|
||||
reactSource: null,
|
||||
parentChain: ["form.settings"],
|
||||
children: [],
|
||||
formatted: '<browser-element url="https://example.com/page">button.primary</browser-element>',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createFakePersister(): AttachmentPersister & {
|
||||
blobCalls: Array<{ blob: Blob; mimeType: string; fileName: string | null }>;
|
||||
fileUriCalls: Array<{ uri: string; mimeType: string; fileName: string | null }>;
|
||||
deletedBatches: AttachmentMetadata[][];
|
||||
} {
|
||||
const blobCalls: Array<{ blob: Blob; mimeType: string; fileName: string | null }> = [];
|
||||
const fileUriCalls: Array<{ uri: string; mimeType: string; fileName: string | null }> = [];
|
||||
const deletedBatches: AttachmentMetadata[][] = [];
|
||||
return {
|
||||
blobCalls,
|
||||
fileUriCalls,
|
||||
deletedBatches,
|
||||
persistFromBlob: async ({ blob, mimeType, fileName }) => {
|
||||
blobCalls.push({ blob, mimeType, fileName });
|
||||
return { ...imageMetadata, id: `blob-${blobCalls.length}` };
|
||||
},
|
||||
persistFromFileUri: async ({ uri, mimeType, fileName }) => {
|
||||
fileUriCalls.push({ uri, mimeType, fileName });
|
||||
return { ...imageMetadata, id: `uri-${fileUriCalls.length}` };
|
||||
},
|
||||
deleteAttachments: (metadata) => {
|
||||
deletedBatches.push(metadata);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface FakeSendCall {
|
||||
agentId: string;
|
||||
text: string;
|
||||
options: {
|
||||
messageId: string;
|
||||
images: Array<{ data: string; mimeType: string }>;
|
||||
attachments: AgentAttachment[];
|
||||
};
|
||||
}
|
||||
|
||||
function createFakeSendClient(
|
||||
options: { rejection?: Error } = {},
|
||||
): ComposerSendClient & { calls: FakeSendCall[] } {
|
||||
const calls: FakeSendCall[] = [];
|
||||
return {
|
||||
calls,
|
||||
sendAgentMessage: async (agentId, text, opts) => {
|
||||
calls.push({ agentId, text, options: opts });
|
||||
if (options.rejection) {
|
||||
throw options.rejection;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface FakeStream extends AgentStreamWriter {
|
||||
head: Map<string, StreamItem[]>;
|
||||
tail: Map<string, StreamItem[]>;
|
||||
}
|
||||
|
||||
function createFakeStream(initialHead: Map<string, StreamItem[]> = new Map()): FakeStream {
|
||||
const fake: FakeStream = {
|
||||
head: new Map(initialHead),
|
||||
tail: new Map(),
|
||||
getHead: (agentId) => fake.head.get(agentId),
|
||||
setHead: (updater) => {
|
||||
fake.head = updater(fake.head);
|
||||
},
|
||||
setTail: (updater) => {
|
||||
fake.tail = updater(fake.tail);
|
||||
},
|
||||
};
|
||||
return fake;
|
||||
}
|
||||
|
||||
function createFakeQueue(
|
||||
initial: Map<string, QueuedComposerMessage[]> = new Map(),
|
||||
): QueueWriter & { state: Map<string, QueuedComposerMessage[]> } {
|
||||
const fake: QueueWriter & { state: Map<string, QueuedComposerMessage[]> } = {
|
||||
state: new Map(initial),
|
||||
read: (agentId) => fake.state.get(agentId) ?? [],
|
||||
write: (updater) => {
|
||||
fake.state = updater(fake.state);
|
||||
},
|
||||
};
|
||||
return fake;
|
||||
}
|
||||
|
||||
const passthroughEncodeImages = async (images: AttachmentMetadata[]) =>
|
||||
images.map((image) => ({ data: image.id, mimeType: image.mimeType }));
|
||||
|
||||
describe("cancelComposerAgent", () => {
|
||||
function baseInput(): {
|
||||
client: ComposerCancelClient & { canceledIds: string[] };
|
||||
agentId: string;
|
||||
isAgentRunning: boolean;
|
||||
isCancellingAgent: boolean;
|
||||
isConnected: boolean;
|
||||
} {
|
||||
const canceledIds: string[] = [];
|
||||
return {
|
||||
client: {
|
||||
canceledIds,
|
||||
cancelAgent: async (id) => {
|
||||
canceledIds.push(id);
|
||||
},
|
||||
},
|
||||
agentId: "agent",
|
||||
isAgentRunning: true,
|
||||
isCancellingAgent: false,
|
||||
isConnected: true,
|
||||
};
|
||||
}
|
||||
|
||||
it("issues a cancel and reports true when the agent is running, connected, and not already canceling", () => {
|
||||
const input = baseInput();
|
||||
const result = cancelComposerAgent(input);
|
||||
expect(result).toBe(true);
|
||||
expect(input.client.canceledIds).toEqual(["agent"]);
|
||||
});
|
||||
|
||||
it("does nothing when the agent is not running", () => {
|
||||
const input = baseInput();
|
||||
const result = cancelComposerAgent({ ...input, isAgentRunning: false });
|
||||
expect(result).toBe(false);
|
||||
expect(input.client.canceledIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("does nothing when the agent is already being canceled", () => {
|
||||
const input = baseInput();
|
||||
const result = cancelComposerAgent({ ...input, isCancellingAgent: true });
|
||||
expect(result).toBe(false);
|
||||
expect(input.client.canceledIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("does nothing when disconnected or the client is null", () => {
|
||||
const input = baseInput();
|
||||
expect(cancelComposerAgent({ ...input, isConnected: false })).toBe(false);
|
||||
expect(cancelComposerAgent({ ...input, client: null })).toBe(false);
|
||||
expect(input.client.canceledIds).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickAndPersistImages", () => {
|
||||
it("returns [] when the picker yields nothing", async () => {
|
||||
const persister = createFakePersister();
|
||||
const result = await pickAndPersistImages({
|
||||
pickImages: async () => null,
|
||||
persister,
|
||||
});
|
||||
expect(result).toEqual([]);
|
||||
expect(persister.blobCalls).toEqual([]);
|
||||
expect(persister.fileUriCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it("persists blob sources via persistFromBlob with the picked mime type and file name", async () => {
|
||||
const persister = createFakePersister();
|
||||
const blob = new Blob(["image"]);
|
||||
const result = await pickAndPersistImages({
|
||||
pickImages: async () => [
|
||||
{ source: { kind: "blob", blob }, mimeType: "image/png", fileName: "img-1.png" },
|
||||
],
|
||||
persister,
|
||||
});
|
||||
expect(persister.blobCalls).toEqual([{ blob, mimeType: "image/png", fileName: "img-1.png" }]);
|
||||
expect(result.map((m) => m.id)).toEqual(["blob-1"]);
|
||||
});
|
||||
|
||||
it("persists file_uri sources via persistFromFileUri", async () => {
|
||||
const persister = createFakePersister();
|
||||
const result = await pickAndPersistImages({
|
||||
pickImages: async () => [
|
||||
{ source: { kind: "file_uri", uri: "/tmp/x.jpg" }, mimeType: null, fileName: null },
|
||||
],
|
||||
persister,
|
||||
});
|
||||
expect(persister.fileUriCalls).toEqual([
|
||||
{ uri: "/tmp/x.jpg", mimeType: "image/jpeg", fileName: null },
|
||||
]);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dispatchComposerAgentMessage", () => {
|
||||
it("sends text + image data + structured attachments and appends user_message to the tail when head is empty", async () => {
|
||||
const client = createFakeSendClient();
|
||||
const stream = createFakeStream();
|
||||
const image = imageWithId("img-2");
|
||||
|
||||
await dispatchComposerAgentMessage({
|
||||
client,
|
||||
agentId: "agent",
|
||||
text: "send attachments",
|
||||
attachments: [
|
||||
{ kind: "image", metadata: image },
|
||||
{ kind: "github_pr", item: prItem },
|
||||
],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
stream,
|
||||
});
|
||||
|
||||
expect(client.calls).toHaveLength(1);
|
||||
const [call] = client.calls;
|
||||
expect(call.agentId).toBe("agent");
|
||||
expect(call.text).toBe("send attachments");
|
||||
expect(call.options.images).toEqual([{ data: image.id, mimeType: image.mimeType }]);
|
||||
expect(call.options.attachments).toEqual([
|
||||
{
|
||||
type: "github_pr",
|
||||
mimeType: "application/github-pr",
|
||||
number: 202,
|
||||
title: "Refactor composer attachments",
|
||||
url: "https://github.com/acme/paseo/pull/202",
|
||||
body: "PR body",
|
||||
baseRefName: "main",
|
||||
headRefName: "composer-attachments",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(stream.head.get("agent")).toBeUndefined();
|
||||
const tail = stream.tail.get("agent");
|
||||
expect(tail).toHaveLength(1);
|
||||
const userMessage = tail?.[0] as Extract<StreamItem, { kind: "user_message" }>;
|
||||
expect(userMessage.kind).toBe("user_message");
|
||||
expect(userMessage.text).toBe("send attachments");
|
||||
expect(userMessage.images).toEqual([image]);
|
||||
expect(userMessage.attachments).toEqual(call.options.attachments);
|
||||
expect(userMessage.id).toBe(call.options.messageId);
|
||||
});
|
||||
|
||||
it("appends to the existing head when one is present", async () => {
|
||||
const existingItem: StreamItem = {
|
||||
kind: "user_message",
|
||||
id: "prior",
|
||||
text: "prior",
|
||||
timestamp: new Date(0),
|
||||
};
|
||||
const stream = createFakeStream(new Map([["agent", [existingItem]]]));
|
||||
const client = createFakeSendClient();
|
||||
|
||||
await dispatchComposerAgentMessage({
|
||||
client,
|
||||
agentId: "agent",
|
||||
text: "next message",
|
||||
attachments: [],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
stream,
|
||||
});
|
||||
|
||||
expect(stream.head.get("agent")).toHaveLength(2);
|
||||
expect(stream.tail.get("agent")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("submits empty wire arrays when no attachments are provided", async () => {
|
||||
const client = createFakeSendClient();
|
||||
const stream = createFakeStream();
|
||||
|
||||
await dispatchComposerAgentMessage({
|
||||
client,
|
||||
agentId: "agent",
|
||||
text: "plain message",
|
||||
attachments: [],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
stream,
|
||||
});
|
||||
|
||||
expect(client.calls[0]?.options).toMatchObject({
|
||||
images: [],
|
||||
attachments: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("serializes workspace review attachments through the structured attachment path", async () => {
|
||||
const client = createFakeSendClient();
|
||||
const stream = createFakeStream();
|
||||
const review = reviewWorkspaceAttachment("Please simplify this.");
|
||||
|
||||
await dispatchComposerAgentMessage({
|
||||
client,
|
||||
agentId: "agent",
|
||||
text: "review this",
|
||||
attachments: [review],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
stream,
|
||||
});
|
||||
|
||||
expect(client.calls[0]?.options.attachments).toEqual([review.attachment]);
|
||||
expect(client.calls[0]?.options.images).toEqual([]);
|
||||
});
|
||||
|
||||
it("serializes browser_element workspace attachments as text attachments at the wire boundary", async () => {
|
||||
const client = createFakeSendClient();
|
||||
const stream = createFakeStream();
|
||||
const browserElement = browserElementWorkspaceAttachment();
|
||||
|
||||
await dispatchComposerAgentMessage({
|
||||
client,
|
||||
agentId: "agent",
|
||||
text: "inspect element",
|
||||
attachments: [browserElement],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
stream,
|
||||
});
|
||||
|
||||
expect(client.calls[0]?.options.attachments).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
mimeType: "text/plain",
|
||||
title: "Browser element · button",
|
||||
text: browserElement.attachment.formatted,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("queueComposerMessage", () => {
|
||||
it("queues a trimmed message under the agent id and returns the new entry", () => {
|
||||
const queue = createFakeQueue();
|
||||
const result = queueComposerMessage({
|
||||
agentId: "agent",
|
||||
text: " draft ",
|
||||
attachments: [],
|
||||
queue,
|
||||
});
|
||||
|
||||
expect(result.queued?.text).toBe("draft");
|
||||
expect(queue.state.get("agent")).toEqual([
|
||||
{ id: result.queued?.id, text: "draft", attachments: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not queue an empty message with no attachments", () => {
|
||||
const queue = createFakeQueue();
|
||||
const result = queueComposerMessage({
|
||||
agentId: "agent",
|
||||
text: " ",
|
||||
attachments: [],
|
||||
queue,
|
||||
});
|
||||
expect(result.queued).toBeNull();
|
||||
expect(queue.state.get("agent")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("captures workspace review attachments at queue time alongside user attachments", () => {
|
||||
const queue = createFakeQueue();
|
||||
const review = reviewWorkspaceAttachment("Initial queued review.");
|
||||
const image = imageWithId("img-queue");
|
||||
queueComposerMessage({
|
||||
agentId: "agent",
|
||||
text: "queue this",
|
||||
attachments: [{ kind: "image", metadata: image }, review],
|
||||
queue,
|
||||
});
|
||||
|
||||
expect(queue.state.get("agent")?.[0]?.attachments).toEqual([
|
||||
{ kind: "image", metadata: image },
|
||||
review,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("editQueuedComposerMessage", () => {
|
||||
it("returns null and leaves the queue untouched when the message id is missing", () => {
|
||||
const queue = createFakeQueue(
|
||||
new Map([["agent", [{ id: "other", text: "other", attachments: [] }]]]),
|
||||
);
|
||||
const result = editQueuedComposerMessage({ agentId: "agent", messageId: "missing", queue });
|
||||
expect(result).toBeNull();
|
||||
expect(queue.state.get("agent")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns the text and only user attachments, removing the queued entry", () => {
|
||||
const review = reviewWorkspaceAttachment("Queued snapshot.");
|
||||
const image = imageWithId("img-queued-edit");
|
||||
const queue = createFakeQueue(
|
||||
new Map([
|
||||
[
|
||||
"agent",
|
||||
[
|
||||
{
|
||||
id: "msg-1",
|
||||
text: "queued draft",
|
||||
attachments: [{ kind: "image", metadata: image }, review],
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const result = editQueuedComposerMessage({ agentId: "agent", messageId: "msg-1", queue });
|
||||
expect(result).toEqual({
|
||||
text: "queued draft",
|
||||
attachments: [{ kind: "image", metadata: image }],
|
||||
});
|
||||
expect(queue.state.get("agent")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendQueuedComposerMessageNow", () => {
|
||||
it("returns missing without submitting when the message id is gone", async () => {
|
||||
const queue = createFakeQueue();
|
||||
const submitted: Array<{ text: string; attachments: ComposerAttachment[] }> = [];
|
||||
const result = await sendQueuedComposerMessageNow({
|
||||
agentId: "agent",
|
||||
messageId: "msg-1",
|
||||
queue,
|
||||
submitMessage: async (input) => {
|
||||
submitted.push(input);
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({ status: "missing" });
|
||||
expect(submitted).toEqual([]);
|
||||
});
|
||||
|
||||
it("removes the queued entry and submits its text + attachments", async () => {
|
||||
const review = reviewWorkspaceAttachment("Queued for send.");
|
||||
const queue = createFakeQueue(
|
||||
new Map([["agent", [{ id: "msg-1", text: "send me", attachments: [review] }]]]),
|
||||
);
|
||||
const submitted: Array<{ text: string; attachments: ComposerAttachment[] }> = [];
|
||||
const result = await sendQueuedComposerMessageNow({
|
||||
agentId: "agent",
|
||||
messageId: "msg-1",
|
||||
queue,
|
||||
submitMessage: async (input) => {
|
||||
submitted.push(input);
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({ status: "submitted" });
|
||||
expect(queue.state.get("agent")).toEqual([]);
|
||||
expect(submitted).toEqual([{ text: "send me", attachments: [review] }]);
|
||||
});
|
||||
|
||||
it("restores the queued entry to the front and surfaces the error message on failure", async () => {
|
||||
const queue = createFakeQueue(
|
||||
new Map([
|
||||
[
|
||||
"agent",
|
||||
[
|
||||
{ id: "msg-1", text: "first", attachments: [] },
|
||||
{ id: "msg-2", text: "second", attachments: [] },
|
||||
],
|
||||
],
|
||||
]),
|
||||
);
|
||||
const result = await sendQueuedComposerMessageNow({
|
||||
agentId: "agent",
|
||||
messageId: "msg-1",
|
||||
queue,
|
||||
submitMessage: async () => {
|
||||
throw new Error("network down");
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({ status: "failed", errorMessage: "network down" });
|
||||
const state = queue.state.get("agent");
|
||||
expect(state?.map((m) => m.id)).toEqual(["msg-1", "msg-2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeComposerAttachmentAtIndex", () => {
|
||||
it("removes an image attachment and asks the persister to delete the underlying metadata", () => {
|
||||
const image = imageWithId("img-remove");
|
||||
const persister = createFakePersister();
|
||||
const next = removeComposerAttachmentAtIndex({
|
||||
attachments: [{ kind: "image", metadata: image }] satisfies UserComposerAttachment[],
|
||||
index: 0,
|
||||
deleteAttachments: persister.deleteAttachments,
|
||||
});
|
||||
expect(next).toEqual([]);
|
||||
expect(persister.deletedBatches).toEqual([[image]]);
|
||||
});
|
||||
|
||||
it("removes a github attachment without scheduling any storage deletes", () => {
|
||||
const persister = createFakePersister();
|
||||
const next = removeComposerAttachmentAtIndex({
|
||||
attachments: [
|
||||
{ kind: "github_issue", item: issueItem },
|
||||
{ kind: "github_pr", item: prItem },
|
||||
] satisfies UserComposerAttachment[],
|
||||
index: 0,
|
||||
deleteAttachments: persister.deleteAttachments,
|
||||
});
|
||||
expect(next).toEqual([{ kind: "github_pr", item: prItem }]);
|
||||
expect(persister.deletedBatches).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("openComposerAttachment", () => {
|
||||
it("opens the lightbox for image attachments", () => {
|
||||
const image = imageWithId("img-body");
|
||||
const lightboxCalls: AttachmentMetadata[] = [];
|
||||
const externalUrlCalls: string[] = [];
|
||||
openComposerAttachment({
|
||||
attachment: { kind: "image", metadata: image },
|
||||
setLightboxMetadata: (metadata) => {
|
||||
lightboxCalls.push(metadata);
|
||||
},
|
||||
openWorkspaceAttachment: () => false,
|
||||
openExternalUrl: (url) => {
|
||||
externalUrlCalls.push(url);
|
||||
},
|
||||
});
|
||||
expect(lightboxCalls).toEqual([image]);
|
||||
expect(externalUrlCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it("delegates workspace review attachments to the workspace opener", () => {
|
||||
const review = reviewWorkspaceAttachment("Open me.");
|
||||
const workspaceCalls: ComposerAttachment[] = [];
|
||||
openComposerAttachment({
|
||||
attachment: review,
|
||||
setLightboxMetadata: () => {
|
||||
throw new Error("unexpected lightbox call");
|
||||
},
|
||||
openWorkspaceAttachment: ({ attachment }) => {
|
||||
workspaceCalls.push(attachment);
|
||||
return true;
|
||||
},
|
||||
openExternalUrl: () => {
|
||||
throw new Error("unexpected external url call");
|
||||
},
|
||||
});
|
||||
expect(workspaceCalls).toEqual([review]);
|
||||
});
|
||||
|
||||
it("opens GitHub item URLs through the external url opener", () => {
|
||||
const externalUrlCalls: string[] = [];
|
||||
openComposerAttachment({
|
||||
attachment: { kind: "github_issue", item: issueItem },
|
||||
setLightboxMetadata: () => {
|
||||
throw new Error("unexpected lightbox call");
|
||||
},
|
||||
openWorkspaceAttachment: () => false,
|
||||
openExternalUrl: (url) => {
|
||||
externalUrlCalls.push(url);
|
||||
},
|
||||
});
|
||||
expect(externalUrlCalls).toEqual([issueItem.url]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggleGithubAttachment", () => {
|
||||
it("appends a GitHub issue when not already attached", () => {
|
||||
const next = toggleGithubAttachment([], issueItem);
|
||||
expect(next).toEqual([{ kind: "github_issue", item: issueItem }]);
|
||||
});
|
||||
|
||||
it("appends a GitHub PR when not already attached", () => {
|
||||
const next = toggleGithubAttachment([], prItem);
|
||||
expect(next).toEqual([{ kind: "github_pr", item: prItem }]);
|
||||
});
|
||||
|
||||
it("removes an existing GitHub item with the same kind+number", () => {
|
||||
const next = toggleGithubAttachment([{ kind: "github_issue", item: issueItem }], issueItem);
|
||||
expect(next).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not affect other items with different kind or number", () => {
|
||||
const start: UserComposerAttachment[] = [
|
||||
{ kind: "github_issue", item: issueItem },
|
||||
{ kind: "github_pr", item: prItem },
|
||||
];
|
||||
const otherIssue: GitHubSearchItem = { ...issueItem, number: 999 };
|
||||
const next = toggleGithubAttachment(start, otherIssue);
|
||||
expect(next).toEqual([
|
||||
{ kind: "github_issue", item: issueItem },
|
||||
{ kind: "github_pr", item: prItem },
|
||||
{ kind: "github_issue", item: otherIssue },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findGithubItemByOption / isAttachmentSelectedForGithubItem", () => {
|
||||
it("locates items via their composite kind:number id", () => {
|
||||
expect(findGithubItemByOption([issueItem, prItem], "issue:101")).toBe(issueItem);
|
||||
expect(findGithubItemByOption([issueItem, prItem], "pr:202")).toBe(prItem);
|
||||
expect(findGithubItemByOption([issueItem], "pr:404")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("recognizes when an attachment list already contains a matching GitHub item", () => {
|
||||
const attachments: ComposerAttachment[] = [
|
||||
{ kind: "image", metadata: imageWithId("img-x") },
|
||||
{ kind: "github_issue", item: issueItem },
|
||||
reviewWorkspaceAttachment("ignored"),
|
||||
];
|
||||
expect(isAttachmentSelectedForGithubItem(attachments, issueItem)).toBe(true);
|
||||
expect(isAttachmentSelectedForGithubItem(attachments, prItem)).toBe(false);
|
||||
});
|
||||
});
|
||||
325
packages/app/src/components/composer-actions.ts
Normal file
325
packages/app/src/components/composer-actions.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
import type { GitHubSearchItem } from "@server/shared/messages";
|
||||
import type {
|
||||
AttachmentMetadata,
|
||||
ComposerAttachment,
|
||||
UserComposerAttachment,
|
||||
} from "@/attachments/types";
|
||||
import {
|
||||
isWorkspaceAttachment,
|
||||
userAttachmentsOnly,
|
||||
} from "@/attachments/workspace-attachment-utils";
|
||||
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
|
||||
import { generateMessageId, type StreamItem } from "@/types/stream";
|
||||
import type { PickedImageAttachmentInput } from "@/hooks/image-attachment-picker";
|
||||
|
||||
export interface QueuedComposerMessage {
|
||||
id: string;
|
||||
text: string;
|
||||
attachments: ComposerAttachment[];
|
||||
}
|
||||
|
||||
export interface AttachmentPersister {
|
||||
persistFromBlob: (input: {
|
||||
blob: Blob;
|
||||
mimeType: string;
|
||||
fileName: string | null;
|
||||
}) => Promise<AttachmentMetadata>;
|
||||
persistFromFileUri: (input: {
|
||||
uri: string;
|
||||
mimeType: string;
|
||||
fileName: string | null;
|
||||
}) => Promise<AttachmentMetadata>;
|
||||
deleteAttachments: (metadata: AttachmentMetadata[]) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface ComposerSendClient {
|
||||
sendAgentMessage: (
|
||||
agentId: string,
|
||||
text: string,
|
||||
options: {
|
||||
messageId: string;
|
||||
images: Array<{ data: string; mimeType: string }>;
|
||||
attachments: ReturnType<typeof splitComposerAttachmentsForSubmit>["attachments"];
|
||||
},
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface ComposerCancelClient {
|
||||
cancelAgent: (agentId: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface AgentStreamWriter {
|
||||
getHead: (agentId: string) => StreamItem[] | undefined;
|
||||
setHead: (updater: (prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>) => void;
|
||||
setTail: (updater: (prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>) => void;
|
||||
}
|
||||
|
||||
export interface QueueWriter {
|
||||
read: (agentId: string) => QueuedComposerMessage[];
|
||||
write: (
|
||||
updater: (prev: Map<string, QueuedComposerMessage[]>) => Map<string, QueuedComposerMessage[]>,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export async function pickAndPersistImages(input: {
|
||||
pickImages: () => Promise<PickedImageAttachmentInput[] | null>;
|
||||
persister: Pick<AttachmentPersister, "persistFromBlob" | "persistFromFileUri">;
|
||||
}): Promise<AttachmentMetadata[]> {
|
||||
const result = await input.pickImages();
|
||||
if (!result?.length) return [];
|
||||
return await Promise.all(
|
||||
result.map(async (picked) => {
|
||||
const fileName = picked.fileName ?? null;
|
||||
const mimeType = picked.mimeType || "image/jpeg";
|
||||
if (picked.source.kind === "blob") {
|
||||
return await input.persister.persistFromBlob({
|
||||
blob: picked.source.blob,
|
||||
mimeType,
|
||||
fileName,
|
||||
});
|
||||
}
|
||||
return await input.persister.persistFromFileUri({
|
||||
uri: picked.source.uri,
|
||||
mimeType,
|
||||
fileName,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function removeComposerAttachmentAtIndex<T extends ComposerAttachment>(input: {
|
||||
attachments: T[];
|
||||
index: number;
|
||||
deleteAttachments: AttachmentPersister["deleteAttachments"];
|
||||
}): T[] {
|
||||
const removed = input.attachments[input.index];
|
||||
if (removed?.kind === "image") {
|
||||
void input.deleteAttachments([removed.metadata]);
|
||||
}
|
||||
return input.attachments.filter((_, i) => i !== input.index);
|
||||
}
|
||||
|
||||
export interface CancelComposerAgentInput {
|
||||
client: ComposerCancelClient | null;
|
||||
agentId: string;
|
||||
isAgentRunning: boolean;
|
||||
isCancellingAgent: boolean;
|
||||
isConnected: boolean;
|
||||
}
|
||||
|
||||
export function cancelComposerAgent(input: CancelComposerAgentInput): boolean {
|
||||
if (!input.isAgentRunning || input.isCancellingAgent) return false;
|
||||
if (!input.isConnected || !input.client) return false;
|
||||
void input.client.cancelAgent(input.agentId);
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface DispatchComposerAgentMessageInput {
|
||||
client: ComposerSendClient;
|
||||
agentId: string;
|
||||
text: string;
|
||||
attachments: ComposerAttachment[];
|
||||
encodeImages: (
|
||||
images: AttachmentMetadata[],
|
||||
) => Promise<Array<{ data: string; mimeType: string }> | undefined>;
|
||||
stream: AgentStreamWriter;
|
||||
}
|
||||
|
||||
export async function dispatchComposerAgentMessage(
|
||||
input: DispatchComposerAgentMessageInput,
|
||||
): Promise<void> {
|
||||
const wirePayload = splitComposerAttachmentsForSubmit(input.attachments);
|
||||
const messageId = generateMessageId();
|
||||
const userMessage: StreamItem = {
|
||||
kind: "user_message",
|
||||
id: messageId,
|
||||
text: input.text,
|
||||
timestamp: new Date(),
|
||||
...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}),
|
||||
...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}),
|
||||
};
|
||||
appendUserMessageToStream(input.agentId, userMessage, input.stream);
|
||||
const imagesData = await input.encodeImages(wirePayload.images);
|
||||
await input.client.sendAgentMessage(input.agentId, input.text, {
|
||||
messageId,
|
||||
images: imagesData ?? [],
|
||||
attachments: wirePayload.attachments,
|
||||
});
|
||||
}
|
||||
|
||||
function appendUserMessageToStream(
|
||||
agentId: string,
|
||||
userMessage: StreamItem,
|
||||
stream: AgentStreamWriter,
|
||||
): void {
|
||||
const head = stream.getHead(agentId);
|
||||
if (head && head.length > 0) {
|
||||
stream.setHead((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, [...(prev.get(agentId) ?? []), userMessage]);
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
stream.setTail((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, [...(prev.get(agentId) ?? []), userMessage]);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
export interface QueueComposerMessageInput {
|
||||
agentId: string;
|
||||
text: string;
|
||||
attachments: ComposerAttachment[];
|
||||
queue: QueueWriter;
|
||||
}
|
||||
|
||||
export interface QueueComposerMessageResult {
|
||||
queued: QueuedComposerMessage | null;
|
||||
}
|
||||
|
||||
export function queueComposerMessage(input: QueueComposerMessageInput): QueueComposerMessageResult {
|
||||
const trimmed = input.text.trim();
|
||||
if (!trimmed && input.attachments.length === 0) {
|
||||
return { queued: null };
|
||||
}
|
||||
const item: QueuedComposerMessage = {
|
||||
id: generateMessageId(),
|
||||
text: trimmed,
|
||||
attachments: input.attachments,
|
||||
};
|
||||
input.queue.write((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(input.agentId, [...(prev.get(input.agentId) ?? []), item]);
|
||||
return next;
|
||||
});
|
||||
return { queued: item };
|
||||
}
|
||||
|
||||
export interface EditQueuedComposerMessageInput {
|
||||
agentId: string;
|
||||
messageId: string;
|
||||
queue: QueueWriter;
|
||||
}
|
||||
|
||||
export interface EditQueuedComposerMessageResult {
|
||||
text: string;
|
||||
attachments: UserComposerAttachment[];
|
||||
}
|
||||
|
||||
export function editQueuedComposerMessage(
|
||||
input: EditQueuedComposerMessageInput,
|
||||
): EditQueuedComposerMessageResult | null {
|
||||
const item = input.queue.read(input.agentId).find((q) => q.id === input.messageId);
|
||||
if (!item) return null;
|
||||
input.queue.write((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(
|
||||
input.agentId,
|
||||
(prev.get(input.agentId) ?? []).filter((q) => q.id !== input.messageId),
|
||||
);
|
||||
return next;
|
||||
});
|
||||
return {
|
||||
text: item.text,
|
||||
attachments: userAttachmentsOnly(item.attachments),
|
||||
};
|
||||
}
|
||||
|
||||
export interface SendQueuedComposerMessageNowInput {
|
||||
agentId: string;
|
||||
messageId: string;
|
||||
queue: QueueWriter;
|
||||
submitMessage: (input: { text: string; attachments: ComposerAttachment[] }) => Promise<void>;
|
||||
}
|
||||
|
||||
export type SendQueuedComposerMessageNowResult =
|
||||
| { status: "missing" }
|
||||
| { status: "submitted" }
|
||||
| { status: "failed"; errorMessage: string };
|
||||
|
||||
export async function sendQueuedComposerMessageNow(
|
||||
input: SendQueuedComposerMessageNowInput,
|
||||
): Promise<SendQueuedComposerMessageNowResult> {
|
||||
const item = input.queue.read(input.agentId).find((q) => q.id === input.messageId);
|
||||
if (!item) return { status: "missing" };
|
||||
input.queue.write((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(
|
||||
input.agentId,
|
||||
(prev.get(input.agentId) ?? []).filter((q) => q.id !== input.messageId),
|
||||
);
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
await input.submitMessage({ text: item.text, attachments: item.attachments });
|
||||
return { status: "submitted" };
|
||||
} catch (error) {
|
||||
input.queue.write((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(input.agentId, [item, ...(prev.get(input.agentId) ?? [])]);
|
||||
return next;
|
||||
});
|
||||
return {
|
||||
status: "failed",
|
||||
errorMessage: error instanceof Error ? error.message : "Failed to send message",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface OpenComposerAttachmentInput {
|
||||
attachment: ComposerAttachment;
|
||||
setLightboxMetadata: (metadata: AttachmentMetadata) => void;
|
||||
openWorkspaceAttachment: (input: { attachment: ComposerAttachment }) => boolean;
|
||||
openExternalUrl: (url: string) => void;
|
||||
}
|
||||
|
||||
export function openComposerAttachment(input: OpenComposerAttachmentInput): void {
|
||||
if (input.attachment.kind === "image") {
|
||||
input.setLightboxMetadata(input.attachment.metadata);
|
||||
return;
|
||||
}
|
||||
if (isWorkspaceAttachment(input.attachment)) {
|
||||
input.openWorkspaceAttachment({ attachment: input.attachment });
|
||||
return;
|
||||
}
|
||||
input.openExternalUrl(input.attachment.item.url);
|
||||
}
|
||||
|
||||
export function buildGithubAttachment(item: GitHubSearchItem): UserComposerAttachment {
|
||||
return item.kind === "pr" ? { kind: "github_pr", item } : { kind: "github_issue", item };
|
||||
}
|
||||
|
||||
export function toggleGithubAttachment(
|
||||
current: UserComposerAttachment[],
|
||||
item: GitHubSearchItem,
|
||||
): UserComposerAttachment[] {
|
||||
const matches = (attachment: UserComposerAttachment) =>
|
||||
attachment.kind !== "image" &&
|
||||
attachment.item.kind === item.kind &&
|
||||
attachment.item.number === item.number;
|
||||
if (current.some(matches)) {
|
||||
return current.filter((attachment) => !matches(attachment));
|
||||
}
|
||||
return [...current, buildGithubAttachment(item)];
|
||||
}
|
||||
|
||||
export function findGithubItemByOption(
|
||||
items: readonly GitHubSearchItem[],
|
||||
optionId: string,
|
||||
): GitHubSearchItem | undefined {
|
||||
return items.find((candidate) => `${candidate.kind}:${candidate.number}` === optionId);
|
||||
}
|
||||
|
||||
export function isAttachmentSelectedForGithubItem(
|
||||
current: readonly ComposerAttachment[],
|
||||
item: GitHubSearchItem,
|
||||
): boolean {
|
||||
return userAttachmentsOnly(current).some(
|
||||
(attachment) =>
|
||||
attachment.kind !== "image" &&
|
||||
attachment.item.kind === item.kind &&
|
||||
attachment.item.number === item.number,
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
|
||||
import { composerWorkspaceAttachment } from "@/attachments/composer-workspace-attachments";
|
||||
import {
|
||||
isWorkspaceAttachment,
|
||||
workspaceAttachmentToSubmitAttachment,
|
||||
} from "@/attachments/workspace-attachment-utils";
|
||||
import type { AgentAttachment } from "@server/shared/messages";
|
||||
import { buildGitHubAttachmentFromSearchItem } from "@/utils/review-attachments";
|
||||
|
||||
@@ -18,8 +21,8 @@ export function splitComposerAttachmentsForSubmit(attachments: ComposerAttachmen
|
||||
continue;
|
||||
}
|
||||
|
||||
if (composerWorkspaceAttachment.is(attachment)) {
|
||||
const workspaceAttachment = composerWorkspaceAttachment.toSubmitAttachment(attachment);
|
||||
if (isWorkspaceAttachment(attachment)) {
|
||||
const workspaceAttachment = workspaceAttachmentToSubmitAttachment(attachment);
|
||||
if (workspaceAttachment) {
|
||||
reviewAttachments.push(workspaceAttachment);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,7 +23,6 @@ import {
|
||||
import Animated from "react-native-reanimated";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||
import { generateMessageId, type StreamItem } from "@/types/stream";
|
||||
import {
|
||||
AgentStatusBar,
|
||||
DraftAgentStatusBar,
|
||||
@@ -31,7 +30,6 @@ import {
|
||||
} from "./agent-status-bar";
|
||||
import { ContextWindowMeter } from "./context-window-meter";
|
||||
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
|
||||
import type { PickedImageAttachmentInput } from "@/hooks/image-attachment-picker";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
MessageInput,
|
||||
@@ -44,6 +42,22 @@ import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import { focusWithRetries } from "@/utils/web-focus";
|
||||
import {
|
||||
cancelComposerAgent,
|
||||
dispatchComposerAgentMessage,
|
||||
editQueuedComposerMessage,
|
||||
findGithubItemByOption,
|
||||
isAttachmentSelectedForGithubItem,
|
||||
openComposerAttachment,
|
||||
pickAndPersistImages,
|
||||
queueComposerMessage,
|
||||
removeComposerAttachmentAtIndex,
|
||||
sendQueuedComposerMessageNow,
|
||||
toggleGithubAttachment,
|
||||
type AgentStreamWriter,
|
||||
type QueueWriter,
|
||||
type QueuedComposerMessage,
|
||||
} from "@/components/composer-actions";
|
||||
import { useVoiceOptional } from "@/contexts/voice-context";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
@@ -79,17 +93,12 @@ import type {
|
||||
import { composerWorkspaceAttachment } from "@/attachments/composer-workspace-attachments";
|
||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||
import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
|
||||
import { splitComposerAttachmentsForSubmit } from "@/components/composer-attachments";
|
||||
import { AttachmentPill } from "@/components/attachment-pill";
|
||||
import { AttachmentLightbox } from "@/components/attachment-lightbox";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { useIsDictationReady } from "@/hooks/use-is-dictation-ready";
|
||||
|
||||
interface QueuedMessage {
|
||||
id: string;
|
||||
text: string;
|
||||
attachments: ComposerAttachment[];
|
||||
}
|
||||
type QueuedMessage = QueuedComposerMessage;
|
||||
|
||||
type AttachmentListUpdater =
|
||||
| UserComposerAttachment[]
|
||||
@@ -128,37 +137,6 @@ function resolveMessagePlaceholder(isDesktopWebBreakpoint: boolean): string {
|
||||
return isDesktopWebBreakpoint ? DESKTOP_MESSAGE_PLACEHOLDER : MOBILE_MESSAGE_PLACEHOLDER;
|
||||
}
|
||||
|
||||
async function pickAndPersistImages(
|
||||
pickImages: () => Promise<PickedImageAttachmentInput[] | null>,
|
||||
): Promise<ImageAttachment[]> {
|
||||
const result = await pickImages();
|
||||
if (!result?.length) return [];
|
||||
return await Promise.all(
|
||||
result.map(async (pickedImage) => {
|
||||
if (pickedImage.source.kind === "blob") {
|
||||
return await persistAttachmentFromBlob({
|
||||
blob: pickedImage.source.blob,
|
||||
mimeType: pickedImage.mimeType || "image/jpeg",
|
||||
fileName: pickedImage.fileName ?? null,
|
||||
});
|
||||
}
|
||||
return await persistAttachmentFromFileUri({
|
||||
uri: pickedImage.source.uri,
|
||||
mimeType: pickedImage.mimeType || "image/jpeg",
|
||||
fileName: pickedImage.fileName ?? null,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function removeAttachmentAtIndex<T extends ComposerAttachment>(prev: T[], index: number): T[] {
|
||||
const removed = prev[index];
|
||||
if (removed?.kind === "image") {
|
||||
void deleteAttachments([removed.metadata]);
|
||||
}
|
||||
return prev.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
function buildCancelButtonStyle(isConnected: boolean, isCancellingAgent: boolean): object[] {
|
||||
const disabled = !isConnected || isCancellingAgent ? styles.buttonDisabled : undefined;
|
||||
return [styles.cancelButton, disabled].filter((value): value is object => Boolean(value));
|
||||
@@ -237,45 +215,6 @@ function renderLeftContent(args: RenderLeftContentArgs): ReactElement {
|
||||
return <AgentStatusBar agentId={agentId} serverId={serverId} onDropdownClose={focusInput} />;
|
||||
}
|
||||
|
||||
function findGithubItemByOption(
|
||||
items: readonly GitHubSearchItem[],
|
||||
optionId: string,
|
||||
): GitHubSearchItem | undefined {
|
||||
return items.find((candidate) => `${candidate.kind}:${candidate.number}` === optionId);
|
||||
}
|
||||
|
||||
function isAttachmentSelectedForGithubItem(
|
||||
current: readonly ComposerAttachment[],
|
||||
item: GitHubSearchItem,
|
||||
): boolean {
|
||||
return composerWorkspaceAttachment
|
||||
.userAttachmentsOnly(current)
|
||||
.some(
|
||||
(attachment) =>
|
||||
attachment.kind !== "image" &&
|
||||
attachment.item.kind === item.kind &&
|
||||
attachment.item.number === item.number,
|
||||
);
|
||||
}
|
||||
|
||||
function buildGithubAttachment(item: GitHubSearchItem): UserComposerAttachment {
|
||||
return item.kind === "pr" ? { kind: "github_pr", item } : { kind: "github_issue", item };
|
||||
}
|
||||
|
||||
function toggleGithubAttachment(
|
||||
current: UserComposerAttachment[],
|
||||
item: GitHubSearchItem,
|
||||
): UserComposerAttachment[] {
|
||||
const matches = (attachment: UserComposerAttachment) =>
|
||||
attachment.kind !== "image" &&
|
||||
attachment.item.kind === item.kind &&
|
||||
attachment.item.number === item.number;
|
||||
if (current.some(matches)) {
|
||||
return current.filter((attachment) => !matches(attachment));
|
||||
}
|
||||
return [...current, buildGithubAttachment(item)];
|
||||
}
|
||||
|
||||
interface RenderAttachmentPreviewListArgs {
|
||||
selectedAttachments: ComposerAttachment[];
|
||||
isComposerLocked: boolean;
|
||||
@@ -416,104 +355,6 @@ function attemptStartRealtimeVoice(args: AttemptStartRealtimeVoiceArgs): void {
|
||||
});
|
||||
}
|
||||
|
||||
interface DispatchAgentMessageSendArgs {
|
||||
client: NonNullable<ReturnType<typeof useHostRuntimeClient>>;
|
||||
serverId: string;
|
||||
targetAgentId: string;
|
||||
text: string;
|
||||
sendAttachments: ComposerAttachment[];
|
||||
setAgentStreamHead: ReturnType<typeof useSessionStore.getState>["setAgentStreamHead"];
|
||||
setAgentStreamTail: ReturnType<typeof useSessionStore.getState>["setAgentStreamTail"];
|
||||
}
|
||||
|
||||
function appendUserMessageToStream(
|
||||
args: DispatchAgentMessageSendArgs & { userMessage: StreamItem },
|
||||
): void {
|
||||
const { serverId, targetAgentId, userMessage, setAgentStreamHead, setAgentStreamTail } = args;
|
||||
const currentHead = useSessionStore
|
||||
.getState()
|
||||
.sessions[serverId]?.agentStreamHead?.get(targetAgentId);
|
||||
if (currentHead && currentHead.length > 0) {
|
||||
setAgentStreamHead(serverId, (prev) => {
|
||||
const head = prev.get(targetAgentId) || [];
|
||||
const updated = new Map(prev);
|
||||
updated.set(targetAgentId, [...head, userMessage]);
|
||||
return updated;
|
||||
});
|
||||
return;
|
||||
}
|
||||
setAgentStreamTail(serverId, (prev) => {
|
||||
const currentStream = prev.get(targetAgentId) || [];
|
||||
const updated = new Map(prev);
|
||||
updated.set(targetAgentId, [...currentStream, userMessage]);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
async function dispatchAgentMessageSend(args: DispatchAgentMessageSendArgs): Promise<void> {
|
||||
const { client, targetAgentId, text, sendAttachments } = args;
|
||||
const wirePayload = splitComposerAttachmentsForSubmit(sendAttachments);
|
||||
const clientMessageId = generateMessageId();
|
||||
const userMessage: StreamItem = {
|
||||
kind: "user_message",
|
||||
id: clientMessageId,
|
||||
text,
|
||||
timestamp: new Date(),
|
||||
...(wirePayload.images.length > 0 ? { images: wirePayload.images } : {}),
|
||||
...(wirePayload.attachments.length > 0 ? { attachments: wirePayload.attachments } : {}),
|
||||
};
|
||||
appendUserMessageToStream({ ...args, userMessage });
|
||||
const imagesData = await encodeImages(wirePayload.images);
|
||||
await client.sendAgentMessage(targetAgentId, text, {
|
||||
messageId: clientMessageId,
|
||||
images: imagesData ?? [],
|
||||
attachments: wirePayload.attachments,
|
||||
});
|
||||
}
|
||||
|
||||
function openComposerAttachment(
|
||||
attachment: ComposerAttachment,
|
||||
setLightboxMetadata: (metadata: AttachmentMetadata) => void,
|
||||
openWorkspaceAttachment: (input: { attachment: ComposerAttachment }) => boolean,
|
||||
): void {
|
||||
if (attachment.kind === "image") {
|
||||
setLightboxMetadata(attachment.metadata);
|
||||
return;
|
||||
}
|
||||
if (composerWorkspaceAttachment.is(attachment)) {
|
||||
openWorkspaceAttachment({ attachment });
|
||||
return;
|
||||
}
|
||||
void openExternalUrl(attachment.item.url);
|
||||
}
|
||||
|
||||
interface CancelRunningAgentArgs {
|
||||
isAgentRunning: boolean;
|
||||
isCancellingAgent: boolean;
|
||||
isConnected: boolean;
|
||||
client: ReturnType<typeof useHostRuntimeClient>;
|
||||
agentIdRef: { current: string };
|
||||
setIsCancellingAgent: (value: boolean) => void;
|
||||
messageInputRef: { current: MessageInputRef | null };
|
||||
}
|
||||
|
||||
function cancelRunningAgent(args: CancelRunningAgentArgs): void {
|
||||
const {
|
||||
isAgentRunning,
|
||||
isCancellingAgent,
|
||||
isConnected,
|
||||
client,
|
||||
agentIdRef,
|
||||
setIsCancellingAgent,
|
||||
messageInputRef,
|
||||
} = args;
|
||||
if (!isAgentRunning || isCancellingAgent) return;
|
||||
if (!isConnected || !client) return;
|
||||
setIsCancellingAgent(true);
|
||||
void client.cancelAgent(agentIdRef.current);
|
||||
messageInputRef.current?.focus();
|
||||
}
|
||||
|
||||
function focusMessageInputWithPlatformStrategy(messageInputRef: {
|
||||
current: MessageInputRef | null;
|
||||
}): void {
|
||||
@@ -1152,14 +993,18 @@ export function Composer({
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
await dispatchAgentMessageSend({
|
||||
const stream: AgentStreamWriter = {
|
||||
getHead: (id) => useSessionStore.getState().sessions[serverId]?.agentStreamHead?.get(id),
|
||||
setHead: (updater) => setAgentStreamHead(serverId, updater),
|
||||
setTail: (updater) => setAgentStreamTail(serverId, updater),
|
||||
};
|
||||
await dispatchComposerAgentMessage({
|
||||
client,
|
||||
serverId,
|
||||
targetAgentId,
|
||||
agentId: targetAgentId,
|
||||
text,
|
||||
sendAttachments,
|
||||
setAgentStreamHead,
|
||||
setAgentStreamTail,
|
||||
attachments: sendAttachments,
|
||||
encodeImages,
|
||||
stream,
|
||||
});
|
||||
onAttentionPromptSend?.();
|
||||
};
|
||||
@@ -1172,33 +1017,23 @@ export function Composer({
|
||||
const isAgentRunning = agentState.status === "running";
|
||||
const hasAgent = agentState.status !== null;
|
||||
|
||||
const updateQueue = useCallback(
|
||||
(updater: (current: QueuedMessage[]) => QueuedMessage[]) => {
|
||||
setQueuedMessages(serverId, (prev: Map<string, QueuedMessage[]>) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, updater(prev.get(agentId) ?? []));
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[agentId, serverId, setQueuedMessages],
|
||||
const queueWriter = useMemo<QueueWriter>(
|
||||
() => ({
|
||||
read: (id) => useSessionStore.getState().sessions[serverId]?.queuedMessages?.get(id) ?? [],
|
||||
write: (updater) => setQueuedMessages(serverId, updater),
|
||||
}),
|
||||
[serverId, setQueuedMessages],
|
||||
);
|
||||
|
||||
const queueMessage = useCallback(
|
||||
(queuedMessage: string, queuedAttachments: ComposerAttachment[]) => {
|
||||
const trimmedMessage = queuedMessage.trim();
|
||||
if (!trimmedMessage && queuedAttachments.length === 0) return;
|
||||
|
||||
const newItem = {
|
||||
id: generateMessageId(),
|
||||
text: trimmedMessage,
|
||||
const result = queueComposerMessage({
|
||||
agentId,
|
||||
text: queuedMessage,
|
||||
attachments: queuedAttachments,
|
||||
};
|
||||
|
||||
setQueuedMessages(serverId, (prev: Map<string, QueuedMessage[]>) => {
|
||||
const next = new Map(prev);
|
||||
next.set(agentId, [...(prev.get(agentId) ?? []), newItem]);
|
||||
return next;
|
||||
queue: queueWriter,
|
||||
});
|
||||
if (!result.queued) return;
|
||||
|
||||
setUserInput("");
|
||||
setSelectedAttachments([]);
|
||||
@@ -1208,9 +1043,8 @@ export function Composer({
|
||||
[
|
||||
agentId,
|
||||
clearSentAttachments,
|
||||
queueWriter,
|
||||
resetSuppression,
|
||||
serverId,
|
||||
setQueuedMessages,
|
||||
setSelectedAttachments,
|
||||
setUserInput,
|
||||
],
|
||||
@@ -1284,7 +1118,15 @@ export function Composer({
|
||||
);
|
||||
|
||||
const handlePickImage = useCallback(async () => {
|
||||
const newImages = await pickAndPersistImages(pickImages);
|
||||
const newImages = await pickAndPersistImages({
|
||||
pickImages,
|
||||
persister: {
|
||||
persistFromBlob: ({ blob, mimeType, fileName }) =>
|
||||
persistAttachmentFromBlob({ blob, mimeType, fileName }),
|
||||
persistFromFileUri: ({ uri, mimeType, fileName }) =>
|
||||
persistAttachmentFromFileUri({ uri, mimeType, fileName }),
|
||||
},
|
||||
});
|
||||
if (newImages.length === 0) return;
|
||||
addImages(newImages);
|
||||
}, [addImages, pickImages]);
|
||||
@@ -1298,14 +1140,23 @@ export function Composer({
|
||||
if (didRemoveWorkspaceAttachment) {
|
||||
return;
|
||||
}
|
||||
setSelectedAttachments((prev) => removeAttachmentAtIndex(prev, index));
|
||||
setSelectedAttachments((prev) =>
|
||||
removeComposerAttachmentAtIndex({ attachments: prev, index, deleteAttachments }),
|
||||
);
|
||||
},
|
||||
[removeAttachment, selectedAttachments, setSelectedAttachments],
|
||||
);
|
||||
|
||||
const handleOpenAttachment = useCallback(
|
||||
(attachment: ComposerAttachment) => {
|
||||
openComposerAttachment(attachment, setLightboxMetadata, openAttachment);
|
||||
openComposerAttachment({
|
||||
attachment,
|
||||
setLightboxMetadata,
|
||||
openWorkspaceAttachment: openAttachment,
|
||||
openExternalUrl: (url) => {
|
||||
void openExternalUrl(url);
|
||||
},
|
||||
});
|
||||
},
|
||||
[openAttachment],
|
||||
);
|
||||
@@ -1317,15 +1168,16 @@ export function Composer({
|
||||
}, [isAgentRunning, isConnected]);
|
||||
|
||||
const handleCancelAgent = useCallback(() => {
|
||||
cancelRunningAgent({
|
||||
const didCancel = cancelComposerAgent({
|
||||
client,
|
||||
agentId: agentIdRef.current,
|
||||
isAgentRunning,
|
||||
isCancellingAgent,
|
||||
isConnected,
|
||||
client,
|
||||
agentIdRef,
|
||||
setIsCancellingAgent,
|
||||
messageInputRef,
|
||||
});
|
||||
if (!didCancel) return;
|
||||
setIsCancellingAgent(true);
|
||||
messageInputRef.current?.focus();
|
||||
}, [client, isAgentRunning, isCancellingAgent, isConnected]);
|
||||
|
||||
const focusMessageInputForKeyboardAction = useCallback(() => {
|
||||
@@ -1391,33 +1243,34 @@ export function Composer({
|
||||
|
||||
const handleEditQueuedMessage = useCallback(
|
||||
(id: string) => {
|
||||
const item = queuedMessages.find((q) => q.id === id);
|
||||
if (!item) return;
|
||||
|
||||
updateQueue((current) => current.filter((q) => q.id !== id));
|
||||
setUserInput(item.text);
|
||||
setSelectedAttachments(composerWorkspaceAttachment.userAttachmentsOnly(item.attachments));
|
||||
const result = editQueuedComposerMessage({
|
||||
agentId,
|
||||
messageId: id,
|
||||
queue: queueWriter,
|
||||
});
|
||||
if (!result) return;
|
||||
setUserInput(result.text);
|
||||
setSelectedAttachments(result.attachments);
|
||||
},
|
||||
[queuedMessages, setSelectedAttachments, setUserInput, updateQueue],
|
||||
[agentId, queueWriter, setSelectedAttachments, setUserInput],
|
||||
);
|
||||
|
||||
const handleSendQueuedNow = useCallback(
|
||||
async (id: string) => {
|
||||
const item = queuedMessages.find((q) => q.id === id);
|
||||
if (!item) return;
|
||||
if (!sendAgentMessageRef.current && !onSubmitMessageRef.current) return;
|
||||
|
||||
updateQueue((current) => current.filter((q) => q.id !== id));
|
||||
|
||||
// Reuse the regular send path; server-side send atomically interrupts any active run.
|
||||
try {
|
||||
await submitMessage(item.text, item.attachments);
|
||||
} catch (error) {
|
||||
updateQueue((current) => [item, ...current]);
|
||||
setSendError(error instanceof Error ? error.message : "Failed to send message");
|
||||
const result = await sendQueuedComposerMessageNow({
|
||||
agentId,
|
||||
messageId: id,
|
||||
queue: queueWriter,
|
||||
submitMessage: ({ text, attachments: queuedAttachments }) =>
|
||||
submitMessage(text, queuedAttachments),
|
||||
});
|
||||
if (result.status === "failed") {
|
||||
setSendError(result.errorMessage);
|
||||
}
|
||||
},
|
||||
[queuedMessages, submitMessage, updateQueue],
|
||||
[agentId, queueWriter, submitMessage],
|
||||
);
|
||||
|
||||
const handleQueue = useCallback(
|
||||
|
||||
Reference in New Issue
Block a user