From 0c710b09f52fc0a92a01dee464dcff9bf2c98849 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 22 Feb 2026 16:22:21 +0700 Subject: [PATCH] refactor(app): introduce attachment storage layer and draft lifecycle management Replace inline data-URL image handling with a persistent attachment storage service, add draft lifecycle states (active/abandoned/sent) with generation tracking, and update fetchAgent to return project placement alongside agent snapshots. --- .../local-file-attachment-store.ts | 208 ++++++ .../native/native-file-attachment-store.ts | 17 + packages/app/src/attachments/service.ts | 125 ++++ packages/app/src/attachments/store.ts | 38 ++ packages/app/src/attachments/types.ts | 63 ++ .../attachments/use-attachment-preview-url.ts | 63 ++ packages/app/src/attachments/utils.ts | 78 +++ .../web/indexeddb-attachment-store.ts | 213 ++++++ ...ent-input-area.draft-persist-guard.test.ts | 48 ++ .../agent-input-area.draft-persist-guard.ts | 20 + .../app/src/components/agent-input-area.tsx | 138 +++- packages/app/src/components/left-sidebar.tsx | 1 - packages/app/src/components/message-input.tsx | 24 +- packages/app/src/components/message.tsx | 25 +- packages/app/src/contexts/session-context.tsx | 12 +- packages/app/src/hooks/use-file-drop-zone.ts | 45 +- .../app/src/hooks/use-sidebar-agents-list.ts | 6 +- packages/app/src/runtime/host-runtime.ts | 3 + .../src/screens/agent/agent-ready-screen.tsx | 25 +- .../src/screens/agent/draft-agent-screen.tsx | 17 +- .../app/src/stores/create-flow-store.test.ts | 49 ++ packages/app/src/stores/create-flow-store.ts | 61 +- packages/app/src/stores/draft-keys.test.ts | 33 + packages/app/src/stores/draft-keys.ts | 18 + .../src/stores/draft-store.lifecycle.test.ts | 69 ++ .../src/stores/draft-store.migration.test.ts | 71 ++ packages/app/src/stores/draft-store.ts | 627 +++++++++++++++++- packages/app/src/stores/session-store.ts | 18 +- packages/app/src/test/window-local-storage.ts | 38 ++ packages/app/src/types/stream.test.ts | 8 +- packages/app/src/types/stream.ts | 6 +- .../app/src/utils/agent-directory-sync.ts | 22 +- packages/app/src/utils/encode-images.ts | 66 +- .../image-attachments-from-files.test.ts | 77 ++- .../src/utils/image-attachments-from-files.ts | 46 +- .../src/utils/pending-create-images.test.ts | 45 +- .../app/src/utils/pending-create-images.ts | 9 +- packages/cli/src/commands/agent/archive.ts | 5 +- packages/cli/src/commands/agent/attach.ts | 6 +- packages/cli/src/commands/agent/inspect.ts | 6 +- packages/cli/src/commands/agent/logs.ts | 6 +- packages/cli/src/commands/agent/mode.ts | 5 +- packages/cli/src/commands/agent/stop.ts | 6 +- packages/cli/src/commands/agent/update.ts | 14 +- packages/cli/src/commands/permit/allow.ts | 5 +- packages/cli/src/commands/permit/deny.ts | 5 +- packages/server/src/client/daemon-client.ts | 25 +- .../src/server/daemon-client.e2e.test.ts | 22 +- .../daemon-e2e/agent-operations.e2e.test.ts | 34 +- .../claude-autonomous-wake.real.e2e.test.ts | 19 +- ...hile-running-stuck-claude.real.e2e.test.ts | 6 +- .../send-while-running-stuck.real.e2e.test.ts | 6 +- packages/server/src/server/session.ts | 102 ++- ...ges.create-agent-client-message-id.test.ts | 23 + packages/server/src/shared/messages.ts | 4 +- 55 files changed, 2381 insertions(+), 350 deletions(-) create mode 100644 packages/app/src/attachments/local-file-attachment-store.ts create mode 100644 packages/app/src/attachments/native/native-file-attachment-store.ts create mode 100644 packages/app/src/attachments/service.ts create mode 100644 packages/app/src/attachments/store.ts create mode 100644 packages/app/src/attachments/types.ts create mode 100644 packages/app/src/attachments/use-attachment-preview-url.ts create mode 100644 packages/app/src/attachments/utils.ts create mode 100644 packages/app/src/attachments/web/indexeddb-attachment-store.ts create mode 100644 packages/app/src/components/agent-input-area.draft-persist-guard.test.ts create mode 100644 packages/app/src/components/agent-input-area.draft-persist-guard.ts create mode 100644 packages/app/src/stores/create-flow-store.test.ts create mode 100644 packages/app/src/stores/draft-keys.test.ts create mode 100644 packages/app/src/stores/draft-keys.ts create mode 100644 packages/app/src/stores/draft-store.lifecycle.test.ts create mode 100644 packages/app/src/stores/draft-store.migration.test.ts create mode 100644 packages/app/src/test/window-local-storage.ts create mode 100644 packages/server/src/shared/messages.create-agent-client-message-id.test.ts diff --git a/packages/app/src/attachments/local-file-attachment-store.ts b/packages/app/src/attachments/local-file-attachment-store.ts new file mode 100644 index 000000000..688fc0e2b --- /dev/null +++ b/packages/app/src/attachments/local-file-attachment-store.ts @@ -0,0 +1,208 @@ +import * as FileSystem from "expo-file-system/legacy"; +import { + type AttachmentStore, + type AttachmentStorageType, + type AttachmentMetadata, + type SaveAttachmentInput, +} from "@/attachments/types"; +import { + blobToBase64, + fileUriToPath, + generateAttachmentId, + getFileExtensionFromName, + normalizeMimeType, + parseDataUrl, + pathToFileUri, +} from "@/attachments/utils"; + +const IMAGE_EXTENSION_BY_MIME_TYPE: Record = { + "image/png": ".png", + "image/jpeg": ".jpg", + "image/jpg": ".jpg", + "image/gif": ".gif", + "image/webp": ".webp", + "image/avif": ".avif", + "image/heic": ".heic", + "image/heif": ".heif", + "image/tiff": ".tiff", + "image/bmp": ".bmp", + "image/svg+xml": ".svg", +}; + +function extensionForAttachment(params: { fileName?: string | null; mimeType: string }): string { + const fromName = getFileExtensionFromName(params.fileName); + if (fromName) { + return fromName; + } + return IMAGE_EXTENSION_BY_MIME_TYPE[params.mimeType] ?? ".img"; +} + +async function ensureDirectory(uri: string): Promise { + const info = await FileSystem.getInfoAsync(uri); + if (info.exists && info.isDirectory) { + return; + } + await FileSystem.makeDirectoryAsync(uri, { intermediates: true }); +} + +async function writeFromSource(input: { + source: SaveAttachmentInput["source"]; + targetUri: string; + mimeType: string; +}): Promise { + if (input.source.kind === "file_uri") { + const from = pathToFileUri(input.source.uri); + if (from === input.targetUri) { + return; + } + await FileSystem.copyAsync({ from, to: input.targetUri }); + return; + } + + if (input.source.kind === "data_url") { + const parsed = parseDataUrl(input.source.dataUrl); + const mimeType = normalizeMimeType(parsed.mimeType || input.mimeType); + const base64 = parsed.base64; + await FileSystem.writeAsStringAsync(input.targetUri, base64, { + encoding: FileSystem.EncodingType.Base64, + }); + if (mimeType !== input.mimeType) { + return; + } + return; + } + + const base64 = await blobToBase64(input.source.blob); + await FileSystem.writeAsStringAsync(input.targetUri, base64, { + encoding: FileSystem.EncodingType.Base64, + }); +} + +function attachmentUri(metadata: AttachmentMetadata): string { + return pathToFileUri(metadata.storageKey); +} + +export function createLocalFileAttachmentStore(params: { + storageType: Extract; + baseDirectoryName: string; + resolvePreviewUrl: (attachment: AttachmentMetadata) => Promise; + releasePreviewUrl?: (input: { + attachment: AttachmentMetadata; + url: string; + }) => Promise; +}): AttachmentStore { + const baseDirectory = FileSystem.cacheDirectory + ? `${FileSystem.cacheDirectory}${params.baseDirectoryName}/` + : null; + + async function resolveTarget(input: SaveAttachmentInput): Promise<{ + id: string; + mimeType: string; + fileName: string | null; + createdAt: number; + targetUri: string; + storageKey: string; + }> { + if (!baseDirectory) { + throw new Error("expo-file-system cacheDirectory is unavailable."); + } + + await ensureDirectory(baseDirectory); + + const id = input.id ?? generateAttachmentId(); + const mimeTypeFromSource = + input.source.kind === "data_url" + ? parseDataUrl(input.source.dataUrl).mimeType + : input.source.kind === "blob" + ? input.source.blob.type + : undefined; + const mimeType = normalizeMimeType(input.mimeType ?? mimeTypeFromSource); + const fileName = input.fileName ?? null; + const extension = extensionForAttachment({ fileName, mimeType }); + const createdAt = Date.now(); + const targetUri = `${baseDirectory}${id}${extension}`; + const storageKey = fileUriToPath(targetUri); + + return { + id, + mimeType, + fileName, + createdAt, + targetUri, + storageKey, + }; + } + + return { + storageType: params.storageType, + + async save(input): Promise { + const target = await resolveTarget(input); + await writeFromSource({ + source: input.source, + targetUri: target.targetUri, + mimeType: target.mimeType, + }); + + const info = await FileSystem.getInfoAsync(target.targetUri); + const byteSize = + info.exists && typeof (info as { size?: number }).size === "number" + ? (info as { size: number }).size + : null; + return { + id: target.id, + mimeType: target.mimeType, + storageType: params.storageType, + storageKey: target.storageKey, + fileName: target.fileName, + byteSize, + createdAt: target.createdAt, + }; + }, + + async encodeBase64({ attachment }): Promise { + const uri = attachmentUri(attachment); + return await FileSystem.readAsStringAsync(uri, { + encoding: FileSystem.EncodingType.Base64, + }); + }, + + async resolvePreviewUrl({ attachment }): Promise { + return await params.resolvePreviewUrl(attachment); + }, + + ...(params.releasePreviewUrl + ? { + async releasePreviewUrl(input: { + attachment: AttachmentMetadata; + url: string; + }): Promise { + await params.releasePreviewUrl?.(input); + }, + } + : {}), + + async delete({ attachment }): Promise { + await FileSystem.deleteAsync(attachmentUri(attachment), { idempotent: true }); + }, + + async garbageCollect({ referencedIds }): Promise { + if (!baseDirectory) { + return; + } + await ensureDirectory(baseDirectory); + const entries = await FileSystem.readDirectoryAsync(baseDirectory); + await Promise.all( + entries.map(async (entryName) => { + const id = entryName.split(".", 1)[0] ?? ""; + if (!id || referencedIds.has(id)) { + return; + } + await FileSystem.deleteAsync(`${baseDirectory}${entryName}`, { + idempotent: true, + }); + }) + ); + }, + }; +} diff --git a/packages/app/src/attachments/native/native-file-attachment-store.ts b/packages/app/src/attachments/native/native-file-attachment-store.ts new file mode 100644 index 000000000..2eab6236c --- /dev/null +++ b/packages/app/src/attachments/native/native-file-attachment-store.ts @@ -0,0 +1,17 @@ +import { createLocalFileAttachmentStore } from "@/attachments/local-file-attachment-store"; + +export function createNativeFileAttachmentStore() { + return createLocalFileAttachmentStore({ + storageType: "native-file", + baseDirectoryName: "paseo-native-attachments", + resolvePreviewUrl: async (attachment) => { + if (attachment.storageKey.startsWith("file://")) { + return attachment.storageKey; + } + if (attachment.storageKey.startsWith("/")) { + return `file://${attachment.storageKey}`; + } + return attachment.storageKey; + }, + }); +} diff --git a/packages/app/src/attachments/service.ts b/packages/app/src/attachments/service.ts new file mode 100644 index 000000000..0fdcf82c9 --- /dev/null +++ b/packages/app/src/attachments/service.ts @@ -0,0 +1,125 @@ +import type { AttachmentMetadata } from "@/attachments/types"; +import { getAttachmentStore } from "@/attachments/store"; + +export async function persistAttachmentFromBlob(input: { + blob: Blob; + mimeType?: string; + fileName?: string | null; + id?: string; +}): Promise { + const store = await getAttachmentStore(); + return await store.save({ + id: input.id, + mimeType: input.mimeType, + fileName: input.fileName, + source: { kind: "blob", blob: input.blob }, + }); +} + +export async function persistAttachmentFromDataUrl(input: { + dataUrl: string; + mimeType?: string; + fileName?: string | null; + id?: string; +}): Promise { + const store = await getAttachmentStore(); + return await store.save({ + id: input.id, + mimeType: input.mimeType, + fileName: input.fileName, + source: { kind: "data_url", dataUrl: input.dataUrl }, + }); +} + +export async function persistAttachmentFromFileUri(input: { + uri: string; + mimeType?: string; + fileName?: string | null; + id?: string; +}): Promise { + const store = await getAttachmentStore(); + return await store.save({ + id: input.id, + mimeType: input.mimeType, + fileName: input.fileName, + source: { kind: "file_uri", uri: input.uri }, + }); +} + +export async function encodeAttachmentsForSend( + attachments: readonly AttachmentMetadata[] | undefined +): Promise | undefined> { + if (!attachments || attachments.length === 0) { + return undefined; + } + + const store = await getAttachmentStore(); + const encoded = await Promise.all( + attachments.map(async (attachment) => { + try { + const data = await store.encodeBase64({ attachment }); + return { + data, + mimeType: attachment.mimeType, + }; + } catch (error) { + console.error("[attachments] Failed to encode attachment for send", { + id: attachment.id, + error, + }); + return null; + } + }) + ); + + const valid = encoded.filter( + (entry): entry is { data: string; mimeType: string } => entry !== null + ); + return valid.length > 0 ? valid : undefined; +} + +export async function resolveAttachmentPreviewUrl( + attachment: AttachmentMetadata +): Promise { + const store = await getAttachmentStore(); + return await store.resolvePreviewUrl({ attachment }); +} + +export async function releaseAttachmentPreviewUrl(input: { + attachment: AttachmentMetadata; + url: string; +}): Promise { + const store = await getAttachmentStore(); + if (!store.releasePreviewUrl) { + return; + } + await store.releasePreviewUrl({ attachment: input.attachment, url: input.url }); +} + +export async function deleteAttachments( + attachments: readonly AttachmentMetadata[] | undefined +): Promise { + if (!attachments || attachments.length === 0) { + return; + } + const store = await getAttachmentStore(); + await Promise.all( + attachments.map(async (attachment) => { + try { + await store.delete({ attachment }); + } catch (error) { + console.warn("[attachments] Failed to delete attachment", { + id: attachment.id, + error, + }); + } + }) + ); +} + +export async function garbageCollectAttachments(input: { + referencedIds: ReadonlySet; +}): Promise { + const store = await getAttachmentStore(); + await store.garbageCollect({ referencedIds: input.referencedIds }); +} diff --git a/packages/app/src/attachments/store.ts b/packages/app/src/attachments/store.ts new file mode 100644 index 000000000..611e03bc9 --- /dev/null +++ b/packages/app/src/attachments/store.ts @@ -0,0 +1,38 @@ +import { Platform } from "react-native"; +import { isTauriEnvironment } from "@/utils/tauri"; +import type { AttachmentStore } from "@/attachments/types"; + +let attachmentStorePromise: Promise | null = null; + +async function createAttachmentStore(): Promise { + if (Platform.OS === "web") { + if (isTauriEnvironment()) { + const { createDesktopAttachmentStore } = await import( + "@/desktop/attachments/desktop-attachment-store" + ); + return createDesktopAttachmentStore(); + } + + const { createIndexedDbAttachmentStore } = await import( + "@/attachments/web/indexeddb-attachment-store" + ); + return createIndexedDbAttachmentStore(); + } + + const { createNativeFileAttachmentStore } = await import( + "@/attachments/native/native-file-attachment-store" + ); + return createNativeFileAttachmentStore(); +} + +export async function getAttachmentStore(): Promise { + if (!attachmentStorePromise) { + attachmentStorePromise = createAttachmentStore(); + } + return await attachmentStorePromise; +} + +/** Test-only hook to inject a deterministic store implementation. */ +export function __setAttachmentStoreForTests(store: AttachmentStore | null): void { + attachmentStorePromise = store ? Promise.resolve(store) : null; +} diff --git a/packages/app/src/attachments/types.ts b/packages/app/src/attachments/types.ts new file mode 100644 index 000000000..9d4bf0718 --- /dev/null +++ b/packages/app/src/attachments/types.ts @@ -0,0 +1,63 @@ +export type AttachmentStorageType = "web-indexeddb" | "desktop-file" | "native-file"; + +export interface AttachmentMetadata { + id: string; + mimeType: string; + storageType: AttachmentStorageType; + /** + * Platform-specific location key. + * - web-indexeddb: object store key + * - desktop-file/native-file: absolute file path without preview URL indirection + */ + storageKey: string; + fileName?: string | null; + byteSize?: number | null; + createdAt: number; +} + +export type AttachmentDataSource = + | { kind: "blob"; blob: Blob } + | { kind: "data_url"; dataUrl: string } + | { kind: "file_uri"; uri: string }; + +export interface SaveAttachmentInput { + id?: string; + mimeType?: string; + fileName?: string | null; + source: AttachmentDataSource; +} + +export interface ResolvePreviewUrlInput { + attachment: AttachmentMetadata; +} + +export interface ReleasePreviewUrlInput { + attachment: AttachmentMetadata; + url: string; +} + +export interface EncodeAttachmentInput { + attachment: AttachmentMetadata; +} + +export interface DeleteAttachmentInput { + attachment: AttachmentMetadata; +} + +export interface GarbageCollectInput { + referencedIds: ReadonlySet; +} + +/** + * Async storage contract for attachment bytes. + * Metadata is persisted in drafts/messages; bytes live in platform stores. + */ +export interface AttachmentStore { + readonly storageType: AttachmentStorageType; + save(input: SaveAttachmentInput): Promise; + encodeBase64(input: EncodeAttachmentInput): Promise; + resolvePreviewUrl(input: ResolvePreviewUrlInput): Promise; + releasePreviewUrl?(input: ReleasePreviewUrlInput): Promise; + delete(input: DeleteAttachmentInput): Promise; + garbageCollect(input: GarbageCollectInput): Promise; +} diff --git a/packages/app/src/attachments/use-attachment-preview-url.ts b/packages/app/src/attachments/use-attachment-preview-url.ts new file mode 100644 index 000000000..7ccead12f --- /dev/null +++ b/packages/app/src/attachments/use-attachment-preview-url.ts @@ -0,0 +1,63 @@ +import { useEffect, useRef, useState } from "react"; +import type { AttachmentMetadata } from "@/attachments/types"; +import { + releaseAttachmentPreviewUrl, + resolveAttachmentPreviewUrl, +} from "@/attachments/service"; + +export function useAttachmentPreviewUrl( + attachment: AttachmentMetadata | null | undefined +): string | null { + const [url, setUrl] = useState(null); + const activeAttachmentRef = useRef(null); + + useEffect(() => { + let disposed = false; + let currentUrl: string | null = null; + + activeAttachmentRef.current = attachment ?? null; + if (!attachment) { + setUrl(null); + return; + } + + void (async () => { + try { + const resolved = await resolveAttachmentPreviewUrl(attachment); + if (disposed) { + await releaseAttachmentPreviewUrl({ attachment, url: resolved }); + return; + } + currentUrl = resolved; + setUrl(resolved); + } catch (error) { + console.error("[attachments] Failed to resolve preview URL", { + attachmentId: attachment.id, + error, + }); + if (!disposed) { + setUrl(null); + } + } + })(); + + return () => { + disposed = true; + const activeAttachment = activeAttachmentRef.current; + if (!currentUrl || !activeAttachment) { + return; + } + void releaseAttachmentPreviewUrl({ + attachment: activeAttachment, + url: currentUrl, + }); + }; + }, [ + attachment?.id, + attachment?.storageType, + attachment?.storageKey, + attachment?.mimeType, + ]); + + return url; +} diff --git a/packages/app/src/attachments/utils.ts b/packages/app/src/attachments/utils.ts new file mode 100644 index 000000000..66d8266ff --- /dev/null +++ b/packages/app/src/attachments/utils.ts @@ -0,0 +1,78 @@ +import { generateMessageId } from "@/types/stream"; + +export function generateAttachmentId(): string { + return `att_${generateMessageId()}`; +} + +export function normalizeMimeType(input: string | undefined | null): string { + if (!input) { + return "image/jpeg"; + } + const trimmed = input.trim(); + return trimmed.length > 0 ? trimmed : "image/jpeg"; +} + +export function parseDataUrl(dataUrl: string): { mimeType: string; base64: string } { + const match = /^data:([^;,]+)?;base64,(.+)$/i.exec(dataUrl); + if (!match) { + throw new Error("Malformed data URL for attachment."); + } + const [, mimeTypeRaw, base64] = match; + if (!base64) { + throw new Error("Attachment data URL is missing base64 payload."); + } + return { + mimeType: normalizeMimeType(mimeTypeRaw), + base64, + }; +} + +export async function blobToBase64(blob: Blob): Promise { + return await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result !== "string") { + reject(new Error("Unexpected FileReader result while encoding attachment.")); + return; + } + const payload = reader.result.split(",", 2)[1]; + if (!payload) { + reject(new Error("Attachment FileReader result did not contain base64 payload.")); + return; + } + resolve(payload); + }; + reader.onerror = () => { + reject(reader.error ?? new Error("Failed to read attachment blob.")); + }; + reader.readAsDataURL(blob); + }); +} + +export function pathToFileUri(path: string): string { + if (path.startsWith("file://")) { + return path; + } + if (path.startsWith("/")) { + return `file://${path}`; + } + return path; +} + +export function fileUriToPath(uri: string): string { + if (!uri.startsWith("file://")) { + return uri; + } + return decodeURIComponent(uri.replace(/^file:\/\//, "")); +} + +export function getFileExtensionFromName(fileName: string | null | undefined): string { + if (!fileName) { + return ""; + } + const idx = fileName.lastIndexOf("."); + if (idx <= 0 || idx === fileName.length - 1) { + return ""; + } + return fileName.slice(idx); +} diff --git a/packages/app/src/attachments/web/indexeddb-attachment-store.ts b/packages/app/src/attachments/web/indexeddb-attachment-store.ts new file mode 100644 index 000000000..5f0f9aea9 --- /dev/null +++ b/packages/app/src/attachments/web/indexeddb-attachment-store.ts @@ -0,0 +1,213 @@ +import { + type AttachmentStore, + type AttachmentMetadata, + type SaveAttachmentInput, +} from "@/attachments/types"; +import { + blobToBase64, + generateAttachmentId, + normalizeMimeType, + parseDataUrl, +} from "@/attachments/utils"; + +type StoredBlobRecord = { + id: string; + blob: Blob; + createdAt: number; + fileName: string | null; +}; + +const DB_NAME = "paseo-attachment-bytes"; +const STORE_NAME = "attachments"; +const DB_VERSION = 1; + +function ensureIndexedDb(): IDBFactory { + const idb = globalThis.indexedDB; + if (!idb) { + throw new Error("IndexedDB is unavailable in this runtime."); + } + return idb; +} + +function openAttachmentDb(): Promise { + return new Promise((resolve, reject) => { + const request = ensureIndexedDb().open(DB_NAME, DB_VERSION); + + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME, { keyPath: "id" }); + } + }; + + request.onsuccess = () => { + resolve(request.result); + }; + + request.onerror = () => { + reject(request.error ?? new Error("Failed to open attachment IndexedDB.")); + }; + }); +} + +function runTx( + db: IDBDatabase, + mode: IDBTransactionMode, + run: (store: IDBObjectStore) => IDBRequest +): Promise { + return new Promise((resolve, reject) => { + const transaction = db.transaction(STORE_NAME, mode); + const store = transaction.objectStore(STORE_NAME); + const request = run(store); + + request.onsuccess = () => { + resolve(request.result as T); + }; + + request.onerror = () => { + reject(request.error ?? new Error("IndexedDB transaction request failed.")); + }; + + transaction.onerror = () => { + reject(transaction.error ?? new Error("IndexedDB transaction failed.")); + }; + }); +} + +async function sourceToBlob(input: SaveAttachmentInput): Promise<{ blob: Blob; mimeType: string }> { + const source = input.source; + if (source.kind === "blob") { + const mimeType = normalizeMimeType(input.mimeType ?? source.blob.type); + const blob = source.blob.type === mimeType ? source.blob : source.blob.slice(0, source.blob.size, mimeType); + return { blob, mimeType }; + } + + if (source.kind === "data_url") { + const parsed = parseDataUrl(source.dataUrl); + const response = await fetch(source.dataUrl); + const blob = await response.blob(); + const mimeType = normalizeMimeType(input.mimeType ?? parsed.mimeType ?? blob.type); + return { + blob: blob.type === mimeType ? blob : blob.slice(0, blob.size, mimeType), + mimeType, + }; + } + + const response = await fetch(source.uri); + const blob = await response.blob(); + const mimeType = normalizeMimeType(input.mimeType ?? blob.type); + return { + blob: blob.type === mimeType ? blob : blob.slice(0, blob.size, mimeType), + mimeType, + }; +} + +async function loadBlob(db: IDBDatabase, id: string): Promise { + const record = await runTx(db, "readonly", (store) => + store.get(id) + ); + if (!record?.blob) { + throw new Error(`Attachment ${id} was not found in IndexedDB.`); + } + return record.blob; +} + +export function createIndexedDbAttachmentStore(): AttachmentStore { + return { + storageType: "web-indexeddb", + + async save(input): Promise { + const id = input.id ?? generateAttachmentId(); + const createdAt = Date.now(); + const { blob, mimeType } = await sourceToBlob(input); + const fileName = input.fileName ?? null; + const db = await openAttachmentDb(); + + try { + await runTx(db, "readwrite", (store) => + store.put({ id, blob, createdAt, fileName } satisfies StoredBlobRecord) + ); + } finally { + db.close(); + } + + return { + id, + mimeType, + storageType: "web-indexeddb", + storageKey: id, + fileName, + byteSize: blob.size, + createdAt, + }; + }, + + async encodeBase64({ attachment }): Promise { + const db = await openAttachmentDb(); + try { + const blob = await loadBlob(db, attachment.storageKey); + return await blobToBase64(blob); + } finally { + db.close(); + } + }, + + async resolvePreviewUrl({ attachment }): Promise { + const db = await openAttachmentDb(); + try { + const blob = await loadBlob(db, attachment.storageKey); + return URL.createObjectURL(blob); + } finally { + db.close(); + } + }, + + async releasePreviewUrl({ url }): Promise { + URL.revokeObjectURL(url); + }, + + async delete({ attachment }): Promise { + const db = await openAttachmentDb(); + try { + await runTx(db, "readwrite", (store) => store.delete(attachment.storageKey)); + } finally { + db.close(); + } + }, + + async garbageCollect({ referencedIds }): Promise { + const db = await openAttachmentDb(); + try { + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readwrite"); + const store = tx.objectStore(STORE_NAME); + const cursorRequest = store.openCursor(); + + cursorRequest.onerror = () => { + reject(cursorRequest.error ?? new Error("Failed to iterate IndexedDB attachment store.")); + }; + + cursorRequest.onsuccess = () => { + const cursor = cursorRequest.result; + if (!cursor) { + resolve(); + return; + } + + const key = String(cursor.key); + if (!referencedIds.has(key)) { + cursor.delete(); + } + cursor.continue(); + }; + + tx.onerror = () => { + reject(tx.error ?? new Error("Failed to garbage collect IndexedDB attachments.")); + }; + }); + } finally { + db.close(); + } + }, + }; +} diff --git a/packages/app/src/components/agent-input-area.draft-persist-guard.test.ts b/packages/app/src/components/agent-input-area.draft-persist-guard.test.ts new file mode 100644 index 000000000..c27a16255 --- /dev/null +++ b/packages/app/src/components/agent-input-area.draft-persist-guard.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { shouldSkipDraftPersist } from "./agent-input-area.draft-persist-guard"; + +describe("shouldSkipDraftPersist", () => { + it("blocks persist while hydrate for current uncontrolled generation is incomplete", () => { + expect( + shouldSkipDraftPersist({ + isControlled: false, + currentGeneration: 2, + hydratedGeneration: 1, + isCurrentGeneration: true, + }) + ).toBe(true); + }); + + it("allows persist after hydrate completes for current generation", () => { + expect( + shouldSkipDraftPersist({ + isControlled: false, + currentGeneration: 3, + hydratedGeneration: 3, + isCurrentGeneration: true, + }) + ).toBe(false); + }); + + it("blocks persist for stale generations", () => { + expect( + shouldSkipDraftPersist({ + isControlled: false, + currentGeneration: 4, + hydratedGeneration: 4, + isCurrentGeneration: false, + }) + ).toBe(true); + }); + + it("does not block controlled draft persistence", () => { + expect( + shouldSkipDraftPersist({ + isControlled: true, + currentGeneration: 0, + hydratedGeneration: 0, + isCurrentGeneration: true, + }) + ).toBe(false); + }); +}); diff --git a/packages/app/src/components/agent-input-area.draft-persist-guard.ts b/packages/app/src/components/agent-input-area.draft-persist-guard.ts new file mode 100644 index 000000000..ba2b60374 --- /dev/null +++ b/packages/app/src/components/agent-input-area.draft-persist-guard.ts @@ -0,0 +1,20 @@ +export function shouldSkipDraftPersist(input: { + isControlled: boolean; + currentGeneration: number; + hydratedGeneration: number; + isCurrentGeneration: boolean; +}): boolean { + if (input.isControlled) { + return false; + } + + if (input.currentGeneration <= 0) { + return true; + } + + if (!input.isCurrentGeneration) { + return true; + } + + return input.hydratedGeneration !== input.currentGeneration; +} diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 2102b82c7..74c778a9d 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -12,6 +12,7 @@ import { AgentStatusBar } from './agent-status-bar' import { useImageAttachmentPicker } from '@/hooks/use-image-attachment-picker' import { useSessionStore } from '@/stores/session-store' import { useDraftStore } from '@/stores/draft-store' +import { buildDraftStoreKey } from '@/stores/draft-keys' import { MessageInput, type MessagePayload, @@ -30,6 +31,11 @@ import { Shortcut } from '@/components/ui/shortcut' import { Autocomplete } from '@/components/ui/autocomplete' import { useAgentAutocomplete } from '@/hooks/use-agent-autocomplete' import { useHostRuntimeSession } from '@/runtime/host-runtime' +import { + deleteAttachments, + persistAttachmentFromFileUri, +} from '@/attachments/service' +import { shouldSkipDraftPersist } from '@/components/agent-input-area.draft-persist-guard' type QueuedMessage = { id: string @@ -40,6 +46,7 @@ type QueuedMessage = { interface AgentInputAreaProps { agentId: string serverId: string + draftId?: string onSubmitMessage?: (payload: MessagePayload) => Promise /** Externally controlled loading state. When true, disables the submit button. */ isSubmitLoading?: boolean @@ -60,6 +67,7 @@ const EMPTY_ARRAY: readonly QueuedMessage[] = [] export function AgentInputArea({ agentId, serverId, + draftId, onSubmitMessage, isSubmitLoading = false, blurOnSubmit = false, @@ -89,8 +97,13 @@ export function AgentInputArea({ const agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId)) + const draftStoreKey = buildDraftStoreKey({ serverId, agentId, draftId }) const getDraftInput = useDraftStore((state) => state.getDraftInput) + const hydrateDraftInput = useDraftStore((state) => state.hydrateDraftInput) const saveDraftInput = useDraftStore((state) => state.saveDraftInput) + const clearDraftInput = useDraftStore((state) => state.clearDraftInput) + const beginDraftGeneration = useDraftStore((state) => state.beginDraftGeneration) + const isDraftGenerationCurrent = useDraftStore((state) => state.isDraftGenerationCurrent) const queuedMessagesRaw = useSessionStore((state) => state.sessions[serverId]?.queuedMessages?.get(agentId) @@ -111,6 +124,8 @@ export function AgentInputArea({ const [sendError, setSendError] = useState(null) const lastHandledMessageInputActionRequestIdRef = useRef(null) const messageInputRef = useRef(null) + const draftGenerationRef = useRef(0) + const hydratedGenerationRef = useRef(0) const autocomplete = useAgentAutocomplete({ userInput, @@ -176,10 +191,10 @@ export function AgentInputArea({ throw new Error('Host is not connected') } - const messageId = generateMessageId() + const clientMessageId = generateMessageId() const userMessage: StreamItem = { kind: 'user_message', - id: messageId, + id: clientMessageId, text, timestamp: new Date(), ...(images && images.length > 0 ? { images } : {}), @@ -209,7 +224,7 @@ export function AgentInputArea({ const imagesData = await encodeImages(images) await client.sendAgentMessage(agentId, text, { - messageId, + messageId: clientMessageId, ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), }) } @@ -295,7 +310,7 @@ export function AgentInputArea({ forceSend?: boolean ) { const trimmedMessage = message.trim() - if (!trimmedMessage) return + if (!trimmedMessage && !imageAttachments?.length) return // When the parent controls submission (e.g. draft agent creation), let it // decide what to do even if the socket is currently disconnected (so we // don't no-op and lose deterministic error handling in the UI/tests). @@ -322,6 +337,7 @@ export function AgentInputArea({ try { await submitMessage(trimmedMessage, imageAttachments) + clearDraftInput({ draftKey: draftStoreKey, lifecycle: 'sent' }) } catch (error) { console.error('[AgentInput] Failed to send message:', error) // Restore input so the user never loses their message @@ -353,15 +369,26 @@ export function AgentInputArea({ return } - const newImages = result.assets.map((asset) => ({ - uri: asset.uri, - mimeType: asset.mimeType || 'image/jpeg', - })) + const newImages = await Promise.all( + result.assets.map(async (asset) => { + return await persistAttachmentFromFileUri({ + uri: asset.uri, + mimeType: asset.mimeType || 'image/jpeg', + fileName: asset.fileName ?? null, + }) + }) + ) setSelectedImages((prev) => [...prev, ...newImages]) } function handleRemoveImage(index: number) { - setSelectedImages((prev) => prev.filter((_, i) => i !== index)) + setSelectedImages((prev) => { + const removed = prev[index] + if (removed) { + void deleteAttachments([removed]) + } + return prev.filter((_, i) => i !== index) + }) } useEffect(() => { @@ -377,27 +404,72 @@ export function AgentInputArea({ if (isControlled) { return } - const draft = getDraftInput(agentId) - if (!draft) { - setUserInput('') - setSelectedImages([]) - return - } + const generation = beginDraftGeneration(draftStoreKey) + draftGenerationRef.current = generation + hydratedGenerationRef.current = 0 + setUserInput('') + setSelectedImages([]) + let cancelled = false - setUserInput(draft.text) - setSelectedImages(draft.images as ImageAttachment[]) - }, [agentId, getDraftInput, isControlled]) + void (async () => { + const draft = await hydrateDraftInput(draftStoreKey) + if (cancelled) { + return + } + if (!isDraftGenerationCurrent({ draftKey: draftStoreKey, generation })) { + return + } + if (!draft) { + hydratedGenerationRef.current = generation + return + } + + setUserInput(draft.text) + setSelectedImages(draft.images) + hydratedGenerationRef.current = generation + })() + + return () => { + cancelled = true + } + }, [ + beginDraftGeneration, + draftStoreKey, + hydrateDraftInput, + isControlled, + isDraftGenerationCurrent, + setUserInput, + ]) // Persist drafts into the shared session store with change detection to avoid redundant work useEffect(() => { - const existing = getDraftInput(agentId) + const currentGeneration = draftGenerationRef.current + const isCurrentGeneration = + currentGeneration > 0 + ? isDraftGenerationCurrent({ draftKey: draftStoreKey, generation: currentGeneration }) + : true + if ( + shouldSkipDraftPersist({ + isControlled, + currentGeneration, + hydratedGeneration: hydratedGenerationRef.current, + isCurrentGeneration, + }) + ) { + return + } + + const existing = getDraftInput(draftStoreKey) const isSameText = existing?.text === userInput - const existingImages: ImageAttachment[] = (existing?.images ?? []) as ImageAttachment[] + const existingImages: ImageAttachment[] = existing?.images ?? [] const isSameImages = existingImages.length === selectedImages.length && existingImages.every((img, idx) => { return ( - img.uri === selectedImages[idx]?.uri && img.mimeType === selectedImages[idx]?.mimeType + img.id === selectedImages[idx]?.id && + img.mimeType === selectedImages[idx]?.mimeType && + img.storageType === selectedImages[idx]?.storageType && + img.storageKey === selectedImages[idx]?.storageKey ) }) @@ -405,8 +477,28 @@ export function AgentInputArea({ return } - saveDraftInput(agentId, { text: userInput, images: selectedImages }) - }, [agentId, userInput, selectedImages, getDraftInput, saveDraftInput]) + const hasContent = userInput.trim().length > 0 || selectedImages.length > 0 + if (!hasContent) { + if (existing) { + clearDraftInput({ draftKey: draftStoreKey, lifecycle: 'abandoned' }) + } + return + } + + saveDraftInput({ + draftKey: draftStoreKey, + draft: { text: userInput, images: selectedImages }, + }) + }, [ + clearDraftInput, + draftStoreKey, + getDraftInput, + isControlled, + isDraftGenerationCurrent, + saveDraftInput, + selectedImages, + userInput, + ]) // Keyboard-dispatched message-input actions are routed through store requests. useEffect(() => { diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index a463e0f08..9b2f56036 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -134,7 +134,6 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) { isRevalidating, refreshAll, } = useSidebarAgentsList({ - isOpen, serverId: activeServerId, selectedProjectFilterKeys, }); diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index f106e6baa..952cba77d 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -28,14 +28,13 @@ import { collectImageFilesFromClipboardData, filesToImageAttachments, } from '@/utils/image-attachments-from-files' +import type { AttachmentMetadata } from '@/attachments/types' +import { useAttachmentPreviewUrl } from '@/attachments/use-attachment-preview-url' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { Shortcut } from '@/components/ui/shortcut' import type { MessageInputKeyboardActionKind } from '@/keyboard/actions' -export interface ImageAttachment { - uri: string - mimeType: string -} +export type ImageAttachment = AttachmentMetadata export interface MessagePayload { text: string @@ -111,6 +110,14 @@ type TextAreaHandle = { } & Record } +function ImageAttachmentThumbnail({ image }: { image: ImageAttachment }) { + const uri = useAttachmentPreviewUrl(image) + if (!uri) { + return + } + return +} + export const MessageInput = forwardRef(function MessageInput( { value, @@ -636,14 +643,14 @@ export const MessageInput = forwardRef(funct {images.map((image, index) => ( onRemoveImage(index) : undefined} > {({ hovered }) => ( <> - + {onRemoveImage && ( ({ width: 48, height: 48, }, + imageThumbnailPlaceholder: { + width: 48, + height: 48, + backgroundColor: theme.colors.surface2, + }, removeImageButton: { position: 'absolute', top: 0, diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index b9154c3bd..9659e4c91 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -75,6 +75,7 @@ import { openExternalUrl } from "@/utils/open-external-url"; export type { InlinePathTarget } from "@/utils/inline-path"; import { useToolCallSheet } from "./tool-call-sheet"; import { ToolCallDetailsContent } from "./tool-call-details"; +import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url"; interface UserMessageProps { message: string; @@ -306,6 +307,11 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({ width: 48, height: 48, }, + imageThumbnailPlaceholder: { + width: 48, + height: 48, + backgroundColor: theme.colors.surface1, + }, copyButton: { alignSelf: "flex-end", padding: theme.spacing[1], @@ -319,6 +325,18 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({ }, })); +function UserMessageAttachmentThumbnail({ + image, +}: { + image: UserMessageImageAttachment; +}) { + const uri = useAttachmentPreviewUrl(image); + if (!uri) { + return ; + } + return ; +} + export const UserMessage = memo(function UserMessage({ message, images = [], @@ -368,13 +386,10 @@ export const UserMessage = memo(function UserMessage({ > {images.map((image, index) => ( - + ))} diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index bc1aa8d58..5fa3ef68c 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -42,6 +42,8 @@ import { normalizeAgentSnapshot, } from "@/utils/agent-snapshots"; import { resolveProjectPlacement } from "@/utils/project-placement"; +import { buildDraftStoreKey } from "@/stores/draft-keys"; +import type { AttachmentMetadata } from "@/attachments/types"; // Re-export types from session-store and draft-store for backward compatibility export type { DraftInput } from "@/stores/draft-store"; @@ -259,7 +261,7 @@ function SessionProviderInternal({ | (( agentId: string, message: string, - images?: Array<{ uri: string; mimeType?: string }> + images?: AttachmentMetadata[] ) => Promise) | null >(null); @@ -1172,7 +1174,9 @@ function SessionProviderInternal({ }); // Remove draft input - clearDraftInput(agentId); + clearDraftInput({ + draftKey: buildDraftStoreKey({ serverId, agentId }), + }); setPendingPermissions(serverId, (prev) => { let changed = false; @@ -1270,7 +1274,7 @@ function SessionProviderInternal({ async ( agentId: string, message: string, - images?: Array<{ uri: string; mimeType?: string }> + images?: AttachmentMetadata[] ) => { const messageId = generateMessageId(); const userMessage: StreamItem = { @@ -1385,7 +1389,7 @@ function SessionProviderInternal({ }: { config: any; initialPrompt: string; - images?: Array<{ uri: string; mimeType?: string }>; + images?: AttachmentMetadata[]; git?: any; worktreeName?: string; requestId?: string; diff --git a/packages/app/src/hooks/use-file-drop-zone.ts b/packages/app/src/hooks/use-file-drop-zone.ts index 138b95af1..b5e7ccbea 100644 --- a/packages/app/src/hooks/use-file-drop-zone.ts +++ b/packages/app/src/hooks/use-file-drop-zone.ts @@ -2,6 +2,10 @@ import { useState, useRef, useEffect } from "react"; import { Platform } from "react-native"; import type { ImageAttachment } from "@/components/message-input"; import { getCurrentTauriWindow, getTauri } from "@/utils/tauri"; +import { + persistAttachmentFromBlob, + persistAttachmentFromFileUri, +} from "@/attachments/service"; interface UseFileDropZoneOptions { onFilesDropped: (files: ImageAttachment[]) => void; @@ -66,34 +70,17 @@ function isImagePath(path: string): boolean { return getFileExtension(path) in IMAGE_MIME_BY_EXTENSION; } -function filePathToImageAttachment(path: string): ImageAttachment { +async function filePathToImageAttachment(path: string): Promise { const extension = getFileExtension(path); const mimeType = IMAGE_MIME_BY_EXTENSION[extension] ?? "image/jpeg"; - const convertFileSrc = getTauri()?.core?.convertFileSrc; - const uri = - typeof convertFileSrc === "function" ? convertFileSrc(path) : path; - - return { - uri, - mimeType, - }; + return await persistAttachmentFromFileUri({ uri: path, mimeType }); } async function fileToImageAttachment(file: File): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => { - if (typeof reader.result === "string") { - resolve({ - uri: reader.result, - mimeType: file.type || "image/jpeg", - }); - } else { - reject(new Error("Failed to read file as data URL")); - } - }; - reader.onerror = () => reject(reader.error); - reader.readAsDataURL(file); + return await persistAttachmentFromBlob({ + blob: file, + mimeType: file.type || "image/jpeg", + fileName: file.name, }); } @@ -177,8 +164,16 @@ export function useFileDropZone({ return; } - const attachments = imagePaths.map(filePathToImageAttachment); - onFilesDroppedRef.current(attachments); + void Promise.all(imagePaths.map(filePathToImageAttachment)) + .then((attachments) => { + if (attachments.length === 0) { + return; + } + onFilesDroppedRef.current(attachments); + }) + .catch((error) => { + console.error("[useFileDropZone] Failed to persist dropped files:", error); + }); } ); diff --git a/packages/app/src/hooks/use-sidebar-agents-list.ts b/packages/app/src/hooks/use-sidebar-agents-list.ts index cb651f95e..c67372834 100644 --- a/packages/app/src/hooks/use-sidebar-agents-list.ts +++ b/packages/app/src/hooks/use-sidebar-agents-list.ts @@ -144,11 +144,9 @@ function toAggregatedAgent(params: { } export function useSidebarAgentsList(options?: { - isOpen?: boolean; serverId?: string | null; selectedProjectFilterKeys?: string[]; }): SidebarAgentsListResult { - const isSidebarOpen = options?.isOpen ?? true; const { daemons } = useDaemonRegistry(); const runtime = getHostRuntimeStore(); const daemonLabelSignature = useMemo( @@ -181,7 +179,7 @@ export function useSidebarAgentsList(options?: { [options?.selectedProjectFilterKeys] ); - const isActive = Boolean(serverId && isSidebarOpen); + const isActive = Boolean(serverId); const liveAgents = useSessionStore((state) => isActive && serverId ? state.sessions[serverId]?.agents ?? null : null ); @@ -309,7 +307,7 @@ export function useSidebarAgentsList(options?: { if (!isActive || !serverId || connectionStatus !== "online") { return; } - void runtime.refreshAgentDirectory({ serverId }).catch(() => undefined); + void runtime.refreshAgentDirectory({ serverId, page: { limit: 50 } }).catch(() => undefined); }, [connectionStatus, isActive, runtime, serverId]); const isDirectoryLoading = diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 6fc05684e..2f3ac7eb6 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -1020,6 +1020,7 @@ export class HostRuntimeStore { const bootstrap = this.refreshAgentDirectory({ serverId, subscribe: { subscriptionId: `app:${serverId}` }, + page: { limit: 200 }, }) .then(() => undefined) .catch((error) => { @@ -1094,6 +1095,7 @@ export class HostRuntimeStore { serverId: string; filter?: FetchAgentsOptions["filter"]; subscribe?: FetchAgentsOptions["subscribe"]; + page?: FetchAgentsOptions["page"]; }): Promise<{ agents: ReturnType["agents"]; subscriptionId: string | null; @@ -1113,6 +1115,7 @@ export class HostRuntimeStore { const payload = await client.fetchAgents({ filter: input.filter ?? { labels: { ui: "true" } }, ...(input.subscribe ? { subscribe: input.subscribe } : {}), + ...(input.page ? { page: input.page } : {}), }); const agents = applyFetchedAgentDirectory({ serverId: input.serverId, diff --git a/packages/app/src/screens/agent/agent-ready-screen.tsx b/packages/app/src/screens/agent/agent-ready-screen.tsx index e2c578ac8..e24bc24fd 100644 --- a/packages/app/src/screens/agent/agent-ready-screen.tsx +++ b/packages/app/src/screens/agent/agent-ready-screen.tsx @@ -77,7 +77,6 @@ import { derivePendingPermissionKey, normalizeAgentSnapshot, } from "@/utils/agent-snapshots"; -import { resolveProjectPlacement } from "@/utils/project-placement"; import { mergePendingCreateImages } from "@/utils/pending-create-images"; import { shouldClearAgentAttentionOnView } from "@/utils/agent-attention"; import type { DaemonClient } from "@server/client/daemon-client"; @@ -424,9 +423,13 @@ function AgentScreenContent({ const streamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS; const pendingCreate = useCreateFlowStore((state) => state.pending); + const markPendingCreateLifecycle = useCreateFlowStore( + (state) => state.markLifecycle + ); const clearPendingCreate = useCreateFlowStore((state) => state.clear); const isPendingCreateForRoute = Boolean(pendingCreate) && + pendingCreate?.lifecycle === "active" && pendingCreate?.serverId === serverId && pendingCreate?.agentId === resolvedAgentId; @@ -624,7 +627,7 @@ function AgentScreenContent({ return [ { kind: "user_message", - id: pendingCreate.messageId, + id: pendingCreate.clientMessageId, text: pendingCreate.text, timestamp: new Date(pendingCreate.timestamp), ...(pendingCreate.images && pendingCreate.images.length > 0 @@ -725,7 +728,7 @@ function AgentScreenContent({ const hasUserMessage = streamItems.some( (item) => item.kind === "user_message" && - (item.id === pendingCreate.messageId || item.text === pendingCreate.text) + item.id === pendingCreate.clientMessageId ); if (agent && hasUserMessage) { if ( @@ -741,8 +744,7 @@ function AgentScreenContent({ const merged = mergePendingCreateImages({ streamItems: current, - messageId: pendingCreate.messageId, - text: pendingCreate.text, + clientMessageId: pendingCreate.clientMessageId, images: pendingCreate.images, }); if (merged === current) { @@ -754,12 +756,14 @@ function AgentScreenContent({ return next; }); } + markPendingCreateLifecycle("sent"); clearPendingCreate(); } }, [ agent, clearPendingCreate, isPendingCreateForRoute, + markPendingCreateLifecycle, pendingCreate, resolvedAgentId, serverId, @@ -827,24 +831,21 @@ function AgentScreenContent({ .sessions[serverId] ?.agents.get(resolvedAgentId); if (!currentAgent && client) { - const snapshot = await client.fetchAgent(resolvedAgentId); + const result = await client.fetchAgent(resolvedAgentId); if (attemptToken !== initAttemptTokenRef.current) { return; } - if (!snapshot) { + if (!result) { setMissingAgentState({ kind: "not_found", message: `Agent not found: ${resolvedAgentId}`, }); return; } - const normalized = normalizeAgentSnapshot(snapshot, serverId); + const normalized = normalizeAgentSnapshot(result.agent, serverId); const hydrated = { ...normalized, - projectPlacement: resolveProjectPlacement({ - projectPlacement: null, - cwd: normalized.cwd, - }), + projectPlacement: result.project, }; setAgents(serverId, (prev) => { const next = new Map(prev); diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index b19bbeef8..7a8e84abf 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -34,6 +34,7 @@ import { buildWorkingDirectorySuggestions } from '@/utils/working-directory-sugg import { useExplorerOpenGesture } from '@/hooks/use-explorer-open-gesture' import { useSessionStore } from '@/stores/session-store' import { useCreateFlowStore } from '@/stores/create-flow-store' +import { generateDraftId } from '@/stores/draft-keys' import { getHostRuntimeStore, useHostRuntimeSession } from '@/runtime/host-runtime' import { ExplorerSidebarAnimationProvider } from '@/contexts/explorer-sidebar-animation-context' import { usePanelStore, type ExplorerCheckoutContext } from '@/stores/panel-store' @@ -244,6 +245,7 @@ function DraftAgentScreenContent({ ) const tauriDragHandlers = useTauriDragHandlers() const isExplorerOpen = isMobile ? mobileView === 'file-explorer' : desktopFileExplorerOpen + const draftIdRef = useRef(generateDraftId()) const [worktreeMode, setWorktreeMode] = useState<'none' | 'create' | 'attach'>('none') const [baseBranch, setBaseBranch] = useState('') @@ -262,6 +264,7 @@ function DraftAgentScreenContent({ const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null) const setPendingCreateAttempt = useCreateFlowStore((state) => state.setPending) const updatePendingAgentId = useCreateFlowStore((state) => state.updateAgentId) + const markPendingCreateLifecycle = useCreateFlowStore((state) => state.markLifecycle) const clearPendingCreateAttempt = useCreateFlowStore((state) => state.clear) useEffect(() => { @@ -277,7 +280,7 @@ function DraftAgentScreenContent({ }, [workingDirSearchQuery]) type CreateAttempt = { - messageId: string + clientMessageId: string text: string timestamp: Date images?: UserMessageImageAttachment[] @@ -832,7 +835,7 @@ function DraftAgentScreenContent({ return [ { kind: 'user_message', - id: machine.attempt.messageId, + id: machine.attempt.clientMessageId, text: machine.attempt.text, timestamp: machine.attempt.timestamp, ...(machine.attempt.images && machine.attempt.images.length > 0 @@ -972,15 +975,16 @@ function DraftAgentScreenContent({ throw new Error('Host is not connected') } const attempt = { - messageId: generateMessageId(), + clientMessageId: generateMessageId(), text: trimmedPrompt, timestamp: new Date(), ...(images && images.length > 0 ? { images } : {}), } setPendingCreateAttempt({ + draftId: draftIdRef.current, serverId: selectedServerId, agentId: null, - messageId: attempt.messageId, + clientMessageId: attempt.clientMessageId, text: attempt.text, timestamp: attempt.timestamp.getTime(), ...(attempt.images && attempt.images.length > 0 ? { images: attempt.images } : {}), @@ -1026,6 +1030,7 @@ function DraftAgentScreenContent({ config, labels: { ui: 'true' }, initialPrompt: trimmedPrompt, + clientMessageId: attempt.clientMessageId, ...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}), git: gitOptions, }) @@ -1042,12 +1047,14 @@ function DraftAgentScreenContent({ message: 'Failed to create agent', }) onCreateFlowActiveChange?.(false) + markPendingCreateLifecycle('abandoned') clearPendingCreateAttempt() throw new Error('Failed to create agent') } catch (error) { const message = error instanceof Error ? error.message : 'Failed to create agent' dispatch({ type: 'CREATE_FAILED', message }) onCreateFlowActiveChange?.(false) + markPendingCreateLifecycle('abandoned') clearPendingCreateAttempt() throw error // Re-throw so AgentInputArea knows it failed } @@ -1073,6 +1080,7 @@ function DraftAgentScreenContent({ createAgentClient, setPendingCreateAttempt, updatePendingAgentId, + markPendingCreateLifecycle, clearPendingCreateAttempt, workingDir, isAttachWorktree, @@ -1324,6 +1332,7 @@ function DraftAgentScreenContent({ autoFocus={machine.tag === 'draft'} onAddImages={handleAddImagesCallback} commandDraftConfig={draftCommandConfig} + draftId={draftIdRef.current} /> diff --git a/packages/app/src/stores/create-flow-store.test.ts b/packages/app/src/stores/create-flow-store.test.ts new file mode 100644 index 000000000..55f080cf4 --- /dev/null +++ b/packages/app/src/stores/create-flow-store.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { useCreateFlowStore } from "./create-flow-store"; + +describe("create-flow-store", () => { + beforeEach(() => { + useCreateFlowStore.setState({ pending: null }); + }); + + it("tracks lifecycle transitions explicitly", () => { + const store = useCreateFlowStore.getState(); + store.setPending({ + draftId: "draft-1", + serverId: "server-1", + agentId: null, + clientMessageId: "msg-1", + text: "hello", + timestamp: Date.now(), + images: [], + }); + + expect(useCreateFlowStore.getState().pending?.lifecycle).toBe("active"); + + useCreateFlowStore.getState().markLifecycle("abandoned"); + expect(useCreateFlowStore.getState().pending?.lifecycle).toBe("abandoned"); + }); + + it("rekeys draft id idempotently", () => { + useCreateFlowStore.getState().setPending({ + draftId: "draft-a", + serverId: "server-1", + agentId: null, + clientMessageId: "msg-1", + text: "hello", + timestamp: Date.now(), + images: [], + }); + + useCreateFlowStore.getState().rekeyDraft({ + fromDraftId: "draft-a", + toDraftId: "draft-b", + }); + useCreateFlowStore.getState().rekeyDraft({ + fromDraftId: "draft-a", + toDraftId: "draft-b", + }); + + expect(useCreateFlowStore.getState().pending?.draftId).toBe("draft-b"); + }); +}); diff --git a/packages/app/src/stores/create-flow-store.ts b/packages/app/src/stores/create-flow-store.ts index c9c3c6f23..62c83ac4a 100644 --- a/packages/app/src/stores/create-flow-store.ts +++ b/packages/app/src/stores/create-flow-store.ts @@ -1,28 +1,77 @@ import { create } from "zustand"; import type { UserMessageImageAttachment } from "@/types/stream"; +export type CreateFlowLifecycleState = "active" | "abandoned" | "sent"; + type PendingCreateAttempt = { + draftId: string; serverId: string; agentId: string | null; - messageId: string; + clientMessageId: string; text: string; timestamp: number; + lifecycle: CreateFlowLifecycleState; images?: UserMessageImageAttachment[]; }; type CreateFlowState = { pending: PendingCreateAttempt | null; - setPending: (pending: PendingCreateAttempt) => void; + setPending: ( + pending: Omit + ) => void; updateAgentId: (agentId: string) => void; + markLifecycle: (lifecycle: CreateFlowLifecycleState) => void; + rekeyDraft: (input: { fromDraftId: string; toDraftId: string }) => void; clear: () => void; }; export const useCreateFlowStore = create((set) => ({ pending: null, - setPending: (pending) => set({ pending }), + setPending: (pending) => + set({ + pending: { + ...pending, + lifecycle: "active", + }, + }), updateAgentId: (agentId) => - set((state) => - state.pending ? { pending: { ...state.pending, agentId } } : state - ), + set((state) => { + if (!state.pending || state.pending.agentId === agentId) { + return state; + } + return { + pending: { + ...state.pending, + agentId, + }, + }; + }), + markLifecycle: (lifecycle) => + set((state) => { + if (!state.pending || state.pending.lifecycle === lifecycle) { + return state; + } + return { + pending: { + ...state.pending, + lifecycle, + }, + }; + }), + rekeyDraft: ({ fromDraftId, toDraftId }) => + set((state) => { + if (!state.pending || state.pending.draftId !== fromDraftId) { + return state; + } + if (state.pending.draftId === toDraftId) { + return state; + } + return { + pending: { + ...state.pending, + draftId: toDraftId, + }, + }; + }), clear: () => set({ pending: null }), })); diff --git a/packages/app/src/stores/draft-keys.test.ts b/packages/app/src/stores/draft-keys.test.ts new file mode 100644 index 000000000..024fe6a8a --- /dev/null +++ b/packages/app/src/stores/draft-keys.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { buildDraftStoreKey } from "./draft-keys"; + +describe("buildDraftStoreKey", () => { + it("isolates agent drafts by server and agent ids", () => { + const keyA = buildDraftStoreKey({ + serverId: "server-a", + agentId: "agent-1", + }); + const keyB = buildDraftStoreKey({ + serverId: "server-b", + agentId: "agent-1", + }); + const keyC = buildDraftStoreKey({ + serverId: "server-a", + agentId: "agent-2", + }); + + expect(keyA).not.toBe(keyB); + expect(keyA).not.toBe(keyC); + expect(keyB).not.toBe(keyC); + }); + + it("uses draftId keyspace for create flow drafts", () => { + const key = buildDraftStoreKey({ + serverId: "server-a", + agentId: "__new_agent__", + draftId: "draft-123", + }); + + expect(key).toBe("draft:server-a:draft-123"); + }); +}); diff --git a/packages/app/src/stores/draft-keys.ts b/packages/app/src/stores/draft-keys.ts new file mode 100644 index 000000000..a9b497016 --- /dev/null +++ b/packages/app/src/stores/draft-keys.ts @@ -0,0 +1,18 @@ +import { generateMessageId } from "@/types/stream"; + +export function generateDraftId(): string { + return `draft_${generateMessageId()}`; +} + +export function buildDraftStoreKey(input: { + serverId: string; + agentId: string; + draftId?: string | null; +}): string { + const serverId = input.serverId.trim(); + const explicitDraftId = input.draftId?.trim(); + if (explicitDraftId) { + return `draft:${serverId}:${explicitDraftId}`; + } + return `agent:${serverId}:${input.agentId.trim()}`; +} diff --git a/packages/app/src/stores/draft-store.lifecycle.test.ts b/packages/app/src/stores/draft-store.lifecycle.test.ts new file mode 100644 index 000000000..201801fd9 --- /dev/null +++ b/packages/app/src/stores/draft-store.lifecycle.test.ts @@ -0,0 +1,69 @@ +import "@/test/window-local-storage"; +import { describe, expect, it } from "vitest"; +import { __draftStoreTestUtils } from "./draft-store"; + +describe("draft-store lifecycle", () => { + it("prunes finalized tombstones after TTL", () => { + const nowMs = 1_000_000; + const drafts = { + oldSent: { + input: { text: "", images: [] }, + lifecycle: "sent" as const, + updatedAt: 0, + version: 2, + }, + recentAbandoned: { + input: { text: "", images: [] }, + lifecycle: "abandoned" as const, + updatedAt: nowMs + 2 * 60 * 1000, + version: 2, + }, + active: { + input: { text: "a", images: [] }, + lifecycle: "active" as const, + updatedAt: 0, + version: 1, + }, + }; + + const pruned = __draftStoreTestUtils.pruneFinalizedDraftRecords({ + drafts, + nowMs: nowMs + 6 * 60 * 1000, + }); + + expect(pruned.oldSent).toBeUndefined(); + expect(pruned.recentAbandoned).toBeDefined(); + expect(pruned.active).toBeDefined(); + }); + + it("normalizes clear-with-lifecycle into a tombstone without attachments", () => { + const cleared = __draftStoreTestUtils.applyClearDraftRecord({ + record: { + input: { + text: "hello", + images: [ + { + id: "att-1", + mimeType: "image/jpeg", + storageType: "web-indexeddb", + storageKey: "att-1", + createdAt: 1, + }, + ], + }, + lifecycle: "active", + updatedAt: 1, + version: 1, + }, + lifecycle: "sent", + nowMs: 2, + }); + + expect(cleared).toEqual({ + input: { text: "", images: [] }, + lifecycle: "sent", + updatedAt: 2, + version: 2, + }); + }); +}); diff --git a/packages/app/src/stores/draft-store.migration.test.ts b/packages/app/src/stores/draft-store.migration.test.ts new file mode 100644 index 000000000..cecaf9620 --- /dev/null +++ b/packages/app/src/stores/draft-store.migration.test.ts @@ -0,0 +1,71 @@ +import "@/test/window-local-storage"; +import { describe, expect, it } from "vitest"; +import { __draftStoreTestUtils } from "./draft-store"; + +describe("draft-store migration", () => { + it("normalizes attachment metadata and strips persisted preview URLs", () => { + const migrated = __draftStoreTestUtils.migratePersistedState({ + drafts: { + "agent:server:agent": { + input: { + text: "hello", + images: [ + { + id: "att-1", + mimeType: "image/png", + storageType: "desktop-file", + storageKey: "/tmp/att-1.png", + createdAt: 1700000000000, + previewUri: "asset://should-not-persist", + }, + ], + }, + lifecycle: "active", + updatedAt: 1700000000001, + version: 1, + }, + }, + createModalDraft: null, + }); + + expect(migrated.drafts["agent:server:agent"]?.input.images).toEqual([ + { + id: "att-1", + mimeType: "image/png", + storageType: "desktop-file", + storageKey: "/tmp/att-1.png", + createdAt: 1700000000000, + }, + ]); + }); + + it("is idempotent for already-migrated shapes", () => { + const original = { + drafts: { + "agent:server:agent": { + input: { + text: "hello", + images: [ + { + id: "att-1", + mimeType: "image/jpeg", + storageType: "web-indexeddb", + storageKey: "att-1", + createdAt: 1700000000000, + }, + ], + }, + lifecycle: "active", + updatedAt: 1700000000001, + version: 2, + }, + }, + createModalDraft: null, + }; + + const once = __draftStoreTestUtils.migratePersistedState(original); + const twice = __draftStoreTestUtils.migratePersistedState(once); + + expect(twice).toEqual(once); + }); +}); diff --git a/packages/app/src/stores/draft-store.ts b/packages/app/src/stores/draft-store.ts index ac61046da..6e198dc3c 100644 --- a/packages/app/src/stores/draft-store.ts +++ b/packages/app/src/stores/draft-store.ts @@ -1,64 +1,647 @@ import { create } from "zustand"; -import { persist, createJSONStorage } from "zustand/middleware"; +import { createJSONStorage, persist } from "zustand/middleware"; import AsyncStorage from "@react-native-async-storage/async-storage"; +import type { AttachmentMetadata } from "@/attachments/types"; +import { + garbageCollectAttachments, + persistAttachmentFromDataUrl, + persistAttachmentFromFileUri, +} from "@/attachments/service"; + +const DRAFT_STORE_VERSION = 2; +const FINALIZED_DRAFT_TTL_MS = 5 * 60 * 1000; + +type LegacyDraftImage = { + uri: string; + mimeType?: string; +}; + +type PersistedDraftImage = AttachmentMetadata | LegacyDraftImage; export interface DraftInput { text: string; - images: Array<{ uri: string; mimeType: string }>; + images: AttachmentMetadata[]; +} + +export type DraftLifecycleState = "active" | "abandoned" | "sent"; + +interface DraftRecord { + input: { + text: string; + images: PersistedDraftImage[]; + }; + lifecycle: DraftLifecycleState; + updatedAt: number; + version: number; } interface DraftStoreState { - drafts: Record; - createModalDraft: DraftInput | null; + drafts: Record; + createModalDraft: DraftRecord | null; } interface DraftStoreActions { - getDraftInput: (agentId: string) => DraftInput | undefined; - saveDraftInput: (agentId: string, draft: DraftInput) => void; - clearDraftInput: (agentId: string) => void; + getDraftInput: (draftKey: string) => DraftInput | undefined; + hydrateDraftInput: (draftKey: string) => Promise; + saveDraftInput: (input: { draftKey: string; draft: DraftInput }) => void; + markDraftLifecycle: (input: { + draftKey: string; + lifecycle: DraftLifecycleState; + }) => void; + clearDraftInput: (input: { + draftKey: string; + lifecycle?: Exclude; + }) => void; getCreateModalDraft: () => DraftInput | null; saveCreateModalDraft: (draft: DraftInput | null) => void; + beginDraftGeneration: (draftKey: string) => number; + isDraftGenerationCurrent: (input: { + draftKey: string; + generation: number; + }) => boolean; + collectActiveAttachmentIds: () => string[]; } type DraftStore = DraftStoreState & DraftStoreActions; +const draftGenerations = new Map(); +let gcScheduled = false; + +function createDraftRecord(input: { + draft: { text: string; images: PersistedDraftImage[] }; + lifecycle: DraftLifecycleState; + previousVersion?: number; +}): DraftRecord { + const normalizedImages = input.draft.images.map((image) => { + if (isAttachmentMetadata(image)) { + return normalizeAttachmentMetadata(image); + } + return image; + }); + return { + input: { + text: input.draft.text, + images: normalizedImages, + }, + lifecycle: input.lifecycle, + updatedAt: Date.now(), + version: (input.previousVersion ?? 0) + 1, + }; +} + +function isAttachmentMetadata(value: unknown): value is AttachmentMetadata { + if (!value || typeof value !== "object") { + return false; + } + const record = value as Record; + return ( + typeof record.id === "string" && + typeof record.mimeType === "string" && + typeof record.storageType === "string" && + typeof record.storageKey === "string" && + typeof record.createdAt === "number" + ); +} + +function isLegacyDraftImage(value: unknown): value is LegacyDraftImage { + if (!value || typeof value !== "object") { + return false; + } + const record = value as Record; + return typeof record.uri === "string"; +} + +function normalizeAttachmentMetadata(image: AttachmentMetadata): AttachmentMetadata { + return { + id: image.id, + mimeType: image.mimeType, + storageType: image.storageType, + storageKey: image.storageKey, + createdAt: image.createdAt, + ...(typeof image.fileName === "string" || image.fileName === null + ? { fileName: image.fileName } + : {}), + ...(typeof image.byteSize === "number" || image.byteSize === null + ? { byteSize: image.byteSize } + : {}), + }; +} + +function normalizePersistedImage(value: unknown): PersistedDraftImage | null { + if (isAttachmentMetadata(value)) { + return normalizeAttachmentMetadata(value); + } + if (isLegacyDraftImage(value)) { + return { + uri: value.uri, + ...(value.mimeType ? { mimeType: value.mimeType } : {}), + }; + } + return null; +} + +function hasLegacyImages(images: readonly PersistedDraftImage[]): boolean { + return images.some((entry) => !isAttachmentMetadata(entry)); +} + +function toDraftInputIfReady(record: DraftRecord | null | undefined): DraftInput | undefined { + if (!record) { + return undefined; + } + if (record.lifecycle !== "active") { + return undefined; + } + if (hasLegacyImages(record.input.images)) { + return undefined; + } + return { + text: record.input.text, + images: (record.input.images as AttachmentMetadata[]).map( + normalizeAttachmentMetadata + ), + }; +} + +function collectReferencedAttachmentIdsFromState(state: DraftStoreState): Set { + const referencedIds = new Set(); + + for (const draftRecord of Object.values(state.drafts)) { + if (draftRecord.lifecycle !== "active") { + continue; + } + for (const image of draftRecord.input.images) { + if (isAttachmentMetadata(image)) { + referencedIds.add(image.id); + } + } + } + + const modalRecord = state.createModalDraft; + if (modalRecord?.lifecycle === "active") { + for (const image of modalRecord.input.images) { + if (isAttachmentMetadata(image)) { + referencedIds.add(image.id); + } + } + } + + return referencedIds; +} + +function pruneFinalizedDraftRecords(input: { + drafts: Record; + nowMs: number; +}): Record { + let changed = false; + const next: Record = {}; + for (const [draftKey, record] of Object.entries(input.drafts)) { + if ( + record.lifecycle !== "active" && + input.nowMs - record.updatedAt >= FINALIZED_DRAFT_TTL_MS + ) { + changed = true; + continue; + } + next[draftKey] = record; + } + return changed ? next : input.drafts; +} + +function applyClearDraftRecord(input: { + record: DraftRecord; + lifecycle?: Exclude; + nowMs: number; +}): DraftRecord | null { + if (!input.lifecycle) { + return null; + } + + return { + ...input.record, + input: { text: "", images: [] }, + lifecycle: input.lifecycle, + updatedAt: input.nowMs, + version: input.record.version + 1, + }; +} + +async function runAttachmentGc(): Promise { + gcScheduled = false; + const nowMs = Date.now(); + + useDraftStore.setState((state) => { + const prunedDrafts = pruneFinalizedDraftRecords({ drafts: state.drafts, nowMs }); + if (prunedDrafts === state.drafts) { + return state; + } + return { + ...state, + drafts: prunedDrafts, + }; + }); + + const referencedIds = new Set(); + for (const id of useDraftStore.getState().collectActiveAttachmentIds()) { + referencedIds.add(id); + } + + try { + const [{ useCreateFlowStore }, { useSessionStore }] = await Promise.all([ + import("@/stores/create-flow-store"), + import("@/stores/session-store"), + ]); + + const pendingCreate = useCreateFlowStore.getState().pending; + if (pendingCreate?.lifecycle === "active" && pendingCreate.images) { + for (const image of pendingCreate.images) { + referencedIds.add(image.id); + } + } + + const sessions = useSessionStore.getState().sessions; + for (const session of Object.values(sessions)) { + for (const queue of session.queuedMessages.values()) { + for (const queuedMessage of queue) { + for (const image of queuedMessage.images ?? []) { + referencedIds.add(image.id); + } + } + } + + for (const stream of session.agentStreamTail.values()) { + for (const item of stream) { + if (item.kind !== "user_message") { + continue; + } + for (const image of item.images ?? []) { + referencedIds.add(image.id); + } + } + } + + for (const stream of session.agentStreamHead.values()) { + for (const item of stream) { + if (item.kind !== "user_message") { + continue; + } + for (const image of item.images ?? []) { + referencedIds.add(image.id); + } + } + } + } + } catch (error) { + console.warn("[DraftStore] Failed to collect attachment GC references", error); + } + + try { + await garbageCollectAttachments({ referencedIds }); + } catch (error) { + console.warn("[DraftStore] Attachment garbage collection failed", error); + } +} + +function scheduleAttachmentGc(): void { + if (gcScheduled) { + return; + } + gcScheduled = true; + if (typeof queueMicrotask === "function") { + queueMicrotask(() => { + void runAttachmentGc(); + }); + return; + } + setTimeout(() => { + void runAttachmentGc(); + }, 0); +} + +async function migrateAllLegacyDrafts(): Promise { + const state = useDraftStore.getState(); + const keys = Object.entries(state.drafts) + .filter(([, record]) => hasLegacyImages(record.input.images)) + .map(([draftKey]) => draftKey); + + for (const draftKey of keys) { + try { + await state.hydrateDraftInput(draftKey); + } catch (error) { + console.warn("[DraftStore] Failed to migrate draft during startup", { + draftKey, + error, + }); + } + } +} + +async function migrateLegacyImages(images: readonly PersistedDraftImage[]): Promise { + if (images.length === 0) { + return []; + } + + const migrated = await Promise.all( + images.map(async (entry) => { + if (isAttachmentMetadata(entry)) { + return entry; + } + if (!isLegacyDraftImage(entry)) { + return null; + } + + try { + if (entry.uri.startsWith("data:")) { + return await persistAttachmentFromDataUrl({ + dataUrl: entry.uri, + mimeType: entry.mimeType, + }); + } + + return await persistAttachmentFromFileUri({ + uri: entry.uri, + mimeType: entry.mimeType, + }); + } catch (error) { + console.warn("[DraftStore] Failed to migrate legacy draft attachment", { + uri: entry.uri, + error, + }); + return null; + } + }) + ); + + return migrated.filter((entry): entry is AttachmentMetadata => entry !== null); +} + +function migratePersistedState(state: unknown): DraftStoreState { + const input = (state ?? {}) as { + drafts?: Record; + createModalDraft?: unknown; + }; + + const nextDrafts: Record = {}; + for (const [draftKey, rawRecord] of Object.entries(input.drafts ?? {})) { + if (!rawRecord || typeof rawRecord !== "object") { + continue; + } + const parsed = rawRecord as Record; + + if ("input" in parsed && parsed.input && typeof parsed.input === "object") { + const recordInput = parsed.input as Record; + nextDrafts[draftKey] = { + input: { + text: typeof recordInput.text === "string" ? recordInput.text : "", + images: Array.isArray(recordInput.images) + ? (recordInput.images + .map((entry) => normalizePersistedImage(entry)) + .filter((entry): entry is PersistedDraftImage => entry !== null)) + : [], + }, + lifecycle: + parsed.lifecycle === "sent" || parsed.lifecycle === "abandoned" + ? (parsed.lifecycle as DraftLifecycleState) + : "active", + updatedAt: typeof parsed.updatedAt === "number" ? parsed.updatedAt : Date.now(), + version: typeof parsed.version === "number" ? parsed.version : 1, + }; + continue; + } + + // Legacy shape: { text, images }. + const legacy = parsed as { text?: unknown; images?: unknown }; + nextDrafts[draftKey] = { + input: { + text: typeof legacy.text === "string" ? legacy.text : "", + images: Array.isArray(legacy.images) + ? (legacy.images + .map((entry) => normalizePersistedImage(entry)) + .filter((entry): entry is PersistedDraftImage => entry !== null)) + : [], + }, + lifecycle: "active", + updatedAt: Date.now(), + version: 1, + }; + } + + let createModalDraft: DraftRecord | null = null; + if (input.createModalDraft && typeof input.createModalDraft === "object") { + const record = input.createModalDraft as Record; + if (record.input && typeof record.input === "object") { + const recordInput = record.input as Record; + createModalDraft = { + input: { + text: typeof recordInput.text === "string" ? recordInput.text : "", + images: Array.isArray(recordInput.images) + ? (recordInput.images + .map((entry) => normalizePersistedImage(entry)) + .filter((entry): entry is PersistedDraftImage => entry !== null)) + : [], + }, + lifecycle: + record.lifecycle === "sent" || record.lifecycle === "abandoned" + ? (record.lifecycle as DraftLifecycleState) + : "active", + updatedAt: typeof record.updatedAt === "number" ? record.updatedAt : Date.now(), + version: typeof record.version === "number" ? record.version : 1, + }; + } else { + const legacy = record as { text?: unknown; images?: unknown }; + createModalDraft = { + input: { + text: typeof legacy.text === "string" ? legacy.text : "", + images: Array.isArray(legacy.images) + ? (legacy.images + .map((entry) => normalizePersistedImage(entry)) + .filter((entry): entry is PersistedDraftImage => entry !== null)) + : [], + }, + lifecycle: "active", + updatedAt: Date.now(), + version: 1, + }; + } + } + + return { + drafts: nextDrafts, + createModalDraft, + }; +} + export const useDraftStore = create()( persist( (set, get) => ({ drafts: {}, createModalDraft: null, - getDraftInput: (agentId) => { - return get().drafts[agentId]; + getDraftInput: (draftKey) => { + const record = get().drafts[draftKey]; + return toDraftInputIfReady(record); }, - saveDraftInput: (agentId, draft) => { - set((state) => ({ - drafts: { - ...state.drafts, - [agentId]: draft, - }, - })); - }, + hydrateDraftInput: async (draftKey) => { + const current = get().drafts[draftKey]; + if (!current) { + return undefined; + } + if (current.lifecycle !== "active") { + return undefined; + } + const ready = toDraftInputIfReady(current); + if (ready) { + return ready; + } + + const migratedImages = await migrateLegacyImages(current.input.images); + const migratedDraft: DraftInput = { + text: current.input.text, + images: migratedImages, + }; - clearDraftInput: (agentId) => { set((state) => { - const { [agentId]: _, ...rest } = state.drafts; - return { drafts: rest }; + const existing = state.drafts[draftKey]; + if (!existing || existing.version !== current.version) { + return state; + } + return { + drafts: { + ...state.drafts, + [draftKey]: createDraftRecord({ + draft: migratedDraft, + lifecycle: existing.lifecycle, + previousVersion: existing.version, + }), + }, + }; }); + + scheduleAttachmentGc(); + return migratedDraft; + }, + + saveDraftInput: ({ draftKey, draft }) => { + set((state) => { + const existing = state.drafts[draftKey]; + return { + drafts: { + ...state.drafts, + [draftKey]: createDraftRecord({ + draft, + lifecycle: "active", + previousVersion: existing?.version, + }), + }, + }; + }); + scheduleAttachmentGc(); + }, + + markDraftLifecycle: ({ draftKey, lifecycle }) => { + set((state) => { + const existing = state.drafts[draftKey]; + if (!existing || existing.lifecycle === lifecycle) { + return state; + } + return { + drafts: { + ...state.drafts, + [draftKey]: { + ...existing, + lifecycle, + updatedAt: Date.now(), + version: existing.version + 1, + }, + }, + }; + }); + scheduleAttachmentGc(); + }, + + clearDraftInput: ({ draftKey, lifecycle }) => { + set((state) => { + const existing = state.drafts[draftKey]; + if (!existing) { + return state; + } + const cleared = applyClearDraftRecord({ + record: existing, + lifecycle, + nowMs: Date.now(), + }); + if (cleared) { + return { + drafts: { + ...state.drafts, + [draftKey]: cleared, + }, + }; + } + const nextDrafts = { ...state.drafts }; + delete nextDrafts[draftKey]; + return { drafts: nextDrafts }; + }); + + draftGenerations.delete(draftKey); + scheduleAttachmentGc(); }, getCreateModalDraft: () => { - return get().createModalDraft; + const record = get().createModalDraft; + return toDraftInputIfReady(record) ?? null; }, saveCreateModalDraft: (draft) => { - set({ createModalDraft: draft }); + set((state) => { + if (!draft) { + return { createModalDraft: null }; + } + return { + createModalDraft: createDraftRecord({ + draft, + lifecycle: "active", + previousVersion: state.createModalDraft?.version, + }), + }; + }); + scheduleAttachmentGc(); + }, + + beginDraftGeneration: (draftKey) => { + const next = (draftGenerations.get(draftKey) ?? 0) + 1; + draftGenerations.set(draftKey, next); + return next; + }, + + isDraftGenerationCurrent: ({ draftKey, generation }) => { + return (draftGenerations.get(draftKey) ?? 0) === generation; + }, + + collectActiveAttachmentIds: () => { + return Array.from(collectReferencedAttachmentIdsFromState(get()).values()); }, }), { name: "paseo-drafts", + version: DRAFT_STORE_VERSION, storage: createJSONStorage(() => AsyncStorage), + migrate: (persistedState) => { + return migratePersistedState(persistedState); + }, + onRehydrateStorage: () => { + return () => { + void migrateAllLegacyDrafts(); + scheduleAttachmentGc(); + }; + }, } ) ); + +export const __draftStoreTestUtils = { + migratePersistedState, + normalizeAttachmentMetadata, + pruneFinalizedDraftRecords, + applyClearDraftRecord, +}; diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 95ee090b4..5046fc10d 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -5,6 +5,7 @@ import type { useAudioPlayer } from "@/hooks/use-audio-player"; import type { AgentDirectoryEntry } from "@/types/agent-directory"; import type { StreamItem } from "@/types/stream"; import type { PendingPermission } from "@/types/shared"; +import type { AttachmentMetadata } from "@/attachments/types"; import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle"; import type { AgentPermissionResponse, @@ -210,7 +211,10 @@ export interface SessionState { fileExplorer: Map; // Queued messages - queuedMessages: Map }>>; + queuedMessages: Map< + string, + Array<{ id: string; text: string; images?: AttachmentMetadata[] }> + >; } // Global store state @@ -278,7 +282,17 @@ interface SessionStoreActions { setFileExplorer: (serverId: string, state: Map | ((prev: Map) => Map)) => void; // Queued messages - setQueuedMessages: (serverId: string, value: Map }>> | ((prev: Map }>>) => Map }>>)) => void; + setQueuedMessages: ( + serverId: string, + value: + | Map> + | (( + prev: Map< + string, + Array<{ id: string; text: string; images?: AttachmentMetadata[] }> + > + ) => Map>) + ) => void; // Hydration setHasHydratedAgents: (serverId: string, hydrated: boolean) => void; diff --git a/packages/app/src/test/window-local-storage.ts b/packages/app/src/test/window-local-storage.ts new file mode 100644 index 000000000..768264733 --- /dev/null +++ b/packages/app/src/test/window-local-storage.ts @@ -0,0 +1,38 @@ +class MemoryLocalStorage { + private readonly map = new Map(); + + get length(): number { + return this.map.size; + } + + clear(): void { + this.map.clear(); + } + + getItem(key: string): string | null { + return this.map.has(key) ? this.map.get(key) ?? null : null; + } + + key(index: number): string | null { + return Array.from(this.map.keys())[index] ?? null; + } + + removeItem(key: string): void { + this.map.delete(key); + } + + setItem(key: string, value: string): void { + this.map.set(key, value); + } +} + +if (typeof window === "undefined") { + const storage = new MemoryLocalStorage(); + Object.defineProperty(globalThis, "window", { + value: { + localStorage: storage, + }, + configurable: true, + writable: true, + }); +} diff --git a/packages/app/src/types/stream.test.ts b/packages/app/src/types/stream.test.ts index a04ee626e..afc91513e 100644 --- a/packages/app/src/types/stream.test.ts +++ b/packages/app/src/types/stream.test.ts @@ -483,7 +483,13 @@ describe("stream reducer canonical tool calls", () => { it("preserves optimistic user message images when authoritative user message arrives", () => { const messageId = "msg-user-images"; const optimisticImages = [ - { uri: "file:///tmp/optimistic.jpg", mimeType: "image/jpeg" }, + { + id: "att-optimistic", + mimeType: "image/jpeg", + storageType: "native-file" as const, + storageKey: "/tmp/optimistic.jpg", + createdAt: Date.now(), + }, ]; const initialState: StreamItem[] = [ { diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index 7cf763c7e..eb7016d1e 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -3,6 +3,7 @@ import type { ToolCallDetail, } from "@server/server/agent/agent-sdk-types"; import type { AgentStreamEventPayload } from "@server/shared/messages"; +import type { AttachmentMetadata } from "@/attachments/types"; import { extractTaskEntriesFromToolCall, } from "../utils/tool-call-parsers"; @@ -59,10 +60,7 @@ export type StreamItem = | ActivityLogItem | CompactionItem; -export interface UserMessageImageAttachment { - uri: string; - mimeType: string; -} +export type UserMessageImageAttachment = AttachmentMetadata; export interface UserMessageItem { kind: "user_message"; diff --git a/packages/app/src/utils/agent-directory-sync.ts b/packages/app/src/utils/agent-directory-sync.ts index c58dd8345..2bece08e6 100644 --- a/packages/app/src/utils/agent-directory-sync.ts +++ b/packages/app/src/utils/agent-directory-sync.ts @@ -47,20 +47,32 @@ export function applyFetchedAgentDirectory(input: { serverId: string; entries: FetchAgentsEntry[]; }): { agents: Map } { - const { agents, pendingPermissions } = buildAgentDirectoryState(input); + const { agents: fetchedAgents, pendingPermissions } = buildAgentDirectoryState(input); const store = useSessionStore.getState(); - store.setAgents(input.serverId, agents); + store.setAgents(input.serverId, (prev) => { + const merged = new Map(prev); + for (const [id, agent] of fetchedAgents) { + merged.set(id, agent); + } + return merged; + }); const lastActivityByAgentId = new Map(); - for (const agent of agents.values()) { + for (const agent of fetchedAgents.values()) { lastActivityByAgentId.set(agent.id, agent.lastActivityAt); } store.setAgentLastActivityBatch(lastActivityByAgentId); - store.setPendingPermissions(input.serverId, pendingPermissions); + store.setPendingPermissions(input.serverId, (prev) => { + const merged = new Map(prev); + for (const [key, entry] of pendingPermissions) { + merged.set(key, entry); + } + return merged; + }); store.setInitializingAgents(input.serverId, new Map()); store.setHasHydratedAgents(input.serverId, true); - return { agents }; + return { agents: fetchedAgents }; } diff --git a/packages/app/src/utils/encode-images.ts b/packages/app/src/utils/encode-images.ts index 20b226df4..4eb4db728 100644 --- a/packages/app/src/utils/encode-images.ts +++ b/packages/app/src/utils/encode-images.ts @@ -1,68 +1,10 @@ -import { Platform } from "react-native"; -import { File } from "expo-file-system"; +import type { AttachmentMetadata } from "@/attachments/types"; +import { encodeAttachmentsForSend } from "@/attachments/service"; -type ImageInput = { uri: string; mimeType?: string }; +type ImageInput = AttachmentMetadata; export async function encodeImages( images?: ImageInput[] ): Promise | undefined> { - if (!images || images.length === 0) { - return undefined; - } - - const encodedImages = await Promise.all( - images.map(async ({ uri, mimeType }) => { - try { - const data = await (async () => { - if (Platform.OS === "web") { - if (uri.startsWith("data:")) { - const [, base64] = uri.split(",", 2); - if (!base64) { - throw new Error("Malformed data URI for image."); - } - return base64; - } - const response = await fetch(uri); - const blob = await response.blob(); - const base64 = await new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => { - if (typeof reader.result !== "string") { - reject(new Error("Unexpected FileReader result type.")); - return; - } - const [, resultBase64] = reader.result.split(",", 2); - if (!resultBase64) { - reject(new Error("Failed to read image data as base64.")); - return; - } - resolve(resultBase64); - }; - reader.onerror = () => { - reject(reader.error ?? new Error("Failed to read image data.")); - }; - reader.readAsDataURL(blob); - }); - return base64; - } - - const file = new File(uri); - return await file.base64(); - })(); - - return { - data, - mimeType: mimeType ?? "image/jpeg", - }; - } catch (error) { - console.error("[encodeImages] Failed to convert image:", error); - return null; - } - }) - ); - - const validImages = encodedImages.filter( - (entry): entry is { data: string; mimeType: string } => entry !== null - ); - return validImages.length > 0 ? validImages : undefined; + return await encodeAttachmentsForSend(images); } diff --git a/packages/app/src/utils/image-attachments-from-files.test.ts b/packages/app/src/utils/image-attachments-from-files.test.ts index 70664c989..29569badf 100644 --- a/packages/app/src/utils/image-attachments-from-files.test.ts +++ b/packages/app/src/utils/image-attachments-from-files.test.ts @@ -1,8 +1,10 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { collectImageFilesFromClipboardData, filesToImageAttachments, } from "./image-attachments-from-files"; +import { __setAttachmentStoreForTests } from "@/attachments/store"; +import type { AttachmentStore } from "@/attachments/types"; function createClipboardItem(params: { kind: string; @@ -16,6 +18,52 @@ function createClipboardItem(params: { }; } +function createTestStore(): AttachmentStore { + let sequence = 0; + return { + storageType: "web-indexeddb", + async save(input) { + sequence += 1; + const id = input.id ?? `att-${sequence}`; + const mimeType = input.mimeType ?? "image/jpeg"; + const fileName = input.fileName ?? null; + let byteSize = 0; + if (input.source.kind === "blob") { + byteSize = input.source.blob.size; + } else if (input.source.kind === "data_url") { + byteSize = input.source.dataUrl.length; + } else { + byteSize = input.source.uri.length; + } + return { + id, + mimeType, + storageType: "web-indexeddb", + storageKey: id, + fileName, + byteSize, + createdAt: 1700000000000 + sequence, + }; + }, + async encodeBase64() { + throw new Error("not used in this test"); + }, + async resolvePreviewUrl() { + throw new Error("not used in this test"); + }, + async delete() {}, + async garbageCollect() {}, + }; +} + +beforeEach(() => { + __setAttachmentStoreForTests(createTestStore()); +}); + +afterEach(() => { + __setAttachmentStoreForTests(null); +}); + describe("collectImageFilesFromClipboardData", () => { it("returns only image files from clipboard items", () => { const imagePng = new File([new Uint8Array([0, 1, 2, 3])], "paste.png", { @@ -55,7 +103,7 @@ describe("collectImageFilesFromClipboardData", () => { }); describe("filesToImageAttachments", () => { - it("converts files into data URI image attachments and keeps order", async () => { + it("persists files as attachment metadata in order", async () => { const pngFile = new File([new Uint8Array([0, 1, 2, 3])], "first.png", { type: "image/png", }); @@ -67,13 +115,34 @@ describe("filesToImageAttachments", () => { expect(attachments).toEqual([ { - uri: "data:image/png;base64,AAECAw==", + id: "att-1", mimeType: "image/png", + storageType: "web-indexeddb", + storageKey: "att-1", + fileName: "first.png", + byteSize: 4, + createdAt: 1700000000001, }, { - uri: "data:image/jpeg;base64,BAUGBw==", + id: "att-2", mimeType: "image/jpeg", + storageType: "web-indexeddb", + storageKey: "att-2", + fileName: "second", + byteSize: 4, + createdAt: 1700000000002, }, ]); }); + + it("handles large files without creating data URLs", async () => { + const large = new File([new Uint8Array(4 * 1024 * 1024)], "large.png", { + type: "image/png", + }); + + const [attachment] = await filesToImageAttachments([large]); + + expect(attachment?.storageType).toBe("web-indexeddb"); + expect(attachment?.byteSize).toBe(4 * 1024 * 1024); + }); }); diff --git a/packages/app/src/utils/image-attachments-from-files.ts b/packages/app/src/utils/image-attachments-from-files.ts index cfff51c6a..d75c207a1 100644 --- a/packages/app/src/utils/image-attachments-from-files.ts +++ b/packages/app/src/utils/image-attachments-from-files.ts @@ -1,3 +1,6 @@ +import type { AttachmentMetadata } from "@/attachments/types"; +import { persistAttachmentFromBlob } from "@/attachments/service"; + export interface ClipboardItemLike { kind?: string; type?: string; @@ -8,23 +11,7 @@ export interface ClipboardDataLike { items?: ArrayLike | null; } -export interface ImageAttachmentFromFile { - uri: string; - mimeType: string; -} - -function toBase64(buffer: ArrayBuffer): string { - const bytes = new Uint8Array(buffer); - const chunkSize = 0x8000; - let binary = ""; - - for (let index = 0; index < bytes.length; index += chunkSize) { - const chunk = bytes.subarray(index, index + chunkSize); - binary += String.fromCharCode(...chunk); - } - - return btoa(binary); -} +export type ImageAttachmentFromFile = AttachmentMetadata; export function collectImageFilesFromClipboardData( clipboardData?: ClipboardDataLike | null @@ -54,14 +41,25 @@ export function collectImageFilesFromClipboardData( export async function filesToImageAttachments( files: readonly File[] ): Promise { - return Promise.all( + const attachments = await Promise.all( files.map(async (file) => { - const mimeType = file.type || "image/jpeg"; - const base64 = toBase64(await file.arrayBuffer()); - return { - uri: `data:${mimeType};base64,${base64}`, - mimeType, - }; + try { + return await persistAttachmentFromBlob({ + blob: file, + mimeType: file.type || "image/jpeg", + fileName: file.name, + }); + } catch (error) { + console.error("[attachments] Failed to persist file attachment", { + fileName: file.name, + error, + }); + return null; + } }) ); + + return attachments.filter( + (entry): entry is ImageAttachmentFromFile => entry !== null + ); } diff --git a/packages/app/src/utils/pending-create-images.test.ts b/packages/app/src/utils/pending-create-images.test.ts index 1571037ed..7ebec1ae9 100644 --- a/packages/app/src/utils/pending-create-images.test.ts +++ b/packages/app/src/utils/pending-create-images.test.ts @@ -5,7 +5,7 @@ import { mergePendingCreateImages } from "./pending-create-images"; function userMessage(params: { id: string; text: string; - images?: Array<{ uri: string; mimeType: string }>; + images?: Array<{ id: string; storageType: "native-file"; storageKey: string; mimeType: string; createdAt: number }>; }): StreamItem { return { kind: "user_message", @@ -16,25 +16,35 @@ function userMessage(params: { }; } +function buildImage(id: string) { + return [ + { + id, + storageType: "native-file" as const, + storageKey: `/tmp/${id}.jpg`, + mimeType: "image/jpeg", + createdAt: Date.now(), + }, + ]; +} + describe("mergePendingCreateImages", () => { it("returns same reference when pending images are absent", () => { const streamItems = [userMessage({ id: "msg-1", text: "hello" })]; const result = mergePendingCreateImages({ streamItems, - messageId: "msg-1", - text: "hello", + clientMessageId: "msg-1", images: [], }); expect(result).toBe(streamItems); }); - it("merges images by messageId when the matched message has none", () => { + it("merges images by clientMessageId when matched message has none", () => { const streamItems = [userMessage({ id: "msg-1", text: "hello" })]; - const images = [{ uri: "file:///tmp/image-1.jpg", mimeType: "image/jpeg" }]; + const images = buildImage("image-1"); const result = mergePendingCreateImages({ streamItems, - messageId: "msg-1", - text: "hello", + clientMessageId: "msg-1", images, }); @@ -47,34 +57,27 @@ describe("mergePendingCreateImages", () => { expect(updated.images).toEqual(images); }); - it("falls back to text matching when messageId does not match", () => { + it("does not merge when clientMessageId does not match", () => { const streamItems = [userMessage({ id: "msg-1", text: "same text" })]; - const images = [{ uri: "file:///tmp/image-2.jpg", mimeType: "image/jpeg" }]; + const images = buildImage("image-2"); const result = mergePendingCreateImages({ streamItems, - messageId: "missing-id", - text: "same text", + clientMessageId: "missing-id", images, }); - const updated = result[0]; - expect(updated?.kind).toBe("user_message"); - if (updated?.kind !== "user_message") { - throw new Error("Expected user_message item"); - } - expect(updated.images).toEqual(images); + expect(result).toBe(streamItems); }); it("does not overwrite existing user message images", () => { - const existingImages = [{ uri: "file:///tmp/existing.jpg", mimeType: "image/jpeg" }]; + const existingImages = buildImage("existing"); const streamItems = [ userMessage({ id: "msg-1", text: "hello", images: existingImages }), ]; const result = mergePendingCreateImages({ streamItems, - messageId: "msg-1", - text: "hello", - images: [{ uri: "file:///tmp/new.jpg", mimeType: "image/jpeg" }], + clientMessageId: "msg-1", + images: buildImage("new"), }); expect(result).toBe(streamItems); diff --git a/packages/app/src/utils/pending-create-images.ts b/packages/app/src/utils/pending-create-images.ts index 2fbd25b16..de52b80d8 100644 --- a/packages/app/src/utils/pending-create-images.ts +++ b/packages/app/src/utils/pending-create-images.ts @@ -2,15 +2,13 @@ import type { StreamItem, UserMessageImageAttachment } from "@/types/stream"; type MergePendingCreateImagesParams = { streamItems: StreamItem[]; - messageId: string; - text: string; + clientMessageId: string; images?: UserMessageImageAttachment[]; }; export function mergePendingCreateImages({ streamItems, - messageId, - text, + clientMessageId, images, }: MergePendingCreateImagesParams): StreamItem[] { if (!images || images.length === 0) { @@ -18,8 +16,7 @@ export function mergePendingCreateImages({ } const targetIndex = streamItems.findIndex( - (item) => - item.kind === "user_message" && (item.id === messageId || item.text === text) + (item) => item.kind === "user_message" && item.id === clientMessageId ); if (targetIndex < 0) { return streamItems; diff --git a/packages/cli/src/commands/agent/archive.ts b/packages/cli/src/commands/agent/archive.ts index b439b6049..f8d14bb37 100644 --- a/packages/cli/src/commands/agent/archive.ts +++ b/packages/cli/src/commands/agent/archive.ts @@ -57,8 +57,8 @@ export async function runArchiveCommand( } try { - const agent = await client.fetchAgent(agentIdArg) - if (!agent) { + const fetchResult = await client.fetchAgent(agentIdArg) + if (!fetchResult) { const error: CommandError = { code: 'AGENT_NOT_FOUND', message: `Agent not found: ${agentIdArg}`, @@ -66,6 +66,7 @@ export async function runArchiveCommand( } throw error } + const agent = fetchResult.agent const agentId = agent.id // Check if agent is already archived diff --git a/packages/cli/src/commands/agent/attach.ts b/packages/cli/src/commands/agent/attach.ts index 02a8c465a..f9f245c38 100644 --- a/packages/cli/src/commands/agent/attach.ts +++ b/packages/cli/src/commands/agent/attach.ts @@ -118,14 +118,14 @@ export async function runAttachCommand( } try { - const agent = await client.fetchAgent(id) - if (!agent) { + const fetchResult = await client.fetchAgent(id) + if (!fetchResult) { console.error(`Error: No agent found matching: ${id}`) console.error('Use `paseo ls` to list available agents') await client.close() process.exit(1) } - const resolvedId = agent.id + const resolvedId = fetchResult.agent.id // Print header console.log(`Attaching to agent ${resolvedId.substring(0, 7)}...`) diff --git a/packages/cli/src/commands/agent/inspect.ts b/packages/cli/src/commands/agent/inspect.ts index c6f19ccfa..fc76552ab 100644 --- a/packages/cli/src/commands/agent/inspect.ts +++ b/packages/cli/src/commands/agent/inspect.ts @@ -231,8 +231,8 @@ export async function runInspectCommand( } try { - const snapshot = await client.fetchAgent(agentIdArg) - if (!snapshot) { + const fetchResult = await client.fetchAgent(agentIdArg) + if (!fetchResult) { const error: CommandError = { code: 'AGENT_NOT_FOUND', message: `Agent not found: ${agentIdArg}`, @@ -243,7 +243,7 @@ export async function runInspectCommand( await client.close() - const inspectData = toInspectData(snapshot) + const inspectData = toInspectData(fetchResult.agent) return { type: 'list', diff --git a/packages/cli/src/commands/agent/logs.ts b/packages/cli/src/commands/agent/logs.ts index 1d5168aef..0e7333b4a 100644 --- a/packages/cli/src/commands/agent/logs.ts +++ b/packages/cli/src/commands/agent/logs.ts @@ -99,14 +99,14 @@ export async function runLogsCommand( } try { - const agent = await client.fetchAgent(id) - if (!agent) { + const fetchResult = await client.fetchAgent(id) + if (!fetchResult) { console.error(`Error: No agent found matching: ${id}`) console.error('Use `paseo ls` to list available agents') await client.close() process.exit(1) } - const resolvedId = agent.id + const resolvedId = fetchResult.agent.id // For follow mode, we stream events continuously if (options.follow) { diff --git a/packages/cli/src/commands/agent/mode.ts b/packages/cli/src/commands/agent/mode.ts index 79fdc4f69..afa05c27b 100644 --- a/packages/cli/src/commands/agent/mode.ts +++ b/packages/cli/src/commands/agent/mode.ts @@ -65,8 +65,8 @@ export async function runModeCommand( let client: Awaited> | undefined try { client = await connectToDaemon({ host: options.host as string | undefined }) - const agent = await client.fetchAgent(id) - if (!agent) { + const fetchResult = await client.fetchAgent(id) + if (!fetchResult) { const error: CommandError = { code: 'AGENT_NOT_FOUND', message: `No agent found matching: ${id}`, @@ -74,6 +74,7 @@ export async function runModeCommand( } throw error } + const agent = fetchResult.agent const resolvedId = agent.id if (options.list) { diff --git a/packages/cli/src/commands/agent/stop.ts b/packages/cli/src/commands/agent/stop.ts index bd43af2ce..6ac71107d 100644 --- a/packages/cli/src/commands/agent/stop.ts +++ b/packages/cli/src/commands/agent/stop.ts @@ -71,8 +71,8 @@ export async function runStopCommand( }) } else if (id) { // Stop specific agent - const agent = await client.fetchAgent(id) - if (!agent) { + const fetchResult = await client.fetchAgent(id) + if (!fetchResult) { const error: CommandError = { code: 'AGENT_NOT_FOUND', message: `No agent found matching: ${id}`, @@ -80,7 +80,7 @@ export async function runStopCommand( } throw error } - agents = [agent] + agents = [fetchResult.agent] } // Stop each agent diff --git a/packages/cli/src/commands/agent/update.ts b/packages/cli/src/commands/agent/update.ts index 19c9a2d6f..a555be80f 100644 --- a/packages/cli/src/commands/agent/update.ts +++ b/packages/cli/src/commands/agent/update.ts @@ -127,8 +127,8 @@ export async function runUpdateCommand( } try { - const agent = await client.fetchAgent(agentIdArg) - if (!agent) { + const fetchResult = await client.fetchAgent(agentIdArg) + if (!fetchResult) { const error: CommandError = { code: 'AGENT_NOT_FOUND', message: `Agent not found: ${agentIdArg}`, @@ -136,15 +136,15 @@ export async function runUpdateCommand( } throw error } - const agentId = agent.id + const agentId = fetchResult.agent.id await client.updateAgent(agentId, { ...(name ? { name } : {}), ...(Object.keys(labels).length > 0 ? { labels } : {}), }) - const updated = await client.fetchAgent(agentId) - if (!updated) { + const updatedResult = await client.fetchAgent(agentId) + if (!updatedResult) { throw new Error(`Agent not found after update: ${agentId}`) } @@ -154,8 +154,8 @@ export async function runUpdateCommand( type: 'single', data: { agentId, - name: updated.title, - labels: formatLabels(updated.labels), + name: updatedResult.agent.title, + labels: formatLabels(updatedResult.agent.labels), }, schema: updateSchema, } diff --git a/packages/cli/src/commands/permit/allow.ts b/packages/cli/src/commands/permit/allow.ts index c56b11bcd..1da3fa964 100644 --- a/packages/cli/src/commands/permit/allow.ts +++ b/packages/cli/src/commands/permit/allow.ts @@ -79,8 +79,8 @@ export async function runAllowCommand( } try { - const agent = await client.fetchAgent(agentIdOrPrefix) - if (!agent) { + const fetchResult = await client.fetchAgent(agentIdOrPrefix) + if (!fetchResult) { await client.close() const error: CommandError = { code: 'AGENT_NOT_FOUND', @@ -89,6 +89,7 @@ export async function runAllowCommand( } throw error } + const agent = fetchResult.agent const resolvedAgentId = agent.id // Get pending permissions for this agent diff --git a/packages/cli/src/commands/permit/deny.ts b/packages/cli/src/commands/permit/deny.ts index 35fb4e006..be5f8a9e4 100644 --- a/packages/cli/src/commands/permit/deny.ts +++ b/packages/cli/src/commands/permit/deny.ts @@ -45,8 +45,8 @@ export async function runDenyCommand( } try { - const agent = await client.fetchAgent(agentIdOrPrefix) - if (!agent) { + const fetchResult = await client.fetchAgent(agentIdOrPrefix) + if (!fetchResult) { await client.close() const error: CommandError = { code: 'AGENT_NOT_FOUND', @@ -55,6 +55,7 @@ export async function runDenyCommand( } throw error } + const agent = fetchResult.agent const resolvedAgentId = agent.id // Get pending permissions for this agent diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 1cdc9a0e7..9ca019849 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -12,6 +12,7 @@ import { import type { AgentStreamEventPayload, AgentSnapshotPayload, + ProjectPlacementPayload, AgentPermissionResolvedMessage, CreateAgentRequestMessage, FileDownloadTokenResponse, @@ -202,6 +203,7 @@ export type CreateAgentRequestOptions = { provider?: AgentProvider cwd?: string initialPrompt?: string + clientMessageId?: string outputSchema?: Record images?: CreateAgentRequestMessage['images'] git?: GitSetupOptions @@ -284,6 +286,11 @@ export type FetchAgentsOptions = Omit export type FetchAgentsEntry = FetchAgentsPayload['entries'][number] export type FetchAgentsPageInfo = FetchAgentsPayload['pageInfo'] +export type FetchAgentResult = { + agent: AgentSnapshotPayload + project: ProjectPlacementPayload | null +} + export type WaitForFinishResult = { status: 'idle' | 'error' | 'permission' | 'timeout' final: AgentSnapshotPayload | null @@ -1089,7 +1096,7 @@ export class DaemonClient { }) } - async fetchAgent(agentId: string, requestId?: string): Promise { + async fetchAgent(agentId: string, requestId?: string): Promise { const resolvedRequestId = this.createRequestId(requestId) const message = SessionInboundMessageSchema.parse({ type: 'fetch_agent_request', @@ -1114,7 +1121,10 @@ export class DaemonClient { if (payload.error) { throw new Error(payload.error) } - return payload.agent + if (!payload.agent) { + return null + } + return { agent: payload.agent, project: payload.project ?? null } } private resubscribeCheckoutDiffSubscriptions(): void { @@ -1158,6 +1168,7 @@ export class DaemonClient { requestId, config, ...(options.initialPrompt ? { initialPrompt: options.initialPrompt } : {}), + ...(options.clientMessageId ? { clientMessageId: options.clientMessageId } : {}), ...(options.outputSchema ? { outputSchema: options.outputSchema } : {}), ...(options.images && options.images.length > 0 ? { images: options.images } : {}), ...(options.git ? { git: options.git } : {}), @@ -2316,9 +2327,9 @@ export class DaemonClient { predicate: (snapshot: AgentSnapshotPayload) => boolean, timeout = 60000 ): Promise { - const initial = await this.fetchAgent(agentId).catch(() => null) - if (initial && predicate(initial)) { - return initial + const initialResult = await this.fetchAgent(agentId).catch(() => null) + if (initialResult && predicate(initialResult.agent)) { + return initialResult.agent } const deadline = Date.now() + timeout @@ -2374,8 +2385,8 @@ export class DaemonClient { } pollInFlight = true try { - const snapshot = await this.fetchAgent(agentId).catch(() => null) - maybeResolve(snapshot) + const result = await this.fetchAgent(agentId).catch(() => null) + maybeResolve(result?.agent ?? null) } finally { pollInFlight = false } diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index 898327d63..ebdea38ac 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -140,7 +140,7 @@ describe("daemon client E2E", () => { expect(ctx.client.isConnected).toBe(true); const agents = await ctx.client.fetchAgents(); - expect(Array.isArray(agents)).toBe(true); + expect(Array.isArray(agents.entries)).toBe(true); const cwd = tmpCwd(); const created = await ctx.client.createAgent({ @@ -218,10 +218,12 @@ describe("daemon client E2E", () => { const result = await ctx.client.archiveAgent(created.id); expect(result.archivedAt).toBeTruthy(); - const archived = await ctx.client.fetchAgent(created.id); - expect(archived).not.toBeNull(); - expect(archived?.archivedAt).toBeTruthy(); - expect(archived?.status).not.toBe("running"); + const archivedResult = await ctx.client.fetchAgent(created.id); + expect(archivedResult).not.toBeNull(); + expect(archivedResult?.agent.archivedAt).toBeTruthy(); + expect(archivedResult?.agent.status).not.toBe("running"); + expect(archivedResult?.project).not.toBeNull(); + expect(archivedResult?.project?.checkout.cwd).toBe(cwd); const runningAgents = await ctx.client.fetchAgents({ filter: { includeArchived: true, statuses: ["running"] }, @@ -342,8 +344,10 @@ describe("daemon client E2E", () => { }), ]); - expect(Array.isArray(first)).toBe(true); - expect(Array.isArray(second)).toBe(true); + expect(first.requestId).toBe(firstRequestId); + expect(second.requestId).toBe(secondRequestId); + expect(Array.isArray(first.entries)).toBe(true); + expect(Array.isArray(second.entries)).toBe(true); }, 15000); test( @@ -399,8 +403,8 @@ describe("daemon client E2E", () => { expect(agent.id).toBeTruthy(); expect(agent.status).toBe("idle"); - const fetched = await ctx.client.fetchAgent(agent.id); - expect(fetched?.id).toBe(agent.id); + const fetchedResult = await ctx.client.fetchAgent(agent.id); + expect(fetchedResult?.agent.id).toBe(agent.id); const agentUpdate = await agentUpdatePromise; expect(agentUpdate.payload.agent.id).toBe(agent.id); diff --git a/packages/server/src/server/daemon-e2e/agent-operations.e2e.test.ts b/packages/server/src/server/daemon-e2e/agent-operations.e2e.test.ts index a471c4ba3..5396fde85 100644 --- a/packages/server/src/server/daemon-e2e/agent-operations.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/agent-operations.e2e.test.ts @@ -68,14 +68,14 @@ describe("daemon E2E", () => { limit: 200, projection: "projected", }); - const refreshedState = await ctx.client.fetchAgent(agent.id); + const refreshedResult = await ctx.client.fetchAgent(agent.id); // Verify agent is still idle - expect(refreshedState?.status).toBe("idle"); + expect(refreshedResult?.agent.status).toBe("idle"); // CRITICAL: The timestamp should NOT have changed // Just opening/clicking an agent should not update its updatedAt - expect(refreshedState?.updatedAt).toBe(initialUpdatedAt); + expect(refreshedResult?.agent.updatedAt).toBe(initialUpdatedAt); // Also clear attention (what happens when opening an agent with notification) await ctx.client.clearAgentAttention(agent.id); @@ -86,10 +86,10 @@ describe("daemon E2E", () => { limit: 200, projection: "projected", }); - const stateAfterClear = await ctx.client.fetchAgent(agent.id); + const clearResult = await ctx.client.fetchAgent(agent.id); // Timestamp should STILL not have changed - expect(stateAfterClear?.updatedAt).toBe(initialUpdatedAt); + expect(clearResult?.agent.updatedAt).toBe(initialUpdatedAt); // Cleanup rmSync(cwd, { recursive: true, force: true }); @@ -341,7 +341,7 @@ describe("daemon E2E", () => { // Initially, there should be no agents (fresh session) const initialAgents = await ctx.client.fetchAgents(); - expect(initialAgents).toHaveLength(0); + expect(initialAgents.entries).toHaveLength(0); // Create first agent const agent1 = await ctx.client.createAgent({ @@ -355,10 +355,10 @@ describe("daemon E2E", () => { // fetchAgents should now return 1 agent const afterFirst = await ctx.client.fetchAgents(); - expect(afterFirst).toHaveLength(1); - expect(afterFirst[0].id).toBe(agent1.id); + expect(afterFirst.entries).toHaveLength(1); + expect(afterFirst.entries[0]?.agent.id).toBe(agent1.id); // Title may or may not be set depending on timing - expect(afterFirst[0].cwd).toBe(cwd1); + expect(afterFirst.entries[0]?.agent.cwd).toBe(cwd1); // Create second agent const agent2 = await ctx.client.createAgent({ @@ -372,15 +372,15 @@ describe("daemon E2E", () => { // fetchAgents should now return 2 agents const afterSecond = await ctx.client.fetchAgents(); - expect(afterSecond).toHaveLength(2); + expect(afterSecond.entries).toHaveLength(2); // Verify both agents are present with correct IDs and states - const ids = afterSecond.map((a) => a.id); + const ids = afterSecond.entries.map((entry) => entry.agent.id); expect(ids).toContain(agent1.id); expect(ids).toContain(agent2.id); - const agent1State = afterSecond.find((a) => a.id === agent1.id); - const agent2State = afterSecond.find((a) => a.id === agent2.id); + const agent1State = afterSecond.entries.find((entry) => entry.agent.id === agent1.id)?.agent; + const agent2State = afterSecond.entries.find((entry) => entry.agent.id === agent2.id)?.agent; // Title may or may not be set depending on timing expect(agent1State?.cwd).toBe(cwd1); @@ -395,12 +395,12 @@ describe("daemon E2E", () => { // fetchAgents should now return only 1 agent const afterDelete = await ctx.client.fetchAgents(); - expect(afterDelete).toHaveLength(1); - expect(afterDelete[0].id).toBe(agent2.id); - expect(afterDelete[0].cwd).toBe(cwd2); + expect(afterDelete.entries).toHaveLength(1); + expect(afterDelete.entries[0]?.agent.id).toBe(agent2.id); + expect(afterDelete.entries[0]?.agent.cwd).toBe(cwd2); // Verify agent1 is no longer in the list - const deletedAgent = afterDelete.find((a) => a.id === agent1.id); + const deletedAgent = afterDelete.entries.find((entry) => entry.agent.id === agent1.id); expect(deletedAgent).toBeUndefined(); // Cleanup diff --git a/packages/server/src/server/daemon-e2e/claude-autonomous-wake.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/claude-autonomous-wake.real.e2e.test.ts index 288eed28b..1e6148542 100644 --- a/packages/server/src/server/daemon-e2e/claude-autonomous-wake.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/claude-autonomous-wake.real.e2e.test.ts @@ -545,8 +545,8 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = const settled = await client.waitForFinish(agent.id, 20_000); expect(settled.status).toBe("idle"); - const finalSnapshot = await client.fetchAgent(agent.id); - expect(finalSnapshot?.status).toBe("idle"); + const finalResult = await client.fetchAgent(agent.id); + expect(finalResult?.agent.status).toBe("idle"); } finally { await client.close(); await daemon.close(); @@ -750,13 +750,13 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = try { secondCompletion = await client.waitForFinish(agent.id, 20_000); } catch { - const atTimeout = await client.fetchAgent(agent.id); + const atTimeoutResult = await client.fetchAgent(agent.id); // eslint-disable-next-line no-console console.log( JSON.stringify({ cycle, phase: "second_completion_timeout", - statusAtTimeout: atTimeout?.status ?? "unknown", + statusAtTimeout: atTimeoutResult?.agent.status ?? "unknown", }) ); secondCompletion = await client.waitForFinish(agent.id, 30_000); @@ -830,12 +830,12 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = try { secondCompletion = await client.waitForFinish(agent.id, 20_000); } catch { - const atTimeout = await client.fetchAgent(agent.id); + const atTimeoutResult = await client.fetchAgent(agent.id); // eslint-disable-next-line no-console console.log( JSON.stringify({ phase: "second_background_completion_timeout", - statusAtTimeout: atTimeout?.status ?? "unknown", + statusAtTimeout: atTimeoutResult?.agent.status ?? "unknown", }) ); secondCompletion = await client.waitForFinish(agent.id, 30_000); @@ -995,9 +995,9 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = error instanceof Error ? error : new Error(String(error ?? "wait_for_finish failed")); } - let afterWait = await client.fetchAgent(agent.id); + let afterWaitResult = await client.fetchAgent(agent.id); let cancelRecovered = true; - if (afterWait?.status === "running") { + if (afterWaitResult?.agent.status === "running") { await client.cancelAgent(agent.id); try { const afterCancel = await client.waitForFinish(agent.id, 15_000); @@ -1005,12 +1005,13 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = } catch { cancelRecovered = false; } - afterWait = await client.fetchAgent(agent.id); + afterWaitResult = await client.fetchAgent(agent.id); } expect(waitError).toBeNull(); expect(cancelRecovered).toBe(true); + const afterWait = afterWaitResult?.agent ?? null; const sessionId = afterWait?.persistence?.sessionId ?? afterWait?.runtimeInfo?.sessionId ?? diff --git a/packages/server/src/server/daemon-e2e/send-while-running-stuck-claude.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/send-while-running-stuck-claude.real.e2e.test.ts index 345c311d6..130085846 100644 --- a/packages/server/src/server/daemon-e2e/send-while-running-stuck-claude.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/send-while-running-stuck-claude.real.e2e.test.ts @@ -102,9 +102,9 @@ describe("daemon E2E (real claude) - send while running recovery", () => { } await secondary.waitForFinish(agent.id, 180_000); - const finalSnapshot = await secondary.fetchAgent(agent.id); - if (finalSnapshot) { - applySnapshot(finalSnapshot); + const finalResult = await secondary.fetchAgent(agent.id); + if (finalResult) { + applySnapshot(finalResult.agent); } // Sending while running should clear processing even if reconnect misses the diff --git a/packages/server/src/server/daemon-e2e/send-while-running-stuck.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/send-while-running-stuck.real.e2e.test.ts index 659d879c0..1d90a8bb9 100644 --- a/packages/server/src/server/daemon-e2e/send-while-running-stuck.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/send-while-running-stuck.real.e2e.test.ts @@ -99,9 +99,9 @@ describe("daemon E2E (real codex) - send while running recovery", () => { } await secondary.waitForFinish(agent.id, 120_000); - const finalSnapshot = await secondary.fetchAgent(agent.id); - if (finalSnapshot) { - applySnapshot(finalSnapshot); + const finalResult = await secondary.fetchAgent(agent.id); + if (finalResult) { + applySnapshot(finalResult.agent); } // Sending while running should clear processing even if reconnect misses the diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 48e51d804..891eaa665 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -4926,6 +4926,22 @@ export class Session { } } + private getFetchAgentsSortValueFromAgent( + agent: AgentSnapshotPayload, + key: FetchAgentsRequestSort['key'] + ): string | number | null { + switch (key) { + case 'status_priority': + return this.getStatusPriority(agent) + case 'created_at': + return Date.parse(agent.createdAt) + case 'updated_at': + return Date.parse(agent.updatedAt) + case 'title': + return agent.title?.toLocaleLowerCase() ?? '' + } + } + private compareSortValues(left: string | number | null, right: string | number | null): number { if (left === right) { return 0 @@ -4942,21 +4958,21 @@ export class Session { return String(left).localeCompare(String(right)) } - private compareFetchAgentsEntries( - left: FetchAgentsResponseEntry, - right: FetchAgentsResponseEntry, + private compareFetchAgentsAgents( + left: AgentSnapshotPayload, + right: AgentSnapshotPayload, sort: FetchAgentsRequestSort[] ): number { for (const spec of sort) { - const leftValue = this.getFetchAgentsSortValue(left, spec.key) - const rightValue = this.getFetchAgentsSortValue(right, spec.key) + const leftValue = this.getFetchAgentsSortValueFromAgent(left, spec.key) + const rightValue = this.getFetchAgentsSortValueFromAgent(right, spec.key) const base = this.compareSortValues(leftValue, rightValue) if (base === 0) { continue } return spec.direction === 'asc' ? base : -base } - return left.agent.id.localeCompare(right.agent.id) + return left.id.localeCompare(right.id) } private encodeFetchAgentsCursor( @@ -5050,13 +5066,13 @@ export class Session { } } - private compareEntryWithCursor( - entry: FetchAgentsResponseEntry, + private compareAgentWithCursor( + agent: AgentSnapshotPayload, cursor: FetchAgentsCursor, sort: FetchAgentsRequestSort[] ): number { for (const spec of sort) { - const leftValue = this.getFetchAgentsSortValue(entry, spec.key) + const leftValue = this.getFetchAgentsSortValueFromAgent(agent, spec.key) const rightValue = cursor.values[spec.key] !== undefined ? (cursor.values[spec.key] ?? null) : null const base = this.compareSortValues(leftValue, rightValue) @@ -5065,7 +5081,7 @@ export class Session { } return spec.direction === 'asc' ? base : -base } - return entry.agent.id.localeCompare(cursor.id) + return agent.id.localeCompare(cursor.id) } private async listFetchAgentsEntries( @@ -5092,31 +5108,45 @@ export class Session { return placementPromise } - let entries = await Promise.all( - agents.map(async (agent) => ({ - agent, - project: await getPlacement(agent.cwd), - })) - ) - entries = entries.filter((entry) => - this.matchesAgentFilter({ - agent: entry.agent, - project: entry.project, - filter, - }) - ) - - entries.sort((left, right) => this.compareFetchAgentsEntries(left, right, sort)) - + let candidates = [...agents] + candidates.sort((left, right) => this.compareFetchAgentsAgents(left, right, sort)) const cursorToken = request.page?.cursor if (cursorToken) { const cursor = this.decodeFetchAgentsCursor(cursorToken, sort) - entries = entries.filter((entry) => this.compareEntryWithCursor(entry, cursor, sort) > 0) + candidates = candidates.filter((agent) => this.compareAgentWithCursor(agent, cursor, sort) > 0) } - const limit = request.page?.limit ?? entries.length - const pagedEntries = entries.slice(0, limit) - const hasMore = entries.length > limit + const limit = request.page?.limit ?? 200 + + const matchedEntries: FetchAgentsResponseEntry[] = [] + const batchSize = 25 + for (let start = 0; start < candidates.length && matchedEntries.length <= limit; start += batchSize) { + const batch = candidates.slice(start, start + batchSize) + const batchEntries = await Promise.all( + batch.map(async (agent) => ({ + agent, + project: await getPlacement(agent.cwd), + })) + ) + for (const entry of batchEntries) { + if ( + !this.matchesAgentFilter({ + agent: entry.agent, + project: entry.project, + filter, + }) + ) { + continue + } + matchedEntries.push(entry) + if (matchedEntries.length > limit) { + break + } + } + } + + const pagedEntries = matchedEntries.slice(0, limit) + const hasMore = matchedEntries.length > limit const nextCursor = hasMore && pagedEntries.length > 0 ? this.encodeFetchAgentsCursor(pagedEntries[pagedEntries.length - 1], sort) @@ -5197,7 +5227,7 @@ export class Session { if (!resolved.ok) { this.emit({ type: 'fetch_agent_response', - payload: { requestId, agent: null, error: resolved.error }, + payload: { requestId, agent: null, project: null, error: resolved.error }, }) return } @@ -5206,14 +5236,20 @@ export class Session { if (!agent) { this.emit({ type: 'fetch_agent_response', - payload: { requestId, agent: null, error: `Agent not found: ${resolved.agentId}` }, + payload: { + requestId, + agent: null, + project: null, + error: `Agent not found: ${resolved.agentId}`, + }, }) return } + const project = await this.buildProjectPlacement(agent.cwd) this.emit({ type: 'fetch_agent_response', - payload: { requestId, agent, error: null }, + payload: { requestId, agent, project, error: null }, }) } diff --git a/packages/server/src/shared/messages.create-agent-client-message-id.test.ts b/packages/server/src/shared/messages.create-agent-client-message-id.test.ts new file mode 100644 index 000000000..0a7075790 --- /dev/null +++ b/packages/server/src/shared/messages.create-agent-client-message-id.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { SessionInboundMessageSchema } from "./messages"; + +describe("create_agent_request clientMessageId", () => { + it("accepts clientMessageId for stable initial prompt transfer", () => { + const parsed = SessionInboundMessageSchema.parse({ + type: "create_agent_request", + requestId: "req-1", + clientMessageId: "client-msg-1", + config: { + provider: "claude", + cwd: "/tmp/project", + }, + initialPrompt: "hello", + }); + + expect(parsed.type).toBe("create_agent_request"); + if (parsed.type !== "create_agent_request") { + throw new Error("Expected create_agent_request"); + } + expect(parsed.clientMessageId).toBe("client-msg-1"); + }); +}); diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 3e65a1418..384837139 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -491,7 +491,7 @@ export const FetchAgentsRequestMessageSchema = z.object({ .optional(), page: z .object({ - limit: z.number().int().positive().max(1000), + limit: z.number().int().positive().max(200), cursor: z.string().min(1).optional(), }) .optional(), @@ -578,6 +578,7 @@ export const CreateAgentRequestMessageSchema = z.object({ config: AgentSessionConfigSchema, worktreeName: z.string().optional(), initialPrompt: z.string().optional(), + clientMessageId: z.string().optional(), outputSchema: z.record(z.unknown()).optional(), images: z .array( @@ -1469,6 +1470,7 @@ export const FetchAgentResponseMessageSchema = z.object({ payload: z.object({ requestId: z.string(), agent: AgentSnapshotPayloadSchema.nullable(), + project: ProjectPlacementPayloadSchema.nullable().optional(), error: z.string().nullable(), }), })