Files
openchamber/packages/ui/src/lib/projectContextApi.ts
T
Bohdan Triapitsyn 43c4cc625f fix(ui): open saved plans against their owning project
Saved Project knowledge plans opened as an empty editor whenever the
viewer could not resolve the owning project from the current directory:
managed chats (openchamber:chats is not a registered project), worktrees
outside the repo path, and plan tabs restored after a reload. Titles
still rendered because the list reads the manifest through the correct
owner.

- Thread the owner explicitly (savedProjectPlan = { projectRef, planId })
  from the panel, mobile surfaces, and persisted context tabs; PlanView
  no longer guesses the project.
- An unrecognized directory resolves to no owner instead of borrowing
  the active project's knowledge.
- Serialize plan writes per document (planSaveQueue) so close/switch
  within the autosave debounce no longer drops the last edits, saves
  cannot land out of order, and a recovered save clears the error banner.
- Send saved-plan contents inline in Improve/Implement prompts (they
  have no file path); disable those actions for managed-chat plans,
  which have no project directory to create a session in.
- Drop persisted plan tabs that carry an id without an owner rather than
  reopening them against a guessed project.
2026-08-27 20:18:12 +03:00

351 lines
11 KiB
TypeScript

/**
* Client for the OpenChamber project context routes.
*
* Notes, todos, and plan markdown are owned by the server
* (`packages/web/server/lib/project-context`). This module only speaks HTTP:
* it resolves no storage paths and never reads plan files directly, so the
* shared UI has no knowledge of where any of it lives on disk.
*
* Every function throws on failure. An authoritative read must never resolve
* to an empty value that a caller could mistake for "the project has nothing".
*/
import { createProjectIdFromPath } from './projectId';
import { runtimeFetch } from './runtime-fetch';
export interface ProjectTodoItem {
id: string;
text: string;
completed: boolean;
createdAt: number;
}
export interface ProjectPlanLink {
id: string;
file: string;
title: string;
createdAt: number;
pinned: boolean;
}
export type ProjectNoteSource = 'manual' | 'selection' | 'agent';
export interface ProjectNote {
id: string;
body: string;
createdAt: number;
updatedAt: number;
source: ProjectNoteSource;
pinned: boolean;
/** The message this note was distilled from, when it came from a chat. */
origin?: { sessionId: string; messageId?: string };
}
interface ProjectContextData {
notes: ProjectNote[];
todos: ProjectTodoItem[];
plans: ProjectPlanLink[];
}
interface ProjectPlanContent extends ProjectPlanLink {
body: string;
raw: string;
}
export interface ProjectRef {
id: string;
path: string;
}
/**
* A saved project plan plus the project that owns it, carried as one value so
* a viewer can never end up with a plan id whose owner it has to guess.
* PlanView resolves no owner on its own: the panel (or the persisted tab,
* or the mobile surface) that opened the plan knows the owner exactly.
*/
export interface SavedProjectPlanTarget {
projectRef: ProjectRef;
planId: string;
}
export const PROJECT_NOTE_BODY_MAX_LENGTH = 3000;
export const PROJECT_TODO_TEXT_MAX_LENGTH = 120;
/**
* Split a plan document into title and body, mirroring the server's own rule so
* an unsaved editor buffer and an imported file title exactly the way the
* stored file will.
*/
export const parsePlanMarkdown = (raw: string, fallback: string): { title: string; body: string } => {
const normalized = (typeof raw === 'string' ? raw : '').replace(/\r\n?/g, '\n');
const heading = normalized.match(/^\s*#\s+(.+?)\s*(?:\n+|$)/);
if (heading) {
return {
title: heading[1].trim() || fallback,
body: normalized.slice(heading[0].length).replace(/^\n+/, ''),
};
}
const firstLine = normalized.split('\n').map((line) => line.trim()).find(Boolean);
return {
title: firstLine ? firstLine.replace(/^#+\s*/, '').trim() || fallback : fallback,
body: normalized.trim(),
};
};
/**
* The storage id is derived from the project path, not from `project.id`.
* Project ids in settings have churned across versions; the path-derived id is
* what the server uses to name the config file, so both sides must agree on it.
*/
export const resolveProjectContextId = (project: ProjectRef | null | undefined): string => {
const projectPath = typeof project?.path === 'string' ? project.path.trim() : '';
if (!projectPath) {
return '';
}
return createProjectIdFromPath(projectPath);
};
const basePath = (projectId: string): string => `/api/project-context/${encodeURIComponent(projectId)}`;
const requireProjectId = (project: ProjectRef): string => {
const projectId = resolveProjectContextId(project);
if (!projectId) {
throw new Error('Project has no resolvable path');
}
return projectId;
};
const readErrorMessage = async (response: Response, fallback: string): Promise<string> => {
try {
const payload = await response.json() as { error?: unknown } | null;
if (payload && typeof payload.error === 'string' && payload.error.trim()) {
return payload.error;
}
} catch {
// Fall through to the generic message.
}
return `${fallback} (${response.status})`;
};
const parseContext = (payload: unknown): ProjectContextData => {
const record = payload as Partial<ProjectContextData> | null;
if (!record || typeof record !== 'object') {
throw new Error('Malformed project context response');
}
return {
notes: Array.isArray(record.notes) ? record.notes : [],
todos: Array.isArray(record.todos) ? record.todos : [],
plans: Array.isArray(record.plans) ? record.plans : [],
};
};
export const fetchProjectContext = async (
project: ProjectRef,
options: { signal?: AbortSignal } = {},
): Promise<ProjectContextData> => {
const response = await runtimeFetch(basePath(requireProjectId(project)), {
cache: 'no-store',
signal: options.signal,
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Failed to load project context'));
}
return parseContext(await response.json());
};
export const saveProjectTodos = async (
project: ProjectRef,
todos: ProjectTodoItem[],
): Promise<ProjectContextData> => {
const response = await runtimeFetch(`${basePath(requireProjectId(project))}/todos`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ todos }),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Failed to save project todos'));
}
return parseContext(await response.json());
};
export const createProjectNote = async (
project: ProjectRef,
value: { body: string; source?: ProjectNoteSource; origin?: { sessionId: string; messageId?: string } },
): Promise<{ note: ProjectNote; context: ProjectContextData }> => {
const response = await runtimeFetch(`${basePath(requireProjectId(project))}/notes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
body: value.body,
...(value.source ? { source: value.source } : {}),
...(value.origin ? { origin: value.origin } : {}),
}),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Failed to create note'));
}
const payload = await response.json() as { note?: ProjectNote; context?: unknown };
if (!payload?.note) {
throw new Error('Malformed note create response');
}
return { note: payload.note, context: parseContext(payload.context) };
};
/**
* Patch a note. Only the supplied fields are sent, so pinning cannot roll back
* an edit that landed between the two requests.
*
* Resolves `null` when the note is gone.
*/
export const updateProjectNote = async (
project: ProjectRef,
noteId: string,
patch: { body?: string; pinned?: boolean },
): Promise<ProjectNote | null> => {
const response = await runtimeFetch(
`${basePath(requireProjectId(project))}/notes/${encodeURIComponent(noteId)}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
},
);
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Failed to save note'));
}
const payload = await response.json() as { note?: ProjectNote };
if (!payload?.note) {
throw new Error('Malformed note save response');
}
return payload.note;
};
export const deleteProjectNote = async (
project: ProjectRef,
noteId: string,
): Promise<ProjectContextData> => {
const response = await runtimeFetch(
`${basePath(requireProjectId(project))}/notes/${encodeURIComponent(noteId)}`,
{ method: 'DELETE' },
);
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Failed to delete note'));
}
return parseContext(await response.json());
};
/** Resolves `null` when the plan is gone. */
export const setProjectPlanPinned = async (
project: ProjectRef,
planId: string,
pinned: boolean,
): Promise<ProjectPlanLink | null> => {
const response = await runtimeFetch(
`${basePath(requireProjectId(project))}/plans/${encodeURIComponent(planId)}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pinned }),
},
);
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Failed to update plan'));
}
const payload = await response.json() as { plan?: ProjectPlanLink };
return payload?.plan ?? null;
};
/**
* Plans are addressed by id. The caller supplies content, never a path, so a
* plan can only ever be created inside the project's own plans directory.
*/
export const createProjectPlan = async (
project: ProjectRef,
value: { title: string; body: string },
): Promise<{ plan: ProjectPlanLink; context: ProjectContextData }> => {
const response = await runtimeFetch(`${basePath(requireProjectId(project))}/plans`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: value.title, body: value.body }),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Failed to create plan'));
}
const payload = await response.json() as { plan?: ProjectPlanLink; context?: unknown };
if (!payload?.plan) {
throw new Error('Malformed plan create response');
}
return { plan: payload.plan, context: parseContext(payload.context) };
};
/** Resolves `null` only when the plan or its markdown is genuinely gone. */
export const fetchProjectPlan = async (
project: ProjectRef,
planId: string,
options: { signal?: AbortSignal } = {},
): Promise<ProjectPlanContent | null> => {
const response = await runtimeFetch(
`${basePath(requireProjectId(project))}/plans/${encodeURIComponent(planId)}`,
{ cache: 'no-store', signal: options.signal },
);
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Failed to read plan'));
}
return await response.json() as ProjectPlanContent;
};
/**
* Overwrite a plan's markdown with the editor's exact buffer.
*
* Resolves `null` when the plan or its file is gone, so an editor open on a
* deleted plan reports that instead of silently recreating it.
*/
export const updateProjectPlan = async (
project: ProjectRef,
planId: string,
raw: string,
): Promise<{ plan: ProjectPlanLink; raw: string } | null> => {
const response = await runtimeFetch(
`${basePath(requireProjectId(project))}/plans/${encodeURIComponent(planId)}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ raw }),
},
);
if (response.status === 404) {
return null;
}
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Failed to save plan'));
}
const payload = await response.json() as { plan?: ProjectPlanLink; raw?: string };
if (!payload?.plan) {
throw new Error('Malformed plan save response');
}
return { plan: payload.plan, raw: typeof payload.raw === 'string' ? payload.raw : raw };
};
export const deleteProjectPlan = async (
project: ProjectRef,
planId: string,
): Promise<ProjectContextData> => {
const response = await runtimeFetch(
`${basePath(requireProjectId(project))}/plans/${encodeURIComponent(planId)}`,
{ method: 'DELETE' },
);
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Failed to delete plan'));
}
return parseContext(await response.json());
};