fix(threads): publish fork activity with list replacement

This commit is contained in:
murashit 2026-07-22 12:53:04 +09:00
parent 05ea87643a
commit 811a4b7158
12 changed files with 252 additions and 23 deletions

View file

@ -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. 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. 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.

View file

@ -22,11 +22,18 @@ export interface ThreadManagementActionsHost {
setStatus: (status: string) => void; setStatus: (status: string) => void;
setComposerText: (text: string) => void; setComposerText: (text: string) => void;
openThreadInNewView: (threadId: string) => Promise<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; beginThreadForkPublication: (sourceThreadId: string) => ThreadForkPublication;
threadHasPendingOrRunningPanel: (threadId: string) => boolean; threadHasPendingOrRunningPanel: (threadId: string) => boolean;
} }
type CurrentPanelAdoption =
| { readonly adopted: false }
| {
readonly adopted: true;
readonly activityPublication: { publish(commit: () => void): void };
};
interface ThreadForkPublication { interface ThreadForkPublication {
record(thread: Thread): void; record(thread: Thread): void;
finish(options?: { sourceArchived?: boolean }): void; finish(options?: { sourceArchived?: boolean }): void;
@ -138,6 +145,7 @@ async function forkThreadFromTurn(
} }
let publication: ThreadForkPublication | null = null; let publication: ThreadForkPublication | null = null;
let publicationFinished = false; let publicationFinished = false;
let activityPublication: { publish(commit: () => void): void } | null = null;
try { try {
const sourceName = inheritedForkThreadName(threadId, threadManagementState(host).threadList.listedThreads); const sourceName = inheritedForkThreadName(threadId, threadManagementState(host).threadList.listedThreads);
@ -164,7 +172,7 @@ async function forkThreadFromTurn(
if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return; if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return;
} }
if (archiveSource) { if (archiveSource) {
let adoption: { adopted: boolean }; let adoption: CurrentPanelAdoption;
try { try {
adoption = await host.openThreadInCurrentPanel(forkedThreadId, () => undefined); adoption = await host.openThreadInCurrentPanel(forkedThreadId, () => undefined);
} catch (error) { } catch (error) {
@ -179,13 +187,17 @@ async function forkThreadFromTurn(
} }
return; return;
} }
activityPublication = adoption.activityPublication;
const sourceArchived = await archiveReplacedSource(host, threadId, forkedThreadId, { const sourceArchived = await archiveReplacedSource(host, threadId, forkedThreadId, {
failureMessage: "Forked the thread, but could not archive the previous version", failureMessage: "Forked the thread, but could not archive the previous version",
}); });
publication.finish({ sourceArchived }); activityPublication.publish(() => publication?.finish({ sourceArchived }));
activityPublication = null;
publicationFinished = true; publicationFinished = true;
return; return;
} }
publication.finish();
publicationFinished = true;
try { try {
await host.openThreadInNewView(forkedThreadId); await host.openThreadInNewView(forkedThreadId);
} catch (error) { } catch (error) {
@ -197,7 +209,10 @@ async function forkThreadFromTurn(
if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return; if (!threadManagementScopeStillTargetsOriginalPanel(host, scope)) return;
host.addSystemMessage(error instanceof Error ? error.message : String(error)); host.addSystemMessage(error instanceof Error ? error.message : String(error));
} finally { } 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 publication: ThreadForkPublication | null = null;
let publicationFinished = false; let publicationFinished = false;
let activityPublication: { publish(commit: () => void): void } | null = null;
try { try {
host.setStatus(STATUS_ROLLBACK_STARTING); host.setStatus(STATUS_ROLLBACK_STARTING);
@ -266,6 +282,7 @@ async function rollbackThread(host: ThreadManagementActionsHost, threadId: strin
} }
return; return;
} }
activityPublication = adoption.activityPublication;
if (activeThreadId(threadManagementState(host)) === forkedThread.id) { if (activeThreadId(threadManagementState(host)) === forkedThread.id) {
host.addSystemMessage("Rolled back the latest turn. Local file changes were not reverted."); host.addSystemMessage("Rolled back the latest turn. Local file changes were not reverted.");
host.setStatus(STATUS_ROLLBACK_COMPLETE); host.setStatus(STATUS_ROLLBACK_COMPLETE);
@ -274,14 +291,18 @@ async function rollbackThread(host: ThreadManagementActionsHost, threadId: strin
saveMarkdown: false, saveMarkdown: false,
failureMessage: "Rolled back the latest turn, but could not archive the previous version", 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; publicationFinished = true;
} catch (error) { } catch (error) {
if (!threadManagementScopeStillTargetsPanel(host, scope)) return; if (!threadManagementScopeStillTargetsPanel(host, scope)) return;
host.addSystemMessage(error instanceof Error ? error.message : String(error)); host.addSystemMessage(error instanceof Error ? error.message : String(error));
host.setStatus(STATUS_ROLLBACK_FAILED); host.setStatus(STATUS_ROLLBACK_FAILED);
} finally { } finally {
if (!publicationFinished) publication?.finish(); if (!publicationFinished) {
if (activityPublication) activityPublication.publish(() => publication?.finish());
else publication?.finish();
}
} }
} }

View file

@ -57,6 +57,7 @@ interface ChatPanelThreadHost {
showLatest(): void; showLatest(): void;
}; };
getClosing: () => boolean; getClosing: () => boolean;
beginPanelActivityPublication(replacementThreadId: string): { publish(commit: () => void): void };
} }
interface ChatPanelThreadFoundationInput { interface ChatPanelThreadFoundationInput {
@ -272,14 +273,22 @@ export function createThreadActionBundle(host: ChatPanelThreadHost, input: ChatP
}, },
openThreadInNewView: (threadId) => environment.plugin.workspace.openThreadInNewView(threadId), openThreadInNewView: (threadId) => environment.plugin.workspace.openThreadInNewView(threadId),
openThreadInCurrentPanel: async (threadId, onAdopted) => { openThreadInCurrentPanel: async (threadId, onAdopted) => {
let adopted = false; const activityPublication = host.beginPanelActivityPublication(threadId);
await lifecycle.resume.resumeThread(threadId, undefined, { const adoption = { completed: false };
onAdopted: () => { try {
adopted = true; await lifecycle.resume.resumeThread(threadId, undefined, {
onAdopted(); onAdopted: () => {
}, adoption.completed = true;
}); onAdopted();
return { adopted }; },
});
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), beginThreadForkPublication: (sourceThreadId) => environment.plugin.threadOperationCoordinator.beginForkPublication(sourceThreadId),
threadHasPendingOrRunningPanel: (threadId) => environment.plugin.workspace.threadHasPendingOrRunningPanel(threadId), threadHasPendingOrRunningPanel: (threadId) => environment.plugin.workspace.threadHasPendingOrRunningPanel(threadId),

View file

@ -116,6 +116,11 @@ export interface ChatWorkspacePanelSnapshot {
threadId: string | null; threadId: string | null;
turnBusy: boolean; turnBusy: boolean;
pending: boolean; pending: boolean;
publishedActivity: {
threadId: string | null;
turnBusy: boolean;
pending: boolean;
};
hasComposerDraft: boolean; hasComposerDraft: boolean;
connected: boolean; connected: boolean;
} }

View file

@ -72,6 +72,7 @@ interface ChatPanelSessionRuntimeHost {
resumeWork: ChatResumeWorkTracker; resumeWork: ChatResumeWorkTracker;
threadStreamScrollBinding: ChatThreadStreamScrollBinding; threadStreamScrollBinding: ChatThreadStreamScrollBinding;
getClosing: () => boolean; getClosing: () => boolean;
beginPanelActivityPublication(replacementThreadId: string): { publish(commit: () => void): void };
} }
export class ChatPanelSessionRuntime { export class ChatPanelSessionRuntime {

View file

@ -21,6 +21,8 @@ export class ChatPanelSession implements ChatPanelHandle {
private opened = false; private opened = false;
private closing = false; private closing = false;
private observedPanelActivity: PanelActivity | null = null; private observedPanelActivity: PanelActivity | null = null;
private readonly panelActivityPublicationSlots: PanelActivityPublicationSlot[] = [];
private activePanelActivityHold: PanelActivityHold | null = null;
private unsubscribePanelActivity: (() => void) | null = null; private unsubscribePanelActivity: (() => void) | null = null;
private pendingRuntimeRestore: ChatPanelRuntimeSnapshot | null; private pendingRuntimeRestore: ChatPanelRuntimeSnapshot | null;
@ -100,10 +102,15 @@ export class ChatPanelSession implements ChatPanelHandle {
openPanelSnapshot(): ChatWorkspacePanelSnapshot { openPanelSnapshot(): ChatWorkspacePanelSnapshot {
const activity = panelActivity(this.state); const activity = panelActivity(this.state);
const publishedActivity = this.activePanelActivityHold?.activity ?? activity;
return { return {
viewId: this.environment.obsidian.viewId, viewId: this.environment.obsidian.viewId,
...activity, ...activity,
threadId: this.closing ? null : activity.threadId, threadId: this.closing ? null : activity.threadId,
publishedActivity: {
...publishedActivity,
threadId: this.closing ? null : publishedActivity.threadId,
},
hasComposerDraft: this.state.composer.draft.trim().length > 0, hasComposerDraft: this.state.composer.draft.trim().length > 0,
connected: this.runtime.connection.manager.isConnected(), connected: this.runtime.connection.manager.isConnected(),
}; };
@ -260,6 +267,9 @@ export class ChatPanelSession implements ChatPanelHandle {
const next = panelActivity(this.state); const next = panelActivity(this.state);
if (panelActivityEquals(this.observedPanelActivity, next)) return; if (panelActivityEquals(this.observedPanelActivity, next)) return;
this.observedPanelActivity = next; 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();
}); });
this.notifyPanelActivityChanged(); this.notifyPanelActivityChanged();
@ -269,6 +279,47 @@ export class ChatPanelSession implements ChatPanelHandle {
this.environment.plugin.workspace.notifyPanelActivityChanged(); 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 { private activeThreadTitle(): string | null {
const activeThread = activeThreadState(this.state); const activeThread = activeThreadState(this.state);
if (!activeThread) return null; if (!activeThread) return null;
@ -306,6 +357,7 @@ export class ChatPanelSession implements ChatPanelHandle {
resumeWork: this.resumeWork, resumeWork: this.resumeWork,
threadStreamScrollBinding: this.threadStreamScrollBinding, threadStreamScrollBinding: this.threadStreamScrollBinding,
getClosing: () => this.closing, getClosing: () => this.closing,
beginPanelActivityPublication: (replacementThreadId) => this.beginPanelActivityPublication(replacementThreadId),
}); });
} }
} }
@ -316,6 +368,17 @@ interface PanelActivity {
readonly pending: boolean; 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 { function panelActivity(state: ChatState): PanelActivity {
return { return {
threadId: panelThreadId(state), threadId: panelThreadId(state),

View file

@ -213,10 +213,10 @@ export class CodexPanelRuntime implements ChatViewRuntimeOwner, ThreadsViewRunti
private openPanelActivities(): readonly ThreadsViewPanelActivity[] { private openPanelActivities(): readonly ThreadsViewPanelActivity[] {
return this.panels.getOpenPanelSnapshots().map((snapshot) => ({ return this.panels.getOpenPanelSnapshots().map((snapshot) => ({
threadId: snapshot.threadId, threadId: snapshot.publishedActivity.threadId,
selected: snapshot.lastFocused, selected: snapshot.lastFocused,
pending: snapshot.pending, pending: snapshot.publishedActivity.pending,
running: snapshot.turnBusy, running: snapshot.publishedActivity.turnBusy,
})); }));
} }

View file

@ -716,6 +716,7 @@ function restoredPanelSnapshot(leaf: WorkspaceLeaf, index: number): WorkspacePan
threadId, threadId,
turnBusy: false, turnBusy: false,
pending: false, pending: false,
publishedActivity: { threadId, turnBusy: false, pending: false },
hasComposerDraft: false, hasComposerDraft: false,
connected: false, connected: false,
lastFocused: false, lastFocused: false,

View file

@ -48,6 +48,9 @@ type ThreadManagementActionsHostMock = Omit<
record: Mock<ReturnType<ThreadManagementActionsHost["beginThreadForkPublication"]>["record"]>; record: Mock<ReturnType<ThreadManagementActionsHost["beginThreadForkPublication"]>["record"]>;
finish: Mock<ReturnType<ThreadManagementActionsHost["beginThreadForkPublication"]>["finish"]>; finish: Mock<ReturnType<ThreadManagementActionsHost["beginThreadForkPublication"]>["finish"]>;
}; };
activityPublication: {
publish: Mock<(commit: () => void) => void>;
};
beginThreadForkPublication: Mock<ThreadManagementActionsHost["beginThreadForkPublication"]>; beginThreadForkPublication: Mock<ThreadManagementActionsHost["beginThreadForkPublication"]>;
}; };
@ -312,7 +315,7 @@ describe("thread management actions", () => {
); );
expect(host.openThreadInNewView).toHaveBeenCalledWith("forked"); expect(host.openThreadInNewView).toHaveBeenCalledWith("forked");
expect(host.forkPublication.finish).toHaveBeenCalledWith(); 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.operations.archiveThread).not.toHaveBeenCalled();
expect(host.openThreadInCurrentPanel).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(callOrder(host.openThreadInCurrentPanel)).toBeLessThan(callOrder(host.operations.archiveThread));
expect(host.forkPublication.finish).toHaveBeenCalledWith({ sourceArchived: true }); 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.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 () => { 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(callOrder(host.setComposerText)).toBeLessThan(callOrder(host.addSystemMessage));
expect(host.forkPublication.record).toHaveBeenCalledWith(panelThread("forked")); expect(host.forkPublication.record).toHaveBeenCalledWith(panelThread("forked"));
expect(host.forkPublication.finish).toHaveBeenCalledWith({ sourceArchived: true }); 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"); expect(activeThreadId(host.stateStore.getState())).toBe("forked");
}); });
@ -671,7 +678,7 @@ describe("thread management actions", () => {
host.openThreadInCurrentPanel.mockImplementation(async (threadId, onAdopted) => { host.openThreadInCurrentPanel.mockImplementation(async (threadId, onAdopted) => {
adoptThread(host, threadId); adoptThread(host, threadId);
onAdopted(); onAdopted();
return { adopted: true }; return { adopted: true, activityPublication: host.activityPublication };
}); });
await threadManagementActions(host).rollbackThread("source"); await threadManagementActions(host).rollbackThread("source");
@ -929,6 +936,7 @@ function hostMock({
record: vi.fn(), record: vi.fn(),
finish: vi.fn(), finish: vi.fn(),
}; };
const activityPublication = { publish: vi.fn((commit: () => void) => commit()) };
return { return {
stateStore, stateStore,
threadTransport, threadTransport,
@ -942,9 +950,10 @@ function hostMock({
.mockImplementation(async (threadId, onAdopted) => { .mockImplementation(async (threadId, onAdopted) => {
adoptThread({ stateStore }, threadId); adoptThread({ stateStore }, threadId);
onAdopted(); onAdopted();
return { adopted: true }; return { adopted: true, activityPublication };
}), }),
forkPublication, forkPublication,
activityPublication,
beginThreadForkPublication: vi.fn<ThreadManagementActionsHost["beginThreadForkPublication"]>().mockReturnValue(forkPublication), beginThreadForkPublication: vi.fn<ThreadManagementActionsHost["beginThreadForkPublication"]>().mockReturnValue(forkPublication),
threadHasPendingOrRunningPanel: vi.fn(() => false), threadHasPendingOrRunningPanel: vi.fn(() => false),
}; };

View file

@ -187,6 +187,7 @@ describe("ChatPanelSessionRuntime actions", () => {
resumeWork, resumeWork,
threadStreamScrollBinding, threadStreamScrollBinding,
getClosing: () => false, getClosing: () => false,
beginPanelActivityPublication: () => ({ publish: (commit) => commit() }),
}); });
return { runtime, stateStore, resumeWork, deferredTasks, threadStreamScrollBinding }; return { runtime, stateStore, resumeWork, deferredTasks, threadStreamScrollBinding };
} }

View file

@ -366,4 +366,117 @@ describe("CodexChatView workspace restoration", () => {
expect(notifyPanelActivityChanged).toHaveBeenCalledOnce(); expect(notifyPanelActivityChanged).toHaveBeenCalledOnce();
expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: "thread-1", turnBusy: false, pending: true }); 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();
});
}); });

View file

@ -200,12 +200,18 @@ export function thread(id: string): Thread {
} }
export function panelSnapshot(overrides: PanelSnapshotFixtureOverrides = {}): ReturnType<CodexChatView["surface"]["openPanelSnapshot"]> { 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 { return {
viewId: "view", viewId: "view",
threadId: "thread", threadId: "thread",
turnBusy: false, 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, hasComposerDraft: false,
connected: true, connected: true,
...snapshotOverrides, ...snapshotOverrides,