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