mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
fix(threads): publish fork activity with list replacement
This commit is contained in:
parent
05ea87643a
commit
811a4b7158
12 changed files with 252 additions and 23 deletions
|
|
@ -56,7 +56,7 @@ Chat-visible state should have one authoritative owner. Components should consum
|
|||
|
||||
TanStack Query is the single panel-side owner of cached app-server resources. Features may project authoritative event results into that state, but should not introduce parallel cache or synchronization mechanisms. Partial read models are reconciled at explicit lifecycle boundaries rather than kept globally and continuously consistent.
|
||||
|
||||
Thread-list updates have three explicit stages. `ThreadOperationCoordinator` orders lifecycle facts across multi-step operations such as fork without knowing list mutation details. The pure read-model projector converts each committed fact sequence into one mutation batch, and the shared app-server query owner applies that batch while structurally providing narrow `ThreadCatalog` read ports to consumers. Ordinary app-server notifications and completed local operations use the same path, while RPC sequencing and fork-specific buffering remain outside both the projector and query owner.
|
||||
Thread lifecycle changes should be projected from completed operation facts into the shared read model. When one user action changes multiple visible projections, publish them coherently without delaying live operational state or coupling independent panels.
|
||||
|
||||
Reads with different completeness requirements have separate lifecycles. Complete operation-local reads must not replace bounded shared history, and transient activity should come from its owning live state rather than forcing history refreshes.
|
||||
|
||||
|
|
|
|||
|
|
@ -22,11 +22,18 @@ export interface ThreadManagementActionsHost {
|
|||
setStatus: (status: string) => void;
|
||||
setComposerText: (text: string) => void;
|
||||
openThreadInNewView: (threadId: string) => Promise<void>;
|
||||
openThreadInCurrentPanel: (threadId: string, onAdopted: () => void) => Promise<{ adopted: boolean }>;
|
||||
openThreadInCurrentPanel: (threadId: string, onAdopted: () => void) => Promise<CurrentPanelAdoption>;
|
||||
beginThreadForkPublication: (sourceThreadId: string) => ThreadForkPublication;
|
||||
threadHasPendingOrRunningPanel: (threadId: string) => boolean;
|
||||
}
|
||||
|
||||
type CurrentPanelAdoption =
|
||||
| { readonly adopted: false }
|
||||
| {
|
||||
readonly adopted: true;
|
||||
readonly activityPublication: { publish(commit: () => void): void };
|
||||
};
|
||||
|
||||
interface ThreadForkPublication {
|
||||
record(thread: Thread): void;
|
||||
finish(options?: { sourceArchived?: boolean }): void;
|
||||
|
|
@ -138,6 +145,7 @@ async function forkThreadFromTurn(
|
|||
}
|
||||
let publication: ThreadForkPublication | null = null;
|
||||
let publicationFinished = false;
|
||||
let activityPublication: { publish(commit: () => void): void } | null = null;
|
||||
|
||||
try {
|
||||
const sourceName = inheritedForkThreadName(threadId, threadManagementState(host).threadList.listedThreads);
|
||||
|
|
@ -164,7 +172,7 @@ async function forkThreadFromTurn(
|
|||
if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return;
|
||||
}
|
||||
if (archiveSource) {
|
||||
let adoption: { adopted: boolean };
|
||||
let adoption: CurrentPanelAdoption;
|
||||
try {
|
||||
adoption = await host.openThreadInCurrentPanel(forkedThreadId, () => undefined);
|
||||
} catch (error) {
|
||||
|
|
@ -179,13 +187,17 @@ async function forkThreadFromTurn(
|
|||
}
|
||||
return;
|
||||
}
|
||||
activityPublication = adoption.activityPublication;
|
||||
const sourceArchived = await archiveReplacedSource(host, threadId, forkedThreadId, {
|
||||
failureMessage: "Forked the thread, but could not archive the previous version",
|
||||
});
|
||||
publication.finish({ sourceArchived });
|
||||
activityPublication.publish(() => publication?.finish({ sourceArchived }));
|
||||
activityPublication = null;
|
||||
publicationFinished = true;
|
||||
return;
|
||||
}
|
||||
publication.finish();
|
||||
publicationFinished = true;
|
||||
try {
|
||||
await host.openThreadInNewView(forkedThreadId);
|
||||
} catch (error) {
|
||||
|
|
@ -197,7 +209,10 @@ async function forkThreadFromTurn(
|
|||
if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return;
|
||||
host.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
if (!publicationFinished) publication?.finish();
|
||||
if (!publicationFinished) {
|
||||
if (activityPublication) activityPublication.publish(() => publication?.finish());
|
||||
else publication?.finish();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -240,6 +255,7 @@ async function rollbackThread(host: ThreadManagementActionsHost, threadId: strin
|
|||
};
|
||||
let publication: ThreadForkPublication | null = null;
|
||||
let publicationFinished = false;
|
||||
let activityPublication: { publish(commit: () => void): void } | null = null;
|
||||
|
||||
try {
|
||||
host.setStatus(STATUS_ROLLBACK_STARTING);
|
||||
|
|
@ -266,6 +282,7 @@ async function rollbackThread(host: ThreadManagementActionsHost, threadId: strin
|
|||
}
|
||||
return;
|
||||
}
|
||||
activityPublication = adoption.activityPublication;
|
||||
if (activeThreadId(threadManagementState(host)) === forkedThread.id) {
|
||||
host.addSystemMessage("Rolled back the latest turn. Local file changes were not reverted.");
|
||||
host.setStatus(STATUS_ROLLBACK_COMPLETE);
|
||||
|
|
@ -274,14 +291,18 @@ async function rollbackThread(host: ThreadManagementActionsHost, threadId: strin
|
|||
saveMarkdown: false,
|
||||
failureMessage: "Rolled back the latest turn, but could not archive the previous version",
|
||||
});
|
||||
publication.finish({ sourceArchived });
|
||||
activityPublication.publish(() => publication?.finish({ sourceArchived }));
|
||||
activityPublication = null;
|
||||
publicationFinished = true;
|
||||
} catch (error) {
|
||||
if (!threadManagementScopeStillTargetsPanel(host, scope)) return;
|
||||
host.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||
host.setStatus(STATUS_ROLLBACK_FAILED);
|
||||
} finally {
|
||||
if (!publicationFinished) publication?.finish();
|
||||
if (!publicationFinished) {
|
||||
if (activityPublication) activityPublication.publish(() => publication?.finish());
|
||||
else publication?.finish();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ interface ChatPanelThreadHost {
|
|||
showLatest(): void;
|
||||
};
|
||||
getClosing: () => boolean;
|
||||
beginPanelActivityPublication(replacementThreadId: string): { publish(commit: () => void): void };
|
||||
}
|
||||
|
||||
interface ChatPanelThreadFoundationInput {
|
||||
|
|
@ -272,14 +273,22 @@ export function createThreadActionBundle(host: ChatPanelThreadHost, input: ChatP
|
|||
},
|
||||
openThreadInNewView: (threadId) => environment.plugin.workspace.openThreadInNewView(threadId),
|
||||
openThreadInCurrentPanel: async (threadId, onAdopted) => {
|
||||
let adopted = false;
|
||||
await lifecycle.resume.resumeThread(threadId, undefined, {
|
||||
onAdopted: () => {
|
||||
adopted = true;
|
||||
onAdopted();
|
||||
},
|
||||
});
|
||||
return { adopted };
|
||||
const activityPublication = host.beginPanelActivityPublication(threadId);
|
||||
const adoption = { completed: false };
|
||||
try {
|
||||
await lifecycle.resume.resumeThread(threadId, undefined, {
|
||||
onAdopted: () => {
|
||||
adoption.completed = true;
|
||||
onAdopted();
|
||||
},
|
||||
});
|
||||
if (adoption.completed) return { adopted: true, activityPublication };
|
||||
activityPublication.publish(() => undefined);
|
||||
return { adopted: false };
|
||||
} catch (error) {
|
||||
activityPublication.publish(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
beginThreadForkPublication: (sourceThreadId) => environment.plugin.threadOperationCoordinator.beginForkPublication(sourceThreadId),
|
||||
threadHasPendingOrRunningPanel: (threadId) => environment.plugin.workspace.threadHasPendingOrRunningPanel(threadId),
|
||||
|
|
|
|||
|
|
@ -116,6 +116,11 @@ export interface ChatWorkspacePanelSnapshot {
|
|||
threadId: string | null;
|
||||
turnBusy: boolean;
|
||||
pending: boolean;
|
||||
publishedActivity: {
|
||||
threadId: string | null;
|
||||
turnBusy: boolean;
|
||||
pending: boolean;
|
||||
};
|
||||
hasComposerDraft: boolean;
|
||||
connected: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ interface ChatPanelSessionRuntimeHost {
|
|||
resumeWork: ChatResumeWorkTracker;
|
||||
threadStreamScrollBinding: ChatThreadStreamScrollBinding;
|
||||
getClosing: () => boolean;
|
||||
beginPanelActivityPublication(replacementThreadId: string): { publish(commit: () => void): void };
|
||||
}
|
||||
|
||||
export class ChatPanelSessionRuntime {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ export class ChatPanelSession implements ChatPanelHandle {
|
|||
private opened = false;
|
||||
private closing = false;
|
||||
private observedPanelActivity: PanelActivity | null = null;
|
||||
private readonly panelActivityPublicationSlots: PanelActivityPublicationSlot[] = [];
|
||||
private activePanelActivityHold: PanelActivityHold | null = null;
|
||||
private unsubscribePanelActivity: (() => void) | null = null;
|
||||
private pendingRuntimeRestore: ChatPanelRuntimeSnapshot | null;
|
||||
|
||||
|
|
@ -100,10 +102,15 @@ export class ChatPanelSession implements ChatPanelHandle {
|
|||
|
||||
openPanelSnapshot(): ChatWorkspacePanelSnapshot {
|
||||
const activity = panelActivity(this.state);
|
||||
const publishedActivity = this.activePanelActivityHold?.activity ?? activity;
|
||||
return {
|
||||
viewId: this.environment.obsidian.viewId,
|
||||
...activity,
|
||||
threadId: this.closing ? null : activity.threadId,
|
||||
publishedActivity: {
|
||||
...publishedActivity,
|
||||
threadId: this.closing ? null : publishedActivity.threadId,
|
||||
},
|
||||
hasComposerDraft: this.state.composer.draft.trim().length > 0,
|
||||
connected: this.runtime.connection.manager.isConnected(),
|
||||
};
|
||||
|
|
@ -260,6 +267,9 @@ export class ChatPanelSession implements ChatPanelHandle {
|
|||
const next = panelActivity(this.state);
|
||||
if (panelActivityEquals(this.observedPanelActivity, next)) return;
|
||||
this.observedPanelActivity = next;
|
||||
const hold = this.activePanelActivityHold;
|
||||
if (hold && (next.threadId === hold.activity.threadId || hold.replacementThreadIds.has(next.threadId))) return;
|
||||
if (hold) this.activePanelActivityHold = null;
|
||||
this.notifyPanelActivityChanged();
|
||||
});
|
||||
this.notifyPanelActivityChanged();
|
||||
|
|
@ -269,6 +279,47 @@ export class ChatPanelSession implements ChatPanelHandle {
|
|||
this.environment.plugin.workspace.notifyPanelActivityChanged();
|
||||
}
|
||||
|
||||
beginPanelActivityPublication(replacementThreadId: string): { publish(commit: () => void): void } {
|
||||
const hold: PanelActivityHold = this.activePanelActivityHold ?? {
|
||||
activity: panelActivity(this.state),
|
||||
replacementThreadIds: new Set<string | null>(),
|
||||
pendingPublications: 0,
|
||||
};
|
||||
hold.replacementThreadIds.add(replacementThreadId);
|
||||
hold.pendingPublications += 1;
|
||||
this.activePanelActivityHold = hold;
|
||||
const slot: PanelActivityPublicationSlot = { hold, commit: null };
|
||||
this.panelActivityPublicationSlots.push(slot);
|
||||
return {
|
||||
publish: (commit) => {
|
||||
if (slot.commit) return;
|
||||
slot.commit = commit;
|
||||
hold.pendingPublications -= 1;
|
||||
let failure: unknown = null;
|
||||
let commitFailed = false;
|
||||
let first = this.panelActivityPublicationSlots[0];
|
||||
while (first?.commit && first.hold.pendingPublications === 0) {
|
||||
this.panelActivityPublicationSlots.shift();
|
||||
const slotHold = first.hold;
|
||||
const publishActivity =
|
||||
this.activePanelActivityHold === slotHold && !this.panelActivityPublicationSlots.some((pending) => pending.hold === slotHold);
|
||||
if (publishActivity) this.activePanelActivityHold = null;
|
||||
try {
|
||||
first.commit();
|
||||
} catch (error) {
|
||||
if (!commitFailed) failure = error;
|
||||
commitFailed = true;
|
||||
}
|
||||
if (publishActivity && !panelActivityEquals(slotHold.activity, panelActivity(this.state))) {
|
||||
this.notifyPanelActivityChanged();
|
||||
}
|
||||
first = this.panelActivityPublicationSlots[0];
|
||||
}
|
||||
if (commitFailed) throw failure;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private activeThreadTitle(): string | null {
|
||||
const activeThread = activeThreadState(this.state);
|
||||
if (!activeThread) return null;
|
||||
|
|
@ -306,6 +357,7 @@ export class ChatPanelSession implements ChatPanelHandle {
|
|||
resumeWork: this.resumeWork,
|
||||
threadStreamScrollBinding: this.threadStreamScrollBinding,
|
||||
getClosing: () => this.closing,
|
||||
beginPanelActivityPublication: (replacementThreadId) => this.beginPanelActivityPublication(replacementThreadId),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -316,6 +368,17 @@ interface PanelActivity {
|
|||
readonly pending: boolean;
|
||||
}
|
||||
|
||||
interface PanelActivityHold {
|
||||
readonly activity: PanelActivity;
|
||||
readonly replacementThreadIds: Set<string | null>;
|
||||
pendingPublications: number;
|
||||
}
|
||||
|
||||
interface PanelActivityPublicationSlot {
|
||||
readonly hold: PanelActivityHold;
|
||||
commit: (() => void) | null;
|
||||
}
|
||||
|
||||
function panelActivity(state: ChatState): PanelActivity {
|
||||
return {
|
||||
threadId: panelThreadId(state),
|
||||
|
|
|
|||
|
|
@ -213,10 +213,10 @@ export class CodexPanelRuntime implements ChatViewRuntimeOwner, ThreadsViewRunti
|
|||
|
||||
private openPanelActivities(): readonly ThreadsViewPanelActivity[] {
|
||||
return this.panels.getOpenPanelSnapshots().map((snapshot) => ({
|
||||
threadId: snapshot.threadId,
|
||||
threadId: snapshot.publishedActivity.threadId,
|
||||
selected: snapshot.lastFocused,
|
||||
pending: snapshot.pending,
|
||||
running: snapshot.turnBusy,
|
||||
pending: snapshot.publishedActivity.pending,
|
||||
running: snapshot.publishedActivity.turnBusy,
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -716,6 +716,7 @@ function restoredPanelSnapshot(leaf: WorkspaceLeaf, index: number): WorkspacePan
|
|||
threadId,
|
||||
turnBusy: false,
|
||||
pending: false,
|
||||
publishedActivity: { threadId, turnBusy: false, pending: false },
|
||||
hasComposerDraft: false,
|
||||
connected: false,
|
||||
lastFocused: false,
|
||||
|
|
|
|||
|
|
@ -48,6 +48,9 @@ type ThreadManagementActionsHostMock = Omit<
|
|||
record: Mock<ReturnType<ThreadManagementActionsHost["beginThreadForkPublication"]>["record"]>;
|
||||
finish: Mock<ReturnType<ThreadManagementActionsHost["beginThreadForkPublication"]>["finish"]>;
|
||||
};
|
||||
activityPublication: {
|
||||
publish: Mock<(commit: () => void) => void>;
|
||||
};
|
||||
beginThreadForkPublication: Mock<ThreadManagementActionsHost["beginThreadForkPublication"]>;
|
||||
};
|
||||
|
||||
|
|
@ -312,7 +315,7 @@ describe("thread management actions", () => {
|
|||
);
|
||||
expect(host.openThreadInNewView).toHaveBeenCalledWith("forked");
|
||||
expect(host.forkPublication.finish).toHaveBeenCalledWith();
|
||||
expect(callOrder(host.openThreadInNewView)).toBeLessThan(callOrder(host.forkPublication.finish));
|
||||
expect(callOrder(host.forkPublication.finish)).toBeLessThan(callOrder(host.openThreadInNewView));
|
||||
expect(host.operations.archiveThread).not.toHaveBeenCalled();
|
||||
expect(host.openThreadInCurrentPanel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
@ -331,6 +334,8 @@ describe("thread management actions", () => {
|
|||
expect(callOrder(host.openThreadInCurrentPanel)).toBeLessThan(callOrder(host.operations.archiveThread));
|
||||
expect(host.forkPublication.finish).toHaveBeenCalledWith({ sourceArchived: true });
|
||||
expect(callOrder(host.operations.archiveThread)).toBeLessThan(callOrder(host.forkPublication.finish));
|
||||
expect(callOrder(host.operations.archiveThread)).toBeLessThan(callOrder(host.activityPublication.publish));
|
||||
expect(callOrder(host.activityPublication.publish)).toBeLessThan(callOrder(host.forkPublication.finish));
|
||||
});
|
||||
|
||||
it("keeps the replacement panel when source archiving fails", async () => {
|
||||
|
|
@ -595,6 +600,8 @@ describe("thread management actions", () => {
|
|||
expect(callOrder(host.setComposerText)).toBeLessThan(callOrder(host.addSystemMessage));
|
||||
expect(host.forkPublication.record).toHaveBeenCalledWith(panelThread("forked"));
|
||||
expect(host.forkPublication.finish).toHaveBeenCalledWith({ sourceArchived: true });
|
||||
expect(callOrder(host.operations.archiveThread)).toBeLessThan(callOrder(host.activityPublication.publish));
|
||||
expect(callOrder(host.activityPublication.publish)).toBeLessThan(callOrder(host.forkPublication.finish));
|
||||
expect(activeThreadId(host.stateStore.getState())).toBe("forked");
|
||||
});
|
||||
|
||||
|
|
@ -671,7 +678,7 @@ describe("thread management actions", () => {
|
|||
host.openThreadInCurrentPanel.mockImplementation(async (threadId, onAdopted) => {
|
||||
adoptThread(host, threadId);
|
||||
onAdopted();
|
||||
return { adopted: true };
|
||||
return { adopted: true, activityPublication: host.activityPublication };
|
||||
});
|
||||
|
||||
await threadManagementActions(host).rollbackThread("source");
|
||||
|
|
@ -929,6 +936,7 @@ function hostMock({
|
|||
record: vi.fn(),
|
||||
finish: vi.fn(),
|
||||
};
|
||||
const activityPublication = { publish: vi.fn((commit: () => void) => commit()) };
|
||||
return {
|
||||
stateStore,
|
||||
threadTransport,
|
||||
|
|
@ -942,9 +950,10 @@ function hostMock({
|
|||
.mockImplementation(async (threadId, onAdopted) => {
|
||||
adoptThread({ stateStore }, threadId);
|
||||
onAdopted();
|
||||
return { adopted: true };
|
||||
return { adopted: true, activityPublication };
|
||||
}),
|
||||
forkPublication,
|
||||
activityPublication,
|
||||
beginThreadForkPublication: vi.fn<ThreadManagementActionsHost["beginThreadForkPublication"]>().mockReturnValue(forkPublication),
|
||||
threadHasPendingOrRunningPanel: vi.fn(() => false),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -187,6 +187,7 @@ describe("ChatPanelSessionRuntime actions", () => {
|
|||
resumeWork,
|
||||
threadStreamScrollBinding,
|
||||
getClosing: () => false,
|
||||
beginPanelActivityPublication: () => ({ publish: (commit) => commit() }),
|
||||
});
|
||||
return { runtime, stateStore, resumeWork, deferredTasks, threadStreamScrollBinding };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -366,4 +366,117 @@ describe("CodexChatView workspace restoration", () => {
|
|||
expect(notifyPanelActivityChanged).toHaveBeenCalledOnce();
|
||||
expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: "thread-1", turnBusy: false, pending: true });
|
||||
});
|
||||
|
||||
it("publishes a replacement panel activity only after its operation finishes", async () => {
|
||||
const notifyPanelActivityChanged = vi.fn();
|
||||
connectionMockState().client = connectedClient({
|
||||
"thread/resume": vi.fn((params?: unknown) => Promise.resolve(resumedThread((params as { threadId: string }).threadId))),
|
||||
});
|
||||
const view = await chatView({ host: chatHost({ notifyPanelActivityChanged }) });
|
||||
await view.onOpen();
|
||||
await view.surface.openThread("source");
|
||||
notifyPanelActivityChanged.mockClear();
|
||||
|
||||
const publication = (view.surface as ChatPanelSession).beginPanelActivityPublication("replacement");
|
||||
await view.surface.openThread("replacement");
|
||||
|
||||
expect(view.surface.openPanelSnapshot()).toMatchObject({
|
||||
threadId: "replacement",
|
||||
publishedActivity: { threadId: "source" },
|
||||
});
|
||||
expect(notifyPanelActivityChanged).not.toHaveBeenCalled();
|
||||
|
||||
const commit = vi.fn();
|
||||
publication.publish(commit);
|
||||
|
||||
expect(view.surface.openPanelSnapshot()).toMatchObject({ publishedActivity: { threadId: "replacement" } });
|
||||
expect(commit).toHaveBeenCalledOnce();
|
||||
expect(notifyPanelActivityChanged).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("holds overlapping activity commits until the panel has no remaining publication", async () => {
|
||||
const notifyPanelActivityChanged = vi.fn();
|
||||
connectionMockState().client = connectedClient({
|
||||
"thread/resume": vi.fn((params?: unknown) => Promise.resolve(resumedThread((params as { threadId: string }).threadId))),
|
||||
});
|
||||
const view = await chatView({ host: chatHost({ notifyPanelActivityChanged }) });
|
||||
await view.onOpen();
|
||||
await view.surface.openThread("source");
|
||||
notifyPanelActivityChanged.mockClear();
|
||||
|
||||
const session = view.surface as ChatPanelSession;
|
||||
const first = session.beginPanelActivityPublication("replacement");
|
||||
const second = session.beginPanelActivityPublication("replacement");
|
||||
await view.surface.openThread("replacement");
|
||||
const firstCommit = vi.fn();
|
||||
const secondCommit = vi.fn();
|
||||
|
||||
second.publish(secondCommit);
|
||||
expect(firstCommit).not.toHaveBeenCalled();
|
||||
expect(secondCommit).not.toHaveBeenCalled();
|
||||
expect(view.surface.openPanelSnapshot()).toMatchObject({ publishedActivity: { threadId: "source" } });
|
||||
|
||||
first.publish(firstCommit);
|
||||
expect(firstCommit).toHaveBeenCalledOnce();
|
||||
expect(secondCommit).toHaveBeenCalledOnce();
|
||||
expect(firstCommit.mock.invocationCallOrder[0]).toBeLessThan(secondCommit.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY);
|
||||
expect(view.surface.openPanelSnapshot()).toMatchObject({ publishedActivity: { threadId: "replacement" } });
|
||||
expect(notifyPanelActivityChanged).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("publishes a newer unrelated navigation without releasing the held list commit", async () => {
|
||||
const notifyPanelActivityChanged = vi.fn();
|
||||
connectionMockState().client = connectedClient({
|
||||
"thread/resume": vi.fn((params?: unknown) => Promise.resolve(resumedThread((params as { threadId: string }).threadId))),
|
||||
});
|
||||
const view = await chatView({ host: chatHost({ notifyPanelActivityChanged }) });
|
||||
await view.onOpen();
|
||||
await view.surface.openThread("source");
|
||||
notifyPanelActivityChanged.mockClear();
|
||||
|
||||
const publication = (view.surface as ChatPanelSession).beginPanelActivityPublication("replacement");
|
||||
await view.surface.openThread("replacement");
|
||||
await view.surface.openThread("newer");
|
||||
|
||||
expect(view.surface.openPanelSnapshot()).toMatchObject({
|
||||
threadId: "newer",
|
||||
publishedActivity: { threadId: "newer" },
|
||||
});
|
||||
expect(notifyPanelActivityChanged).toHaveBeenCalledOnce();
|
||||
|
||||
const commit = vi.fn();
|
||||
publication.publish(commit);
|
||||
expect(commit).toHaveBeenCalledOnce();
|
||||
expect(notifyPanelActivityChanged).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps a later replacement held while an earlier superseded commit is unfinished", async () => {
|
||||
const notifyPanelActivityChanged = vi.fn();
|
||||
connectionMockState().client = connectedClient({
|
||||
"thread/resume": vi.fn((params?: unknown) => Promise.resolve(resumedThread((params as { threadId: string }).threadId))),
|
||||
});
|
||||
const view = await chatView({ host: chatHost({ notifyPanelActivityChanged }) });
|
||||
await view.onOpen();
|
||||
await view.surface.openThread("source");
|
||||
const session = view.surface as ChatPanelSession;
|
||||
const first = session.beginPanelActivityPublication("first-replacement");
|
||||
await view.surface.openThread("first-replacement");
|
||||
await view.surface.openThread("unrelated");
|
||||
notifyPanelActivityChanged.mockClear();
|
||||
|
||||
const second = session.beginPanelActivityPublication("second-replacement");
|
||||
await view.surface.openThread("second-replacement");
|
||||
const firstCommit = vi.fn();
|
||||
const secondCommit = vi.fn();
|
||||
|
||||
second.publish(secondCommit);
|
||||
expect(secondCommit).not.toHaveBeenCalled();
|
||||
expect(view.surface.openPanelSnapshot()).toMatchObject({ publishedActivity: { threadId: "unrelated" } });
|
||||
expect(notifyPanelActivityChanged).not.toHaveBeenCalled();
|
||||
|
||||
first.publish(firstCommit);
|
||||
expect(firstCommit.mock.invocationCallOrder[0]).toBeLessThan(secondCommit.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY);
|
||||
expect(view.surface.openPanelSnapshot()).toMatchObject({ publishedActivity: { threadId: "second-replacement" } });
|
||||
expect(notifyPanelActivityChanged).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -200,12 +200,18 @@ export function thread(id: string): Thread {
|
|||
}
|
||||
|
||||
export function panelSnapshot(overrides: PanelSnapshotFixtureOverrides = {}): ReturnType<CodexChatView["surface"]["openPanelSnapshot"]> {
|
||||
const { pendingApprovals, pendingUserInputs, pendingMcpElicitations, ...snapshotOverrides } = overrides;
|
||||
const { pendingApprovals, pendingUserInputs, pendingMcpElicitations, publishedActivity, ...snapshotOverrides } = overrides;
|
||||
const pending = (pendingApprovals ?? 0) + (pendingUserInputs ?? 0) + (pendingMcpElicitations ?? 0) > 0;
|
||||
return {
|
||||
viewId: "view",
|
||||
threadId: "thread",
|
||||
turnBusy: false,
|
||||
pending: (pendingApprovals ?? 0) + (pendingUserInputs ?? 0) + (pendingMcpElicitations ?? 0) > 0,
|
||||
pending,
|
||||
publishedActivity: publishedActivity ?? {
|
||||
threadId: snapshotOverrides.threadId ?? "thread",
|
||||
turnBusy: snapshotOverrides.turnBusy ?? false,
|
||||
pending: snapshotOverrides.pending ?? pending,
|
||||
},
|
||||
hasComposerDraft: false,
|
||||
connected: true,
|
||||
...snapshotOverrides,
|
||||
|
|
|
|||
Loading…
Reference in a new issue