Files
openchamber/packages/ui/src/stores/useProjectContextStore.ts
T

451 lines
15 KiB
TypeScript
Raw Normal View History

/**
* Project context store: notes, todos, and plan links, keyed by project.
*
* Replaces the `openchamber:project-notes-updated` / `openchamber:project-plan-saved`
* window events that previously forced every mounted panel to re-read the whole
* config. Writers now mutate the store and every reader re-renders from it.
*
* Storage is server-owned; this store is a cache with optimistic mutations.
* See `packages/web/server/lib/project-context/DOCUMENTATION.md`.
*/
import { create } from 'zustand';
import {
createProjectNote,
createProjectPlan,
deleteProjectNote,
deleteProjectPlan,
fetchProjectContext,
resolveProjectContextId,
saveProjectTodos,
setProjectPlanPinned,
updateProjectNote,
updateProjectPlan,
type ProjectNote,
type ProjectNoteSource,
type ProjectPlanLink,
type ProjectRef,
type ProjectTodoItem,
} from '@/lib/projectContextApi';
interface ProjectContextEntry {
notes: ProjectNote[];
todos: ProjectTodoItem[];
plans: ProjectPlanLink[];
/** True once an authoritative load has succeeded at least once. */
loaded: boolean;
loading: boolean;
/** Last load or save failure. Never clears cached data on its own. */
error: string | null;
}
interface MutationFlags {
/** A note write is in flight; a slower load must not overwrite the list. */
notes: boolean;
/** A todo write is in flight; same rule. */
todos: boolean;
/** A plan write is in flight; same rule. */
plans: boolean;
}
interface ProjectContextState {
entries: Record<string, ProjectContextEntry>;
}
interface ProjectContextActions {
getEntry: (project: ProjectRef | null | undefined) => ProjectContextEntry;
load: (project: ProjectRef, options?: { force?: boolean }) => Promise<void>;
saveTodos: (project: ProjectRef, todos: ProjectTodoItem[]) => Promise<boolean>;
createNote: (
project: ProjectRef,
value: { body: string; source?: ProjectNoteSource; origin?: { sessionId: string; messageId?: string } },
) => Promise<ProjectNote | null>;
saveNoteBody: (project: ProjectRef, noteId: string, body: string) => Promise<boolean>;
setNotePinned: (project: ProjectRef, noteId: string, pinned: boolean) => Promise<boolean>;
deleteNote: (project: ProjectRef, noteId: string) => Promise<boolean>;
createPlan: (project: ProjectRef, value: { title: string; body: string }) => Promise<ProjectPlanLink | null>;
savePlan: (project: ProjectRef, planId: string, raw: string) => Promise<boolean>;
setPlanPinned: (project: ProjectRef, planId: string, pinned: boolean) => Promise<boolean>;
deletePlan: (project: ProjectRef, planId: string) => Promise<boolean>;
reset: () => void;
}
type ProjectContextStore = ProjectContextState & ProjectContextActions;
export const EMPTY_PROJECT_CONTEXT_ENTRY: ProjectContextEntry = {
notes: [],
todos: [],
plans: [],
loaded: false,
loading: false,
error: null,
};
/**
* Per-project write chains and in-flight mutation flags.
*
* Kept outside the store because they are coordination state, not rendered
* state: putting them in the store would re-render every consumer whenever a
* write starts or finishes.
*/
const writeChains = new Map<string, Promise<unknown>>();
const mutationFlags = new Map<string, MutationFlags>();
const flagsFor = (projectId: string): MutationFlags => {
const existing = mutationFlags.get(projectId);
if (existing) return existing;
const created: MutationFlags = { notes: false, todos: false, plans: false };
mutationFlags.set(projectId, created);
return created;
};
/**
* Serialize writes per project so two saves cannot interleave into a
* last-writer-wins race against the server's own read-modify-write.
*/
const enqueueWrite = <T>(projectId: string, operation: () => Promise<T>): Promise<T> => {
const previous = writeChains.get(projectId) ?? Promise.resolve();
const next = previous.then(operation, operation);
writeChains.set(projectId, next.catch(() => undefined));
return next;
};
const errorMessage = (error: unknown, fallback: string): string => (
error instanceof Error && error.message ? error.message : fallback
);
export const useProjectContextStore = create<ProjectContextStore>((set, get) => {
const patchEntry = (projectId: string, patch: Partial<ProjectContextEntry>) => {
set((state) => ({
entries: {
...state.entries,
[projectId]: { ...(state.entries[projectId] ?? EMPTY_PROJECT_CONTEXT_ENTRY), ...patch },
},
}));
};
const currentEntry = (projectId: string): ProjectContextEntry => (
get().entries[projectId] ?? EMPTY_PROJECT_CONTEXT_ENTRY
);
return {
entries: {},
getEntry: (project) => {
const projectId = resolveProjectContextId(project);
if (!projectId) return EMPTY_PROJECT_CONTEXT_ENTRY;
return get().entries[projectId] ?? EMPTY_PROJECT_CONTEXT_ENTRY;
},
/**
* Load authoritative context.
*
* A failure sets `error` and leaves any previously loaded data in place:
* an unreachable server must not read as "this project has no notes",
* which is exactly how a user loses trust in a notes panel.
*/
load: async (project, options = {}) => {
const projectId = resolveProjectContextId(project);
if (!projectId) return;
const entry = currentEntry(projectId);
if (entry.loading) return;
if (entry.loaded && !options.force) return;
patchEntry(projectId, { loading: true });
try {
const data = await fetchProjectContext(project);
const flags = flagsFor(projectId);
const committed = currentEntry(projectId);
// A mutation that started after this load began is newer than the
// snapshot; keep the local value for that field group only.
patchEntry(projectId, {
notes: flags.notes ? committed.notes : data.notes,
todos: flags.todos ? committed.todos : data.todos,
plans: flags.plans ? committed.plans : data.plans,
loaded: true,
loading: false,
error: null,
});
} catch (error) {
patchEntry(projectId, {
loading: false,
error: errorMessage(error, 'Failed to load project context'),
});
}
},
/**
* Optimistically apply todos, then persist.
*
* On failure the previous list is restored, so the panel never shows a
* state that is not on disk without also showing the error.
*/
saveTodos: async (project, todos) => {
const projectId = resolveProjectContextId(project);
if (!projectId) return false;
const previous = currentEntry(projectId).todos;
patchEntry(projectId, { todos, error: null });
const flags = flagsFor(projectId);
flags.todos = true;
try {
const committed = await enqueueWrite(projectId, () => saveProjectTodos(project, todos));
patchEntry(projectId, { todos: committed.todos, loaded: true });
return true;
} catch (error) {
patchEntry(projectId, {
todos: previous,
error: errorMessage(error, 'Failed to save project todos'),
});
return false;
} finally {
flags.todos = false;
}
},
/**
* Create a note. Not optimistic: the id and timestamps come from the
* server, and a placeholder row that cannot be edited or pinned is worse
* than a brief wait.
*
* The caller may be a chat action running while the panel is not mounted,
* so the committed list is adopted wholesale rather than spliced into a
* possibly-empty local one.
*/
createNote: async (project, value) => {
const projectId = resolveProjectContextId(project);
const body = value.body.trim();
if (!projectId || !body) return null;
const flags = flagsFor(projectId);
flags.notes = true;
try {
const { note, context } = await enqueueWrite(
projectId,
() => createProjectNote(project, { ...value, body }),
);
patchEntry(projectId, { notes: context.notes, loaded: true, error: null });
return note;
} catch (error) {
patchEntry(projectId, { error: errorMessage(error, 'Failed to create note') });
return null;
} finally {
flags.notes = false;
}
},
saveNoteBody: async (project, noteId, body) => {
const projectId = resolveProjectContextId(project);
const trimmed = body.trim();
if (!projectId || !trimmed) return false;
const previous = currentEntry(projectId).notes;
patchEntry(projectId, {
notes: previous.map((note) => (note.id === noteId ? { ...note, body: trimmed } : note)),
error: null,
});
const flags = flagsFor(projectId);
flags.notes = true;
try {
const saved = await enqueueWrite(projectId, () => updateProjectNote(project, noteId, { body: trimmed }));
if (!saved) {
patchEntry(projectId, { notes: currentEntry(projectId).notes.filter((note) => note.id !== noteId) });
return false;
}
patchEntry(projectId, {
notes: currentEntry(projectId).notes.map((note) => (note.id === noteId ? saved : note)),
});
return true;
} catch (error) {
patchEntry(projectId, { notes: previous, error: errorMessage(error, 'Failed to save note') });
return false;
} finally {
flags.notes = false;
}
},
/** Sends `pinned` alone, so it cannot roll back a concurrent body edit. */
setNotePinned: async (project, noteId, pinned) => {
const projectId = resolveProjectContextId(project);
if (!projectId) return false;
const previous = currentEntry(projectId).notes;
patchEntry(projectId, {
notes: previous.map((note) => (note.id === noteId ? { ...note, pinned } : note)),
error: null,
});
const flags = flagsFor(projectId);
flags.notes = true;
try {
const saved = await enqueueWrite(projectId, () => updateProjectNote(project, noteId, { pinned }));
if (!saved) {
patchEntry(projectId, { notes: currentEntry(projectId).notes.filter((note) => note.id !== noteId) });
return false;
}
patchEntry(projectId, {
notes: currentEntry(projectId).notes.map((note) => (note.id === noteId ? saved : note)),
});
return true;
} catch (error) {
patchEntry(projectId, { notes: previous, error: errorMessage(error, 'Failed to save note') });
return false;
} finally {
flags.notes = false;
}
},
deleteNote: async (project, noteId) => {
const projectId = resolveProjectContextId(project);
if (!projectId) return false;
const previous = currentEntry(projectId).notes;
patchEntry(projectId, { notes: previous.filter((note) => note.id !== noteId), error: null });
const flags = flagsFor(projectId);
flags.notes = true;
try {
const context = await enqueueWrite(projectId, () => deleteProjectNote(project, noteId));
patchEntry(projectId, { notes: context.notes });
return true;
} catch (error) {
patchEntry(projectId, { notes: previous, error: errorMessage(error, 'Failed to delete note') });
return false;
} finally {
flags.notes = false;
}
},
/**
* Create a plan. Not optimistic: the id and file name are assigned by the
* server, and a placeholder row that cannot be opened is worse than a
* short wait.
*/
createPlan: async (project, value) => {
const projectId = resolveProjectContextId(project);
if (!projectId) return null;
const flags = flagsFor(projectId);
flags.plans = true;
try {
const { plan, context } = await enqueueWrite(projectId, () => createProjectPlan(project, value));
patchEntry(projectId, { plans: context.plans, loaded: true, error: null });
return plan;
} catch (error) {
patchEntry(projectId, { error: errorMessage(error, 'Failed to create plan') });
return null;
} finally {
flags.plans = false;
}
},
/**
* Persist an edited plan and fold the refreshed title back into the list,
* so renaming a plan's heading in the editor is reflected in the panel
* without a reload. Resolves false when the plan is gone.
*/
savePlan: async (project, planId, raw) => {
const projectId = resolveProjectContextId(project);
if (!projectId) return false;
const flags = flagsFor(projectId);
flags.plans = true;
try {
const result = await enqueueWrite(projectId, () => updateProjectPlan(project, planId, raw));
if (!result) {
patchEntry(projectId, {
plans: currentEntry(projectId).plans.filter((plan) => plan.id !== planId),
});
return false;
}
patchEntry(projectId, {
plans: currentEntry(projectId).plans.map((plan) => (plan.id === planId ? result.plan : plan)),
error: null,
});
return true;
} catch (error) {
patchEntry(projectId, { error: errorMessage(error, 'Failed to save plan') });
return false;
} finally {
flags.plans = false;
}
},
setPlanPinned: async (project, planId, pinned) => {
const projectId = resolveProjectContextId(project);
if (!projectId) return false;
const previous = currentEntry(projectId).plans;
patchEntry(projectId, {
plans: previous.map((plan) => (plan.id === planId ? { ...plan, pinned } : plan)),
error: null,
});
const flags = flagsFor(projectId);
flags.plans = true;
try {
const saved = await enqueueWrite(projectId, () => setProjectPlanPinned(project, planId, pinned));
if (!saved) {
patchEntry(projectId, { plans: currentEntry(projectId).plans.filter((plan) => plan.id !== planId) });
return false;
}
patchEntry(projectId, {
plans: currentEntry(projectId).plans.map((plan) => (plan.id === planId ? saved : plan)),
});
return true;
} catch (error) {
patchEntry(projectId, { plans: previous, error: errorMessage(error, 'Failed to update plan') });
return false;
} finally {
flags.plans = false;
}
},
deletePlan: async (project, planId) => {
const projectId = resolveProjectContextId(project);
if (!projectId) return false;
const previous = currentEntry(projectId);
patchEntry(projectId, { plans: previous.plans.filter((plan) => plan.id !== planId), error: null });
const flags = flagsFor(projectId);
flags.plans = true;
try {
const context = await enqueueWrite(projectId, () => deleteProjectPlan(project, planId));
patchEntry(projectId, { plans: context.plans });
return true;
} catch (error) {
patchEntry(projectId, {
plans: previous.plans,
error: errorMessage(error, 'Failed to delete plan'),
});
return false;
} finally {
flags.plans = false;
}
},
/** Drop every cached project. Used when the active runtime changes. */
reset: () => {
writeChains.clear();
mutationFlags.clear();
set({ entries: {} });
},
};
});