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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user