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