refactor(app): introduce attachment storage layer and draft lifecycle management

Replace inline data-URL image handling with a persistent attachment storage service, add draft lifecycle states (active/abandoned/sent) with generation tracking, and update fetchAgent to return project placement alongside agent snapshots.
This commit is contained in:
Mohamed Boudra
2026-02-22 16:22:21 +07:00
parent 2652bdda2e
commit 0c710b09f5
55 changed files with 2381 additions and 350 deletions
@@ -0,0 +1,49 @@
import { beforeEach, describe, expect, it } from "vitest";
import { useCreateFlowStore } from "./create-flow-store";
describe("create-flow-store", () => {
beforeEach(() => {
useCreateFlowStore.setState({ pending: null });
});
it("tracks lifecycle transitions explicitly", () => {
const store = useCreateFlowStore.getState();
store.setPending({
draftId: "draft-1",
serverId: "server-1",
agentId: null,
clientMessageId: "msg-1",
text: "hello",
timestamp: Date.now(),
images: [],
});
expect(useCreateFlowStore.getState().pending?.lifecycle).toBe("active");
useCreateFlowStore.getState().markLifecycle("abandoned");
expect(useCreateFlowStore.getState().pending?.lifecycle).toBe("abandoned");
});
it("rekeys draft id idempotently", () => {
useCreateFlowStore.getState().setPending({
draftId: "draft-a",
serverId: "server-1",
agentId: null,
clientMessageId: "msg-1",
text: "hello",
timestamp: Date.now(),
images: [],
});
useCreateFlowStore.getState().rekeyDraft({
fromDraftId: "draft-a",
toDraftId: "draft-b",
});
useCreateFlowStore.getState().rekeyDraft({
fromDraftId: "draft-a",
toDraftId: "draft-b",
});
expect(useCreateFlowStore.getState().pending?.draftId).toBe("draft-b");
});
});
+55 -6
View File
@@ -1,28 +1,77 @@
import { create } from "zustand";
import type { UserMessageImageAttachment } from "@/types/stream";
export type CreateFlowLifecycleState = "active" | "abandoned" | "sent";
type PendingCreateAttempt = {
draftId: string;
serverId: string;
agentId: string | null;
messageId: string;
clientMessageId: string;
text: string;
timestamp: number;
lifecycle: CreateFlowLifecycleState;
images?: UserMessageImageAttachment[];
};
type CreateFlowState = {
pending: PendingCreateAttempt | null;
setPending: (pending: PendingCreateAttempt) => void;
setPending: (
pending: Omit<PendingCreateAttempt, "lifecycle">
) => void;
updateAgentId: (agentId: string) => void;
markLifecycle: (lifecycle: CreateFlowLifecycleState) => void;
rekeyDraft: (input: { fromDraftId: string; toDraftId: string }) => void;
clear: () => void;
};
export const useCreateFlowStore = create<CreateFlowState>((set) => ({
pending: null,
setPending: (pending) => set({ pending }),
setPending: (pending) =>
set({
pending: {
...pending,
lifecycle: "active",
},
}),
updateAgentId: (agentId) =>
set((state) =>
state.pending ? { pending: { ...state.pending, agentId } } : state
),
set((state) => {
if (!state.pending || state.pending.agentId === agentId) {
return state;
}
return {
pending: {
...state.pending,
agentId,
},
};
}),
markLifecycle: (lifecycle) =>
set((state) => {
if (!state.pending || state.pending.lifecycle === lifecycle) {
return state;
}
return {
pending: {
...state.pending,
lifecycle,
},
};
}),
rekeyDraft: ({ fromDraftId, toDraftId }) =>
set((state) => {
if (!state.pending || state.pending.draftId !== fromDraftId) {
return state;
}
if (state.pending.draftId === toDraftId) {
return state;
}
return {
pending: {
...state.pending,
draftId: toDraftId,
},
};
}),
clear: () => set({ pending: null }),
}));
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { buildDraftStoreKey } from "./draft-keys";
describe("buildDraftStoreKey", () => {
it("isolates agent drafts by server and agent ids", () => {
const keyA = buildDraftStoreKey({
serverId: "server-a",
agentId: "agent-1",
});
const keyB = buildDraftStoreKey({
serverId: "server-b",
agentId: "agent-1",
});
const keyC = buildDraftStoreKey({
serverId: "server-a",
agentId: "agent-2",
});
expect(keyA).not.toBe(keyB);
expect(keyA).not.toBe(keyC);
expect(keyB).not.toBe(keyC);
});
it("uses draftId keyspace for create flow drafts", () => {
const key = buildDraftStoreKey({
serverId: "server-a",
agentId: "__new_agent__",
draftId: "draft-123",
});
expect(key).toBe("draft:server-a:draft-123");
});
});
+18
View File
@@ -0,0 +1,18 @@
import { generateMessageId } from "@/types/stream";
export function generateDraftId(): string {
return `draft_${generateMessageId()}`;
}
export function buildDraftStoreKey(input: {
serverId: string;
agentId: string;
draftId?: string | null;
}): string {
const serverId = input.serverId.trim();
const explicitDraftId = input.draftId?.trim();
if (explicitDraftId) {
return `draft:${serverId}:${explicitDraftId}`;
}
return `agent:${serverId}:${input.agentId.trim()}`;
}
@@ -0,0 +1,69 @@
import "@/test/window-local-storage";
import { describe, expect, it } from "vitest";
import { __draftStoreTestUtils } from "./draft-store";
describe("draft-store lifecycle", () => {
it("prunes finalized tombstones after TTL", () => {
const nowMs = 1_000_000;
const drafts = {
oldSent: {
input: { text: "", images: [] },
lifecycle: "sent" as const,
updatedAt: 0,
version: 2,
},
recentAbandoned: {
input: { text: "", images: [] },
lifecycle: "abandoned" as const,
updatedAt: nowMs + 2 * 60 * 1000,
version: 2,
},
active: {
input: { text: "a", images: [] },
lifecycle: "active" as const,
updatedAt: 0,
version: 1,
},
};
const pruned = __draftStoreTestUtils.pruneFinalizedDraftRecords({
drafts,
nowMs: nowMs + 6 * 60 * 1000,
});
expect(pruned.oldSent).toBeUndefined();
expect(pruned.recentAbandoned).toBeDefined();
expect(pruned.active).toBeDefined();
});
it("normalizes clear-with-lifecycle into a tombstone without attachments", () => {
const cleared = __draftStoreTestUtils.applyClearDraftRecord({
record: {
input: {
text: "hello",
images: [
{
id: "att-1",
mimeType: "image/jpeg",
storageType: "web-indexeddb",
storageKey: "att-1",
createdAt: 1,
},
],
},
lifecycle: "active",
updatedAt: 1,
version: 1,
},
lifecycle: "sent",
nowMs: 2,
});
expect(cleared).toEqual({
input: { text: "", images: [] },
lifecycle: "sent",
updatedAt: 2,
version: 2,
});
});
});
@@ -0,0 +1,71 @@
import "@/test/window-local-storage";
import { describe, expect, it } from "vitest";
import { __draftStoreTestUtils } from "./draft-store";
describe("draft-store migration", () => {
it("normalizes attachment metadata and strips persisted preview URLs", () => {
const migrated = __draftStoreTestUtils.migratePersistedState({
drafts: {
"agent:server:agent": {
input: {
text: "hello",
images: [
{
id: "att-1",
mimeType: "image/png",
storageType: "desktop-file",
storageKey: "/tmp/att-1.png",
createdAt: 1700000000000,
previewUri: "asset://should-not-persist",
},
],
},
lifecycle: "active",
updatedAt: 1700000000001,
version: 1,
},
},
createModalDraft: null,
});
expect(migrated.drafts["agent:server:agent"]?.input.images).toEqual([
{
id: "att-1",
mimeType: "image/png",
storageType: "desktop-file",
storageKey: "/tmp/att-1.png",
createdAt: 1700000000000,
},
]);
});
it("is idempotent for already-migrated shapes", () => {
const original = {
drafts: {
"agent:server:agent": {
input: {
text: "hello",
images: [
{
id: "att-1",
mimeType: "image/jpeg",
storageType: "web-indexeddb",
storageKey: "att-1",
createdAt: 1700000000000,
},
],
},
lifecycle: "active",
updatedAt: 1700000000001,
version: 2,
},
},
createModalDraft: null,
};
const once = __draftStoreTestUtils.migratePersistedState(original);
const twice = __draftStoreTestUtils.migratePersistedState(once);
expect(twice).toEqual(once);
});
});
+605 -22
View File
@@ -1,64 +1,647 @@
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import { createJSONStorage, persist } from "zustand/middleware";
import AsyncStorage from "@react-native-async-storage/async-storage";
import type { AttachmentMetadata } from "@/attachments/types";
import {
garbageCollectAttachments,
persistAttachmentFromDataUrl,
persistAttachmentFromFileUri,
} from "@/attachments/service";
const DRAFT_STORE_VERSION = 2;
const FINALIZED_DRAFT_TTL_MS = 5 * 60 * 1000;
type LegacyDraftImage = {
uri: string;
mimeType?: string;
};
type PersistedDraftImage = AttachmentMetadata | LegacyDraftImage;
export interface DraftInput {
text: string;
images: Array<{ uri: string; mimeType: string }>;
images: AttachmentMetadata[];
}
export type DraftLifecycleState = "active" | "abandoned" | "sent";
interface DraftRecord {
input: {
text: string;
images: PersistedDraftImage[];
};
lifecycle: DraftLifecycleState;
updatedAt: number;
version: number;
}
interface DraftStoreState {
drafts: Record<string, DraftInput>;
createModalDraft: DraftInput | null;
drafts: Record<string, DraftRecord>;
createModalDraft: DraftRecord | null;
}
interface DraftStoreActions {
getDraftInput: (agentId: string) => DraftInput | undefined;
saveDraftInput: (agentId: string, draft: DraftInput) => void;
clearDraftInput: (agentId: string) => void;
getDraftInput: (draftKey: string) => DraftInput | undefined;
hydrateDraftInput: (draftKey: string) => Promise<DraftInput | undefined>;
saveDraftInput: (input: { draftKey: string; draft: DraftInput }) => void;
markDraftLifecycle: (input: {
draftKey: string;
lifecycle: DraftLifecycleState;
}) => void;
clearDraftInput: (input: {
draftKey: string;
lifecycle?: Exclude<DraftLifecycleState, "active">;
}) => void;
getCreateModalDraft: () => DraftInput | null;
saveCreateModalDraft: (draft: DraftInput | null) => void;
beginDraftGeneration: (draftKey: string) => number;
isDraftGenerationCurrent: (input: {
draftKey: string;
generation: number;
}) => boolean;
collectActiveAttachmentIds: () => string[];
}
type DraftStore = DraftStoreState & DraftStoreActions;
const draftGenerations = new Map<string, number>();
let gcScheduled = false;
function createDraftRecord(input: {
draft: { text: string; images: PersistedDraftImage[] };
lifecycle: DraftLifecycleState;
previousVersion?: number;
}): DraftRecord {
const normalizedImages = input.draft.images.map((image) => {
if (isAttachmentMetadata(image)) {
return normalizeAttachmentMetadata(image);
}
return image;
});
return {
input: {
text: input.draft.text,
images: normalizedImages,
},
lifecycle: input.lifecycle,
updatedAt: Date.now(),
version: (input.previousVersion ?? 0) + 1,
};
}
function isAttachmentMetadata(value: unknown): value is AttachmentMetadata {
if (!value || typeof value !== "object") {
return false;
}
const record = value as Record<string, unknown>;
return (
typeof record.id === "string" &&
typeof record.mimeType === "string" &&
typeof record.storageType === "string" &&
typeof record.storageKey === "string" &&
typeof record.createdAt === "number"
);
}
function isLegacyDraftImage(value: unknown): value is LegacyDraftImage {
if (!value || typeof value !== "object") {
return false;
}
const record = value as Record<string, unknown>;
return typeof record.uri === "string";
}
function normalizeAttachmentMetadata(image: AttachmentMetadata): AttachmentMetadata {
return {
id: image.id,
mimeType: image.mimeType,
storageType: image.storageType,
storageKey: image.storageKey,
createdAt: image.createdAt,
...(typeof image.fileName === "string" || image.fileName === null
? { fileName: image.fileName }
: {}),
...(typeof image.byteSize === "number" || image.byteSize === null
? { byteSize: image.byteSize }
: {}),
};
}
function normalizePersistedImage(value: unknown): PersistedDraftImage | null {
if (isAttachmentMetadata(value)) {
return normalizeAttachmentMetadata(value);
}
if (isLegacyDraftImage(value)) {
return {
uri: value.uri,
...(value.mimeType ? { mimeType: value.mimeType } : {}),
};
}
return null;
}
function hasLegacyImages(images: readonly PersistedDraftImage[]): boolean {
return images.some((entry) => !isAttachmentMetadata(entry));
}
function toDraftInputIfReady(record: DraftRecord | null | undefined): DraftInput | undefined {
if (!record) {
return undefined;
}
if (record.lifecycle !== "active") {
return undefined;
}
if (hasLegacyImages(record.input.images)) {
return undefined;
}
return {
text: record.input.text,
images: (record.input.images as AttachmentMetadata[]).map(
normalizeAttachmentMetadata
),
};
}
function collectReferencedAttachmentIdsFromState(state: DraftStoreState): Set<string> {
const referencedIds = new Set<string>();
for (const draftRecord of Object.values(state.drafts)) {
if (draftRecord.lifecycle !== "active") {
continue;
}
for (const image of draftRecord.input.images) {
if (isAttachmentMetadata(image)) {
referencedIds.add(image.id);
}
}
}
const modalRecord = state.createModalDraft;
if (modalRecord?.lifecycle === "active") {
for (const image of modalRecord.input.images) {
if (isAttachmentMetadata(image)) {
referencedIds.add(image.id);
}
}
}
return referencedIds;
}
function pruneFinalizedDraftRecords(input: {
drafts: Record<string, DraftRecord>;
nowMs: number;
}): Record<string, DraftRecord> {
let changed = false;
const next: Record<string, DraftRecord> = {};
for (const [draftKey, record] of Object.entries(input.drafts)) {
if (
record.lifecycle !== "active" &&
input.nowMs - record.updatedAt >= FINALIZED_DRAFT_TTL_MS
) {
changed = true;
continue;
}
next[draftKey] = record;
}
return changed ? next : input.drafts;
}
function applyClearDraftRecord(input: {
record: DraftRecord;
lifecycle?: Exclude<DraftLifecycleState, "active">;
nowMs: number;
}): DraftRecord | null {
if (!input.lifecycle) {
return null;
}
return {
...input.record,
input: { text: "", images: [] },
lifecycle: input.lifecycle,
updatedAt: input.nowMs,
version: input.record.version + 1,
};
}
async function runAttachmentGc(): Promise<void> {
gcScheduled = false;
const nowMs = Date.now();
useDraftStore.setState((state) => {
const prunedDrafts = pruneFinalizedDraftRecords({ drafts: state.drafts, nowMs });
if (prunedDrafts === state.drafts) {
return state;
}
return {
...state,
drafts: prunedDrafts,
};
});
const referencedIds = new Set<string>();
for (const id of useDraftStore.getState().collectActiveAttachmentIds()) {
referencedIds.add(id);
}
try {
const [{ useCreateFlowStore }, { useSessionStore }] = await Promise.all([
import("@/stores/create-flow-store"),
import("@/stores/session-store"),
]);
const pendingCreate = useCreateFlowStore.getState().pending;
if (pendingCreate?.lifecycle === "active" && pendingCreate.images) {
for (const image of pendingCreate.images) {
referencedIds.add(image.id);
}
}
const sessions = useSessionStore.getState().sessions;
for (const session of Object.values(sessions)) {
for (const queue of session.queuedMessages.values()) {
for (const queuedMessage of queue) {
for (const image of queuedMessage.images ?? []) {
referencedIds.add(image.id);
}
}
}
for (const stream of session.agentStreamTail.values()) {
for (const item of stream) {
if (item.kind !== "user_message") {
continue;
}
for (const image of item.images ?? []) {
referencedIds.add(image.id);
}
}
}
for (const stream of session.agentStreamHead.values()) {
for (const item of stream) {
if (item.kind !== "user_message") {
continue;
}
for (const image of item.images ?? []) {
referencedIds.add(image.id);
}
}
}
}
} catch (error) {
console.warn("[DraftStore] Failed to collect attachment GC references", error);
}
try {
await garbageCollectAttachments({ referencedIds });
} catch (error) {
console.warn("[DraftStore] Attachment garbage collection failed", error);
}
}
function scheduleAttachmentGc(): void {
if (gcScheduled) {
return;
}
gcScheduled = true;
if (typeof queueMicrotask === "function") {
queueMicrotask(() => {
void runAttachmentGc();
});
return;
}
setTimeout(() => {
void runAttachmentGc();
}, 0);
}
async function migrateAllLegacyDrafts(): Promise<void> {
const state = useDraftStore.getState();
const keys = Object.entries(state.drafts)
.filter(([, record]) => hasLegacyImages(record.input.images))
.map(([draftKey]) => draftKey);
for (const draftKey of keys) {
try {
await state.hydrateDraftInput(draftKey);
} catch (error) {
console.warn("[DraftStore] Failed to migrate draft during startup", {
draftKey,
error,
});
}
}
}
async function migrateLegacyImages(images: readonly PersistedDraftImage[]): Promise<AttachmentMetadata[]> {
if (images.length === 0) {
return [];
}
const migrated = await Promise.all(
images.map(async (entry) => {
if (isAttachmentMetadata(entry)) {
return entry;
}
if (!isLegacyDraftImage(entry)) {
return null;
}
try {
if (entry.uri.startsWith("data:")) {
return await persistAttachmentFromDataUrl({
dataUrl: entry.uri,
mimeType: entry.mimeType,
});
}
return await persistAttachmentFromFileUri({
uri: entry.uri,
mimeType: entry.mimeType,
});
} catch (error) {
console.warn("[DraftStore] Failed to migrate legacy draft attachment", {
uri: entry.uri,
error,
});
return null;
}
})
);
return migrated.filter((entry): entry is AttachmentMetadata => entry !== null);
}
function migratePersistedState(state: unknown): DraftStoreState {
const input = (state ?? {}) as {
drafts?: Record<string, unknown>;
createModalDraft?: unknown;
};
const nextDrafts: Record<string, DraftRecord> = {};
for (const [draftKey, rawRecord] of Object.entries(input.drafts ?? {})) {
if (!rawRecord || typeof rawRecord !== "object") {
continue;
}
const parsed = rawRecord as Record<string, unknown>;
if ("input" in parsed && parsed.input && typeof parsed.input === "object") {
const recordInput = parsed.input as Record<string, unknown>;
nextDrafts[draftKey] = {
input: {
text: typeof recordInput.text === "string" ? recordInput.text : "",
images: Array.isArray(recordInput.images)
? (recordInput.images
.map((entry) => normalizePersistedImage(entry))
.filter((entry): entry is PersistedDraftImage => entry !== null))
: [],
},
lifecycle:
parsed.lifecycle === "sent" || parsed.lifecycle === "abandoned"
? (parsed.lifecycle as DraftLifecycleState)
: "active",
updatedAt: typeof parsed.updatedAt === "number" ? parsed.updatedAt : Date.now(),
version: typeof parsed.version === "number" ? parsed.version : 1,
};
continue;
}
// Legacy shape: { text, images }.
const legacy = parsed as { text?: unknown; images?: unknown };
nextDrafts[draftKey] = {
input: {
text: typeof legacy.text === "string" ? legacy.text : "",
images: Array.isArray(legacy.images)
? (legacy.images
.map((entry) => normalizePersistedImage(entry))
.filter((entry): entry is PersistedDraftImage => entry !== null))
: [],
},
lifecycle: "active",
updatedAt: Date.now(),
version: 1,
};
}
let createModalDraft: DraftRecord | null = null;
if (input.createModalDraft && typeof input.createModalDraft === "object") {
const record = input.createModalDraft as Record<string, unknown>;
if (record.input && typeof record.input === "object") {
const recordInput = record.input as Record<string, unknown>;
createModalDraft = {
input: {
text: typeof recordInput.text === "string" ? recordInput.text : "",
images: Array.isArray(recordInput.images)
? (recordInput.images
.map((entry) => normalizePersistedImage(entry))
.filter((entry): entry is PersistedDraftImage => entry !== null))
: [],
},
lifecycle:
record.lifecycle === "sent" || record.lifecycle === "abandoned"
? (record.lifecycle as DraftLifecycleState)
: "active",
updatedAt: typeof record.updatedAt === "number" ? record.updatedAt : Date.now(),
version: typeof record.version === "number" ? record.version : 1,
};
} else {
const legacy = record as { text?: unknown; images?: unknown };
createModalDraft = {
input: {
text: typeof legacy.text === "string" ? legacy.text : "",
images: Array.isArray(legacy.images)
? (legacy.images
.map((entry) => normalizePersistedImage(entry))
.filter((entry): entry is PersistedDraftImage => entry !== null))
: [],
},
lifecycle: "active",
updatedAt: Date.now(),
version: 1,
};
}
}
return {
drafts: nextDrafts,
createModalDraft,
};
}
export const useDraftStore = create<DraftStore>()(
persist(
(set, get) => ({
drafts: {},
createModalDraft: null,
getDraftInput: (agentId) => {
return get().drafts[agentId];
getDraftInput: (draftKey) => {
const record = get().drafts[draftKey];
return toDraftInputIfReady(record);
},
saveDraftInput: (agentId, draft) => {
set((state) => ({
drafts: {
...state.drafts,
[agentId]: draft,
},
}));
},
hydrateDraftInput: async (draftKey) => {
const current = get().drafts[draftKey];
if (!current) {
return undefined;
}
if (current.lifecycle !== "active") {
return undefined;
}
const ready = toDraftInputIfReady(current);
if (ready) {
return ready;
}
const migratedImages = await migrateLegacyImages(current.input.images);
const migratedDraft: DraftInput = {
text: current.input.text,
images: migratedImages,
};
clearDraftInput: (agentId) => {
set((state) => {
const { [agentId]: _, ...rest } = state.drafts;
return { drafts: rest };
const existing = state.drafts[draftKey];
if (!existing || existing.version !== current.version) {
return state;
}
return {
drafts: {
...state.drafts,
[draftKey]: createDraftRecord({
draft: migratedDraft,
lifecycle: existing.lifecycle,
previousVersion: existing.version,
}),
},
};
});
scheduleAttachmentGc();
return migratedDraft;
},
saveDraftInput: ({ draftKey, draft }) => {
set((state) => {
const existing = state.drafts[draftKey];
return {
drafts: {
...state.drafts,
[draftKey]: createDraftRecord({
draft,
lifecycle: "active",
previousVersion: existing?.version,
}),
},
};
});
scheduleAttachmentGc();
},
markDraftLifecycle: ({ draftKey, lifecycle }) => {
set((state) => {
const existing = state.drafts[draftKey];
if (!existing || existing.lifecycle === lifecycle) {
return state;
}
return {
drafts: {
...state.drafts,
[draftKey]: {
...existing,
lifecycle,
updatedAt: Date.now(),
version: existing.version + 1,
},
},
};
});
scheduleAttachmentGc();
},
clearDraftInput: ({ draftKey, lifecycle }) => {
set((state) => {
const existing = state.drafts[draftKey];
if (!existing) {
return state;
}
const cleared = applyClearDraftRecord({
record: existing,
lifecycle,
nowMs: Date.now(),
});
if (cleared) {
return {
drafts: {
...state.drafts,
[draftKey]: cleared,
},
};
}
const nextDrafts = { ...state.drafts };
delete nextDrafts[draftKey];
return { drafts: nextDrafts };
});
draftGenerations.delete(draftKey);
scheduleAttachmentGc();
},
getCreateModalDraft: () => {
return get().createModalDraft;
const record = get().createModalDraft;
return toDraftInputIfReady(record) ?? null;
},
saveCreateModalDraft: (draft) => {
set({ createModalDraft: draft });
set((state) => {
if (!draft) {
return { createModalDraft: null };
}
return {
createModalDraft: createDraftRecord({
draft,
lifecycle: "active",
previousVersion: state.createModalDraft?.version,
}),
};
});
scheduleAttachmentGc();
},
beginDraftGeneration: (draftKey) => {
const next = (draftGenerations.get(draftKey) ?? 0) + 1;
draftGenerations.set(draftKey, next);
return next;
},
isDraftGenerationCurrent: ({ draftKey, generation }) => {
return (draftGenerations.get(draftKey) ?? 0) === generation;
},
collectActiveAttachmentIds: () => {
return Array.from(collectReferencedAttachmentIdsFromState(get()).values());
},
}),
{
name: "paseo-drafts",
version: DRAFT_STORE_VERSION,
storage: createJSONStorage(() => AsyncStorage),
migrate: (persistedState) => {
return migratePersistedState(persistedState);
},
onRehydrateStorage: () => {
return () => {
void migrateAllLegacyDrafts();
scheduleAttachmentGc();
};
},
}
)
);
export const __draftStoreTestUtils = {
migratePersistedState,
normalizeAttachmentMetadata,
pruneFinalizedDraftRecords,
applyClearDraftRecord,
};
+16 -2
View File
@@ -5,6 +5,7 @@ import type { useAudioPlayer } from "@/hooks/use-audio-player";
import type { AgentDirectoryEntry } from "@/types/agent-directory";
import type { StreamItem } from "@/types/stream";
import type { PendingPermission } from "@/types/shared";
import type { AttachmentMetadata } from "@/attachments/types";
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
import type {
AgentPermissionResponse,
@@ -210,7 +211,10 @@ export interface SessionState {
fileExplorer: Map<string, AgentFileExplorerState>;
// Queued messages
queuedMessages: Map<string, Array<{ id: string; text: string; images?: Array<{ uri: string; mimeType: string }> }>>;
queuedMessages: Map<
string,
Array<{ id: string; text: string; images?: AttachmentMetadata[] }>
>;
}
// Global store state
@@ -278,7 +282,17 @@ interface SessionStoreActions {
setFileExplorer: (serverId: string, state: Map<string, AgentFileExplorerState> | ((prev: Map<string, AgentFileExplorerState>) => Map<string, AgentFileExplorerState>)) => void;
// Queued messages
setQueuedMessages: (serverId: string, value: Map<string, Array<{ id: string; text: string; images?: Array<{ uri: string; mimeType: string }> }>> | ((prev: Map<string, Array<{ id: string; text: string; images?: Array<{ uri: string; mimeType: string }> }>>) => Map<string, Array<{ id: string; text: string; images?: Array<{ uri: string; mimeType: string }> }>>)) => void;
setQueuedMessages: (
serverId: string,
value:
| Map<string, Array<{ id: string; text: string; images?: AttachmentMetadata[] }>>
| ((
prev: Map<
string,
Array<{ id: string; text: string; images?: AttachmentMetadata[] }>
>
) => Map<string, Array<{ id: string; text: string; images?: AttachmentMetadata[] }>>)
) => void;
// Hydration
setHasHydratedAgents: (serverId: string, hydrated: boolean) => void;