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
@@ -60,6 +60,18 @@ Leaving the section or the project closes it, so its editor never sits over a
list it no longer matches. Hosts that own a fullscreen plan surface (mobile)
still pass `onOpenPlan` and keep theirs.
The panel owns the only source of truth for which project a plan belongs to,
and it never lets the editor guess. `PlanView` receives the owner as
`savedProjectPlan={{ projectRef, planId }}` — load and autosave both go to that
exact project. An earlier version let the editor re-derive the project from the
current directory, which silently opened an empty document for plans stored
under the managed Chats owner (`openchamber:chats`), for plans opened from a
worktree the directory lookup missed, and for plan tabs restored after a
reload. Persisted plan tabs carry `projectPlanRef` for the same reason; a saved-plan
tab persisted with an id but no owner is dropped on rehydrate rather than
reopened against a guessed project. A plain session plan tab legitimately has
neither an id nor an owner and is kept.
## Pins belong to one session
Notes and plans are project data, but attaching one writes its id to the current
@@ -5,7 +5,7 @@ import { toast } from '@/components/ui';
import { Icon } from '@/components/icon/Icon';
import { requestFileAccess } from '@/lib/desktop';
import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
import { parsePlanMarkdown, type ProjectPlanLink, type ProjectRef } from '@/lib/projectContextApi';
import { parsePlanMarkdown, resolveProjectContextId, type ProjectPlanLink, type ProjectRef } from '@/lib/projectContextApi';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { cn } from '@/lib/utils';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
@@ -23,8 +23,9 @@ export const PlansSection: React.FC<{
plans: ProjectPlanLink[];
/** Panel-wide filter, matched against plan titles. */
query: string;
/** Hosts without a ContextPanel (mobile) render their own plan viewer. */
onOpenPlan?: (plan: { id: string; title: string }) => void;
/** Hosts without a ContextPanel (mobile) render their own plan viewer. The
plan carries its owner so the host viewer never guesses the project. */
onOpenPlan?: (plan: { id: string; title: string; projectRef: ProjectRef }) => void;
pinnedPlanIds: ReadonlySet<string>;
onTogglePinned: (planId: string, pinned: boolean) => Promise<boolean>;
}> = ({ projectRef, plans, query, onOpenPlan, pinnedPlanIds, onTogglePinned }) => {
@@ -155,7 +156,7 @@ export const PlansSection: React.FC<{
const handleOpenPlan = React.useCallback(
(plan: ProjectPlanLink) => {
if (onOpenPlan) {
onOpenPlan({ id: plan.id, title: plan.title });
onOpenPlan({ id: plan.id, title: plan.title, projectRef });
return;
}
const panelDirectory = currentDirectory?.trim() || projectRef.path.trim();
@@ -165,11 +166,15 @@ export const PlansSection: React.FC<{
openContextPanelTab(panelDirectory, {
mode: 'plan',
projectPlanId: plan.id,
dedupeKey: `plan:${plan.id}`,
projectPlanRef: projectRef,
// Storage identity is derived from the project path, not the settings
// id, so the tab identity uses the same derivation. Two projects
// sharing a settings id but not a path must not merge plan tabs.
dedupeKey: `plan:${resolveProjectContextId(projectRef)}:${plan.id}`,
label: plan.title,
});
},
[currentDirectory, onOpenPlan, openContextPanelTab, projectRef.path]
[currentDirectory, onOpenPlan, openContextPanelTab, projectRef]
);
return (
@@ -29,8 +29,9 @@ interface ProjectNotesTodoPanelProps {
canCreateWorktree?: boolean;
onActionComplete?: () => void;
/** When provided, opening a plan calls this instead of the desktop context
panel tab — hosts without ContextPanel (mobile) render their own viewer. */
onOpenPlan?: (plan: { id: string; title: string }) => void;
panel tab — hosts without ContextPanel (mobile) render their own viewer.
The plan carries its owner so the host's viewer cannot guess wrong. */
onOpenPlan?: (plan: { id: string; title: string; projectRef: ProjectRef }) => void;
className?: string;
}
@@ -501,10 +502,10 @@ export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
/>
) : null}
{activeTab === 'plans' && openPlan ? (
{activeTab === 'plans' && openPlan && projectRef ? (
<React.Suspense fallback={null}>
<PlanView
projectPlanId={openPlan.id}
savedProjectPlan={{ projectRef, planId: openPlan.id }}
onNavigatedToChat={() => setOpenPlan(null)}
/>
</React.Suspense>