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