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; persistFromFileUri: (input: { uri: string; mimeType: string; fileName: string | null; }) => Promise; deleteAttachments: (metadata: AttachmentMetadata[]) => Promise | void; } export interface ComposerSendClient { sendAgentMessage: ( agentId: string, text: string, options: { messageId: string; images: Array<{ data: string; mimeType: string }>; attachments: ReturnType["attachments"]; }, ) => Promise; 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; } export interface AgentStreamWriter { getTail: (agentId: string) => StreamItem[] | undefined; getHead: (agentId: string) => StreamItem[] | undefined; setHead: (updater: (prev: Map) => Map) => void; setTail: (updater: (prev: Map) => Map) => void; } export interface QueueWriter { read: (agentId: string) => QueuedComposerMessage[]; write: ( updater: (prev: Map) => Map, ) => void; } export async function pickAndPersistImages(input: { pickImages: () => Promise; persister: Pick; }): Promise { 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; 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[]> { const result: Extract[] = []; 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(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 | undefined>; stream: AgentStreamWriter; } export async function dispatchComposerAgentMessage( input: DispatchComposerAgentMessageInput, ): Promise { 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, }); const rollbackOptimisticMessage = appendUserMessageToStream( input.agentId, userMessage, input.stream, ); try { const imagesData = await input.encodeImages(wirePayload.images); await input.client.sendAgentMessage(input.agentId, input.text, { messageId, images: imagesData ?? [], attachments: wirePayload.attachments, }); } catch (error) { rollbackOptimisticMessage(); throw error; } } 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", }); const write = result.changedHead ? stream.setHead : stream.setTail; const items = result.changedHead ? result.head : result.tail; write((prev) => new Map(prev).set(agentId, items)); return () => { write((prev) => { const current = prev.get(agentId); if (!current) return prev; const nextItems = current.filter( (item) => item.id !== userMessage.id || item.kind !== "user_message" || !item.optimistic, ); if (nextItems.length === current.length) return prev; return new Map(prev).set(agentId, nextItems); }); }; } 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; failedToSendMessage?: string; } export type SendQueuedComposerMessageNowResult = | { status: "missing" } | { status: "submitted" } | { status: "failed"; errorMessage: string }; export async function sendQueuedComposerMessageNow( input: SendQueuedComposerMessageNowInput, ): Promise { 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;