diff --git a/docs/design.md b/docs/design.md index 8d8bf996..a037bb7e 100644 --- a/docs/design.md +++ b/docs/design.md @@ -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. diff --git a/src/features/chat/application/threads/thread-management-actions.ts b/src/features/chat/application/threads/thread-management-actions.ts index 94373a5f..89c99897 100644 --- a/src/features/chat/application/threads/thread-management-actions.ts +++ b/src/features/chat/application/threads/thread-management-actions.ts @@ -22,11 +22,18 @@ export interface ThreadManagementActionsHost { setStatus: (status: string) => void; setComposerText: (text: string) => void; openThreadInNewView: (threadId: string) => Promise; - openThreadInCurrentPanel: (threadId: string, onAdopted: () => void) => Promise<{ adopted: boolean }>; + openThreadInCurrentPanel: (threadId: string, onAdopted: () => void) => Promise; 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(); + } } } diff --git a/src/features/chat/host/bundles/thread-bundle.ts b/src/features/chat/host/bundles/thread-bundle.ts index a9414127..3747976e 100644 --- a/src/features/chat/host/bundles/thread-bundle.ts +++ b/src/features/chat/host/bundles/thread-bundle.ts @@ -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), diff --git a/src/features/chat/host/contracts.ts b/src/features/chat/host/contracts.ts index 47b26593..cf782abf 100644 --- a/src/features/chat/host/contracts.ts +++ b/src/features/chat/host/contracts.ts @@ -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; } diff --git a/src/features/chat/host/session-runtime.ts b/src/features/chat/host/session-runtime.ts index a5f72dcb..6b463449 100644 --- a/src/features/chat/host/session-runtime.ts +++ b/src/features/chat/host/session-runtime.ts @@ -72,6 +72,7 @@ interface ChatPanelSessionRuntimeHost { resumeWork: ChatResumeWorkTracker; threadStreamScrollBinding: ChatThreadStreamScrollBinding; getClosing: () => boolean; + beginPanelActivityPublication(replacementThreadId: string): { publish(commit: () => void): void }; } export class ChatPanelSessionRuntime { diff --git a/src/features/chat/host/session.ts b/src/features/chat/host/session.ts index d09ca95b..f038fde9 100644 --- a/src/features/chat/host/session.ts +++ b/src/features/chat/host/session.ts @@ -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(), + 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; + pendingPublications: number; +} + +interface PanelActivityPublicationSlot { + readonly hold: PanelActivityHold; + commit: (() => void) | null; +} + function panelActivity(state: ChatState): PanelActivity { return { threadId: panelThreadId(state), diff --git a/src/plugin-runtime.ts b/src/plugin-runtime.ts index 4176808a..4b58c0bc 100644 --- a/src/plugin-runtime.ts +++ b/src/plugin-runtime.ts @@ -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, })); } diff --git a/src/workspace/panel-coordinator.ts b/src/workspace/panel-coordinator.ts index d256c62b..dd8ebf0b 100644 --- a/src/workspace/panel-coordinator.ts +++ b/src/workspace/panel-coordinator.ts @@ -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, diff --git a/tests/features/chat/application/threads/thread-management-actions.test.ts b/tests/features/chat/application/threads/thread-management-actions.test.ts index 89f87179..7b475484 100644 --- a/tests/features/chat/application/threads/thread-management-actions.test.ts +++ b/tests/features/chat/application/threads/thread-management-actions.test.ts @@ -48,6 +48,9 @@ type ThreadManagementActionsHostMock = Omit< record: Mock["record"]>; finish: Mock["finish"]>; }; + activityPublication: { + publish: Mock<(commit: () => void) => void>; + }; beginThreadForkPublication: Mock; }; @@ -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().mockReturnValue(forkPublication), threadHasPendingOrRunningPanel: vi.fn(() => false), }; diff --git a/tests/features/chat/host/session-runtime.test.ts b/tests/features/chat/host/session-runtime.test.ts index efc30269..7a40e204 100644 --- a/tests/features/chat/host/session-runtime.test.ts +++ b/tests/features/chat/host/session-runtime.test.ts @@ -187,6 +187,7 @@ describe("ChatPanelSessionRuntime actions", () => { resumeWork, threadStreamScrollBinding, getClosing: () => false, + beginPanelActivityPublication: () => ({ publish: (commit) => commit() }), }); return { runtime, stateStore, resumeWork, deferredTasks, threadStreamScrollBinding }; } diff --git a/tests/features/chat/host/view-restoration.test.ts b/tests/features/chat/host/view-restoration.test.ts index 0fe20c1b..59ed2adf 100644 --- a/tests/features/chat/host/view-restoration.test.ts +++ b/tests/features/chat/host/view-restoration.test.ts @@ -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(); + }); }); diff --git a/tests/support/plugin-fixtures.ts b/tests/support/plugin-fixtures.ts index d750ada7..36c0d0d6 100644 --- a/tests/support/plugin-fixtures.ts +++ b/tests/support/plugin-fixtures.ts @@ -200,12 +200,18 @@ export function thread(id: string): Thread { } export function panelSnapshot(overrides: PanelSnapshotFixtureOverrides = {}): ReturnType { - 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,