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:
@@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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[] = [
|
||||
{
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}...`)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ??
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
}),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user