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.
This commit is contained in:
Mohamed Boudra
2026-02-22 16:22:21 +07:00
parent 2652bdda2e
commit 0c710b09f5
55 changed files with 2381 additions and 350 deletions
@@ -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<string, string> = {
"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<void> {
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<void> {
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<AttachmentStorageType, "desktop-file" | "native-file">;
baseDirectoryName: string;
resolvePreviewUrl: (attachment: AttachmentMetadata) => Promise<string>;
releasePreviewUrl?: (input: {
attachment: AttachmentMetadata;
url: string;
}) => Promise<void>;
}): 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<AttachmentMetadata> {
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<string> {
const uri = attachmentUri(attachment);
return await FileSystem.readAsStringAsync(uri, {
encoding: FileSystem.EncodingType.Base64,
});
},
async resolvePreviewUrl({ attachment }): Promise<string> {
return await params.resolvePreviewUrl(attachment);
},
...(params.releasePreviewUrl
? {
async releasePreviewUrl(input: {
attachment: AttachmentMetadata;
url: string;
}): Promise<void> {
await params.releasePreviewUrl?.(input);
},
}
: {}),
async delete({ attachment }): Promise<void> {
await FileSystem.deleteAsync(attachmentUri(attachment), { idempotent: true });
},
async garbageCollect({ referencedIds }): Promise<void> {
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,
});
})
);
},
};
}
@@ -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;
},
});
}
+125
View File
@@ -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<AttachmentMetadata> {
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<AttachmentMetadata> {
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<AttachmentMetadata> {
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<Array<{ data: string; mimeType: string }> | 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<string> {
const store = await getAttachmentStore();
return await store.resolvePreviewUrl({ attachment });
}
export async function releaseAttachmentPreviewUrl(input: {
attachment: AttachmentMetadata;
url: string;
}): Promise<void> {
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<void> {
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<string>;
}): Promise<void> {
const store = await getAttachmentStore();
await store.garbageCollect({ referencedIds: input.referencedIds });
}
+38
View File
@@ -0,0 +1,38 @@
import { Platform } from "react-native";
import { isTauriEnvironment } from "@/utils/tauri";
import type { AttachmentStore } from "@/attachments/types";
let attachmentStorePromise: Promise<AttachmentStore> | null = null;
async function createAttachmentStore(): Promise<AttachmentStore> {
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<AttachmentStore> {
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;
}
+63
View File
@@ -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<string>;
}
/**
* 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<AttachmentMetadata>;
encodeBase64(input: EncodeAttachmentInput): Promise<string>;
resolvePreviewUrl(input: ResolvePreviewUrlInput): Promise<string>;
releasePreviewUrl?(input: ReleasePreviewUrlInput): Promise<void>;
delete(input: DeleteAttachmentInput): Promise<void>;
garbageCollect(input: GarbageCollectInput): Promise<void>;
}
@@ -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<string | null>(null);
const activeAttachmentRef = useRef<AttachmentMetadata | null>(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;
}
+78
View File
@@ -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<string> {
return await new Promise<string>((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);
}
@@ -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<IDBDatabase> {
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<T>(
db: IDBDatabase,
mode: IDBTransactionMode,
run: (store: IDBObjectStore) => IDBRequest<T>
): Promise<T> {
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<Blob> {
const record = await runTx<StoredBlobRecord | undefined>(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<AttachmentMetadata> {
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<string> {
const db = await openAttachmentDb();
try {
const blob = await loadBlob(db, attachment.storageKey);
return await blobToBase64(blob);
} finally {
db.close();
}
},
async resolvePreviewUrl({ attachment }): Promise<string> {
const db = await openAttachmentDb();
try {
const blob = await loadBlob(db, attachment.storageKey);
return URL.createObjectURL(blob);
} finally {
db.close();
}
},
async releasePreviewUrl({ url }): Promise<void> {
URL.revokeObjectURL(url);
},
async delete({ attachment }): Promise<void> {
const db = await openAttachmentDb();
try {
await runTx(db, "readwrite", (store) => store.delete(attachment.storageKey));
} finally {
db.close();
}
},
async garbageCollect({ referencedIds }): Promise<void> {
const db = await openAttachmentDb();
try {
await new Promise<void>((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();
}
},
};
}
@@ -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);
});
});
@@ -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;
}
+115 -23
View File
@@ -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<void>
/** 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<string | null>(null)
const lastHandledMessageInputActionRequestIdRef = useRef<number | null>(null)
const messageInputRef = useRef<MessageInputRef>(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(() => {
@@ -134,7 +134,6 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
isRevalidating,
refreshAll,
} = useSidebarAgentsList({
isOpen,
serverId: activeServerId,
selectedProjectFilterKeys,
});
+18 -6
View File
@@ -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<string, unknown>
}
function ImageAttachmentThumbnail({ image }: { image: ImageAttachment }) {
const uri = useAttachmentPreviewUrl(image)
if (!uri) {
return <View style={styles.imageThumbnailPlaceholder} />
}
return <Image source={{ uri }} style={styles.imageThumbnail} />
}
export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(function MessageInput(
{
value,
@@ -636,14 +643,14 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
<View style={styles.imagePreviewContainer} testID="message-input-image-preview">
{images.map((image, index) => (
<Pressable
key={`${image.uri}-${index}`}
key={`${image.id}-${index}`}
testID="message-input-image-pill"
style={styles.imagePill}
onPress={onRemoveImage ? () => onRemoveImage(index) : undefined}
>
{({ hovered }) => (
<>
<Image source={{ uri: image.uri }} style={styles.imageThumbnail} />
<ImageAttachmentThumbnail image={image} />
{onRemoveImage && (
<View
style={[
@@ -894,6 +901,11 @@ const styles = StyleSheet.create(((theme: any) => ({
width: 48,
height: 48,
},
imageThumbnailPlaceholder: {
width: 48,
height: 48,
backgroundColor: theme.colors.surface2,
},
removeImageButton: {
position: 'absolute',
top: 0,
+20 -5
View File
@@ -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 <View style={userMessageStylesheet.imageThumbnailPlaceholder} />;
}
return <Image source={{ uri }} style={userMessageStylesheet.imageThumbnail} />;
}
export const UserMessage = memo(function UserMessage({
message,
images = [],
@@ -368,13 +386,10 @@ export const UserMessage = memo(function UserMessage({
>
{images.map((image, index) => (
<View
key={`${image.uri}-${index}`}
key={`${image.id}-${index}`}
style={userMessageStylesheet.imagePill}
>
<Image
source={{ uri: image.uri }}
style={userMessageStylesheet.imageThumbnail}
/>
<UserMessageAttachmentThumbnail image={image} />
</View>
))}
</View>
@@ -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<void>)
| 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;
+20 -25
View File
@@ -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<ImageAttachment> {
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<ImageAttachment> {
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);
});
}
);
@@ -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 =
+3
View File
@@ -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<typeof applyFetchedAgentDirectory>["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,
@@ -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);
@@ -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}
/>
</View>
</View>
@@ -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");
});
});
+55 -6
View File
@@ -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<PendingCreateAttempt, "lifecycle">
) => void;
updateAgentId: (agentId: string) => void;
markLifecycle: (lifecycle: CreateFlowLifecycleState) => void;
rekeyDraft: (input: { fromDraftId: string; toDraftId: string }) => void;
clear: () => void;
};
export const useCreateFlowStore = create<CreateFlowState>((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 }),
}));
@@ -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");
});
});
+18
View File
@@ -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()}`;
}
@@ -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,
});
});
});
@@ -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);
});
});
+605 -22
View File
@@ -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<string, DraftInput>;
createModalDraft: DraftInput | null;
drafts: Record<string, DraftRecord>;
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<DraftInput | undefined>;
saveDraftInput: (input: { draftKey: string; draft: DraftInput }) => void;
markDraftLifecycle: (input: {
draftKey: string;
lifecycle: DraftLifecycleState;
}) => void;
clearDraftInput: (input: {
draftKey: string;
lifecycle?: Exclude<DraftLifecycleState, "active">;
}) => 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<string, number>();
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<string, unknown>;
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<string, unknown>;
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<string> {
const referencedIds = new Set<string>();
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<string, DraftRecord>;
nowMs: number;
}): Record<string, DraftRecord> {
let changed = false;
const next: Record<string, DraftRecord> = {};
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<DraftLifecycleState, "active">;
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<void> {
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<string>();
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<void> {
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<AttachmentMetadata[]> {
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<string, unknown>;
createModalDraft?: unknown;
};
const nextDrafts: Record<string, DraftRecord> = {};
for (const [draftKey, rawRecord] of Object.entries(input.drafts ?? {})) {
if (!rawRecord || typeof rawRecord !== "object") {
continue;
}
const parsed = rawRecord as Record<string, unknown>;
if ("input" in parsed && parsed.input && typeof parsed.input === "object") {
const recordInput = parsed.input as Record<string, unknown>;
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<string, unknown>;
if (record.input && typeof record.input === "object") {
const recordInput = record.input as Record<string, unknown>;
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<DraftStore>()(
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,
};
+16 -2
View File
@@ -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<string, AgentFileExplorerState>;
// Queued messages
queuedMessages: Map<string, Array<{ id: string; text: string; images?: Array<{ uri: string; mimeType: string }> }>>;
queuedMessages: Map<
string,
Array<{ id: string; text: string; images?: AttachmentMetadata[] }>
>;
}
// Global store state
@@ -278,7 +282,17 @@ interface SessionStoreActions {
setFileExplorer: (serverId: string, state: Map<string, AgentFileExplorerState> | ((prev: Map<string, AgentFileExplorerState>) => Map<string, AgentFileExplorerState>)) => void;
// Queued messages
setQueuedMessages: (serverId: string, value: Map<string, Array<{ id: string; text: string; images?: Array<{ uri: string; mimeType: string }> }>> | ((prev: Map<string, Array<{ id: string; text: string; images?: Array<{ uri: string; mimeType: string }> }>>) => Map<string, Array<{ id: string; text: string; images?: Array<{ uri: string; mimeType: string }> }>>)) => void;
setQueuedMessages: (
serverId: string,
value:
| Map<string, Array<{ id: string; text: string; images?: AttachmentMetadata[] }>>
| ((
prev: Map<
string,
Array<{ id: string; text: string; images?: AttachmentMetadata[] }>
>
) => Map<string, Array<{ id: string; text: string; images?: AttachmentMetadata[] }>>)
) => void;
// Hydration
setHasHydratedAgents: (serverId: string, hydrated: boolean) => void;
@@ -0,0 +1,38 @@
class MemoryLocalStorage {
private readonly map = new Map<string, string>();
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,
});
}
+7 -1
View File
@@ -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[] = [
{
+2 -4
View File
@@ -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";
+17 -5
View File
@@ -47,20 +47,32 @@ export function applyFetchedAgentDirectory(input: {
serverId: string;
entries: FetchAgentsEntry[];
}): { agents: Map<string, Agent> } {
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<string, Date>();
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 };
}
+4 -62
View File
@@ -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<Array<{ data: string; mimeType: string }> | 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<string>((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);
}
@@ -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);
});
});
@@ -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<ClipboardItemLike> | 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<ImageAttachmentFromFile[]> {
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
);
}
@@ -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);
@@ -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;
+3 -2
View File
@@ -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
+3 -3
View File
@@ -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)}...`)
+3 -3
View File
@@ -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',
+3 -3
View File
@@ -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) {
+3 -2
View File
@@ -65,8 +65,8 @@ export async function runModeCommand(
let client: Awaited<ReturnType<typeof connectToDaemon>> | 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) {
+3 -3
View File
@@ -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
+7 -7
View File
@@ -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,
}
+3 -2
View File
@@ -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
+3 -2
View File
@@ -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
+18 -7
View File
@@ -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<string, unknown>
images?: CreateAgentRequestMessage['images']
git?: GitSetupOptions
@@ -284,6 +286,11 @@ export type FetchAgentsOptions = Omit<FetchAgentsRequest, 'type' | 'requestId'>
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<AgentSnapshotPayload | null> {
async fetchAgent(agentId: string, requestId?: string): Promise<FetchAgentResult | null> {
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<AgentSnapshotPayload> {
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
}
@@ -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);
@@ -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
@@ -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 ??
@@ -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
@@ -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
+69 -33
View File
@@ -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 },
})
}
@@ -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");
});
});
+3 -1
View File
@@ -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(),
}),
})