* Add npm release track invariant and beta dist-tag procedure * Revert "Keep older chat history and image previews stable (#2490)" The timeline optimistic/pagination rework needs more work than main releases can wait for. Reverted together with #2484; both are reapplied on integration/timeline-optimistic-rework. * Revert "Stop completed turns from appearing stuck (#2484)" See the #2490 revert. #2490 built on this submission-authority change, so the two revert and reapply as a pair.
439 lines
13 KiB
TypeScript
439 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;
|
|
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,
|
|
});
|
|
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<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;
|