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
+55 -17
View File
@@ -8,6 +8,7 @@ import type { DraftStarterRef } from '@/lib/draftStarters';
import { DEFAULT_MONO_FONT, DEFAULT_UI_FONT, type MonoFontOption, type UiFontOption } from '@/lib/fontOptions';
import { getStoredMobileKeyboardMode, type MobileKeyboardMode } from '@/lib/mobileKeyboardMode';
import type { TerminalShell } from '@/lib/api/types';
import type { ProjectRef } from '@/lib/projectContextApi';
import { useFilesViewTabsStore } from './useFilesViewTabsStore';
import { isWindowsArm64 } from '@/lib/platform';
import { isVSCodeRuntime } from '@/lib/desktop';
@@ -37,6 +38,10 @@ type ContextPanelTab = {
panel. Project plans are addressed by id because their markdown is
server-owned and has no client-visible path. */
projectPlanId: string | null;
/** The project that owns `projectPlanId`. Persisted with the tab so a
restored plan tab opens against its own project instead of guessing the
owner from whatever directory happens to be current. */
projectPlanRef: ProjectRef | null;
dedupeKey: string;
label: string | null;
sessionTitleFallback: string | null;
@@ -50,6 +55,7 @@ type ContextPanelTabDescriptor = {
mode: ContextPanelMode;
targetPath?: string | null;
projectPlanId?: string | null;
projectPlanRef?: ProjectRef | null;
dedupeKey?: string | null;
label?: string | null;
sessionTitleFallback?: string | null;
@@ -191,6 +197,18 @@ const normalizePendingDiffScope = (value: unknown): PendingDiffScope | null => {
return value === 'working' || value === 'staged' || value === 'turn' || value === 'branch' ? value : null;
};
/** A plan tab's owner must be a complete project reference or nothing; a
half-valid one is worse than none because it points the editor somewhere. */
const normalizeContextPanelProjectPlanRef = (value: unknown): ProjectRef | null => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
const candidate = value as { id?: unknown; path?: unknown };
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
const path = typeof candidate.path === 'string' ? candidate.path.trim() : '';
return id && path ? { id, path } : null;
};
const buildDefaultContextPanelTabDedupeKey = (mode: ContextPanelMode, targetPath: string | null): string => {
if (mode === 'file') {
return targetPath || mode;
@@ -240,6 +258,7 @@ const createContextPanelTab = (descriptor: ContextPanelTabDescriptor): ContextPa
projectPlanId: typeof descriptor.projectPlanId === 'string' && descriptor.projectPlanId.trim()
? descriptor.projectPlanId.trim()
: null,
projectPlanRef: normalizeContextPanelProjectPlanRef(descriptor.projectPlanRef),
dedupeKey,
label: normalizeContextTabLabel(descriptor.label),
sessionTitleFallback: normalizeContextTabLabel(descriptor.sessionTitleFallback),
@@ -300,6 +319,7 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => {
mode?: unknown;
targetPath?: unknown;
projectPlanId?: unknown;
projectPlanRef?: unknown;
dedupeKey?: unknown;
label?: unknown;
sessionTitleFallback?: unknown;
@@ -323,6 +343,19 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => {
}
const targetPath = normalizeContextTargetPath(typeof candidate.targetPath === 'string' ? candidate.targetPath : null);
const projectPlanId = typeof candidate.projectPlanId === 'string' && candidate.projectPlanId.trim()
? candidate.projectPlanId.trim()
: null;
const projectPlanRef = normalizeContextPanelProjectPlanRef(candidate.projectPlanRef);
// `mode: 'plan'` covers two documents: a saved Project knowledge plan
// (needs both the plan id and its owning project) and a plain session
// filesystem plan (has neither). Only the half-identified form — id
// without owner — is unopenable: the editor would have to guess the
// project from the current directory, which is exactly the bug that made
// saved plans open empty. Such tabs are dropped rather than resurrected.
if (candidate.mode === 'plan' && (projectPlanId !== null) !== (projectPlanRef !== null)) {
continue;
}
const dedupeKey = normalizeContextPanelTabDedupeKey(
candidate.mode,
targetPath,
@@ -338,9 +371,8 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => {
id,
mode: candidate.mode,
targetPath,
projectPlanId: typeof candidate.projectPlanId === 'string' && candidate.projectPlanId.trim()
? candidate.projectPlanId.trim()
: null,
projectPlanId,
projectPlanRef,
dedupeKey,
label: normalizeContextTabLabel(typeof candidate.label === 'string' ? candidate.label : null),
sessionTitleFallback: normalizeContextTabLabel(typeof candidate.sessionTitleFallback === 'string' ? candidate.sessionTitleFallback : null),
@@ -405,20 +437,22 @@ const upsertContextPanelTab = (
const existingIndex = baseTabs.findIndex((tab) => tab.id === nextTab.id);
const tabs = existingIndex === -1
? [...baseTabs, nextTab]
: baseTabs.map((tab, index) => (index === existingIndex
? {
...tab,
mode: nextTab.mode,
targetPath: nextTab.targetPath || tab.targetPath,
dedupeKey: nextTab.dedupeKey,
label: nextTab.label,
sessionTitleFallback: nextTab.sessionTitleFallback || tab.sessionTitleFallback,
stagedDiff: nextTab.stagedDiff,
diffScope: nextTab.diffScope,
readOnly: nextTab.readOnly,
touchedAt: Date.now(),
}
: tab));
: baseTabs.map((tab, index) => (index === existingIndex
? {
...tab,
mode: nextTab.mode,
targetPath: nextTab.targetPath || tab.targetPath,
projectPlanId: nextTab.projectPlanId ?? tab.projectPlanId,
projectPlanRef: nextTab.projectPlanRef ?? tab.projectPlanRef,
dedupeKey: nextTab.dedupeKey,
label: nextTab.label,
sessionTitleFallback: nextTab.sessionTitleFallback || tab.sessionTitleFallback,
stagedDiff: nextTab.stagedDiff,
diffScope: nextTab.diffScope,
readOnly: nextTab.readOnly,
touchedAt: Date.now(),
}
: tab));
// A background upsert (an agent working a page) keeps the panel exactly as
// the user left it: closed stays closed, and whatever tab they were on
@@ -545,6 +579,10 @@ const sanitizeContextPanelByDirectory = (
let tabs = sanitizeContextPanelTabs(candidate.tabs);
let activeTabId = typeof candidate.activeTabId === 'string' ? candidate.activeTabId : null;
// Legacy single-tab state can name a saved project plan, but it carries
// no owner and cannot be migrated into an openable saved-plan tab — that
// combination is dropped by sanitize above. A generic filesystem plan tab
// (no plan id) revives fine from the descriptor alone.
if (tabs.length === 0 && (candidate.mode === 'diff' || candidate.mode === 'file' || candidate.mode === 'context' || candidate.mode === 'plan' || candidate.mode === 'chat')) {
tabs = [createContextPanelTab({
mode: candidate.mode,