Implements the design spec's §2 — "notes cached in zustand keyed by
project id" — which the plan substituted with a hook-local `useState`.
Sharing the `NotesPanel` *component* between the Project Home sub-tab and
the dock did not share the *cache*. Both resolve to the same project, so
two panels mount two `useNotes(P)`, each with its own list. Edit a note
in the dock and blur; the tab's copy is still pre-edit, and the tab's
next blur commits `{...staleRecord, title, body}` — the dock's edit gone
from disk with no error and no indicator. That is the feature's own
primary workflow: take notes in the dock while the agent runs, which is
the reason the dock exists, then go back to the tab.
`notesByProject` plus a per-project in-flight flag now hold the list.
Both surfaces render from one array; two panels mounting for one project
make one read; and because the write is keyed by project, a response
that lands after the user has moved on updates the project it belongs to
rather than whichever is on screen. This is also the boundary §8 says a
detached notes window needs.
Three more bugs in the same code, fixed with it:
- Delete-after-edit could resurrect the note. Clicking Delete with the
textarea focused fires blur first, so `save_note` and `delete_note` go
out back to back; Rust's `write_lock` stops them interleaving but does
not order them, and a delete that wins the lock is undone by the
upsert behind it. A project's mutations now go through one promise
chain, module-scoped for the reason `useTerminal`'s input queue is.
- An unsaved draft vanished when any other note was saved, because the
re-read replaced the list with the backend's. "New note" now persists,
so the backend owns the row from the start — chosen over merging local
drafts because a local-only row in a *shared* cache would exist in the
panel that made it and nowhere else.
- The save outcome was reported for the wrong project after a switch:
the guard covered only the list replacement, so the new project's
SaveIndicator flashed "Saved ✓" for the old project's write. The
indicator now resets on a project change and reports only its own.
`NotesPanel` also re-seeds its draft when the *stored* text of the note
it has selected changes, so an edit made in the other surface reaches
the editor and not only the list. It never overwrites something
half-typed; that still blurs into a last-writer-wins save, as any
blur-commit editor does.
NotesPanel.shared.test.tsx is the configuration none of the existing
tests had: two panels, one project, the real hook. Four of its six
assertions fail against the previous implementation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjL1E2JFNctUqCYotUwqqb
535 lines
18 KiB
TypeScript
535 lines
18 KiB
TypeScript
import { create } from "zustand";
|
|
import type {
|
|
Project,
|
|
TerminalSession,
|
|
AppSettings,
|
|
UpdateInfo,
|
|
ImageUpdateInfo,
|
|
Note,
|
|
} from "../lib/types";
|
|
|
|
const SIDEBAR_COLLAPSED_KEY = "triple-c.sidebar.collapsed";
|
|
|
|
function loadSidebarCollapsed(): boolean {
|
|
try {
|
|
return localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "1";
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function persistSidebarCollapsed(value: boolean) {
|
|
try {
|
|
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, value ? "1" : "0");
|
|
} catch {
|
|
// ignore — storage may be unavailable
|
|
}
|
|
}
|
|
|
|
const NOTES_DOCK_KEY = "triple-c.notes.dock";
|
|
const NOTES_DOCK_WIDTH_KEY = "triple-c.notes.dock.width";
|
|
|
|
/** Wide enough for a note, narrow enough to leave a usable terminal. */
|
|
export const NOTES_DOCK_MIN_WIDTH = 260;
|
|
export const NOTES_DOCK_MAX_WIDTH = 720;
|
|
export const NOTES_DOCK_DEFAULT_WIDTH = 352;
|
|
|
|
function loadNotesDockOpen(): boolean {
|
|
try {
|
|
return localStorage.getItem(NOTES_DOCK_KEY) === "1";
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function persistNotesDockOpen(value: boolean) {
|
|
try {
|
|
localStorage.setItem(NOTES_DOCK_KEY, value ? "1" : "0");
|
|
} catch {
|
|
// ignore — storage may be unavailable
|
|
}
|
|
}
|
|
|
|
/** Clamped on the way in as well as out: a stored value can be anything a
|
|
* previous version, a hand edit, or a different screen left behind. */
|
|
export function clampDockWidth(value: number): number {
|
|
if (!Number.isFinite(value)) return NOTES_DOCK_DEFAULT_WIDTH;
|
|
return Math.min(NOTES_DOCK_MAX_WIDTH, Math.max(NOTES_DOCK_MIN_WIDTH, Math.round(value)));
|
|
}
|
|
|
|
function loadNotesDockWidth(): number {
|
|
try {
|
|
const raw = localStorage.getItem(NOTES_DOCK_WIDTH_KEY);
|
|
return raw === null ? NOTES_DOCK_DEFAULT_WIDTH : clampDockWidth(Number(raw));
|
|
} catch {
|
|
return NOTES_DOCK_DEFAULT_WIDTH;
|
|
}
|
|
}
|
|
|
|
function persistNotesDockWidth(value: number) {
|
|
try {
|
|
localStorage.setItem(NOTES_DOCK_WIDTH_KEY, String(value));
|
|
} catch {
|
|
// ignore — storage may be unavailable
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The main area hosts two tab kinds — terminals and Project Home views — in a
|
|
* single ordered strip. Tabs are addressed by a string key so one array can
|
|
* hold both kinds.
|
|
*/
|
|
export const terminalTabKey = (sessionId: string) => `term:${sessionId}`;
|
|
export const homeTabKey = (projectId: string) => `home:${projectId}`;
|
|
export const isTerminalTab = (key: string) => key.startsWith("term:");
|
|
export const isHomeTab = (key: string) => key.startsWith("home:");
|
|
export const tabKeyId = (key: string) => key.slice(key.indexOf(":") + 1);
|
|
|
|
/** activeSessionId is derived from the active tab so exactly one thing is "current". */
|
|
function activation(activeTabKey: string | null) {
|
|
return {
|
|
activeTabKey,
|
|
activeSessionId:
|
|
activeTabKey && isTerminalTab(activeTabKey) ? tabKeyId(activeTabKey) : null,
|
|
};
|
|
}
|
|
|
|
export type ToastKind = "error" | "success" | "info";
|
|
|
|
export interface Toast {
|
|
id: string;
|
|
kind: ToastKind;
|
|
message: string;
|
|
/** Long text (e.g. a bollard error) shown behind a "Details" disclosure. */
|
|
detail?: string;
|
|
/**
|
|
* Optional identity for a *recurring* notice. Pushing another toast with the
|
|
* same key replaces the one already on screen instead of stacking a second
|
|
* copy of it — three refused file drops leave one card, not three.
|
|
*/
|
|
dedupeKey?: string;
|
|
}
|
|
|
|
let toastCounter = 0;
|
|
|
|
interface AppState {
|
|
// Projects
|
|
projects: Project[];
|
|
selectedProjectId: string | null;
|
|
setProjects: (projects: Project[]) => void;
|
|
setSelectedProject: (id: string | null) => void;
|
|
updateProjectInList: (project: Project) => void;
|
|
removeProjectFromList: (id: string) => void;
|
|
|
|
// Terminal sessions
|
|
sessions: TerminalSession[];
|
|
activeSessionId: string | null;
|
|
addSession: (session: TerminalSession) => void;
|
|
removeSession: (id: string) => void;
|
|
setActiveSession: (id: string | null) => void;
|
|
|
|
// Main-area tab strip (terminals + Project Home views)
|
|
tabOrder: string[];
|
|
activeTabKey: string | null;
|
|
openProjectHome: (projectId: string) => void;
|
|
/**
|
|
* Open a project's home tab *on a particular sub-tab*.
|
|
*
|
|
* The sub-tab is local state inside `ProjectHome`, so this parks a request
|
|
* here for it to pick up: an action taken somewhere else entirely — opening a
|
|
* page in the container's browser from a terminal — has to be able to land
|
|
* the user on the pane that shows the result.
|
|
*/
|
|
openProjectHomeTab: (projectId: string, tab: string) => void;
|
|
/** Consumed once by `ProjectHome`, then cleared. */
|
|
pendingHomeTab: { projectId: string; tab: string } | null;
|
|
clearPendingHomeTab: () => void;
|
|
closeHomeTab: (projectId: string) => void;
|
|
setActiveTabKey: (key: string) => void;
|
|
cycleTab: (delta: number) => void;
|
|
focusTabIndex: (index: number) => void;
|
|
/** Reorder: put `key` at `toIndex` in the strip. Never changes what's active. */
|
|
moveTab: (key: string, toIndex: number) => void;
|
|
/** Nudge the active tab left/right — the keyboard route to the same thing. */
|
|
moveActiveTab: (delta: number) => void;
|
|
|
|
// Per-project notes, cached from the backend.
|
|
//
|
|
// Rust is the source of truth and this is a cache — but it has to be *one*
|
|
// cache. Notes are shown by two surfaces at once (the Project Home sub-tab
|
|
// and the dock, which resolves to the same project), and a hook-local
|
|
// `useState` in each gave them independent copies: an edit made in the dock
|
|
// was invisible to the tab, and the tab's next blur wrote its stale record
|
|
// back over it with no error and no indicator. Keyed by project id so a
|
|
// response that lands after the user has moved on updates the project it
|
|
// belongs to instead of whichever one is on screen.
|
|
//
|
|
// This is also the boundary a detached notes window would need: swap the
|
|
// transport for a `notes-changed` event and both windows feed the same slice.
|
|
notesByProject: Record<string, Note[]>;
|
|
/**
|
|
* Projects with a `list_notes` in flight, so two panels mounting for the
|
|
* same project make one read rather than two, and so a panel whose project
|
|
* has never been read can tell "loading" from "no notes".
|
|
*/
|
|
notesLoading: Record<string, boolean>;
|
|
setProjectNotes: (projectId: string, notes: Note[]) => void;
|
|
setNotesLoading: (projectId: string, loading: boolean) => void;
|
|
|
|
// Inline container progress, replacing the blocking progress modal.
|
|
containerProgress: Record<string, string>;
|
|
setContainerProgress: (projectId: string, message: string | null) => void;
|
|
/** Wall-clock ms when a project was observed transitioning to "running". */
|
|
runningSince: Record<string, number>;
|
|
|
|
// Toasts
|
|
toasts: Toast[];
|
|
pushToast: (toast: Omit<Toast, "id">) => string;
|
|
dismissToast: (id: string) => void;
|
|
|
|
// UI state
|
|
terminalHasSelection: boolean;
|
|
setTerminalHasSelection: (has: boolean) => void;
|
|
// STT toggle for the active session, registered by App so the terminal's
|
|
// Ctrl+Shift+M shortcut can trigger the single status-bar mic instance.
|
|
sttToggle: () => void;
|
|
setSttToggle: (fn: () => void) => void;
|
|
// Active terminal scroll state, surfaced so the status bar can host the
|
|
// "Jump to Current" control. Only the active TerminalView writes these.
|
|
terminalAtBottom: boolean;
|
|
setTerminalAtBottom: (v: boolean) => void;
|
|
scrollActiveToBottom: () => void;
|
|
setScrollActiveToBottom: (fn: () => void) => void;
|
|
sidebarView: "projects" | "settings";
|
|
setSidebarView: (view: "projects" | "settings") => void;
|
|
sidebarCollapsed: boolean;
|
|
setSidebarCollapsed: (collapsed: boolean) => void;
|
|
toggleSidebarCollapsed: () => void;
|
|
/** The notes dock, visible over any tab including a terminal. */
|
|
notesDockOpen: boolean;
|
|
setNotesDockOpen: (open: boolean) => void;
|
|
toggleNotesDock: () => void;
|
|
/** Dock width in CSS px, clamped and persisted per machine. */
|
|
notesDockWidth: number;
|
|
setNotesDockWidth: (width: number) => void;
|
|
dockerAvailable: boolean | null;
|
|
setDockerAvailable: (available: boolean | null) => void;
|
|
imageExists: boolean | null;
|
|
setImageExists: (exists: boolean | null) => void;
|
|
// App settings
|
|
appSettings: AppSettings | null;
|
|
setAppSettings: (settings: AppSettings) => void;
|
|
|
|
// Update info
|
|
updateInfo: UpdateInfo | null;
|
|
setUpdateInfo: (info: UpdateInfo | null) => void;
|
|
appVersion: string;
|
|
setAppVersion: (version: string) => void;
|
|
|
|
// Image update info
|
|
imageUpdateInfo: ImageUpdateInfo | null;
|
|
setImageUpdateInfo: (info: ImageUpdateInfo | null) => void;
|
|
}
|
|
|
|
/** Track running-since transitions so Overview can show an uptime. */
|
|
function trackRunning(
|
|
previous: Project[],
|
|
next: Project[],
|
|
runningSince: Record<string, number>,
|
|
): Record<string, number> {
|
|
let changed = false;
|
|
const result = { ...runningSince };
|
|
const now = Date.now();
|
|
for (const project of next) {
|
|
const before = previous.find((p) => p.id === project.id);
|
|
if (project.status === "running") {
|
|
if (result[project.id] === undefined) {
|
|
result[project.id] = now;
|
|
changed = true;
|
|
}
|
|
} else if (before?.status === "running" || result[project.id] !== undefined) {
|
|
delete result[project.id];
|
|
changed = true;
|
|
}
|
|
}
|
|
return changed ? result : runningSince;
|
|
}
|
|
|
|
export const useAppState = create<AppState>((set) => ({
|
|
// Projects
|
|
projects: [],
|
|
selectedProjectId: null,
|
|
setProjects: (projects) =>
|
|
set((state) => ({
|
|
projects,
|
|
runningSince: trackRunning(state.projects, projects, state.runningSince),
|
|
})),
|
|
setSelectedProject: (id) => set({ selectedProjectId: id }),
|
|
updateProjectInList: (project) =>
|
|
set((state) => {
|
|
const projects = state.projects.map((p) =>
|
|
p.id === project.id ? project : p,
|
|
);
|
|
return {
|
|
projects,
|
|
runningSince: trackRunning(state.projects, projects, state.runningSince),
|
|
};
|
|
}),
|
|
removeProjectFromList: (id) =>
|
|
set((state) => {
|
|
const key = homeTabKey(id);
|
|
const tabOrder = state.tabOrder.filter((k) => k !== key);
|
|
const activeTabKey =
|
|
state.activeTabKey === key
|
|
? (tabOrder[tabOrder.length - 1] ?? null)
|
|
: state.activeTabKey;
|
|
return {
|
|
projects: state.projects.filter((p) => p.id !== id),
|
|
selectedProjectId:
|
|
state.selectedProjectId === id ? null : state.selectedProjectId,
|
|
tabOrder,
|
|
...activation(activeTabKey),
|
|
};
|
|
}),
|
|
|
|
// Terminal sessions
|
|
sessions: [],
|
|
activeSessionId: null,
|
|
addSession: (session) =>
|
|
set((state) => {
|
|
const key = terminalTabKey(session.id);
|
|
return {
|
|
sessions: [...state.sessions, session],
|
|
tabOrder: state.tabOrder.includes(key)
|
|
? state.tabOrder
|
|
: [...state.tabOrder, key],
|
|
...activation(key),
|
|
};
|
|
}),
|
|
removeSession: (id) =>
|
|
set((state) => {
|
|
const key = terminalTabKey(id);
|
|
const index = state.tabOrder.indexOf(key);
|
|
const tabOrder = state.tabOrder.filter((k) => k !== key);
|
|
const activeTabKey =
|
|
state.activeTabKey === key
|
|
? (tabOrder[Math.min(Math.max(index, 0), tabOrder.length - 1)] ?? null)
|
|
: state.activeTabKey;
|
|
return {
|
|
sessions: state.sessions.filter((s) => s.id !== id),
|
|
tabOrder,
|
|
...activation(activeTabKey),
|
|
};
|
|
}),
|
|
setActiveSession: (id) =>
|
|
set(() => activation(id === null ? null : terminalTabKey(id))),
|
|
|
|
// Main-area tabs
|
|
tabOrder: [],
|
|
activeTabKey: null,
|
|
openProjectHome: (projectId) =>
|
|
set((state) => {
|
|
const key = homeTabKey(projectId);
|
|
return {
|
|
selectedProjectId: projectId,
|
|
tabOrder: state.tabOrder.includes(key)
|
|
? state.tabOrder
|
|
: [...state.tabOrder, key],
|
|
...activation(key),
|
|
};
|
|
}),
|
|
openProjectHomeTab: (projectId, tab) =>
|
|
set((state) => {
|
|
const key = homeTabKey(projectId);
|
|
return {
|
|
selectedProjectId: projectId,
|
|
tabOrder: state.tabOrder.includes(key)
|
|
? state.tabOrder
|
|
: [...state.tabOrder, key],
|
|
pendingHomeTab: { projectId, tab },
|
|
...activation(key),
|
|
};
|
|
}),
|
|
pendingHomeTab: null,
|
|
clearPendingHomeTab: () => set({ pendingHomeTab: null }),
|
|
closeHomeTab: (projectId) =>
|
|
set((state) => {
|
|
const key = homeTabKey(projectId);
|
|
const index = state.tabOrder.indexOf(key);
|
|
if (index === -1) return {};
|
|
const tabOrder = state.tabOrder.filter((k) => k !== key);
|
|
const activeTabKey =
|
|
state.activeTabKey === key
|
|
? (tabOrder[Math.min(index, tabOrder.length - 1)] ?? null)
|
|
: state.activeTabKey;
|
|
return { tabOrder, ...activation(activeTabKey) };
|
|
}),
|
|
setActiveTabKey: (key) =>
|
|
set((state) => {
|
|
if (!state.tabOrder.includes(key)) return {};
|
|
const patch = activation(key);
|
|
return isHomeTab(key)
|
|
? { ...patch, selectedProjectId: tabKeyId(key) }
|
|
: patch;
|
|
}),
|
|
cycleTab: (delta) =>
|
|
set((state) => {
|
|
if (state.tabOrder.length === 0) return {};
|
|
const current = state.activeTabKey
|
|
? state.tabOrder.indexOf(state.activeTabKey)
|
|
: -1;
|
|
const next =
|
|
(current + delta + state.tabOrder.length) % state.tabOrder.length;
|
|
const key = state.tabOrder[next];
|
|
const patch = activation(key);
|
|
return isHomeTab(key)
|
|
? { ...patch, selectedProjectId: tabKeyId(key) }
|
|
: patch;
|
|
}),
|
|
focusTabIndex: (index) =>
|
|
set((state) => {
|
|
const key = state.tabOrder[index];
|
|
if (!key) return {};
|
|
const patch = activation(key);
|
|
return isHomeTab(key)
|
|
? { ...patch, selectedProjectId: tabKeyId(key) }
|
|
: patch;
|
|
}),
|
|
// Reordering is deliberately *only* a reordering: dragging a tab does not
|
|
// select it, so a drag can be aimed at a background tab without yanking the
|
|
// main area (and a running terminal's focus) away mid-gesture.
|
|
moveTab: (key, toIndex) =>
|
|
set((state) => {
|
|
const from = state.tabOrder.indexOf(key);
|
|
if (from === -1) return {};
|
|
const to = Math.max(0, Math.min(toIndex, state.tabOrder.length - 1));
|
|
if (from === to) return {};
|
|
const tabOrder = [...state.tabOrder];
|
|
tabOrder.splice(from, 1);
|
|
tabOrder.splice(to, 0, key);
|
|
return { tabOrder };
|
|
}),
|
|
moveActiveTab: (delta) =>
|
|
set((state) => {
|
|
const key = state.activeTabKey;
|
|
if (!key) return {};
|
|
const from = state.tabOrder.indexOf(key);
|
|
if (from === -1) return {};
|
|
// Clamped, not wrapped: a tab dragged off the end would otherwise
|
|
// reappear at the other end, which reads as a bug on a held-down key.
|
|
const to = Math.max(0, Math.min(from + delta, state.tabOrder.length - 1));
|
|
if (from === to) return {};
|
|
const tabOrder = [...state.tabOrder];
|
|
tabOrder.splice(from, 1);
|
|
tabOrder.splice(to, 0, key);
|
|
return { tabOrder };
|
|
}),
|
|
|
|
// Notes
|
|
notesByProject: {},
|
|
notesLoading: {},
|
|
setProjectNotes: (projectId, notes) =>
|
|
set((state) => ({
|
|
notesByProject: { ...state.notesByProject, [projectId]: notes },
|
|
})),
|
|
setNotesLoading: (projectId, loading) =>
|
|
set((state) => {
|
|
if ((state.notesLoading[projectId] ?? false) === loading) return {};
|
|
const next = { ...state.notesLoading };
|
|
if (loading) next[projectId] = true;
|
|
else delete next[projectId];
|
|
return { notesLoading: next };
|
|
}),
|
|
|
|
// Container progress
|
|
containerProgress: {},
|
|
setContainerProgress: (projectId, message) =>
|
|
set((state) => {
|
|
if (message === null) {
|
|
if (state.containerProgress[projectId] === undefined) return {};
|
|
const next = { ...state.containerProgress };
|
|
delete next[projectId];
|
|
return { containerProgress: next };
|
|
}
|
|
if (state.containerProgress[projectId] === message) return {};
|
|
return {
|
|
containerProgress: { ...state.containerProgress, [projectId]: message },
|
|
};
|
|
}),
|
|
runningSince: {},
|
|
|
|
// Toasts
|
|
toasts: [],
|
|
pushToast: (toast) => {
|
|
const id = `toast-${++toastCounter}`;
|
|
set((state) => {
|
|
// A keyed toast supersedes the previous one with that key. The new card
|
|
// gets a new id, so it re-mounts and its dismissal timer restarts.
|
|
const kept = toast.dedupeKey
|
|
? state.toasts.filter((t) => t.dedupeKey !== toast.dedupeKey)
|
|
: state.toasts;
|
|
return { toasts: [...kept, { ...toast, id }] };
|
|
});
|
|
return id;
|
|
},
|
|
dismissToast: (id) =>
|
|
set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id) })),
|
|
|
|
// UI state
|
|
terminalHasSelection: false,
|
|
setTerminalHasSelection: (has) => set({ terminalHasSelection: has }),
|
|
sttToggle: () => {},
|
|
setSttToggle: (fn) => set({ sttToggle: fn }),
|
|
terminalAtBottom: true,
|
|
setTerminalAtBottom: (v) => set({ terminalAtBottom: v }),
|
|
scrollActiveToBottom: () => {},
|
|
setScrollActiveToBottom: (fn) => set({ scrollActiveToBottom: fn }),
|
|
sidebarView: "projects",
|
|
setSidebarView: (view) => set({ sidebarView: view }),
|
|
sidebarCollapsed: loadSidebarCollapsed(),
|
|
setSidebarCollapsed: (collapsed) => {
|
|
persistSidebarCollapsed(collapsed);
|
|
set({ sidebarCollapsed: collapsed });
|
|
},
|
|
toggleSidebarCollapsed: () =>
|
|
set((state) => {
|
|
const next = !state.sidebarCollapsed;
|
|
persistSidebarCollapsed(next);
|
|
return { sidebarCollapsed: next };
|
|
}),
|
|
notesDockOpen: loadNotesDockOpen(),
|
|
setNotesDockOpen: (open) => {
|
|
persistNotesDockOpen(open);
|
|
set({ notesDockOpen: open });
|
|
},
|
|
toggleNotesDock: () =>
|
|
set((state) => {
|
|
const open = !state.notesDockOpen;
|
|
persistNotesDockOpen(open);
|
|
return { notesDockOpen: open };
|
|
}),
|
|
notesDockWidth: loadNotesDockWidth(),
|
|
setNotesDockWidth: (width) => {
|
|
const clamped = clampDockWidth(width);
|
|
persistNotesDockWidth(clamped);
|
|
set({ notesDockWidth: clamped });
|
|
},
|
|
dockerAvailable: null,
|
|
setDockerAvailable: (available) => set({ dockerAvailable: available }),
|
|
imageExists: null,
|
|
setImageExists: (exists) => set({ imageExists: exists }),
|
|
// App settings
|
|
appSettings: null,
|
|
setAppSettings: (settings) => set({ appSettings: settings }),
|
|
|
|
// Update info
|
|
updateInfo: null,
|
|
setUpdateInfo: (info) => set({ updateInfo: info }),
|
|
appVersion: "",
|
|
setAppVersion: (version) => set({ appVersion: version }),
|
|
|
|
// Image update info
|
|
imageUpdateInfo: null,
|
|
setImageUpdateInfo: (info) => set({ imageUpdateInfo: info }),
|
|
}));
|