* fix(app): stop completed turns from appearing stuck Turn activity was inferred from a user-message row flag, so a stale duplicate row could keep the working footer active after the turn ended. Track submission lifecycle separately and route every send path through the shared submission flow. * fix(app): guard pending message identity * test(app): measure submission layout independent of scroll * fix(app): keep submitted messages consistent through reconnects Track each in-flight send until its own RPC establishes acceptance, and keep canonical timeline placement authoritative. Restore legacy cached rewind IDs during cache deserialization. * fix(app): close canonical submission races * test(app): cover canonical submission races in browser * fix(app): keep replacement submissions authoritative Keep current pending rows out of legacy cache migration and leave ambiguous terminal lifecycle events to the daemon snapshot. This prevents fabricated rewind identities and stale completion events from marking replacement turns idle. * fix(app): keep submitted messages stable across sync Give submission transport and canonical timeline ingestion separate authority. Preserve every unresolved local send across replacement, reconcile provider identity once, and bridge RPC acceptance to authoritative running state without deriving lifecycle from timeline rows. * fix(app): settle submissions in either acknowledgement order Complete submission transactions when RPC and provider acknowledgement arrive in either order. Cache only transaction-owned local rows as transient data, preserve canonical ID-less prompts, and invalidate ambiguous legacy display caches instead of inventing provider identity. * fix(app): keep agent visible during history handoff Running and terminal updates could clear create continuity before the initial authoritative timeline arrived, leaving a streaming agent behind the loading screen. End the handoff only when authoritative history is applied. * fix(app): settle attachment-only submissions Canonical providers can acknowledge image-only prompts with empty text. Reconcile those events by client identity without rendering a blank canonical row. * fix(agent): settle out-of-band message submissions Accepted commands that do not allocate a foreground turn previously had no canonical user acknowledgement. Record the command before its handler runs so submission state and reconnect history converge through the normal timeline producer. * fix(app): settle out-of-band submissions compatibly * fix(app): preserve canonical prompt order * test(app): keep workspace status check timing-independent The workspace-status scenario asserted footer settlement before initial agent creation had necessarily completed. The dedicated draft-handoff coverage owns that lifecycle contract.
403 lines
12 KiB
TypeScript
403 lines
12 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 { createUserMessage, generateMessageId, type UserMessageItem } from "@/types/stream";
|
|
import type { MessageSubmissionRejectionOutcome } from "@/composer/submission/model";
|
|
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 | { outOfBand?: boolean }>;
|
|
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 MessageSubmissionWriter {
|
|
begin: (agentId: string, message: UserMessageItem) => void;
|
|
accept: (agentId: string, clientMessageId: string, outOfBand: boolean | undefined) => void;
|
|
reject: (agentId: string, clientMessageId: string) => MessageSubmissionRejectionOutcome;
|
|
}
|
|
|
|
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>;
|
|
submission: MessageSubmissionWriter;
|
|
}
|
|
|
|
export async function dispatchComposerAgentMessage(
|
|
input: DispatchComposerAgentMessageInput,
|
|
): Promise<void> {
|
|
const wirePayload = splitComposerAttachmentsForSubmit(input.attachments, {
|
|
format: input.attachmentSubmitFormat,
|
|
});
|
|
const clientMessageId = generateMessageId();
|
|
const userMessage = createUserMessage({
|
|
clientMessageId,
|
|
text: input.text,
|
|
timestamp: new Date(),
|
|
images: wirePayload.images,
|
|
attachments: wirePayload.attachments,
|
|
});
|
|
input.submission.begin(input.agentId, userMessage);
|
|
try {
|
|
const imagesData = await input.encodeImages(wirePayload.images);
|
|
const result = await input.client.sendAgentMessage(input.agentId, input.text, {
|
|
messageId: clientMessageId,
|
|
images: imagesData ?? [],
|
|
attachments: wirePayload.attachments,
|
|
});
|
|
input.submission.accept(input.agentId, clientMessageId, result?.outOfBand);
|
|
} catch (error) {
|
|
const outcome = input.submission.reject(input.agentId, clientMessageId);
|
|
if (outcome === "accepted") return;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
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;
|