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 { useImageAttachmentPicker } from '@/hooks/use-image-attachment-picker'
|
||||||
import { useSessionStore } from '@/stores/session-store'
|
import { useSessionStore } from '@/stores/session-store'
|
||||||
import { useDraftStore } from '@/stores/draft-store'
|
import { useDraftStore } from '@/stores/draft-store'
|
||||||
|
import { buildDraftStoreKey } from '@/stores/draft-keys'
|
||||||
import {
|
import {
|
||||||
MessageInput,
|
MessageInput,
|
||||||
type MessagePayload,
|
type MessagePayload,
|
||||||
@@ -30,6 +31,11 @@ import { Shortcut } from '@/components/ui/shortcut'
|
|||||||
import { Autocomplete } from '@/components/ui/autocomplete'
|
import { Autocomplete } from '@/components/ui/autocomplete'
|
||||||
import { useAgentAutocomplete } from '@/hooks/use-agent-autocomplete'
|
import { useAgentAutocomplete } from '@/hooks/use-agent-autocomplete'
|
||||||
import { useHostRuntimeSession } from '@/runtime/host-runtime'
|
import { useHostRuntimeSession } from '@/runtime/host-runtime'
|
||||||
|
import {
|
||||||
|
deleteAttachments,
|
||||||
|
persistAttachmentFromFileUri,
|
||||||
|
} from '@/attachments/service'
|
||||||
|
import { shouldSkipDraftPersist } from '@/components/agent-input-area.draft-persist-guard'
|
||||||
|
|
||||||
type QueuedMessage = {
|
type QueuedMessage = {
|
||||||
id: string
|
id: string
|
||||||
@@ -40,6 +46,7 @@ type QueuedMessage = {
|
|||||||
interface AgentInputAreaProps {
|
interface AgentInputAreaProps {
|
||||||
agentId: string
|
agentId: string
|
||||||
serverId: string
|
serverId: string
|
||||||
|
draftId?: string
|
||||||
onSubmitMessage?: (payload: MessagePayload) => Promise<void>
|
onSubmitMessage?: (payload: MessagePayload) => Promise<void>
|
||||||
/** Externally controlled loading state. When true, disables the submit button. */
|
/** Externally controlled loading state. When true, disables the submit button. */
|
||||||
isSubmitLoading?: boolean
|
isSubmitLoading?: boolean
|
||||||
@@ -60,6 +67,7 @@ const EMPTY_ARRAY: readonly QueuedMessage[] = []
|
|||||||
export function AgentInputArea({
|
export function AgentInputArea({
|
||||||
agentId,
|
agentId,
|
||||||
serverId,
|
serverId,
|
||||||
|
draftId,
|
||||||
onSubmitMessage,
|
onSubmitMessage,
|
||||||
isSubmitLoading = false,
|
isSubmitLoading = false,
|
||||||
blurOnSubmit = false,
|
blurOnSubmit = false,
|
||||||
@@ -89,8 +97,13 @@ export function AgentInputArea({
|
|||||||
|
|
||||||
const agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId))
|
const agent = useSessionStore((state) => state.sessions[serverId]?.agents?.get(agentId))
|
||||||
|
|
||||||
|
const draftStoreKey = buildDraftStoreKey({ serverId, agentId, draftId })
|
||||||
const getDraftInput = useDraftStore((state) => state.getDraftInput)
|
const getDraftInput = useDraftStore((state) => state.getDraftInput)
|
||||||
|
const hydrateDraftInput = useDraftStore((state) => state.hydrateDraftInput)
|
||||||
const saveDraftInput = useDraftStore((state) => state.saveDraftInput)
|
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) =>
|
const queuedMessagesRaw = useSessionStore((state) =>
|
||||||
state.sessions[serverId]?.queuedMessages?.get(agentId)
|
state.sessions[serverId]?.queuedMessages?.get(agentId)
|
||||||
@@ -111,6 +124,8 @@ export function AgentInputArea({
|
|||||||
const [sendError, setSendError] = useState<string | null>(null)
|
const [sendError, setSendError] = useState<string | null>(null)
|
||||||
const lastHandledMessageInputActionRequestIdRef = useRef<number | null>(null)
|
const lastHandledMessageInputActionRequestIdRef = useRef<number | null>(null)
|
||||||
const messageInputRef = useRef<MessageInputRef>(null)
|
const messageInputRef = useRef<MessageInputRef>(null)
|
||||||
|
const draftGenerationRef = useRef(0)
|
||||||
|
const hydratedGenerationRef = useRef(0)
|
||||||
|
|
||||||
const autocomplete = useAgentAutocomplete({
|
const autocomplete = useAgentAutocomplete({
|
||||||
userInput,
|
userInput,
|
||||||
@@ -176,10 +191,10 @@ export function AgentInputArea({
|
|||||||
throw new Error('Host is not connected')
|
throw new Error('Host is not connected')
|
||||||
}
|
}
|
||||||
|
|
||||||
const messageId = generateMessageId()
|
const clientMessageId = generateMessageId()
|
||||||
const userMessage: StreamItem = {
|
const userMessage: StreamItem = {
|
||||||
kind: 'user_message',
|
kind: 'user_message',
|
||||||
id: messageId,
|
id: clientMessageId,
|
||||||
text,
|
text,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
...(images && images.length > 0 ? { images } : {}),
|
...(images && images.length > 0 ? { images } : {}),
|
||||||
@@ -209,7 +224,7 @@ export function AgentInputArea({
|
|||||||
|
|
||||||
const imagesData = await encodeImages(images)
|
const imagesData = await encodeImages(images)
|
||||||
await client.sendAgentMessage(agentId, text, {
|
await client.sendAgentMessage(agentId, text, {
|
||||||
messageId,
|
messageId: clientMessageId,
|
||||||
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
|
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -295,7 +310,7 @@ export function AgentInputArea({
|
|||||||
forceSend?: boolean
|
forceSend?: boolean
|
||||||
) {
|
) {
|
||||||
const trimmedMessage = message.trim()
|
const trimmedMessage = message.trim()
|
||||||
if (!trimmedMessage) return
|
if (!trimmedMessage && !imageAttachments?.length) return
|
||||||
// When the parent controls submission (e.g. draft agent creation), let it
|
// 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
|
// 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).
|
// don't no-op and lose deterministic error handling in the UI/tests).
|
||||||
@@ -322,6 +337,7 @@ export function AgentInputArea({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await submitMessage(trimmedMessage, imageAttachments)
|
await submitMessage(trimmedMessage, imageAttachments)
|
||||||
|
clearDraftInput({ draftKey: draftStoreKey, lifecycle: 'sent' })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[AgentInput] Failed to send message:', error)
|
console.error('[AgentInput] Failed to send message:', error)
|
||||||
// Restore input so the user never loses their message
|
// Restore input so the user never loses their message
|
||||||
@@ -353,15 +369,26 @@ export function AgentInputArea({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const newImages = result.assets.map((asset) => ({
|
const newImages = await Promise.all(
|
||||||
uri: asset.uri,
|
result.assets.map(async (asset) => {
|
||||||
mimeType: asset.mimeType || 'image/jpeg',
|
return await persistAttachmentFromFileUri({
|
||||||
}))
|
uri: asset.uri,
|
||||||
|
mimeType: asset.mimeType || 'image/jpeg',
|
||||||
|
fileName: asset.fileName ?? null,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
setSelectedImages((prev) => [...prev, ...newImages])
|
setSelectedImages((prev) => [...prev, ...newImages])
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRemoveImage(index: number) {
|
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(() => {
|
useEffect(() => {
|
||||||
@@ -377,27 +404,72 @@ export function AgentInputArea({
|
|||||||
if (isControlled) {
|
if (isControlled) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const draft = getDraftInput(agentId)
|
const generation = beginDraftGeneration(draftStoreKey)
|
||||||
if (!draft) {
|
draftGenerationRef.current = generation
|
||||||
setUserInput('')
|
hydratedGenerationRef.current = 0
|
||||||
setSelectedImages([])
|
setUserInput('')
|
||||||
return
|
setSelectedImages([])
|
||||||
}
|
let cancelled = false
|
||||||
|
|
||||||
setUserInput(draft.text)
|
void (async () => {
|
||||||
setSelectedImages(draft.images as ImageAttachment[])
|
const draft = await hydrateDraftInput(draftStoreKey)
|
||||||
}, [agentId, getDraftInput, isControlled])
|
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
|
// Persist drafts into the shared session store with change detection to avoid redundant work
|
||||||
useEffect(() => {
|
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 isSameText = existing?.text === userInput
|
||||||
const existingImages: ImageAttachment[] = (existing?.images ?? []) as ImageAttachment[]
|
const existingImages: ImageAttachment[] = existing?.images ?? []
|
||||||
const isSameImages =
|
const isSameImages =
|
||||||
existingImages.length === selectedImages.length &&
|
existingImages.length === selectedImages.length &&
|
||||||
existingImages.every((img, idx) => {
|
existingImages.every((img, idx) => {
|
||||||
return (
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
saveDraftInput(agentId, { text: userInput, images: selectedImages })
|
const hasContent = userInput.trim().length > 0 || selectedImages.length > 0
|
||||||
}, [agentId, userInput, selectedImages, getDraftInput, saveDraftInput])
|
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.
|
// Keyboard-dispatched message-input actions are routed through store requests.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -134,7 +134,6 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
|
|||||||
isRevalidating,
|
isRevalidating,
|
||||||
refreshAll,
|
refreshAll,
|
||||||
} = useSidebarAgentsList({
|
} = useSidebarAgentsList({
|
||||||
isOpen,
|
|
||||||
serverId: activeServerId,
|
serverId: activeServerId,
|
||||||
selectedProjectFilterKeys,
|
selectedProjectFilterKeys,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,14 +28,13 @@ import {
|
|||||||
collectImageFilesFromClipboardData,
|
collectImageFilesFromClipboardData,
|
||||||
filesToImageAttachments,
|
filesToImageAttachments,
|
||||||
} from '@/utils/image-attachments-from-files'
|
} 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 { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import { Shortcut } from '@/components/ui/shortcut'
|
import { Shortcut } from '@/components/ui/shortcut'
|
||||||
import type { MessageInputKeyboardActionKind } from '@/keyboard/actions'
|
import type { MessageInputKeyboardActionKind } from '@/keyboard/actions'
|
||||||
|
|
||||||
export interface ImageAttachment {
|
export type ImageAttachment = AttachmentMetadata
|
||||||
uri: string
|
|
||||||
mimeType: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MessagePayload {
|
export interface MessagePayload {
|
||||||
text: string
|
text: string
|
||||||
@@ -111,6 +110,14 @@ type TextAreaHandle = {
|
|||||||
} & Record<string, unknown>
|
} & 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(
|
export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(function MessageInput(
|
||||||
{
|
{
|
||||||
value,
|
value,
|
||||||
@@ -636,14 +643,14 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
|||||||
<View style={styles.imagePreviewContainer} testID="message-input-image-preview">
|
<View style={styles.imagePreviewContainer} testID="message-input-image-preview">
|
||||||
{images.map((image, index) => (
|
{images.map((image, index) => (
|
||||||
<Pressable
|
<Pressable
|
||||||
key={`${image.uri}-${index}`}
|
key={`${image.id}-${index}`}
|
||||||
testID="message-input-image-pill"
|
testID="message-input-image-pill"
|
||||||
style={styles.imagePill}
|
style={styles.imagePill}
|
||||||
onPress={onRemoveImage ? () => onRemoveImage(index) : undefined}
|
onPress={onRemoveImage ? () => onRemoveImage(index) : undefined}
|
||||||
>
|
>
|
||||||
{({ hovered }) => (
|
{({ hovered }) => (
|
||||||
<>
|
<>
|
||||||
<Image source={{ uri: image.uri }} style={styles.imageThumbnail} />
|
<ImageAttachmentThumbnail image={image} />
|
||||||
{onRemoveImage && (
|
{onRemoveImage && (
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
@@ -894,6 +901,11 @@ const styles = StyleSheet.create(((theme: any) => ({
|
|||||||
width: 48,
|
width: 48,
|
||||||
height: 48,
|
height: 48,
|
||||||
},
|
},
|
||||||
|
imageThumbnailPlaceholder: {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
removeImageButton: {
|
removeImageButton: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ import { openExternalUrl } from "@/utils/open-external-url";
|
|||||||
export type { InlinePathTarget } from "@/utils/inline-path";
|
export type { InlinePathTarget } from "@/utils/inline-path";
|
||||||
import { useToolCallSheet } from "./tool-call-sheet";
|
import { useToolCallSheet } from "./tool-call-sheet";
|
||||||
import { ToolCallDetailsContent } from "./tool-call-details";
|
import { ToolCallDetailsContent } from "./tool-call-details";
|
||||||
|
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||||
|
|
||||||
interface UserMessageProps {
|
interface UserMessageProps {
|
||||||
message: string;
|
message: string;
|
||||||
@@ -306,6 +307,11 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
|
|||||||
width: 48,
|
width: 48,
|
||||||
height: 48,
|
height: 48,
|
||||||
},
|
},
|
||||||
|
imageThumbnailPlaceholder: {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
backgroundColor: theme.colors.surface1,
|
||||||
|
},
|
||||||
copyButton: {
|
copyButton: {
|
||||||
alignSelf: "flex-end",
|
alignSelf: "flex-end",
|
||||||
padding: theme.spacing[1],
|
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({
|
export const UserMessage = memo(function UserMessage({
|
||||||
message,
|
message,
|
||||||
images = [],
|
images = [],
|
||||||
@@ -368,13 +386,10 @@ export const UserMessage = memo(function UserMessage({
|
|||||||
>
|
>
|
||||||
{images.map((image, index) => (
|
{images.map((image, index) => (
|
||||||
<View
|
<View
|
||||||
key={`${image.uri}-${index}`}
|
key={`${image.id}-${index}`}
|
||||||
style={userMessageStylesheet.imagePill}
|
style={userMessageStylesheet.imagePill}
|
||||||
>
|
>
|
||||||
<Image
|
<UserMessageAttachmentThumbnail image={image} />
|
||||||
source={{ uri: image.uri }}
|
|
||||||
style={userMessageStylesheet.imageThumbnail}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ import {
|
|||||||
normalizeAgentSnapshot,
|
normalizeAgentSnapshot,
|
||||||
} from "@/utils/agent-snapshots";
|
} from "@/utils/agent-snapshots";
|
||||||
import { resolveProjectPlacement } from "@/utils/project-placement";
|
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
|
// Re-export types from session-store and draft-store for backward compatibility
|
||||||
export type { DraftInput } from "@/stores/draft-store";
|
export type { DraftInput } from "@/stores/draft-store";
|
||||||
@@ -259,7 +261,7 @@ function SessionProviderInternal({
|
|||||||
| ((
|
| ((
|
||||||
agentId: string,
|
agentId: string,
|
||||||
message: string,
|
message: string,
|
||||||
images?: Array<{ uri: string; mimeType?: string }>
|
images?: AttachmentMetadata[]
|
||||||
) => Promise<void>)
|
) => Promise<void>)
|
||||||
| null
|
| null
|
||||||
>(null);
|
>(null);
|
||||||
@@ -1172,7 +1174,9 @@ function SessionProviderInternal({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Remove draft input
|
// Remove draft input
|
||||||
clearDraftInput(agentId);
|
clearDraftInput({
|
||||||
|
draftKey: buildDraftStoreKey({ serverId, agentId }),
|
||||||
|
});
|
||||||
|
|
||||||
setPendingPermissions(serverId, (prev) => {
|
setPendingPermissions(serverId, (prev) => {
|
||||||
let changed = false;
|
let changed = false;
|
||||||
@@ -1270,7 +1274,7 @@ function SessionProviderInternal({
|
|||||||
async (
|
async (
|
||||||
agentId: string,
|
agentId: string,
|
||||||
message: string,
|
message: string,
|
||||||
images?: Array<{ uri: string; mimeType?: string }>
|
images?: AttachmentMetadata[]
|
||||||
) => {
|
) => {
|
||||||
const messageId = generateMessageId();
|
const messageId = generateMessageId();
|
||||||
const userMessage: StreamItem = {
|
const userMessage: StreamItem = {
|
||||||
@@ -1385,7 +1389,7 @@ function SessionProviderInternal({
|
|||||||
}: {
|
}: {
|
||||||
config: any;
|
config: any;
|
||||||
initialPrompt: string;
|
initialPrompt: string;
|
||||||
images?: Array<{ uri: string; mimeType?: string }>;
|
images?: AttachmentMetadata[];
|
||||||
git?: any;
|
git?: any;
|
||||||
worktreeName?: string;
|
worktreeName?: string;
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import { useState, useRef, useEffect } from "react";
|
|||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import type { ImageAttachment } from "@/components/message-input";
|
import type { ImageAttachment } from "@/components/message-input";
|
||||||
import { getCurrentTauriWindow, getTauri } from "@/utils/tauri";
|
import { getCurrentTauriWindow, getTauri } from "@/utils/tauri";
|
||||||
|
import {
|
||||||
|
persistAttachmentFromBlob,
|
||||||
|
persistAttachmentFromFileUri,
|
||||||
|
} from "@/attachments/service";
|
||||||
|
|
||||||
interface UseFileDropZoneOptions {
|
interface UseFileDropZoneOptions {
|
||||||
onFilesDropped: (files: ImageAttachment[]) => void;
|
onFilesDropped: (files: ImageAttachment[]) => void;
|
||||||
@@ -66,34 +70,17 @@ function isImagePath(path: string): boolean {
|
|||||||
return getFileExtension(path) in IMAGE_MIME_BY_EXTENSION;
|
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 extension = getFileExtension(path);
|
||||||
const mimeType = IMAGE_MIME_BY_EXTENSION[extension] ?? "image/jpeg";
|
const mimeType = IMAGE_MIME_BY_EXTENSION[extension] ?? "image/jpeg";
|
||||||
const convertFileSrc = getTauri()?.core?.convertFileSrc;
|
return await persistAttachmentFromFileUri({ uri: path, mimeType });
|
||||||
const uri =
|
|
||||||
typeof convertFileSrc === "function" ? convertFileSrc(path) : path;
|
|
||||||
|
|
||||||
return {
|
|
||||||
uri,
|
|
||||||
mimeType,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fileToImageAttachment(file: File): Promise<ImageAttachment> {
|
async function fileToImageAttachment(file: File): Promise<ImageAttachment> {
|
||||||
return new Promise((resolve, reject) => {
|
return await persistAttachmentFromBlob({
|
||||||
const reader = new FileReader();
|
blob: file,
|
||||||
reader.onload = () => {
|
mimeType: file.type || "image/jpeg",
|
||||||
if (typeof reader.result === "string") {
|
fileName: file.name,
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,8 +164,16 @@ export function useFileDropZone({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const attachments = imagePaths.map(filePathToImageAttachment);
|
void Promise.all(imagePaths.map(filePathToImageAttachment))
|
||||||
onFilesDroppedRef.current(attachments);
|
.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?: {
|
export function useSidebarAgentsList(options?: {
|
||||||
isOpen?: boolean;
|
|
||||||
serverId?: string | null;
|
serverId?: string | null;
|
||||||
selectedProjectFilterKeys?: string[];
|
selectedProjectFilterKeys?: string[];
|
||||||
}): SidebarAgentsListResult {
|
}): SidebarAgentsListResult {
|
||||||
const isSidebarOpen = options?.isOpen ?? true;
|
|
||||||
const { daemons } = useDaemonRegistry();
|
const { daemons } = useDaemonRegistry();
|
||||||
const runtime = getHostRuntimeStore();
|
const runtime = getHostRuntimeStore();
|
||||||
const daemonLabelSignature = useMemo(
|
const daemonLabelSignature = useMemo(
|
||||||
@@ -181,7 +179,7 @@ export function useSidebarAgentsList(options?: {
|
|||||||
[options?.selectedProjectFilterKeys]
|
[options?.selectedProjectFilterKeys]
|
||||||
);
|
);
|
||||||
|
|
||||||
const isActive = Boolean(serverId && isSidebarOpen);
|
const isActive = Boolean(serverId);
|
||||||
const liveAgents = useSessionStore((state) =>
|
const liveAgents = useSessionStore((state) =>
|
||||||
isActive && serverId ? state.sessions[serverId]?.agents ?? null : null
|
isActive && serverId ? state.sessions[serverId]?.agents ?? null : null
|
||||||
);
|
);
|
||||||
@@ -309,7 +307,7 @@ export function useSidebarAgentsList(options?: {
|
|||||||
if (!isActive || !serverId || connectionStatus !== "online") {
|
if (!isActive || !serverId || connectionStatus !== "online") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void runtime.refreshAgentDirectory({ serverId }).catch(() => undefined);
|
void runtime.refreshAgentDirectory({ serverId, page: { limit: 50 } }).catch(() => undefined);
|
||||||
}, [connectionStatus, isActive, runtime, serverId]);
|
}, [connectionStatus, isActive, runtime, serverId]);
|
||||||
|
|
||||||
const isDirectoryLoading =
|
const isDirectoryLoading =
|
||||||
|
|||||||
@@ -1020,6 +1020,7 @@ export class HostRuntimeStore {
|
|||||||
const bootstrap = this.refreshAgentDirectory({
|
const bootstrap = this.refreshAgentDirectory({
|
||||||
serverId,
|
serverId,
|
||||||
subscribe: { subscriptionId: `app:${serverId}` },
|
subscribe: { subscriptionId: `app:${serverId}` },
|
||||||
|
page: { limit: 200 },
|
||||||
})
|
})
|
||||||
.then(() => undefined)
|
.then(() => undefined)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
@@ -1094,6 +1095,7 @@ export class HostRuntimeStore {
|
|||||||
serverId: string;
|
serverId: string;
|
||||||
filter?: FetchAgentsOptions["filter"];
|
filter?: FetchAgentsOptions["filter"];
|
||||||
subscribe?: FetchAgentsOptions["subscribe"];
|
subscribe?: FetchAgentsOptions["subscribe"];
|
||||||
|
page?: FetchAgentsOptions["page"];
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
agents: ReturnType<typeof applyFetchedAgentDirectory>["agents"];
|
agents: ReturnType<typeof applyFetchedAgentDirectory>["agents"];
|
||||||
subscriptionId: string | null;
|
subscriptionId: string | null;
|
||||||
@@ -1113,6 +1115,7 @@ export class HostRuntimeStore {
|
|||||||
const payload = await client.fetchAgents({
|
const payload = await client.fetchAgents({
|
||||||
filter: input.filter ?? { labels: { ui: "true" } },
|
filter: input.filter ?? { labels: { ui: "true" } },
|
||||||
...(input.subscribe ? { subscribe: input.subscribe } : {}),
|
...(input.subscribe ? { subscribe: input.subscribe } : {}),
|
||||||
|
...(input.page ? { page: input.page } : {}),
|
||||||
});
|
});
|
||||||
const agents = applyFetchedAgentDirectory({
|
const agents = applyFetchedAgentDirectory({
|
||||||
serverId: input.serverId,
|
serverId: input.serverId,
|
||||||
|
|||||||
@@ -77,7 +77,6 @@ import {
|
|||||||
derivePendingPermissionKey,
|
derivePendingPermissionKey,
|
||||||
normalizeAgentSnapshot,
|
normalizeAgentSnapshot,
|
||||||
} from "@/utils/agent-snapshots";
|
} from "@/utils/agent-snapshots";
|
||||||
import { resolveProjectPlacement } from "@/utils/project-placement";
|
|
||||||
import { mergePendingCreateImages } from "@/utils/pending-create-images";
|
import { mergePendingCreateImages } from "@/utils/pending-create-images";
|
||||||
import { shouldClearAgentAttentionOnView } from "@/utils/agent-attention";
|
import { shouldClearAgentAttentionOnView } from "@/utils/agent-attention";
|
||||||
import type { DaemonClient } from "@server/client/daemon-client";
|
import type { DaemonClient } from "@server/client/daemon-client";
|
||||||
@@ -424,9 +423,13 @@ function AgentScreenContent({
|
|||||||
const streamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS;
|
const streamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS;
|
||||||
|
|
||||||
const pendingCreate = useCreateFlowStore((state) => state.pending);
|
const pendingCreate = useCreateFlowStore((state) => state.pending);
|
||||||
|
const markPendingCreateLifecycle = useCreateFlowStore(
|
||||||
|
(state) => state.markLifecycle
|
||||||
|
);
|
||||||
const clearPendingCreate = useCreateFlowStore((state) => state.clear);
|
const clearPendingCreate = useCreateFlowStore((state) => state.clear);
|
||||||
const isPendingCreateForRoute =
|
const isPendingCreateForRoute =
|
||||||
Boolean(pendingCreate) &&
|
Boolean(pendingCreate) &&
|
||||||
|
pendingCreate?.lifecycle === "active" &&
|
||||||
pendingCreate?.serverId === serverId &&
|
pendingCreate?.serverId === serverId &&
|
||||||
pendingCreate?.agentId === resolvedAgentId;
|
pendingCreate?.agentId === resolvedAgentId;
|
||||||
|
|
||||||
@@ -624,7 +627,7 @@ function AgentScreenContent({
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
kind: "user_message",
|
kind: "user_message",
|
||||||
id: pendingCreate.messageId,
|
id: pendingCreate.clientMessageId,
|
||||||
text: pendingCreate.text,
|
text: pendingCreate.text,
|
||||||
timestamp: new Date(pendingCreate.timestamp),
|
timestamp: new Date(pendingCreate.timestamp),
|
||||||
...(pendingCreate.images && pendingCreate.images.length > 0
|
...(pendingCreate.images && pendingCreate.images.length > 0
|
||||||
@@ -725,7 +728,7 @@ function AgentScreenContent({
|
|||||||
const hasUserMessage = streamItems.some(
|
const hasUserMessage = streamItems.some(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.kind === "user_message" &&
|
item.kind === "user_message" &&
|
||||||
(item.id === pendingCreate.messageId || item.text === pendingCreate.text)
|
item.id === pendingCreate.clientMessageId
|
||||||
);
|
);
|
||||||
if (agent && hasUserMessage) {
|
if (agent && hasUserMessage) {
|
||||||
if (
|
if (
|
||||||
@@ -741,8 +744,7 @@ function AgentScreenContent({
|
|||||||
|
|
||||||
const merged = mergePendingCreateImages({
|
const merged = mergePendingCreateImages({
|
||||||
streamItems: current,
|
streamItems: current,
|
||||||
messageId: pendingCreate.messageId,
|
clientMessageId: pendingCreate.clientMessageId,
|
||||||
text: pendingCreate.text,
|
|
||||||
images: pendingCreate.images,
|
images: pendingCreate.images,
|
||||||
});
|
});
|
||||||
if (merged === current) {
|
if (merged === current) {
|
||||||
@@ -754,12 +756,14 @@ function AgentScreenContent({
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
markPendingCreateLifecycle("sent");
|
||||||
clearPendingCreate();
|
clearPendingCreate();
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
agent,
|
agent,
|
||||||
clearPendingCreate,
|
clearPendingCreate,
|
||||||
isPendingCreateForRoute,
|
isPendingCreateForRoute,
|
||||||
|
markPendingCreateLifecycle,
|
||||||
pendingCreate,
|
pendingCreate,
|
||||||
resolvedAgentId,
|
resolvedAgentId,
|
||||||
serverId,
|
serverId,
|
||||||
@@ -827,24 +831,21 @@ function AgentScreenContent({
|
|||||||
.sessions[serverId]
|
.sessions[serverId]
|
||||||
?.agents.get(resolvedAgentId);
|
?.agents.get(resolvedAgentId);
|
||||||
if (!currentAgent && client) {
|
if (!currentAgent && client) {
|
||||||
const snapshot = await client.fetchAgent(resolvedAgentId);
|
const result = await client.fetchAgent(resolvedAgentId);
|
||||||
if (attemptToken !== initAttemptTokenRef.current) {
|
if (attemptToken !== initAttemptTokenRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!snapshot) {
|
if (!result) {
|
||||||
setMissingAgentState({
|
setMissingAgentState({
|
||||||
kind: "not_found",
|
kind: "not_found",
|
||||||
message: `Agent not found: ${resolvedAgentId}`,
|
message: `Agent not found: ${resolvedAgentId}`,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const normalized = normalizeAgentSnapshot(snapshot, serverId);
|
const normalized = normalizeAgentSnapshot(result.agent, serverId);
|
||||||
const hydrated = {
|
const hydrated = {
|
||||||
...normalized,
|
...normalized,
|
||||||
projectPlacement: resolveProjectPlacement({
|
projectPlacement: result.project,
|
||||||
projectPlacement: null,
|
|
||||||
cwd: normalized.cwd,
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
setAgents(serverId, (prev) => {
|
setAgents(serverId, (prev) => {
|
||||||
const next = new Map(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 { useExplorerOpenGesture } from '@/hooks/use-explorer-open-gesture'
|
||||||
import { useSessionStore } from '@/stores/session-store'
|
import { useSessionStore } from '@/stores/session-store'
|
||||||
import { useCreateFlowStore } from '@/stores/create-flow-store'
|
import { useCreateFlowStore } from '@/stores/create-flow-store'
|
||||||
|
import { generateDraftId } from '@/stores/draft-keys'
|
||||||
import { getHostRuntimeStore, useHostRuntimeSession } from '@/runtime/host-runtime'
|
import { getHostRuntimeStore, useHostRuntimeSession } from '@/runtime/host-runtime'
|
||||||
import { ExplorerSidebarAnimationProvider } from '@/contexts/explorer-sidebar-animation-context'
|
import { ExplorerSidebarAnimationProvider } from '@/contexts/explorer-sidebar-animation-context'
|
||||||
import { usePanelStore, type ExplorerCheckoutContext } from '@/stores/panel-store'
|
import { usePanelStore, type ExplorerCheckoutContext } from '@/stores/panel-store'
|
||||||
@@ -244,6 +245,7 @@ function DraftAgentScreenContent({
|
|||||||
)
|
)
|
||||||
const tauriDragHandlers = useTauriDragHandlers()
|
const tauriDragHandlers = useTauriDragHandlers()
|
||||||
const isExplorerOpen = isMobile ? mobileView === 'file-explorer' : desktopFileExplorerOpen
|
const isExplorerOpen = isMobile ? mobileView === 'file-explorer' : desktopFileExplorerOpen
|
||||||
|
const draftIdRef = useRef(generateDraftId())
|
||||||
|
|
||||||
const [worktreeMode, setWorktreeMode] = useState<'none' | 'create' | 'attach'>('none')
|
const [worktreeMode, setWorktreeMode] = useState<'none' | 'create' | 'attach'>('none')
|
||||||
const [baseBranch, setBaseBranch] = useState('')
|
const [baseBranch, setBaseBranch] = useState('')
|
||||||
@@ -262,6 +264,7 @@ function DraftAgentScreenContent({
|
|||||||
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null)
|
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null)
|
||||||
const setPendingCreateAttempt = useCreateFlowStore((state) => state.setPending)
|
const setPendingCreateAttempt = useCreateFlowStore((state) => state.setPending)
|
||||||
const updatePendingAgentId = useCreateFlowStore((state) => state.updateAgentId)
|
const updatePendingAgentId = useCreateFlowStore((state) => state.updateAgentId)
|
||||||
|
const markPendingCreateLifecycle = useCreateFlowStore((state) => state.markLifecycle)
|
||||||
const clearPendingCreateAttempt = useCreateFlowStore((state) => state.clear)
|
const clearPendingCreateAttempt = useCreateFlowStore((state) => state.clear)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -277,7 +280,7 @@ function DraftAgentScreenContent({
|
|||||||
}, [workingDirSearchQuery])
|
}, [workingDirSearchQuery])
|
||||||
|
|
||||||
type CreateAttempt = {
|
type CreateAttempt = {
|
||||||
messageId: string
|
clientMessageId: string
|
||||||
text: string
|
text: string
|
||||||
timestamp: Date
|
timestamp: Date
|
||||||
images?: UserMessageImageAttachment[]
|
images?: UserMessageImageAttachment[]
|
||||||
@@ -832,7 +835,7 @@ function DraftAgentScreenContent({
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
kind: 'user_message',
|
kind: 'user_message',
|
||||||
id: machine.attempt.messageId,
|
id: machine.attempt.clientMessageId,
|
||||||
text: machine.attempt.text,
|
text: machine.attempt.text,
|
||||||
timestamp: machine.attempt.timestamp,
|
timestamp: machine.attempt.timestamp,
|
||||||
...(machine.attempt.images && machine.attempt.images.length > 0
|
...(machine.attempt.images && machine.attempt.images.length > 0
|
||||||
@@ -972,15 +975,16 @@ function DraftAgentScreenContent({
|
|||||||
throw new Error('Host is not connected')
|
throw new Error('Host is not connected')
|
||||||
}
|
}
|
||||||
const attempt = {
|
const attempt = {
|
||||||
messageId: generateMessageId(),
|
clientMessageId: generateMessageId(),
|
||||||
text: trimmedPrompt,
|
text: trimmedPrompt,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
...(images && images.length > 0 ? { images } : {}),
|
...(images && images.length > 0 ? { images } : {}),
|
||||||
}
|
}
|
||||||
setPendingCreateAttempt({
|
setPendingCreateAttempt({
|
||||||
|
draftId: draftIdRef.current,
|
||||||
serverId: selectedServerId,
|
serverId: selectedServerId,
|
||||||
agentId: null,
|
agentId: null,
|
||||||
messageId: attempt.messageId,
|
clientMessageId: attempt.clientMessageId,
|
||||||
text: attempt.text,
|
text: attempt.text,
|
||||||
timestamp: attempt.timestamp.getTime(),
|
timestamp: attempt.timestamp.getTime(),
|
||||||
...(attempt.images && attempt.images.length > 0 ? { images: attempt.images } : {}),
|
...(attempt.images && attempt.images.length > 0 ? { images: attempt.images } : {}),
|
||||||
@@ -1026,6 +1030,7 @@ function DraftAgentScreenContent({
|
|||||||
config,
|
config,
|
||||||
labels: { ui: 'true' },
|
labels: { ui: 'true' },
|
||||||
initialPrompt: trimmedPrompt,
|
initialPrompt: trimmedPrompt,
|
||||||
|
clientMessageId: attempt.clientMessageId,
|
||||||
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
|
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
|
||||||
git: gitOptions,
|
git: gitOptions,
|
||||||
})
|
})
|
||||||
@@ -1042,12 +1047,14 @@ function DraftAgentScreenContent({
|
|||||||
message: 'Failed to create agent',
|
message: 'Failed to create agent',
|
||||||
})
|
})
|
||||||
onCreateFlowActiveChange?.(false)
|
onCreateFlowActiveChange?.(false)
|
||||||
|
markPendingCreateLifecycle('abandoned')
|
||||||
clearPendingCreateAttempt()
|
clearPendingCreateAttempt()
|
||||||
throw new Error('Failed to create agent')
|
throw new Error('Failed to create agent')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Failed to create agent'
|
const message = error instanceof Error ? error.message : 'Failed to create agent'
|
||||||
dispatch({ type: 'CREATE_FAILED', message })
|
dispatch({ type: 'CREATE_FAILED', message })
|
||||||
onCreateFlowActiveChange?.(false)
|
onCreateFlowActiveChange?.(false)
|
||||||
|
markPendingCreateLifecycle('abandoned')
|
||||||
clearPendingCreateAttempt()
|
clearPendingCreateAttempt()
|
||||||
throw error // Re-throw so AgentInputArea knows it failed
|
throw error // Re-throw so AgentInputArea knows it failed
|
||||||
}
|
}
|
||||||
@@ -1073,6 +1080,7 @@ function DraftAgentScreenContent({
|
|||||||
createAgentClient,
|
createAgentClient,
|
||||||
setPendingCreateAttempt,
|
setPendingCreateAttempt,
|
||||||
updatePendingAgentId,
|
updatePendingAgentId,
|
||||||
|
markPendingCreateLifecycle,
|
||||||
clearPendingCreateAttempt,
|
clearPendingCreateAttempt,
|
||||||
workingDir,
|
workingDir,
|
||||||
isAttachWorktree,
|
isAttachWorktree,
|
||||||
@@ -1324,6 +1332,7 @@ function DraftAgentScreenContent({
|
|||||||
autoFocus={machine.tag === 'draft'}
|
autoFocus={machine.tag === 'draft'}
|
||||||
onAddImages={handleAddImagesCallback}
|
onAddImages={handleAddImagesCallback}
|
||||||
commandDraftConfig={draftCommandConfig}
|
commandDraftConfig={draftCommandConfig}
|
||||||
|
draftId={draftIdRef.current}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</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 { create } from "zustand";
|
||||||
import type { UserMessageImageAttachment } from "@/types/stream";
|
import type { UserMessageImageAttachment } from "@/types/stream";
|
||||||
|
|
||||||
|
export type CreateFlowLifecycleState = "active" | "abandoned" | "sent";
|
||||||
|
|
||||||
type PendingCreateAttempt = {
|
type PendingCreateAttempt = {
|
||||||
|
draftId: string;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
agentId: string | null;
|
agentId: string | null;
|
||||||
messageId: string;
|
clientMessageId: string;
|
||||||
text: string;
|
text: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
|
lifecycle: CreateFlowLifecycleState;
|
||||||
images?: UserMessageImageAttachment[];
|
images?: UserMessageImageAttachment[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type CreateFlowState = {
|
type CreateFlowState = {
|
||||||
pending: PendingCreateAttempt | null;
|
pending: PendingCreateAttempt | null;
|
||||||
setPending: (pending: PendingCreateAttempt) => void;
|
setPending: (
|
||||||
|
pending: Omit<PendingCreateAttempt, "lifecycle">
|
||||||
|
) => void;
|
||||||
updateAgentId: (agentId: string) => void;
|
updateAgentId: (agentId: string) => void;
|
||||||
|
markLifecycle: (lifecycle: CreateFlowLifecycleState) => void;
|
||||||
|
rekeyDraft: (input: { fromDraftId: string; toDraftId: string }) => void;
|
||||||
clear: () => void;
|
clear: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useCreateFlowStore = create<CreateFlowState>((set) => ({
|
export const useCreateFlowStore = create<CreateFlowState>((set) => ({
|
||||||
pending: null,
|
pending: null,
|
||||||
setPending: (pending) => set({ pending }),
|
setPending: (pending) =>
|
||||||
|
set({
|
||||||
|
pending: {
|
||||||
|
...pending,
|
||||||
|
lifecycle: "active",
|
||||||
|
},
|
||||||
|
}),
|
||||||
updateAgentId: (agentId) =>
|
updateAgentId: (agentId) =>
|
||||||
set((state) =>
|
set((state) => {
|
||||||
state.pending ? { pending: { ...state.pending, agentId } } : 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 }),
|
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 { 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 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 {
|
export interface DraftInput {
|
||||||
text: string;
|
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 {
|
interface DraftStoreState {
|
||||||
drafts: Record<string, DraftInput>;
|
drafts: Record<string, DraftRecord>;
|
||||||
createModalDraft: DraftInput | null;
|
createModalDraft: DraftRecord | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DraftStoreActions {
|
interface DraftStoreActions {
|
||||||
getDraftInput: (agentId: string) => DraftInput | undefined;
|
getDraftInput: (draftKey: string) => DraftInput | undefined;
|
||||||
saveDraftInput: (agentId: string, draft: DraftInput) => void;
|
hydrateDraftInput: (draftKey: string) => Promise<DraftInput | undefined>;
|
||||||
clearDraftInput: (agentId: string) => void;
|
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;
|
getCreateModalDraft: () => DraftInput | null;
|
||||||
saveCreateModalDraft: (draft: DraftInput | null) => void;
|
saveCreateModalDraft: (draft: DraftInput | null) => void;
|
||||||
|
beginDraftGeneration: (draftKey: string) => number;
|
||||||
|
isDraftGenerationCurrent: (input: {
|
||||||
|
draftKey: string;
|
||||||
|
generation: number;
|
||||||
|
}) => boolean;
|
||||||
|
collectActiveAttachmentIds: () => string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type DraftStore = DraftStoreState & DraftStoreActions;
|
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>()(
|
export const useDraftStore = create<DraftStore>()(
|
||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
drafts: {},
|
drafts: {},
|
||||||
createModalDraft: null,
|
createModalDraft: null,
|
||||||
|
|
||||||
getDraftInput: (agentId) => {
|
getDraftInput: (draftKey) => {
|
||||||
return get().drafts[agentId];
|
const record = get().drafts[draftKey];
|
||||||
|
return toDraftInputIfReady(record);
|
||||||
},
|
},
|
||||||
|
|
||||||
saveDraftInput: (agentId, draft) => {
|
hydrateDraftInput: async (draftKey) => {
|
||||||
set((state) => ({
|
const current = get().drafts[draftKey];
|
||||||
drafts: {
|
if (!current) {
|
||||||
...state.drafts,
|
return undefined;
|
||||||
[agentId]: draft,
|
}
|
||||||
},
|
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) => {
|
set((state) => {
|
||||||
const { [agentId]: _, ...rest } = state.drafts;
|
const existing = state.drafts[draftKey];
|
||||||
return { drafts: rest };
|
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: () => {
|
getCreateModalDraft: () => {
|
||||||
return get().createModalDraft;
|
const record = get().createModalDraft;
|
||||||
|
return toDraftInputIfReady(record) ?? null;
|
||||||
},
|
},
|
||||||
|
|
||||||
saveCreateModalDraft: (draft) => {
|
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",
|
name: "paseo-drafts",
|
||||||
|
version: DRAFT_STORE_VERSION,
|
||||||
storage: createJSONStorage(() => AsyncStorage),
|
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 { AgentDirectoryEntry } from "@/types/agent-directory";
|
||||||
import type { StreamItem } from "@/types/stream";
|
import type { StreamItem } from "@/types/stream";
|
||||||
import type { PendingPermission } from "@/types/shared";
|
import type { PendingPermission } from "@/types/shared";
|
||||||
|
import type { AttachmentMetadata } from "@/attachments/types";
|
||||||
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
|
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
|
||||||
import type {
|
import type {
|
||||||
AgentPermissionResponse,
|
AgentPermissionResponse,
|
||||||
@@ -210,7 +211,10 @@ export interface SessionState {
|
|||||||
fileExplorer: Map<string, AgentFileExplorerState>;
|
fileExplorer: Map<string, AgentFileExplorerState>;
|
||||||
|
|
||||||
// Queued messages
|
// 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
|
// Global store state
|
||||||
@@ -278,7 +282,17 @@ interface SessionStoreActions {
|
|||||||
setFileExplorer: (serverId: string, state: Map<string, AgentFileExplorerState> | ((prev: Map<string, AgentFileExplorerState>) => Map<string, AgentFileExplorerState>)) => void;
|
setFileExplorer: (serverId: string, state: Map<string, AgentFileExplorerState> | ((prev: Map<string, AgentFileExplorerState>) => Map<string, AgentFileExplorerState>)) => void;
|
||||||
|
|
||||||
// Queued messages
|
// 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
|
// Hydration
|
||||||
setHasHydratedAgents: (serverId: string, hydrated: boolean) => void;
|
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", () => {
|
it("preserves optimistic user message images when authoritative user message arrives", () => {
|
||||||
const messageId = "msg-user-images";
|
const messageId = "msg-user-images";
|
||||||
const optimisticImages = [
|
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[] = [
|
const initialState: StreamItem[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
ToolCallDetail,
|
ToolCallDetail,
|
||||||
} from "@server/server/agent/agent-sdk-types";
|
} from "@server/server/agent/agent-sdk-types";
|
||||||
import type { AgentStreamEventPayload } from "@server/shared/messages";
|
import type { AgentStreamEventPayload } from "@server/shared/messages";
|
||||||
|
import type { AttachmentMetadata } from "@/attachments/types";
|
||||||
import {
|
import {
|
||||||
extractTaskEntriesFromToolCall,
|
extractTaskEntriesFromToolCall,
|
||||||
} from "../utils/tool-call-parsers";
|
} from "../utils/tool-call-parsers";
|
||||||
@@ -59,10 +60,7 @@ export type StreamItem =
|
|||||||
| ActivityLogItem
|
| ActivityLogItem
|
||||||
| CompactionItem;
|
| CompactionItem;
|
||||||
|
|
||||||
export interface UserMessageImageAttachment {
|
export type UserMessageImageAttachment = AttachmentMetadata;
|
||||||
uri: string;
|
|
||||||
mimeType: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserMessageItem {
|
export interface UserMessageItem {
|
||||||
kind: "user_message";
|
kind: "user_message";
|
||||||
|
|||||||
@@ -47,20 +47,32 @@ export function applyFetchedAgentDirectory(input: {
|
|||||||
serverId: string;
|
serverId: string;
|
||||||
entries: FetchAgentsEntry[];
|
entries: FetchAgentsEntry[];
|
||||||
}): { agents: Map<string, Agent> } {
|
}): { agents: Map<string, Agent> } {
|
||||||
const { agents, pendingPermissions } = buildAgentDirectoryState(input);
|
const { agents: fetchedAgents, pendingPermissions } = buildAgentDirectoryState(input);
|
||||||
|
|
||||||
const store = useSessionStore.getState();
|
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>();
|
const lastActivityByAgentId = new Map<string, Date>();
|
||||||
for (const agent of agents.values()) {
|
for (const agent of fetchedAgents.values()) {
|
||||||
lastActivityByAgentId.set(agent.id, agent.lastActivityAt);
|
lastActivityByAgentId.set(agent.id, agent.lastActivityAt);
|
||||||
}
|
}
|
||||||
store.setAgentLastActivityBatch(lastActivityByAgentId);
|
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.setInitializingAgents(input.serverId, new Map());
|
||||||
store.setHasHydratedAgents(input.serverId, true);
|
store.setHasHydratedAgents(input.serverId, true);
|
||||||
return { agents };
|
return { agents: fetchedAgents };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,68 +1,10 @@
|
|||||||
import { Platform } from "react-native";
|
import type { AttachmentMetadata } from "@/attachments/types";
|
||||||
import { File } from "expo-file-system";
|
import { encodeAttachmentsForSend } from "@/attachments/service";
|
||||||
|
|
||||||
type ImageInput = { uri: string; mimeType?: string };
|
type ImageInput = AttachmentMetadata;
|
||||||
|
|
||||||
export async function encodeImages(
|
export async function encodeImages(
|
||||||
images?: ImageInput[]
|
images?: ImageInput[]
|
||||||
): Promise<Array<{ data: string; mimeType: string }> | undefined> {
|
): Promise<Array<{ data: string; mimeType: string }> | undefined> {
|
||||||
if (!images || images.length === 0) {
|
return await encodeAttachmentsForSend(images);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
collectImageFilesFromClipboardData,
|
collectImageFilesFromClipboardData,
|
||||||
filesToImageAttachments,
|
filesToImageAttachments,
|
||||||
} from "./image-attachments-from-files";
|
} from "./image-attachments-from-files";
|
||||||
|
import { __setAttachmentStoreForTests } from "@/attachments/store";
|
||||||
|
import type { AttachmentStore } from "@/attachments/types";
|
||||||
|
|
||||||
function createClipboardItem(params: {
|
function createClipboardItem(params: {
|
||||||
kind: string;
|
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", () => {
|
describe("collectImageFilesFromClipboardData", () => {
|
||||||
it("returns only image files from clipboard items", () => {
|
it("returns only image files from clipboard items", () => {
|
||||||
const imagePng = new File([new Uint8Array([0, 1, 2, 3])], "paste.png", {
|
const imagePng = new File([new Uint8Array([0, 1, 2, 3])], "paste.png", {
|
||||||
@@ -55,7 +103,7 @@ describe("collectImageFilesFromClipboardData", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("filesToImageAttachments", () => {
|
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", {
|
const pngFile = new File([new Uint8Array([0, 1, 2, 3])], "first.png", {
|
||||||
type: "image/png",
|
type: "image/png",
|
||||||
});
|
});
|
||||||
@@ -67,13 +115,34 @@ describe("filesToImageAttachments", () => {
|
|||||||
|
|
||||||
expect(attachments).toEqual([
|
expect(attachments).toEqual([
|
||||||
{
|
{
|
||||||
uri: "data:image/png;base64,AAECAw==",
|
id: "att-1",
|
||||||
mimeType: "image/png",
|
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",
|
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 {
|
export interface ClipboardItemLike {
|
||||||
kind?: string;
|
kind?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
@@ -8,23 +11,7 @@ export interface ClipboardDataLike {
|
|||||||
items?: ArrayLike<ClipboardItemLike> | null;
|
items?: ArrayLike<ClipboardItemLike> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImageAttachmentFromFile {
|
export type ImageAttachmentFromFile = AttachmentMetadata;
|
||||||
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 function collectImageFilesFromClipboardData(
|
export function collectImageFilesFromClipboardData(
|
||||||
clipboardData?: ClipboardDataLike | null
|
clipboardData?: ClipboardDataLike | null
|
||||||
@@ -54,14 +41,25 @@ export function collectImageFilesFromClipboardData(
|
|||||||
export async function filesToImageAttachments(
|
export async function filesToImageAttachments(
|
||||||
files: readonly File[]
|
files: readonly File[]
|
||||||
): Promise<ImageAttachmentFromFile[]> {
|
): Promise<ImageAttachmentFromFile[]> {
|
||||||
return Promise.all(
|
const attachments = await Promise.all(
|
||||||
files.map(async (file) => {
|
files.map(async (file) => {
|
||||||
const mimeType = file.type || "image/jpeg";
|
try {
|
||||||
const base64 = toBase64(await file.arrayBuffer());
|
return await persistAttachmentFromBlob({
|
||||||
return {
|
blob: file,
|
||||||
uri: `data:${mimeType};base64,${base64}`,
|
mimeType: file.type || "image/jpeg",
|
||||||
mimeType,
|
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: {
|
function userMessage(params: {
|
||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
images?: Array<{ uri: string; mimeType: string }>;
|
images?: Array<{ id: string; storageType: "native-file"; storageKey: string; mimeType: string; createdAt: number }>;
|
||||||
}): StreamItem {
|
}): StreamItem {
|
||||||
return {
|
return {
|
||||||
kind: "user_message",
|
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", () => {
|
describe("mergePendingCreateImages", () => {
|
||||||
it("returns same reference when pending images are absent", () => {
|
it("returns same reference when pending images are absent", () => {
|
||||||
const streamItems = [userMessage({ id: "msg-1", text: "hello" })];
|
const streamItems = [userMessage({ id: "msg-1", text: "hello" })];
|
||||||
const result = mergePendingCreateImages({
|
const result = mergePendingCreateImages({
|
||||||
streamItems,
|
streamItems,
|
||||||
messageId: "msg-1",
|
clientMessageId: "msg-1",
|
||||||
text: "hello",
|
|
||||||
images: [],
|
images: [],
|
||||||
});
|
});
|
||||||
expect(result).toBe(streamItems);
|
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 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({
|
const result = mergePendingCreateImages({
|
||||||
streamItems,
|
streamItems,
|
||||||
messageId: "msg-1",
|
clientMessageId: "msg-1",
|
||||||
text: "hello",
|
|
||||||
images,
|
images,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -47,34 +57,27 @@ describe("mergePendingCreateImages", () => {
|
|||||||
expect(updated.images).toEqual(images);
|
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 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({
|
const result = mergePendingCreateImages({
|
||||||
streamItems,
|
streamItems,
|
||||||
messageId: "missing-id",
|
clientMessageId: "missing-id",
|
||||||
text: "same text",
|
|
||||||
images,
|
images,
|
||||||
});
|
});
|
||||||
|
|
||||||
const updated = result[0];
|
expect(result).toBe(streamItems);
|
||||||
expect(updated?.kind).toBe("user_message");
|
|
||||||
if (updated?.kind !== "user_message") {
|
|
||||||
throw new Error("Expected user_message item");
|
|
||||||
}
|
|
||||||
expect(updated.images).toEqual(images);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not overwrite existing user message images", () => {
|
it("does not overwrite existing user message images", () => {
|
||||||
const existingImages = [{ uri: "file:///tmp/existing.jpg", mimeType: "image/jpeg" }];
|
const existingImages = buildImage("existing");
|
||||||
const streamItems = [
|
const streamItems = [
|
||||||
userMessage({ id: "msg-1", text: "hello", images: existingImages }),
|
userMessage({ id: "msg-1", text: "hello", images: existingImages }),
|
||||||
];
|
];
|
||||||
const result = mergePendingCreateImages({
|
const result = mergePendingCreateImages({
|
||||||
streamItems,
|
streamItems,
|
||||||
messageId: "msg-1",
|
clientMessageId: "msg-1",
|
||||||
text: "hello",
|
images: buildImage("new"),
|
||||||
images: [{ uri: "file:///tmp/new.jpg", mimeType: "image/jpeg" }],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toBe(streamItems);
|
expect(result).toBe(streamItems);
|
||||||
|
|||||||
@@ -2,15 +2,13 @@ import type { StreamItem, UserMessageImageAttachment } from "@/types/stream";
|
|||||||
|
|
||||||
type MergePendingCreateImagesParams = {
|
type MergePendingCreateImagesParams = {
|
||||||
streamItems: StreamItem[];
|
streamItems: StreamItem[];
|
||||||
messageId: string;
|
clientMessageId: string;
|
||||||
text: string;
|
|
||||||
images?: UserMessageImageAttachment[];
|
images?: UserMessageImageAttachment[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function mergePendingCreateImages({
|
export function mergePendingCreateImages({
|
||||||
streamItems,
|
streamItems,
|
||||||
messageId,
|
clientMessageId,
|
||||||
text,
|
|
||||||
images,
|
images,
|
||||||
}: MergePendingCreateImagesParams): StreamItem[] {
|
}: MergePendingCreateImagesParams): StreamItem[] {
|
||||||
if (!images || images.length === 0) {
|
if (!images || images.length === 0) {
|
||||||
@@ -18,8 +16,7 @@ export function mergePendingCreateImages({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const targetIndex = streamItems.findIndex(
|
const targetIndex = streamItems.findIndex(
|
||||||
(item) =>
|
(item) => item.kind === "user_message" && item.id === clientMessageId
|
||||||
item.kind === "user_message" && (item.id === messageId || item.text === text)
|
|
||||||
);
|
);
|
||||||
if (targetIndex < 0) {
|
if (targetIndex < 0) {
|
||||||
return streamItems;
|
return streamItems;
|
||||||
|
|||||||
@@ -57,8 +57,8 @@ export async function runArchiveCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const agent = await client.fetchAgent(agentIdArg)
|
const fetchResult = await client.fetchAgent(agentIdArg)
|
||||||
if (!agent) {
|
if (!fetchResult) {
|
||||||
const error: CommandError = {
|
const error: CommandError = {
|
||||||
code: 'AGENT_NOT_FOUND',
|
code: 'AGENT_NOT_FOUND',
|
||||||
message: `Agent not found: ${agentIdArg}`,
|
message: `Agent not found: ${agentIdArg}`,
|
||||||
@@ -66,6 +66,7 @@ export async function runArchiveCommand(
|
|||||||
}
|
}
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
const agent = fetchResult.agent
|
||||||
const agentId = agent.id
|
const agentId = agent.id
|
||||||
|
|
||||||
// Check if agent is already archived
|
// Check if agent is already archived
|
||||||
|
|||||||
@@ -118,14 +118,14 @@ export async function runAttachCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const agent = await client.fetchAgent(id)
|
const fetchResult = await client.fetchAgent(id)
|
||||||
if (!agent) {
|
if (!fetchResult) {
|
||||||
console.error(`Error: No agent found matching: ${id}`)
|
console.error(`Error: No agent found matching: ${id}`)
|
||||||
console.error('Use `paseo ls` to list available agents')
|
console.error('Use `paseo ls` to list available agents')
|
||||||
await client.close()
|
await client.close()
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
const resolvedId = agent.id
|
const resolvedId = fetchResult.agent.id
|
||||||
|
|
||||||
// Print header
|
// Print header
|
||||||
console.log(`Attaching to agent ${resolvedId.substring(0, 7)}...`)
|
console.log(`Attaching to agent ${resolvedId.substring(0, 7)}...`)
|
||||||
|
|||||||
@@ -231,8 +231,8 @@ export async function runInspectCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const snapshot = await client.fetchAgent(agentIdArg)
|
const fetchResult = await client.fetchAgent(agentIdArg)
|
||||||
if (!snapshot) {
|
if (!fetchResult) {
|
||||||
const error: CommandError = {
|
const error: CommandError = {
|
||||||
code: 'AGENT_NOT_FOUND',
|
code: 'AGENT_NOT_FOUND',
|
||||||
message: `Agent not found: ${agentIdArg}`,
|
message: `Agent not found: ${agentIdArg}`,
|
||||||
@@ -243,7 +243,7 @@ export async function runInspectCommand(
|
|||||||
|
|
||||||
await client.close()
|
await client.close()
|
||||||
|
|
||||||
const inspectData = toInspectData(snapshot)
|
const inspectData = toInspectData(fetchResult.agent)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'list',
|
type: 'list',
|
||||||
|
|||||||
@@ -99,14 +99,14 @@ export async function runLogsCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const agent = await client.fetchAgent(id)
|
const fetchResult = await client.fetchAgent(id)
|
||||||
if (!agent) {
|
if (!fetchResult) {
|
||||||
console.error(`Error: No agent found matching: ${id}`)
|
console.error(`Error: No agent found matching: ${id}`)
|
||||||
console.error('Use `paseo ls` to list available agents')
|
console.error('Use `paseo ls` to list available agents')
|
||||||
await client.close()
|
await client.close()
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
const resolvedId = agent.id
|
const resolvedId = fetchResult.agent.id
|
||||||
|
|
||||||
// For follow mode, we stream events continuously
|
// For follow mode, we stream events continuously
|
||||||
if (options.follow) {
|
if (options.follow) {
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ export async function runModeCommand(
|
|||||||
let client: Awaited<ReturnType<typeof connectToDaemon>> | undefined
|
let client: Awaited<ReturnType<typeof connectToDaemon>> | undefined
|
||||||
try {
|
try {
|
||||||
client = await connectToDaemon({ host: options.host as string | undefined })
|
client = await connectToDaemon({ host: options.host as string | undefined })
|
||||||
const agent = await client.fetchAgent(id)
|
const fetchResult = await client.fetchAgent(id)
|
||||||
if (!agent) {
|
if (!fetchResult) {
|
||||||
const error: CommandError = {
|
const error: CommandError = {
|
||||||
code: 'AGENT_NOT_FOUND',
|
code: 'AGENT_NOT_FOUND',
|
||||||
message: `No agent found matching: ${id}`,
|
message: `No agent found matching: ${id}`,
|
||||||
@@ -74,6 +74,7 @@ export async function runModeCommand(
|
|||||||
}
|
}
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
const agent = fetchResult.agent
|
||||||
const resolvedId = agent.id
|
const resolvedId = agent.id
|
||||||
|
|
||||||
if (options.list) {
|
if (options.list) {
|
||||||
|
|||||||
@@ -71,8 +71,8 @@ export async function runStopCommand(
|
|||||||
})
|
})
|
||||||
} else if (id) {
|
} else if (id) {
|
||||||
// Stop specific agent
|
// Stop specific agent
|
||||||
const agent = await client.fetchAgent(id)
|
const fetchResult = await client.fetchAgent(id)
|
||||||
if (!agent) {
|
if (!fetchResult) {
|
||||||
const error: CommandError = {
|
const error: CommandError = {
|
||||||
code: 'AGENT_NOT_FOUND',
|
code: 'AGENT_NOT_FOUND',
|
||||||
message: `No agent found matching: ${id}`,
|
message: `No agent found matching: ${id}`,
|
||||||
@@ -80,7 +80,7 @@ export async function runStopCommand(
|
|||||||
}
|
}
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
agents = [agent]
|
agents = [fetchResult.agent]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop each agent
|
// Stop each agent
|
||||||
|
|||||||
@@ -127,8 +127,8 @@ export async function runUpdateCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const agent = await client.fetchAgent(agentIdArg)
|
const fetchResult = await client.fetchAgent(agentIdArg)
|
||||||
if (!agent) {
|
if (!fetchResult) {
|
||||||
const error: CommandError = {
|
const error: CommandError = {
|
||||||
code: 'AGENT_NOT_FOUND',
|
code: 'AGENT_NOT_FOUND',
|
||||||
message: `Agent not found: ${agentIdArg}`,
|
message: `Agent not found: ${agentIdArg}`,
|
||||||
@@ -136,15 +136,15 @@ export async function runUpdateCommand(
|
|||||||
}
|
}
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
const agentId = agent.id
|
const agentId = fetchResult.agent.id
|
||||||
|
|
||||||
await client.updateAgent(agentId, {
|
await client.updateAgent(agentId, {
|
||||||
...(name ? { name } : {}),
|
...(name ? { name } : {}),
|
||||||
...(Object.keys(labels).length > 0 ? { labels } : {}),
|
...(Object.keys(labels).length > 0 ? { labels } : {}),
|
||||||
})
|
})
|
||||||
|
|
||||||
const updated = await client.fetchAgent(agentId)
|
const updatedResult = await client.fetchAgent(agentId)
|
||||||
if (!updated) {
|
if (!updatedResult) {
|
||||||
throw new Error(`Agent not found after update: ${agentId}`)
|
throw new Error(`Agent not found after update: ${agentId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,8 +154,8 @@ export async function runUpdateCommand(
|
|||||||
type: 'single',
|
type: 'single',
|
||||||
data: {
|
data: {
|
||||||
agentId,
|
agentId,
|
||||||
name: updated.title,
|
name: updatedResult.agent.title,
|
||||||
labels: formatLabels(updated.labels),
|
labels: formatLabels(updatedResult.agent.labels),
|
||||||
},
|
},
|
||||||
schema: updateSchema,
|
schema: updateSchema,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,8 +79,8 @@ export async function runAllowCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const agent = await client.fetchAgent(agentIdOrPrefix)
|
const fetchResult = await client.fetchAgent(agentIdOrPrefix)
|
||||||
if (!agent) {
|
if (!fetchResult) {
|
||||||
await client.close()
|
await client.close()
|
||||||
const error: CommandError = {
|
const error: CommandError = {
|
||||||
code: 'AGENT_NOT_FOUND',
|
code: 'AGENT_NOT_FOUND',
|
||||||
@@ -89,6 +89,7 @@ export async function runAllowCommand(
|
|||||||
}
|
}
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
const agent = fetchResult.agent
|
||||||
const resolvedAgentId = agent.id
|
const resolvedAgentId = agent.id
|
||||||
|
|
||||||
// Get pending permissions for this agent
|
// Get pending permissions for this agent
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ export async function runDenyCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const agent = await client.fetchAgent(agentIdOrPrefix)
|
const fetchResult = await client.fetchAgent(agentIdOrPrefix)
|
||||||
if (!agent) {
|
if (!fetchResult) {
|
||||||
await client.close()
|
await client.close()
|
||||||
const error: CommandError = {
|
const error: CommandError = {
|
||||||
code: 'AGENT_NOT_FOUND',
|
code: 'AGENT_NOT_FOUND',
|
||||||
@@ -55,6 +55,7 @@ export async function runDenyCommand(
|
|||||||
}
|
}
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
const agent = fetchResult.agent
|
||||||
const resolvedAgentId = agent.id
|
const resolvedAgentId = agent.id
|
||||||
|
|
||||||
// Get pending permissions for this agent
|
// Get pending permissions for this agent
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
AgentStreamEventPayload,
|
AgentStreamEventPayload,
|
||||||
AgentSnapshotPayload,
|
AgentSnapshotPayload,
|
||||||
|
ProjectPlacementPayload,
|
||||||
AgentPermissionResolvedMessage,
|
AgentPermissionResolvedMessage,
|
||||||
CreateAgentRequestMessage,
|
CreateAgentRequestMessage,
|
||||||
FileDownloadTokenResponse,
|
FileDownloadTokenResponse,
|
||||||
@@ -202,6 +203,7 @@ export type CreateAgentRequestOptions = {
|
|||||||
provider?: AgentProvider
|
provider?: AgentProvider
|
||||||
cwd?: string
|
cwd?: string
|
||||||
initialPrompt?: string
|
initialPrompt?: string
|
||||||
|
clientMessageId?: string
|
||||||
outputSchema?: Record<string, unknown>
|
outputSchema?: Record<string, unknown>
|
||||||
images?: CreateAgentRequestMessage['images']
|
images?: CreateAgentRequestMessage['images']
|
||||||
git?: GitSetupOptions
|
git?: GitSetupOptions
|
||||||
@@ -284,6 +286,11 @@ export type FetchAgentsOptions = Omit<FetchAgentsRequest, 'type' | 'requestId'>
|
|||||||
export type FetchAgentsEntry = FetchAgentsPayload['entries'][number]
|
export type FetchAgentsEntry = FetchAgentsPayload['entries'][number]
|
||||||
export type FetchAgentsPageInfo = FetchAgentsPayload['pageInfo']
|
export type FetchAgentsPageInfo = FetchAgentsPayload['pageInfo']
|
||||||
|
|
||||||
|
export type FetchAgentResult = {
|
||||||
|
agent: AgentSnapshotPayload
|
||||||
|
project: ProjectPlacementPayload | null
|
||||||
|
}
|
||||||
|
|
||||||
export type WaitForFinishResult = {
|
export type WaitForFinishResult = {
|
||||||
status: 'idle' | 'error' | 'permission' | 'timeout'
|
status: 'idle' | 'error' | 'permission' | 'timeout'
|
||||||
final: AgentSnapshotPayload | null
|
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 resolvedRequestId = this.createRequestId(requestId)
|
||||||
const message = SessionInboundMessageSchema.parse({
|
const message = SessionInboundMessageSchema.parse({
|
||||||
type: 'fetch_agent_request',
|
type: 'fetch_agent_request',
|
||||||
@@ -1114,7 +1121,10 @@ export class DaemonClient {
|
|||||||
if (payload.error) {
|
if (payload.error) {
|
||||||
throw new Error(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 {
|
private resubscribeCheckoutDiffSubscriptions(): void {
|
||||||
@@ -1158,6 +1168,7 @@ export class DaemonClient {
|
|||||||
requestId,
|
requestId,
|
||||||
config,
|
config,
|
||||||
...(options.initialPrompt ? { initialPrompt: options.initialPrompt } : {}),
|
...(options.initialPrompt ? { initialPrompt: options.initialPrompt } : {}),
|
||||||
|
...(options.clientMessageId ? { clientMessageId: options.clientMessageId } : {}),
|
||||||
...(options.outputSchema ? { outputSchema: options.outputSchema } : {}),
|
...(options.outputSchema ? { outputSchema: options.outputSchema } : {}),
|
||||||
...(options.images && options.images.length > 0 ? { images: options.images } : {}),
|
...(options.images && options.images.length > 0 ? { images: options.images } : {}),
|
||||||
...(options.git ? { git: options.git } : {}),
|
...(options.git ? { git: options.git } : {}),
|
||||||
@@ -2316,9 +2327,9 @@ export class DaemonClient {
|
|||||||
predicate: (snapshot: AgentSnapshotPayload) => boolean,
|
predicate: (snapshot: AgentSnapshotPayload) => boolean,
|
||||||
timeout = 60000
|
timeout = 60000
|
||||||
): Promise<AgentSnapshotPayload> {
|
): Promise<AgentSnapshotPayload> {
|
||||||
const initial = await this.fetchAgent(agentId).catch(() => null)
|
const initialResult = await this.fetchAgent(agentId).catch(() => null)
|
||||||
if (initial && predicate(initial)) {
|
if (initialResult && predicate(initialResult.agent)) {
|
||||||
return initial
|
return initialResult.agent
|
||||||
}
|
}
|
||||||
|
|
||||||
const deadline = Date.now() + timeout
|
const deadline = Date.now() + timeout
|
||||||
@@ -2374,8 +2385,8 @@ export class DaemonClient {
|
|||||||
}
|
}
|
||||||
pollInFlight = true
|
pollInFlight = true
|
||||||
try {
|
try {
|
||||||
const snapshot = await this.fetchAgent(agentId).catch(() => null)
|
const result = await this.fetchAgent(agentId).catch(() => null)
|
||||||
maybeResolve(snapshot)
|
maybeResolve(result?.agent ?? null)
|
||||||
} finally {
|
} finally {
|
||||||
pollInFlight = false
|
pollInFlight = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ describe("daemon client E2E", () => {
|
|||||||
expect(ctx.client.isConnected).toBe(true);
|
expect(ctx.client.isConnected).toBe(true);
|
||||||
|
|
||||||
const agents = await ctx.client.fetchAgents();
|
const agents = await ctx.client.fetchAgents();
|
||||||
expect(Array.isArray(agents)).toBe(true);
|
expect(Array.isArray(agents.entries)).toBe(true);
|
||||||
|
|
||||||
const cwd = tmpCwd();
|
const cwd = tmpCwd();
|
||||||
const created = await ctx.client.createAgent({
|
const created = await ctx.client.createAgent({
|
||||||
@@ -218,10 +218,12 @@ describe("daemon client E2E", () => {
|
|||||||
const result = await ctx.client.archiveAgent(created.id);
|
const result = await ctx.client.archiveAgent(created.id);
|
||||||
expect(result.archivedAt).toBeTruthy();
|
expect(result.archivedAt).toBeTruthy();
|
||||||
|
|
||||||
const archived = await ctx.client.fetchAgent(created.id);
|
const archivedResult = await ctx.client.fetchAgent(created.id);
|
||||||
expect(archived).not.toBeNull();
|
expect(archivedResult).not.toBeNull();
|
||||||
expect(archived?.archivedAt).toBeTruthy();
|
expect(archivedResult?.agent.archivedAt).toBeTruthy();
|
||||||
expect(archived?.status).not.toBe("running");
|
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({
|
const runningAgents = await ctx.client.fetchAgents({
|
||||||
filter: { includeArchived: true, statuses: ["running"] },
|
filter: { includeArchived: true, statuses: ["running"] },
|
||||||
@@ -342,8 +344,10 @@ describe("daemon client E2E", () => {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(Array.isArray(first)).toBe(true);
|
expect(first.requestId).toBe(firstRequestId);
|
||||||
expect(Array.isArray(second)).toBe(true);
|
expect(second.requestId).toBe(secondRequestId);
|
||||||
|
expect(Array.isArray(first.entries)).toBe(true);
|
||||||
|
expect(Array.isArray(second.entries)).toBe(true);
|
||||||
}, 15000);
|
}, 15000);
|
||||||
|
|
||||||
test(
|
test(
|
||||||
@@ -399,8 +403,8 @@ describe("daemon client E2E", () => {
|
|||||||
|
|
||||||
expect(agent.id).toBeTruthy();
|
expect(agent.id).toBeTruthy();
|
||||||
expect(agent.status).toBe("idle");
|
expect(agent.status).toBe("idle");
|
||||||
const fetched = await ctx.client.fetchAgent(agent.id);
|
const fetchedResult = await ctx.client.fetchAgent(agent.id);
|
||||||
expect(fetched?.id).toBe(agent.id);
|
expect(fetchedResult?.agent.id).toBe(agent.id);
|
||||||
|
|
||||||
const agentUpdate = await agentUpdatePromise;
|
const agentUpdate = await agentUpdatePromise;
|
||||||
expect(agentUpdate.payload.agent.id).toBe(agent.id);
|
expect(agentUpdate.payload.agent.id).toBe(agent.id);
|
||||||
|
|||||||
@@ -68,14 +68,14 @@ describe("daemon E2E", () => {
|
|||||||
limit: 200,
|
limit: 200,
|
||||||
projection: "projected",
|
projection: "projected",
|
||||||
});
|
});
|
||||||
const refreshedState = await ctx.client.fetchAgent(agent.id);
|
const refreshedResult = await ctx.client.fetchAgent(agent.id);
|
||||||
|
|
||||||
// Verify agent is still idle
|
// Verify agent is still idle
|
||||||
expect(refreshedState?.status).toBe("idle");
|
expect(refreshedResult?.agent.status).toBe("idle");
|
||||||
|
|
||||||
// CRITICAL: The timestamp should NOT have changed
|
// CRITICAL: The timestamp should NOT have changed
|
||||||
// Just opening/clicking an agent should not update its updatedAt
|
// 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)
|
// Also clear attention (what happens when opening an agent with notification)
|
||||||
await ctx.client.clearAgentAttention(agent.id);
|
await ctx.client.clearAgentAttention(agent.id);
|
||||||
@@ -86,10 +86,10 @@ describe("daemon E2E", () => {
|
|||||||
limit: 200,
|
limit: 200,
|
||||||
projection: "projected",
|
projection: "projected",
|
||||||
});
|
});
|
||||||
const stateAfterClear = await ctx.client.fetchAgent(agent.id);
|
const clearResult = await ctx.client.fetchAgent(agent.id);
|
||||||
|
|
||||||
// Timestamp should STILL not have changed
|
// Timestamp should STILL not have changed
|
||||||
expect(stateAfterClear?.updatedAt).toBe(initialUpdatedAt);
|
expect(clearResult?.agent.updatedAt).toBe(initialUpdatedAt);
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
@@ -341,7 +341,7 @@ describe("daemon E2E", () => {
|
|||||||
|
|
||||||
// Initially, there should be no agents (fresh session)
|
// Initially, there should be no agents (fresh session)
|
||||||
const initialAgents = await ctx.client.fetchAgents();
|
const initialAgents = await ctx.client.fetchAgents();
|
||||||
expect(initialAgents).toHaveLength(0);
|
expect(initialAgents.entries).toHaveLength(0);
|
||||||
|
|
||||||
// Create first agent
|
// Create first agent
|
||||||
const agent1 = await ctx.client.createAgent({
|
const agent1 = await ctx.client.createAgent({
|
||||||
@@ -355,10 +355,10 @@ describe("daemon E2E", () => {
|
|||||||
|
|
||||||
// fetchAgents should now return 1 agent
|
// fetchAgents should now return 1 agent
|
||||||
const afterFirst = await ctx.client.fetchAgents();
|
const afterFirst = await ctx.client.fetchAgents();
|
||||||
expect(afterFirst).toHaveLength(1);
|
expect(afterFirst.entries).toHaveLength(1);
|
||||||
expect(afterFirst[0].id).toBe(agent1.id);
|
expect(afterFirst.entries[0]?.agent.id).toBe(agent1.id);
|
||||||
// Title may or may not be set depending on timing
|
// 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
|
// Create second agent
|
||||||
const agent2 = await ctx.client.createAgent({
|
const agent2 = await ctx.client.createAgent({
|
||||||
@@ -372,15 +372,15 @@ describe("daemon E2E", () => {
|
|||||||
|
|
||||||
// fetchAgents should now return 2 agents
|
// fetchAgents should now return 2 agents
|
||||||
const afterSecond = await ctx.client.fetchAgents();
|
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
|
// 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(agent1.id);
|
||||||
expect(ids).toContain(agent2.id);
|
expect(ids).toContain(agent2.id);
|
||||||
|
|
||||||
const agent1State = afterSecond.find((a) => a.id === agent1.id);
|
const agent1State = afterSecond.entries.find((entry) => entry.agent.id === agent1.id)?.agent;
|
||||||
const agent2State = afterSecond.find((a) => a.id === agent2.id);
|
const agent2State = afterSecond.entries.find((entry) => entry.agent.id === agent2.id)?.agent;
|
||||||
|
|
||||||
// Title may or may not be set depending on timing
|
// Title may or may not be set depending on timing
|
||||||
expect(agent1State?.cwd).toBe(cwd1);
|
expect(agent1State?.cwd).toBe(cwd1);
|
||||||
@@ -395,12 +395,12 @@ describe("daemon E2E", () => {
|
|||||||
|
|
||||||
// fetchAgents should now return only 1 agent
|
// fetchAgents should now return only 1 agent
|
||||||
const afterDelete = await ctx.client.fetchAgents();
|
const afterDelete = await ctx.client.fetchAgents();
|
||||||
expect(afterDelete).toHaveLength(1);
|
expect(afterDelete.entries).toHaveLength(1);
|
||||||
expect(afterDelete[0].id).toBe(agent2.id);
|
expect(afterDelete.entries[0]?.agent.id).toBe(agent2.id);
|
||||||
expect(afterDelete[0].cwd).toBe(cwd2);
|
expect(afterDelete.entries[0]?.agent.cwd).toBe(cwd2);
|
||||||
|
|
||||||
// Verify agent1 is no longer in the list
|
// 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();
|
expect(deletedAgent).toBeUndefined();
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
|
|||||||
@@ -545,8 +545,8 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
|
|||||||
|
|
||||||
const settled = await client.waitForFinish(agent.id, 20_000);
|
const settled = await client.waitForFinish(agent.id, 20_000);
|
||||||
expect(settled.status).toBe("idle");
|
expect(settled.status).toBe("idle");
|
||||||
const finalSnapshot = await client.fetchAgent(agent.id);
|
const finalResult = await client.fetchAgent(agent.id);
|
||||||
expect(finalSnapshot?.status).toBe("idle");
|
expect(finalResult?.agent.status).toBe("idle");
|
||||||
} finally {
|
} finally {
|
||||||
await client.close();
|
await client.close();
|
||||||
await daemon.close();
|
await daemon.close();
|
||||||
@@ -750,13 +750,13 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
|
|||||||
try {
|
try {
|
||||||
secondCompletion = await client.waitForFinish(agent.id, 20_000);
|
secondCompletion = await client.waitForFinish(agent.id, 20_000);
|
||||||
} catch {
|
} catch {
|
||||||
const atTimeout = await client.fetchAgent(agent.id);
|
const atTimeoutResult = await client.fetchAgent(agent.id);
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log(
|
console.log(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
cycle,
|
cycle,
|
||||||
phase: "second_completion_timeout",
|
phase: "second_completion_timeout",
|
||||||
statusAtTimeout: atTimeout?.status ?? "unknown",
|
statusAtTimeout: atTimeoutResult?.agent.status ?? "unknown",
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
secondCompletion = await client.waitForFinish(agent.id, 30_000);
|
secondCompletion = await client.waitForFinish(agent.id, 30_000);
|
||||||
@@ -830,12 +830,12 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
|
|||||||
try {
|
try {
|
||||||
secondCompletion = await client.waitForFinish(agent.id, 20_000);
|
secondCompletion = await client.waitForFinish(agent.id, 20_000);
|
||||||
} catch {
|
} catch {
|
||||||
const atTimeout = await client.fetchAgent(agent.id);
|
const atTimeoutResult = await client.fetchAgent(agent.id);
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log(
|
console.log(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
phase: "second_background_completion_timeout",
|
phase: "second_background_completion_timeout",
|
||||||
statusAtTimeout: atTimeout?.status ?? "unknown",
|
statusAtTimeout: atTimeoutResult?.agent.status ?? "unknown",
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
secondCompletion = await client.waitForFinish(agent.id, 30_000);
|
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"));
|
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;
|
let cancelRecovered = true;
|
||||||
if (afterWait?.status === "running") {
|
if (afterWaitResult?.agent.status === "running") {
|
||||||
await client.cancelAgent(agent.id);
|
await client.cancelAgent(agent.id);
|
||||||
try {
|
try {
|
||||||
const afterCancel = await client.waitForFinish(agent.id, 15_000);
|
const afterCancel = await client.waitForFinish(agent.id, 15_000);
|
||||||
@@ -1005,12 +1005,13 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
|
|||||||
} catch {
|
} catch {
|
||||||
cancelRecovered = false;
|
cancelRecovered = false;
|
||||||
}
|
}
|
||||||
afterWait = await client.fetchAgent(agent.id);
|
afterWaitResult = await client.fetchAgent(agent.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(waitError).toBeNull();
|
expect(waitError).toBeNull();
|
||||||
expect(cancelRecovered).toBe(true);
|
expect(cancelRecovered).toBe(true);
|
||||||
|
|
||||||
|
const afterWait = afterWaitResult?.agent ?? null;
|
||||||
const sessionId =
|
const sessionId =
|
||||||
afterWait?.persistence?.sessionId ??
|
afterWait?.persistence?.sessionId ??
|
||||||
afterWait?.runtimeInfo?.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);
|
await secondary.waitForFinish(agent.id, 180_000);
|
||||||
const finalSnapshot = await secondary.fetchAgent(agent.id);
|
const finalResult = await secondary.fetchAgent(agent.id);
|
||||||
if (finalSnapshot) {
|
if (finalResult) {
|
||||||
applySnapshot(finalSnapshot);
|
applySnapshot(finalResult.agent);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sending while running should clear processing even if reconnect misses the
|
// 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);
|
await secondary.waitForFinish(agent.id, 120_000);
|
||||||
const finalSnapshot = await secondary.fetchAgent(agent.id);
|
const finalResult = await secondary.fetchAgent(agent.id);
|
||||||
if (finalSnapshot) {
|
if (finalResult) {
|
||||||
applySnapshot(finalSnapshot);
|
applySnapshot(finalResult.agent);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sending while running should clear processing even if reconnect misses the
|
// 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 {
|
private compareSortValues(left: string | number | null, right: string | number | null): number {
|
||||||
if (left === right) {
|
if (left === right) {
|
||||||
return 0
|
return 0
|
||||||
@@ -4942,21 +4958,21 @@ export class Session {
|
|||||||
return String(left).localeCompare(String(right))
|
return String(left).localeCompare(String(right))
|
||||||
}
|
}
|
||||||
|
|
||||||
private compareFetchAgentsEntries(
|
private compareFetchAgentsAgents(
|
||||||
left: FetchAgentsResponseEntry,
|
left: AgentSnapshotPayload,
|
||||||
right: FetchAgentsResponseEntry,
|
right: AgentSnapshotPayload,
|
||||||
sort: FetchAgentsRequestSort[]
|
sort: FetchAgentsRequestSort[]
|
||||||
): number {
|
): number {
|
||||||
for (const spec of sort) {
|
for (const spec of sort) {
|
||||||
const leftValue = this.getFetchAgentsSortValue(left, spec.key)
|
const leftValue = this.getFetchAgentsSortValueFromAgent(left, spec.key)
|
||||||
const rightValue = this.getFetchAgentsSortValue(right, spec.key)
|
const rightValue = this.getFetchAgentsSortValueFromAgent(right, spec.key)
|
||||||
const base = this.compareSortValues(leftValue, rightValue)
|
const base = this.compareSortValues(leftValue, rightValue)
|
||||||
if (base === 0) {
|
if (base === 0) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return spec.direction === 'asc' ? base : -base
|
return spec.direction === 'asc' ? base : -base
|
||||||
}
|
}
|
||||||
return left.agent.id.localeCompare(right.agent.id)
|
return left.id.localeCompare(right.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
private encodeFetchAgentsCursor(
|
private encodeFetchAgentsCursor(
|
||||||
@@ -5050,13 +5066,13 @@ export class Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private compareEntryWithCursor(
|
private compareAgentWithCursor(
|
||||||
entry: FetchAgentsResponseEntry,
|
agent: AgentSnapshotPayload,
|
||||||
cursor: FetchAgentsCursor,
|
cursor: FetchAgentsCursor,
|
||||||
sort: FetchAgentsRequestSort[]
|
sort: FetchAgentsRequestSort[]
|
||||||
): number {
|
): number {
|
||||||
for (const spec of sort) {
|
for (const spec of sort) {
|
||||||
const leftValue = this.getFetchAgentsSortValue(entry, spec.key)
|
const leftValue = this.getFetchAgentsSortValueFromAgent(agent, spec.key)
|
||||||
const rightValue =
|
const rightValue =
|
||||||
cursor.values[spec.key] !== undefined ? (cursor.values[spec.key] ?? null) : null
|
cursor.values[spec.key] !== undefined ? (cursor.values[spec.key] ?? null) : null
|
||||||
const base = this.compareSortValues(leftValue, rightValue)
|
const base = this.compareSortValues(leftValue, rightValue)
|
||||||
@@ -5065,7 +5081,7 @@ export class Session {
|
|||||||
}
|
}
|
||||||
return spec.direction === 'asc' ? base : -base
|
return spec.direction === 'asc' ? base : -base
|
||||||
}
|
}
|
||||||
return entry.agent.id.localeCompare(cursor.id)
|
return agent.id.localeCompare(cursor.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
private async listFetchAgentsEntries(
|
private async listFetchAgentsEntries(
|
||||||
@@ -5092,31 +5108,45 @@ export class Session {
|
|||||||
return placementPromise
|
return placementPromise
|
||||||
}
|
}
|
||||||
|
|
||||||
let entries = await Promise.all(
|
let candidates = [...agents]
|
||||||
agents.map(async (agent) => ({
|
candidates.sort((left, right) => this.compareFetchAgentsAgents(left, right, sort))
|
||||||
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))
|
|
||||||
|
|
||||||
const cursorToken = request.page?.cursor
|
const cursorToken = request.page?.cursor
|
||||||
if (cursorToken) {
|
if (cursorToken) {
|
||||||
const cursor = this.decodeFetchAgentsCursor(cursorToken, sort)
|
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 limit = request.page?.limit ?? 200
|
||||||
const pagedEntries = entries.slice(0, limit)
|
|
||||||
const hasMore = entries.length > limit
|
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 =
|
const nextCursor =
|
||||||
hasMore && pagedEntries.length > 0
|
hasMore && pagedEntries.length > 0
|
||||||
? this.encodeFetchAgentsCursor(pagedEntries[pagedEntries.length - 1], sort)
|
? this.encodeFetchAgentsCursor(pagedEntries[pagedEntries.length - 1], sort)
|
||||||
@@ -5197,7 +5227,7 @@ export class Session {
|
|||||||
if (!resolved.ok) {
|
if (!resolved.ok) {
|
||||||
this.emit({
|
this.emit({
|
||||||
type: 'fetch_agent_response',
|
type: 'fetch_agent_response',
|
||||||
payload: { requestId, agent: null, error: resolved.error },
|
payload: { requestId, agent: null, project: null, error: resolved.error },
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -5206,14 +5236,20 @@ export class Session {
|
|||||||
if (!agent) {
|
if (!agent) {
|
||||||
this.emit({
|
this.emit({
|
||||||
type: 'fetch_agent_response',
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const project = await this.buildProjectPlacement(agent.cwd)
|
||||||
this.emit({
|
this.emit({
|
||||||
type: 'fetch_agent_response',
|
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(),
|
.optional(),
|
||||||
page: z
|
page: z
|
||||||
.object({
|
.object({
|
||||||
limit: z.number().int().positive().max(1000),
|
limit: z.number().int().positive().max(200),
|
||||||
cursor: z.string().min(1).optional(),
|
cursor: z.string().min(1).optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
@@ -578,6 +578,7 @@ export const CreateAgentRequestMessageSchema = z.object({
|
|||||||
config: AgentSessionConfigSchema,
|
config: AgentSessionConfigSchema,
|
||||||
worktreeName: z.string().optional(),
|
worktreeName: z.string().optional(),
|
||||||
initialPrompt: z.string().optional(),
|
initialPrompt: z.string().optional(),
|
||||||
|
clientMessageId: z.string().optional(),
|
||||||
outputSchema: z.record(z.unknown()).optional(),
|
outputSchema: z.record(z.unknown()).optional(),
|
||||||
images: z
|
images: z
|
||||||
.array(
|
.array(
|
||||||
@@ -1469,6 +1470,7 @@ export const FetchAgentResponseMessageSchema = z.object({
|
|||||||
payload: z.object({
|
payload: z.object({
|
||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
agent: AgentSnapshotPayloadSchema.nullable(),
|
agent: AgentSnapshotPayloadSchema.nullable(),
|
||||||
|
project: ProjectPlacementPayloadSchema.nullable().optional(),
|
||||||
error: z.string().nullable(),
|
error: z.string().nullable(),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user