Files
paseo/packages/app/src/composer/actions.ts
T
bd937850b3 feat: add workspace files to chat from Files and Changes
* feat(app): open changed files from context menu

* fix(app): preserve changed-file row behavior

* feat(app): add changed files to open chats

* feat(app): share file actions across explorer and changes

* feat(app): attach workspace files to focused chat

* feat(app): drag workspace files into chat

* refactor(app): use file actions menu for changed files

* fix(app): open changed-file actions on right click

* test(app): cover direct file attachment flow

* fix(app): enable add to chat on native

* fix(app): align workspace file pane rows

* refactor(app): simplify workspace file attachments

* fix(app): preserve uploaded file draft attachments

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-22 15:58:24 +02:00

429 lines
13 KiB
TypeScript

import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
import type {
AttachmentMetadata,
ComposerAttachment,
UserComposerAttachment,
} from "@/attachments/types";
import {
isWorkspaceAttachment,
userAttachmentsOnly,
} from "@/attachments/workspace-attachment-utils";
import {
splitComposerAttachmentsForSubmit,
type ComposerAttachmentSubmitFormat,
} from "@/composer/attachments/submit";
import {
appendOptimisticUserMessageToStream,
buildOptimisticUserMessage,
generateMessageId,
type StreamItem,
type UserMessageItem,
} from "@/types/stream";
import type { PickedImageAttachmentInput } from "@/hooks/image-attachment-picker";
import { i18n } from "@/i18n/i18next";
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>;
uploadFile: (input: { fileName: string; mimeType: string; bytes: Uint8Array }) => Promise<{
requestId: string;
file: {
type: "uploaded_file";
id: string;
fileName: string;
mimeType: string;
size: number;
path: string;
} | null;
error: string | null;
}>;
}
export interface ComposerCancelClient {
cancelAgent: (agentId: string) => Promise<void> | void;
}
export interface AgentStreamWriter {
getTail: (agentId: string) => StreamItem[] | undefined;
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 async function uploadFileAttachments(input: {
client: ComposerSendClient;
files: Array<{ fileName: string; mimeType: string; bytes: Uint8Array }>;
}): Promise<Extract<ComposerAttachment, { kind: "file" }>[]> {
const result: Extract<ComposerAttachment, { kind: "file" }>[] = [];
for (const file of input.files) {
const response = await input.client.uploadFile(file);
if (response.error || !response.file) {
throw new Error(response.error ?? "Upload failed.");
}
result.push({ kind: "file", attachment: response.file });
}
return result;
}
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;
onCancelFailed: (error: unknown) => void;
}
export function cancelComposerAgent(input: CancelComposerAgentInput): boolean {
if (!input.isAgentRunning || input.isCancellingAgent) return false;
if (!input.isConnected || !input.client) return false;
try {
void Promise.resolve(input.client.cancelAgent(input.agentId)).catch(input.onCancelFailed);
} catch (error) {
input.onCancelFailed(error);
return false;
}
return true;
}
export interface DispatchComposerAgentMessageInput {
client: ComposerSendClient;
agentId: string;
text: string;
attachments: ComposerAttachment[];
attachmentSubmitFormat?: ComposerAttachmentSubmitFormat;
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, {
format: input.attachmentSubmitFormat,
});
const messageId = generateMessageId();
const userMessage = buildOptimisticUserMessage({
id: messageId,
text: input.text,
timestamp: new Date(),
images: wirePayload.images,
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: UserMessageItem,
stream: AgentStreamWriter,
): void {
const result = appendOptimisticUserMessageToStream({
tail: stream.getTail(agentId) ?? [],
head: stream.getHead(agentId) ?? [],
message: userMessage,
placement: "active-head",
});
if (result.changedHead) {
stream.setHead((prev) => {
const next = new Map(prev);
next.set(agentId, result.head);
return next;
});
}
if (result.changedTail) {
stream.setTail((prev) => {
const next = new Map(prev);
next.set(agentId, result.tail);
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>;
failedToSendMessage?: string;
}
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
: (input.failedToSendMessage ?? i18n.t("composer.errors.failedToSend")),
};
}
}
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 (input.attachment.kind === "file" || input.attachment.kind === "workspace_file") {
return;
}
if (isWorkspaceAttachment(input.attachment)) {
input.openWorkspaceAttachment({ attachment: input.attachment });
return;
}
input.openExternalUrl(input.attachment.item.url);
}
export function buildForgeAttachment(item: ForgeSearchItem): UserComposerAttachment {
return item.kind === "change_request"
? { kind: "forge_change_request", item }
: { kind: "forge_issue", item };
}
function isForgeAttachment(
attachment: UserComposerAttachment,
): attachment is Extract<
UserComposerAttachment,
{ kind: "forge_issue" | "forge_change_request" | "github_issue" | "github_pr" }
> {
return (
attachment.kind === "forge_issue" ||
attachment.kind === "forge_change_request" ||
// COMPAT(githubAttachmentKinds): added in v0.1.106, remove after 2026-12-28 once daemon floor >= v0.1.106
attachment.kind === "github_issue" ||
attachment.kind === "github_pr"
);
}
export function toggleForgeAttachment(
current: UserComposerAttachment[],
item: ForgeSearchItem,
): UserComposerAttachment[] {
const matches = (attachment: UserComposerAttachment) =>
isForgeAttachment(attachment) &&
attachment.item.kind === item.kind &&
attachment.item.number === item.number;
if (current.some(matches)) {
return current.filter((attachment) => !matches(attachment));
}
return [...current, buildForgeAttachment(item)];
}
interface ToggleGithubAttachmentFromPickerInput {
current: UserComposerAttachment[];
item: ForgeSearchItem;
markGithubAttachmentRemoved: (attachment: UserComposerAttachment) => void;
}
export function toggleGithubAttachmentFromPicker({
current,
item,
markGithubAttachmentRemoved,
}: ToggleGithubAttachmentFromPickerInput): UserComposerAttachment[] {
const existingAttachment = current.find(
(attachment) =>
isForgeAttachment(attachment) &&
attachment.item.kind === item.kind &&
attachment.item.number === item.number,
);
if (existingAttachment) {
markGithubAttachmentRemoved(existingAttachment);
}
return toggleForgeAttachment(current, item);
}
export function findGithubItemByOption(
items: readonly ForgeSearchItem[],
optionId: string,
): ForgeSearchItem | undefined {
return items.find((candidate) => `${candidate.kind}:${candidate.number}` === optionId);
}
export function isAttachmentSelectedForGithubItem(
current: readonly ComposerAttachment[],
item: ForgeSearchItem,
): boolean {
return userAttachmentsOnly(current).some(
(attachment) =>
isForgeAttachment(attachment) &&
attachment.item.kind === item.kind &&
attachment.item.number === item.number,
);
}
export const toggleGithubAttachment = toggleForgeAttachment;