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.
This commit is contained in:
Bohdan Triapitsyn
2026-08-27 20:18:12 +03:00
parent 03f4b5e3e0
commit 43c4cc625f
15 changed files with 826 additions and 125 deletions
@@ -45,4 +45,46 @@ describe('resolveProjectContextOwner', () => {
expect(owner).toEqual({ id: 'openchamber', path: '/workspace/openchamber' });
});
test('returns null for a recognized directory that owns nothing, instead of borrowing the active project', () => {
const owner = resolveProjectContextOwner({
projects,
worktreesByProject: new Map(),
directory: '/some/other/project',
activeProjectId: 'openchamber',
chatDraftOpen: false,
chatDraftTarget: 'project',
homeDirectory: '/Users/test',
});
expect(owner).toBeNull();
});
test('falls back to the active project only when there is no directory at all', () => {
const owner = resolveProjectContextOwner({
projects,
worktreesByProject: new Map(),
directory: null,
activeProjectId: 'openchamber',
chatDraftOpen: false,
chatDraftTarget: 'project',
homeDirectory: '/Users/test',
});
expect(owner).toEqual({ id: 'openchamber', path: '/workspace/openchamber' });
});
test('never falls back to the first project when the active project is unknown', () => {
const owner = resolveProjectContextOwner({
projects,
worktreesByProject: new Map(),
directory: null,
activeProjectId: 'missing-project',
chatDraftOpen: false,
chatDraftTarget: 'project',
homeDirectory: '/Users/test',
});
expect(owner).toBeNull();
});
});
@@ -47,7 +47,16 @@ export const resolveProjectContextOwner = ({
return { id: sessionProject.id, path: sessionProject.path };
}
const activeProject = projects.find((project) => project.id === activeProjectId) ?? projects[0] ?? null;
// A concrete directory that resolves to nothing owns nothing. Falling back
// to the active project here showed one project's knowledge under another
// project's name (the "plans open empty" bug), so the panel stays empty
// instead of lying. The active-project fallback is only for states with no
// directory at all, such as a new-session draft that has not landed yet.
if (normalizedDirectory) {
return null;
}
const activeProject = projects.find((project) => project.id === activeProjectId) ?? null;
return activeProject ? { id: activeProject.id, path: activeProject.path } : null;
};