diff --git a/docs/optimization-backlog.md b/docs/optimization-backlog.md new file mode 100644 index 0000000..ffafefe --- /dev/null +++ b/docs/optimization-backlog.md @@ -0,0 +1,9 @@ +# Optimization Backlog + +## Progress Contract Frontend Consumption + +- Status: pending +- Priority: medium +- Context: The backend now returns `progressSource`, `stage`, `startedAt`, and `expectedDurationMs` on generation task status payloads. The frontend progress UI currently still derives these values locally from message state and static defaults. +- Follow-up: Wire the backend task progress contract through `aiGenerationClient`, task/message view models, and the progress card components so model-aware `expectedDurationMs` and real provider progress can be consumed end to end. +- Boundary: Keep this separate from the task store consolidation. The store consolidation is complete without requiring these fields because `WebGenerationPreviewTask` is not the source for Workbench progress cards. diff --git a/src/stores/useGenerationStore.test.ts b/src/stores/useGenerationStore.test.ts new file mode 100644 index 0000000..fd724c7 --- /dev/null +++ b/src/stores/useGenerationStore.test.ts @@ -0,0 +1,84 @@ +import { afterEach, describe, expect, it } from "../test/testHarness"; +import { useGenerationStore } from "./useGenerationStore"; +import type { WebGenerationPreviewTask } from "../types"; + +function previewTask(id: string, status: WebGenerationPreviewTask["status"] = "running"): WebGenerationPreviewTask { + return { + id, + title: "Task", + type: "image", + status, + progress: status === "completed" ? 100 : 10, + prompt: "prompt", + createdAt: "2026-06-10T08:00:00.000Z", + source: "server", + }; +} + +describe("useGenerationStore task state", () => { + afterEach(() => { + useGenerationStore.setState({ queue: [], tasks: [] }); + }); + + it("merges server preview tasks without duplicating local rows", () => { + const store = useGenerationStore.getState(); + + store.appendTask(previewTask("server-1")); + store.mergeServerTasks([previewTask("server-1", "completed"), previewTask("server-2")]); + + const tasks = useGenerationStore.getState().tasks; + expect(tasks.map((task) => task.id)).toEqual(["server-1", "server-2"]); + expect(tasks[0].status).toBe("completed"); + }); + + it("syncs running queue updates into matching preview tasks", () => { + const store = useGenerationStore.getState(); + + store.addTask({ + id: "local-task-1", + taskId: "server-task-1", + title: "Image", + type: "image", + status: "running", + progress: 5, + prompt: "prompt", + createdAt: Date.now(), + sourceView: "workbench", + }); + + expect(useGenerationStore.getState().tasks[0].id).toBe("server-task-1"); + expect(useGenerationStore.getState().tasks[0].status).toBe("running"); + + store.updateTask("local-task-1", { + status: "completed", + progress: 100, + resultUrl: "https://oss.example/result.png", + }); + + const task = useGenerationStore.getState().tasks[0]; + expect(task.status).toBe("completed"); + expect(task.progress).toBe(100); + expect(task.outputUrl).toBe("https://oss.example/result.png"); + }); + + it("clears preview tasks and running queue together", () => { + const store = useGenerationStore.getState(); + + store.appendTask(previewTask("server-task-1")); + store.addTask({ + id: "local-task-1", + title: "Image", + type: "image", + status: "running", + progress: 5, + prompt: "prompt", + createdAt: Date.now(), + sourceView: "workbench", + }); + + store.clearTasks(); + + expect(useGenerationStore.getState().tasks).toEqual([]); + expect(useGenerationStore.getState().queue).toEqual([]); + }); +}); diff --git a/src/stores/useGenerationStore.ts b/src/stores/useGenerationStore.ts index ad20197..71dd89b 100644 --- a/src/stores/useGenerationStore.ts +++ b/src/stores/useGenerationStore.ts @@ -1,4 +1,5 @@ import { create } from "zustand"; +import type { WebGenerationPreviewTask } from "../types"; export type QueueItemStatus = "pending" | "running" | "completed" | "failed" | "cancelled"; @@ -17,6 +18,8 @@ export interface GenerationQueueItem { params?: Record; } +type PreviewTaskPatch = Partial; + interface PersistedQueueSnapshot { version: 1; items: GenerationQueueItem[]; @@ -53,9 +56,14 @@ function persistQueue(items: GenerationQueueItem[]): void { interface GenerationStoreState { queue: GenerationQueueItem[]; + tasks: WebGenerationPreviewTask[]; addTask: (item: GenerationQueueItem) => void; updateTask: (id: string, patch: Partial) => void; removeTask: (id: string) => void; + setTasks: (tasks: WebGenerationPreviewTask[]) => void; + appendTask: (task: WebGenerationPreviewTask) => void; + mergeServerTasks: (serverTasks: WebGenerationPreviewTask[]) => void; + clearTasks: () => void; getRunningTasks: () => GenerationQueueItem[]; getPendingTasks: () => GenerationQueueItem[]; getTasksByView: (sourceView: string) => GenerationQueueItem[]; @@ -64,14 +72,87 @@ interface GenerationStoreState { const initialQueue = loadPersistedQueue(); +function trimTasks(tasks: WebGenerationPreviewTask[]): WebGenerationPreviewTask[] { + return tasks.slice(0, MAX_ITEMS); +} + +function mergePreviewTaskById( + tasks: WebGenerationPreviewTask[], + taskId: string | undefined, + patch: PreviewTaskPatch, +): WebGenerationPreviewTask[] { + if (!taskId) return tasks; + let changed = false; + const next = tasks.map((task) => { + if (task.id !== taskId) return task; + changed = true; + return { ...task, ...patch }; + }); + return changed ? next : tasks; +} + +function toPreviewTaskStatus(status: GenerationQueueItem["status"]): WebGenerationPreviewTask["status"] { + if (status === "pending") return "queued"; + if (status === "cancelled") return "failed"; + return status; +} + +function toPreviewTaskPatch(item: GenerationQueueItem): PreviewTaskPatch { + const status = toPreviewTaskStatus(item.status); + + return { + status, + progress: item.status === "completed" ? 100 : item.progress, + outputUrl: item.resultUrl || undefined, + errorMessage: item.error || undefined, + }; +} + +function toPreviewTask(item: GenerationQueueItem): WebGenerationPreviewTask | null { + if (item.type === "ecommerce-video") return null; + const type = item.type; + const createdAt = Number.isFinite(item.createdAt) + ? new Date(item.createdAt).toISOString() + : new Date().toISOString(); + + return { + id: item.taskId || item.id, + title: item.title, + type, + status: toPreviewTaskStatus(item.status), + progress: item.status === "completed" ? 100 : item.progress, + prompt: item.prompt, + createdAt, + projectId: + typeof item.params?.projectId === "string" ? item.params.projectId : undefined, + outputUrl: item.resultUrl || undefined, + source: "preview", + errorMessage: item.error || undefined, + }; +} + +function upsertPreviewTask( + tasks: WebGenerationPreviewTask[], + task: WebGenerationPreviewTask | null, +): WebGenerationPreviewTask[] { + if (!task) return tasks; + return trimTasks([task, ...tasks.filter((item) => item.id !== task.id)]); +} + +function previewTaskIdsForItem(item: GenerationQueueItem): string[] { + return Array.from(new Set([item.taskId, item.id].filter(Boolean) as string[])); +} + export const useGenerationStore = create((set, get) => ({ queue: initialQueue, + tasks: [], addTask: (item) => { set((state) => { const next = [item, ...state.queue].slice(0, MAX_ITEMS); + const previewTasks = upsertPreviewTask(state.tasks, toPreviewTask(item)); persistQueue(next.filter((i) => i.status === "pending" || i.status === "running")); - return { queue: next }; + return { queue: next, tasks: previewTasks }; }); }, @@ -80,8 +161,16 @@ export const useGenerationStore = create((set, get) => ({ const next = state.queue.map((item) => item.id === id ? { ...item, ...patch } : item, ); + const updated = next.find((item) => item.id === id); + let previewTasks = state.tasks; + if (updated) { + const previewPatch = toPreviewTaskPatch(updated); + for (const previewTaskId of previewTaskIdsForItem(updated)) { + previewTasks = mergePreviewTaskById(previewTasks, previewTaskId, previewPatch); + } + } persistQueue(next.filter((i) => i.status === "pending" || i.status === "running")); - return { queue: next }; + return { queue: next, tasks: previewTasks }; }); }, @@ -93,6 +182,27 @@ export const useGenerationStore = create((set, get) => ({ }); }, + setTasks: (tasks) => set({ tasks: trimTasks(tasks) }), + + appendTask: (task) => set((state) => ({ + tasks: trimTasks([task, ...state.tasks.filter((item) => item.id !== task.id)]), + })), + + mergeServerTasks: (serverTasks) => set((state) => { + const serverIds = new Set(serverTasks.map((task) => task.id)); + return { + tasks: trimTasks([ + ...serverTasks, + ...state.tasks.filter((task) => !serverIds.has(task.id)), + ]), + }; + }), + + clearTasks: () => { + persistQueue([]); + set({ tasks: [], queue: [] }); + }, + getRunningTasks: () => get().queue.filter((i) => i.status === "running" || i.status === "pending"), getPendingTasks: () => get().queue.filter((i) => i.status === "pending"), getTasksByView: (sourceView) => get().queue.filter((i) => i.sourceView === sourceView), diff --git a/src/stores/useTaskStore.ts b/src/stores/useTaskStore.ts index d8d9368..510a458 100644 --- a/src/stores/useTaskStore.ts +++ b/src/stores/useTaskStore.ts @@ -1,36 +1 @@ -import { create } from 'zustand'; -import type { WebGenerationPreviewTask } from '../types'; - -interface TaskState { - tasks: WebGenerationPreviewTask[]; -} - -interface TaskActions { - setTasks: (tasks: WebGenerationPreviewTask[]) => void; - appendTask: (task: WebGenerationPreviewTask) => void; - mergeServerTasks: (serverTasks: WebGenerationPreviewTask[]) => void; - clearTasks: () => void; -} - -const initialState: TaskState = { - tasks: [], -}; - -export const useTaskStore = create((set) => ({ - ...initialState, - - setTasks: (tasks) => set({ tasks }), - - appendTask: (task) => set((state) => ({ - tasks: [task, ...state.tasks], - })), - - mergeServerTasks: (serverTasks) => set((state) => { - const serverIds = new Set(serverTasks.map((task) => task.id)); - return { - tasks: [...serverTasks, ...state.tasks.filter((task) => !serverIds.has(task.id))].slice(0, 80), - }; - }), - - clearTasks: () => set({ tasks: [] }), -})); +export { useGenerationStore as useTaskStore } from "./useGenerationStore";