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
+142
View File
@@ -0,0 +1,142 @@
import { describe, expect, test } from 'bun:test';
import { createPlanSaveQueue } from './planSaveQueue';
type Deferred = { promise: Promise<void>; resolve: () => void; reject: () => void };
const deferred = (): Deferred => {
let resolve!: () => void;
let reject!: () => void;
const promise = new Promise<void>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
describe('planSaveQueue', () => {
test('runs writes for one document in schedule order even when they resolve out of order', async () => {
const queue = createPlanSaveQueue();
const order: string[] = [];
const first = deferred();
const second = deferred();
const firstDone = queue.schedule('doc', 1, async () => {
await first.promise;
order.push('first');
});
const secondDone = queue.schedule('doc', 2, async () => {
order.push('second');
});
// Second started only after first settles, regardless of timing.
first.resolve();
await firstDone;
second.resolve();
await secondDone;
expect(order).toEqual(['first', 'second']);
});
test('skips a revision at or below the last queued revision for the same document', async () => {
const queue = createPlanSaveQueue();
let writes = 0;
await queue.schedule('doc', 3, async () => {
writes += 1;
});
await queue.schedule('doc', 3, async () => {
writes += 1;
});
await queue.schedule('doc', 2, async () => {
writes += 1;
});
expect(writes).toBe(1);
});
test('never lets a write for one document block another document', async () => {
const queue = createPlanSaveQueue();
const blocked = deferred();
const blockedDone = queue.schedule('a', 1, async () => {
await blocked.promise;
});
let otherRan = false;
await queue.schedule('b', 1, async () => {
otherRan = true;
});
expect(otherRan).toBe(true);
blocked.resolve();
await blockedDone;
});
test('pendingFor waits for the outstanding chain of that document only', async () => {
const queue = createPlanSaveQueue();
const slow = deferred();
let slowSettled = false;
void queue.schedule('a', 1, async () => {
await slow.promise;
slowSettled = true;
});
await queue.schedule('b', 1, async () => {});
await queue.pendingFor('b');
expect(slowSettled).toBe(false);
slow.resolve();
await queue.pendingFor('a');
expect(slowSettled).toBe(true);
});
test('reset clears the revision watermark so a reloaded document can save again', async () => {
const queue = createPlanSaveQueue();
let writes = 0;
await queue.schedule('doc', 5, async () => {
writes += 1;
});
queue.reset('doc');
await queue.schedule('doc', 1, async () => {
writes += 1;
});
expect(writes).toBe(2);
});
test('a failed write does not poison the chain for later writes', async () => {
const queue = createPlanSaveQueue();
const failing = queue.schedule('doc', 1, async () => {
throw new Error('write failed');
});
let secondRan = false;
const second = queue.schedule('doc', 2, async () => {
secondRan = true;
});
await expect(failing).rejects.toThrow('write failed');
await second;
expect(secondRan).toBe(true);
await queue.pendingFor('doc');
});
test('allows the same revision to retry after its write fails', async () => {
const queue = createPlanSaveQueue();
let attempts = 0;
const failing = queue.schedule('doc', 1, async () => {
attempts += 1;
throw new Error('write failed');
});
await expect(failing).rejects.toThrow('write failed');
await queue.schedule('doc', 1, async () => {
attempts += 1;
});
expect(attempts).toBe(2);
});
});
+61
View File
@@ -0,0 +1,61 @@
/**
* Write queue for open plan documents.
*
* Debounced autosave and close-time flushes must reach the disk in edit order,
* and a document re-opened while its own write is still in flight must read
* the post-write state, not race it. The queue serializes writes per logical
* document key and deduplicates revisions so a flush of revision N can never
* run behind, or twice behind, a debounced save of the same revision.
*/
interface PlanSaveQueue {
/**
* Queue one write for `key`. Writes for the same key run in schedule order;
* writes for different keys never block each other. A revision at or below
* the last queued revision for that key is skipped — the queued write
* already carries newer content — and the returned promise tracks the
* outstanding chain so callers can still await it.
*/
schedule: (key: string, revision: number, write: () => Promise<void>) => Promise<void>;
/** Resolves when every write queued for `key` has settled. */
pendingFor: (key: string) => Promise<void>;
/**
* Forgets the revision watermark for `key`. Call when a document is freshly
* loaded: its revision counter restarts, and stale watermarks from a
* previous open must not swallow the first real edit.
*/
reset: (key: string) => void;
}
export const createPlanSaveQueue = (): PlanSaveQueue => {
const chains = new Map<string, Promise<void>>();
const lastRevision = new Map<string, number>();
return {
schedule: (key, revision, write) => {
if (revision <= (lastRevision.get(key) ?? Number.NEGATIVE_INFINITY)) {
return chains.get(key) ?? Promise.resolve();
}
lastRevision.set(key, revision);
const previous = chains.get(key) ?? Promise.resolve();
// A failed write must not poison the chain: the next write for this
// document is still safe to attempt, and error surfacing belongs to the
// caller that owns UI state.
const next = previous.then(write, write);
chains.set(key, next.catch(() => {
// Keep newer queued revisions deduplicated, but let the caller retry
// this exact revision after its write has failed.
if (lastRevision.get(key) === revision) {
lastRevision.delete(key);
}
}));
return next;
},
pendingFor: async (key) => {
await chains.get(key);
},
reset: (key) => {
lastRevision.delete(key);
},
};
};
+11
View File
@@ -57,6 +57,17 @@ export interface ProjectRef {
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;