From f93570848fc7ee07bc65bd6fca901a7b15953d39 Mon Sep 17 00:00:00 2001 From: murashit Date: Sat, 18 Jul 2026 22:54:46 +0900 Subject: [PATCH] refactor(chat): derive Threads activity from panel state --- src/domain/pending-requests/aggregate.ts | 20 +-- .../connection/connection-actions.ts | 4 +- .../pending-request-actions.ts | 2 - .../chat/application/threads/goal-actions.ts | 2 - .../application/threads/resume-actions.ts | 3 - .../chat/host/bundles/composer-bundle.ts | 3 - .../chat/host/bundles/connection-bundle.ts | 10 -- .../chat/host/bundles/thread-bundle.ts | 23 +-- src/features/chat/host/bundles/turn-bundle.ts | 3 - src/features/chat/host/contracts.ts | 9 +- src/features/chat/host/session-runtime.ts | 19 --- src/features/chat/host/session.ts | 60 ++++++-- .../chat/panel/composer-controller.ts | 7 - src/plugin-runtime.ts | 7 +- src/workspace/panel-coordinator.ts | 12 +- .../connection/connection-actions.test.ts | 2 - .../pending-request-actions.test.ts | 24 +--- .../application/threads/goal-actions.test.ts | 28 ---- .../threads/resume-actions.test.ts | 11 -- .../chat/host/session-runtime.test.ts | 16 +-- tests/features/chat/host/turn-bundle.test.ts | 1 - .../chat/host/view-connection.test.ts | 132 ++++++++++++++++-- .../chat/panel/composer-controller.test.ts | 14 -- tests/features/chat/panel/shell.test.tsx | 1 - tests/support/plugin-fixtures.ts | 12 +- tests/workspace/panel-coordinator.test.ts | 2 +- 26 files changed, 201 insertions(+), 226 deletions(-) diff --git a/src/domain/pending-requests/aggregate.ts b/src/domain/pending-requests/aggregate.ts index fe1684f5..e3142712 100644 --- a/src/domain/pending-requests/aggregate.ts +++ b/src/domain/pending-requests/aggregate.ts @@ -11,24 +11,10 @@ interface PendingRequestQueueSource { readonly pendingMcpElicitations: readonly unknown[]; } -interface PendingRequestCountSource { - readonly pendingApprovals: number; - readonly pendingUserInputs: number; - readonly pendingMcpElicitations: number; -} - export function pendingRequestCountsFromQueues(source: PendingRequestQueueSource): PendingRequestCounts { - return pendingRequestCounts({ - pendingApprovals: source.approvals.length, - pendingUserInputs: source.pendingUserInputs.length, - pendingMcpElicitations: source.pendingMcpElicitations.length, - }); -} - -export function pendingRequestCounts(source: PendingRequestCountSource): PendingRequestCounts { - const approvals = source.pendingApprovals; - const userInputs = source.pendingUserInputs; - const mcpElicitations = source.pendingMcpElicitations; + const approvals = source.approvals.length; + const userInputs = source.pendingUserInputs.length; + const mcpElicitations = source.pendingMcpElicitations.length; return { approvals, userInputs, diff --git a/src/features/chat/application/connection/connection-actions.ts b/src/features/chat/application/connection/connection-actions.ts index 6b5649b4..7e89d3ae 100644 --- a/src/features/chat/application/connection/connection-actions.ts +++ b/src/features/chat/application/connection/connection-actions.ts @@ -35,7 +35,6 @@ export interface ChatConnectionActionsHost { setStatus: (statusText: string, phase?: ChatConnectionPhase) => void; addSystemMessage: (text: string) => void; configuredCommand: () => string; - refreshLiveState: () => void; isStaleConnectionError: (error: unknown) => boolean; isStaleResourceContextError: (error: unknown) => boolean; notifyConnectionFailed: () => void; @@ -43,7 +42,7 @@ export interface ChatConnectionActionsHost { type ChatConnectionExitHost = Pick< ChatConnectionActionsHost, - "invalidateThreadWork" | "setStatus" | "stateStore" | "resetThreadTurnPresence" | "refreshLiveState" + "invalidateThreadWork" | "setStatus" | "stateStore" | "resetThreadTurnPresence" >; function handleChatConnectionExit(host: ChatConnectionExitHost): void { @@ -51,7 +50,6 @@ function handleChatConnectionExit(host: ChatConnectionExitHost): void { host.setStatus(STATUS_CONNECTION_STOPPED, { kind: "disconnected", message: STATUS_CONNECTION_STOPPED }); host.stateStore.dispatch({ type: "connection/scoped-cleared" }); host.resetThreadTurnPresence(false); - host.refreshLiveState(); } export interface ChatConnectionActions { diff --git a/src/features/chat/application/pending-requests/pending-request-actions.ts b/src/features/chat/application/pending-requests/pending-request-actions.ts index 42f8a2e4..45e9c674 100644 --- a/src/features/chat/application/pending-requests/pending-request-actions.ts +++ b/src/features/chat/application/pending-requests/pending-request-actions.ts @@ -22,7 +22,6 @@ export interface PendingRequestActionsHost { responder: PendingRequestResponder; composerHasFocus: () => boolean; focusComposer: () => void; - refreshLiveState: () => void; } export interface PendingRequestActions { @@ -121,6 +120,5 @@ function pendingUserInput(host: PendingRequestActionsHost, requestId: PendingReq } function commitRequestAction(host: PendingRequestActionsHost): void { - host.refreshLiveState(); host.focusComposer(); } diff --git a/src/features/chat/application/threads/goal-actions.ts b/src/features/chat/application/threads/goal-actions.ts index e175c342..b879877d 100644 --- a/src/features/chat/application/threads/goal-actions.ts +++ b/src/features/chat/application/threads/goal-actions.ts @@ -17,7 +17,6 @@ export interface ThreadGoalSyncHost { localItemIds: LocalIdSource; addSystemMessage: (text: string) => void; addGoalEvent: (item: GoalThreadStreamItem) => void; - refreshLiveState: () => void; } export interface GoalActionsHost extends ThreadGoalSyncHost { @@ -253,7 +252,6 @@ function applyGoalIfActive( const item = options.reportChange ? goalChangeItem(host.localItemIds.next("goal"), activeThread.goal, goal) : null; host.stateStore.dispatch({ type: "active-thread/goal-set", goal }); if (item) host.addGoalEvent(item); - host.refreshLiveState(); return true; } diff --git a/src/features/chat/application/threads/resume-actions.ts b/src/features/chat/application/threads/resume-actions.ts index b188f8a2..6d481135 100644 --- a/src/features/chat/application/threads/resume-actions.ts +++ b/src/features/chat/application/threads/resume-actions.ts @@ -21,7 +21,6 @@ export interface ResumeActionsHost { notifyActiveThreadIdentityChanged: () => void; recordResumedThread: (thread: Thread) => void; addSystemMessage: (text: string) => void; - refreshLiveState: () => void; syncThreadGoal: (threadId: string) => Promise; recoverTokenUsageFromRollout?: (path: string) => Promise; } @@ -80,7 +79,6 @@ async function resumeThread( if (renderFallbackMessage) { host.addSystemMessage(`Resumed thread ${effect.value.activation.thread.id}`); } - host.refreshLiveState(); return true; } catch (error) { if (isStaleResume(host, resume, currentPanelTarget)) return false; @@ -123,7 +121,6 @@ function recoverResumedThreadTokenUsage( const activeThread = activeThreadState(state); if (!activeThread || activeThread.id !== threadId || activeThread.tokenUsage !== null) return; host.stateStore.dispatch({ type: "active-thread/token-usage-set", tokenUsage }); - host.refreshLiveState(); }) .catch(() => undefined); } diff --git a/src/features/chat/host/bundles/composer-bundle.ts b/src/features/chat/host/bundles/composer-bundle.ts index 0a18660f..edc008b7 100644 --- a/src/features/chat/host/bundles/composer-bundle.ts +++ b/src/features/chat/host/bundles/composer-bundle.ts @@ -58,9 +58,6 @@ export function createChatComposerController( togglePlan: () => void input.runtimeSettings.toggleCollaborationMode(), toggleAutoReview: () => void input.runtimeSettings.toggleAutoReview(), toggleFast: () => void input.runtimeSettings.toggleFastMode(), - onDraftChange: () => { - environment.plugin.workspace.refreshThreadsViewLiveState(); - }, onHeightChange: () => undefined, canFocus: environment.obsidian.isForeground, onAttachmentError: (message) => { diff --git a/src/features/chat/host/bundles/connection-bundle.ts b/src/features/chat/host/bundles/connection-bundle.ts index 19410648..1fa3a8ba 100644 --- a/src/features/chat/host/bundles/connection-bundle.ts +++ b/src/features/chat/host/bundles/connection-bundle.ts @@ -38,9 +38,7 @@ interface ChatPanelConnectionBundleHost { canConnect: () => boolean; deferredTasks: ChatViewDeferredTasks; invalidateThreadWork: () => void; - deferLiveStateRefresh: () => void; refreshTabHeader: () => void; - refreshLiveState: () => void; } export interface ChatPanelConnectionBundle { @@ -173,9 +171,6 @@ export function createConnectionBundle( resetThreadTurnPresence: (hadTurns: boolean) => { autoTitleCoordinator.resetThreadTurnPresence(hadTurns); }, - refreshLiveState: () => { - host.refreshLiveState(); - }, }; const connectionActions = createChatConnectionActions({ ...connectionExitHost, @@ -189,12 +184,10 @@ export function createConnectionBundle( vaultPath: sourceContext.cwd, generation: sourceContext.generation, }); - host.deferLiveStateRefresh(); }, onServerRequest: (request, responder) => { serverRequestResponders.remember(request.id, responder); inboundHandler.handleServerRequest(request); - host.deferLiveStateRefresh(); }, onLog: (message) => { inboundHandler.handleAppServerLog(message); @@ -235,9 +228,6 @@ export function createConnectionBundle( setStatus: status.set, addSystemMessage: status.addSystemMessage, configuredCommand: () => environment.plugin.settingsRef.settings.codexPath(), - refreshLiveState: () => { - host.refreshLiveState(); - }, isStaleConnectionError: (error) => error instanceof StaleConnectionError, isStaleResourceContextError: isStaleAppServerResourceContextError, notifyConnectionFailed: () => { diff --git a/src/features/chat/host/bundles/thread-bundle.ts b/src/features/chat/host/bundles/thread-bundle.ts index 33c22367..25670530 100644 --- a/src/features/chat/host/bundles/thread-bundle.ts +++ b/src/features/chat/host/bundles/thread-bundle.ts @@ -65,7 +65,6 @@ interface ChatPanelThreadFoundationInput { appServer: ChatCurrentAppServerGateway; localItemIds: LocalIdSource; status: ChatPanelThreadStatus; - refreshLiveState: () => void; } interface ChatPanelThreadFoundation { @@ -85,7 +84,6 @@ interface ChatPanelThreadLifecycleInput { status: ChatPanelThreadStatus; threadStart: ThreadStartActions; foundation: ChatPanelThreadFoundation; - refreshLiveState: () => void; notifyActiveThreadIdentityChanged: () => void; } @@ -115,7 +113,7 @@ interface ChatPanelThreadActionBundle { } export function createThreadFoundation(host: ChatPanelThreadHost, input: ChatPanelThreadFoundationInput): ChatPanelThreadFoundation { - const { appServer, localItemIds, status, refreshLiveState } = input; + const { appServer, localItemIds, status } = input; const { environment, stateStore } = host; const threadOperationsTransport = createThreadOperationsTransport(appServer.clientAccess); const threadTitleTransport = createThreadTitleTransport({ @@ -200,7 +198,6 @@ export function createThreadFoundation(host: ChatPanelThreadHost, input: ChatPan addGoalEvent: (item) => { stateStore.dispatch({ type: "thread-stream/item-upserted", item }); }, - refreshLiveState, }, goalOperations, ); @@ -220,8 +217,7 @@ export function createThreadLifecycleBundle( host: ChatPanelThreadHost, input: ChatPanelThreadLifecycleInput, ): ChatPanelThreadLifecycleBundle { - const { appServer, localItemIds, ensureConnected, status, threadStart, foundation, refreshLiveState, notifyActiveThreadIdentityChanged } = - input; + const { appServer, localItemIds, ensureConnected, status, threadStart, foundation, notifyActiveThreadIdentityChanged } = input; let sessionLifecycle: ChatPanelThreadLifecycle | null = null; const goals = createGoalActions( { @@ -241,7 +237,6 @@ export function createThreadLifecycleBundle( addGoalEvent: (item) => { host.stateStore.dispatch({ type: "thread-stream/item-upserted", item }); }, - refreshLiveState, }, foundation.goalOperations, ); @@ -261,7 +256,6 @@ export function createThreadLifecycleBundle( invalidateThreadWork: () => { foundation.invalidateThreadWork(); }, - refreshLiveState, notifyActiveThreadIdentityChanged, }); sessionLifecycle = lifecycle; @@ -336,20 +330,10 @@ function createSessionThreadLifecycle( autoTitleCoordinator: AutoTitleCoordinator; history: HistoryController; invalidateThreadWork: () => void; - refreshLiveState: () => void; notifyActiveThreadIdentityChanged: () => void; }, ): ChatPanelThreadLifecycle { - const { - appServer, - status, - goals, - autoTitleCoordinator, - history, - invalidateThreadWork, - refreshLiveState, - notifyActiveThreadIdentityChanged, - } = input; + const { appServer, status, goals, autoTitleCoordinator, history, invalidateThreadWork, notifyActiveThreadIdentityChanged } = input; const restoration = new RestorationController({ stateStore: host.stateStore, }); @@ -368,7 +352,6 @@ function createSessionThreadLifecycle( host.environment.plugin.threadCatalog.apply({ type: "thread-upserted", thread }); }, addSystemMessage: status.addSystemMessage, - refreshLiveState, syncThreadGoal: (threadId) => goals.syncThreadGoal(threadId), recoverTokenUsageFromRollout: (path) => recoverRolloutTokenUsage(path, (filePath, options) => appServer.readFileBase64(filePath, options)), diff --git a/src/features/chat/host/bundles/turn-bundle.ts b/src/features/chat/host/bundles/turn-bundle.ts index 7aef4e4c..0179dc94 100644 --- a/src/features/chat/host/bundles/turn-bundle.ts +++ b/src/features/chat/host/bundles/turn-bundle.ts @@ -54,7 +54,6 @@ interface ChatPanelTurnInput { reconnect: () => Promise; runtimeProjection: ChatPanelRuntimeProjection; refreshDiagnostics: () => Promise; - refreshLiveState: () => void; notifyActiveThreadIdentityChanged: () => void; } @@ -75,7 +74,6 @@ export function createTurnBundle(host: ChatPanelTurnHost, input: ChatPanelTurnIn reconnect, runtimeProjection, refreshDiagnostics, - refreshLiveState, notifyActiveThreadIdentityChanged, } = input; const pendingRequests = createPendingRequestActions({ @@ -85,7 +83,6 @@ export function createTurnBundle(host: ChatPanelTurnHost, input: ChatPanelTurnIn focusComposer: () => { composerController.focusComposer(); }, - refreshLiveState, }); const threadReferenceResolver = appServer.threadReferences({ prepareInput: (text, snapshot) => composerController.preparedInput(text, snapshot), diff --git a/src/features/chat/host/contracts.ts b/src/features/chat/host/contracts.ts index 748a16fb..ba6e9a8c 100644 --- a/src/features/chat/host/contracts.ts +++ b/src/features/chat/host/contracts.ts @@ -5,7 +5,6 @@ import type { AppServerContextLease, AppServerQueryContextIdentity } from "../.. import type { ObservedResultListener } from "../../../app-server/query/observed-result"; import type { ModelMetadata, ReasoningEffort } from "../../../domain/catalog/metadata"; import type { SendShortcut } from "../../../domain/input/send-shortcut"; -import type { PendingRequestCounts } from "../../../domain/pending-requests/aggregate"; import type { SharedServerMetadata, SharedServerMetadataResource } from "../../../domain/server/metadata"; import type { ArchiveExportSettings } from "../../../domain/threads/archive-markdown"; import type { KeyedOperationQueue } from "../../../shared/runtime/keyed-operation-queue"; @@ -51,7 +50,7 @@ interface WorkspacePanels { openThreadInNewView(threadId: string): Promise; focusThreadInOpenView(threadId: string): Promise; openTurnDiff(state: TurnDiffViewState): Promise; - refreshThreadsViewLiveState(): void; + notifyPanelActivityChanged(): void; openSideChat(sourceThreadId: string, sourceThreadTitle: string | null): Promise; } @@ -102,13 +101,11 @@ export interface ChatViewLifecycleSurface { refreshSettings(): void; } -export type ChatWorkspacePanelTurnLifecycle = { kind: "idle" } | { kind: "starting" } | { kind: "running"; turnId: string }; - export interface ChatWorkspacePanelSnapshot { viewId: string; threadId: string | null; - turnLifecycle: ChatWorkspacePanelTurnLifecycle; - pendingRequests: PendingRequestCounts; + 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 8f5061fe..5cd6a310 100644 --- a/src/features/chat/host/session-runtime.ts +++ b/src/features/chat/host/session-runtime.ts @@ -57,8 +57,6 @@ interface ChatPanelSessionRuntimeParts { }; runtime: { sharedState: ChatPanelSharedStateBinding; - refreshLiveState(): void; - deferLiveStateRefresh(): void; }; disposeOwnedResources: () => void; } @@ -77,7 +75,6 @@ interface ChatPanelSessionRuntimeHost { threadStreamScrollBinding: ChatThreadStreamScrollBinding; getClosing: () => boolean; reconnect?: () => Promise; - viewWindow: () => Window; } export class ChatPanelSessionRuntime { @@ -109,8 +106,6 @@ export class ChatPanelSessionRuntime { this.disposeOwnedResources(); unmount(); this.connection.manager.disconnect(); - this.runtime.refreshLiveState(); - this.runtime.deferLiveStateRefresh(); } } @@ -129,23 +124,15 @@ function composeChatPanelSessionRuntime(host: ChatPanelSessionRuntimeHost): Chat const refreshTabHeader = () => { host.environment.view.refreshTabHeader(); }; - const refreshLiveState = () => { - host.environment.plugin.workspace.refreshThreadsViewLiveState(); - }; - const deferLiveStateRefresh = () => { - host.viewWindow().setTimeout(refreshLiveState, 0); - }; const notifyActiveThreadIdentityChanged = () => { refreshTabHeader(); host.environment.obsidian.requestWorkspaceLayoutSave(); - refreshLiveState(); }; const threadFoundation = createThreadFoundation(host, { appServer: currentAppServer, localItemIds, status, - refreshLiveState, }); const invalidateThreadWork = (): void => { threadFoundation.invalidateThreadWork(); @@ -157,9 +144,7 @@ function composeChatPanelSessionRuntime(host: ChatPanelSessionRuntimeHost): Chat canConnect: () => !host.getClosing(), deferredTasks: host.deferredTasks, invalidateThreadWork, - deferLiveStateRefresh, refreshTabHeader, - refreshLiveState, }, { connection, @@ -207,7 +192,6 @@ function composeChatPanelSessionRuntime(host: ChatPanelSessionRuntimeHost): Chat status, threadStart, foundation: threadFoundation, - refreshLiveState, notifyActiveThreadIdentityChanged, }); const composerController = createChatComposerController(host, { @@ -296,7 +280,6 @@ function composeChatPanelSessionRuntime(host: ChatPanelSessionRuntimeHost): Chat reconnect: reconnectForUser, runtimeProjection: runtime.projection, refreshDiagnostics: () => connectionActions.refreshDiagnostics(), - refreshLiveState, notifyActiveThreadIdentityChanged, }); const shell = createShellBundle(host, { @@ -358,8 +341,6 @@ function composeChatPanelSessionRuntime(host: ChatPanelSessionRuntimeHost): Chat }, runtime: { sharedState, - refreshLiveState, - deferLiveStateRefresh, }, disposeOwnedResources: () => { connectionBundle.invalidateConnectionScope(); diff --git a/src/features/chat/host/session.ts b/src/features/chat/host/session.ts index c1bbb444..4b117cf8 100644 --- a/src/features/chat/host/session.ts +++ b/src/features/chat/host/session.ts @@ -4,14 +4,15 @@ import { appServerQueryContextIdentity, appServerQueryContextIdentityMatches, } from "../../../app-server/query/keys"; -import { pendingRequestCountsFromQueues } from "../../../domain/pending-requests/aggregate"; +import { hasPendingRequests, pendingRequestCountsFromQueues } from "../../../domain/pending-requests/aggregate"; import { threadMeaningfulTitle, threadWindowTitle } from "../../../domain/threads/title"; import { activeThreadState, awaitingResumeThreadState, type ChatState, panelThreadId } from "../application/state/root-reducer"; import { type ChatStateStore, createChatStateStore } from "../application/state/store"; import { ChatResumeWorkTracker } from "../application/threads/resume-work"; +import { chatTurnBusy } from "../application/turns/turn-state"; import { renderChatPanelShell, unmountChatPanelShell } from "../panel/shell.dom"; import { type ChatThreadStreamScrollBinding, createChatThreadStreamScrollBinding } from "../panel/thread-stream-scroll-binding"; -import type { ChatPanelEnvironment, ChatPanelHandle, ChatWorkspacePanelSnapshot, ChatWorkspacePanelTurnLifecycle } from "./contracts"; +import type { ChatPanelEnvironment, ChatPanelHandle, ChatWorkspacePanelSnapshot } from "./contracts"; import { type ChatViewDeferredTasks, createChatViewDeferredTasks } from "./session/deferred-work"; import { ChatPanelSessionRuntime } from "./session-runtime"; import { parseChatPanelViewState } from "./view-state"; @@ -29,6 +30,8 @@ export class ChatPanelSession implements ChatPanelHandle { private pendingAppServerContextReplacement: { panelThreadId: string | null; generation: number } | null = null; private opened = false; private closing = false; + private observedPanelActivity: PanelActivity | null = null; + private unsubscribePanelActivity: (() => void) | null = null; constructor(private readonly environment: ChatPanelEnvironment) { this.observedAppServerContext = this.currentAppServerContext(); @@ -166,12 +169,11 @@ export class ChatPanelSession implements ChatPanelHandle { } openPanelSnapshot(): ChatWorkspacePanelSnapshot { - const pendingRequests = pendingRequestCountsFromQueues(this.state.requests); + const activity = panelActivity(this.state); return { viewId: this.environment.obsidian.viewId, - threadId: this.closing ? null : this.panelThreadId(), - turnLifecycle: openPanelTurnLifecycle(this.state.turn.lifecycle), - pendingRequests, + ...activity, + threadId: this.closing ? null : activity.threadId, hasComposerDraft: this.state.composer.draft.trim().length > 0, connected: this.runtime.connection.manager.isConnected(), }; @@ -221,6 +223,7 @@ export class ChatPanelSession implements ChatPanelHandle { open(): void { this.opened = true; this.closing = false; + this.observePanelActivity(); this.environment.obsidian.registerPointerDown((event) => { this.closeToolbarPanelOnOutsidePointer(event); }); @@ -232,10 +235,18 @@ export class ChatPanelSession implements ChatPanelHandle { async close(): Promise { this.opened = false; this.closing = true; + this.unsubscribePanelActivity?.(); + this.unsubscribePanelActivity = null; + this.observedPanelActivity = null; + const viewWindow = this.viewWindow(); const panelRoot = this.environment.view.panelRoot(); await this.runtime.dispose(() => { unmountChatPanelShell(panelRoot); }); + this.notifyPanelActivityChanged(); + viewWindow.setTimeout(() => { + this.notifyPanelActivityChanged(); + }, 0); } setComposerText(text: string): void { @@ -296,6 +307,22 @@ export class ChatPanelSession implements ChatPanelHandle { this.runtime.shell.closeToolbarPanelOnOutsidePointer(event); } + private observePanelActivity(): void { + this.unsubscribePanelActivity?.(); + this.observedPanelActivity = panelActivity(this.state); + this.unsubscribePanelActivity = this.stateStore.subscribe(() => { + const next = panelActivity(this.state); + if (panelActivityEquals(this.observedPanelActivity, next)) return; + this.observedPanelActivity = next; + this.notifyPanelActivityChanged(); + }); + this.notifyPanelActivityChanged(); + } + + private notifyPanelActivityChanged(): void { + this.environment.plugin.workspace.notifyPanelActivityChanged(); + } + private activeThreadTitle(): string | null { const activeThread = activeThreadState(this.state); if (!activeThread) return null; @@ -334,13 +361,24 @@ export class ChatPanelSession implements ChatPanelHandle { threadStreamScrollBinding: this.threadStreamScrollBinding, getClosing: () => this.closing, reconnect: () => this.reconnect(), - viewWindow: () => this.viewWindow(), }); } } -function openPanelTurnLifecycle(state: ChatState["turn"]["lifecycle"]): ChatWorkspacePanelTurnLifecycle { - if (state.kind === "running") return { kind: "running", turnId: state.turnId }; - if (state.kind === "starting") return { kind: "starting" }; - return { kind: "idle" }; +interface PanelActivity { + readonly threadId: string | null; + readonly turnBusy: boolean; + readonly pending: boolean; +} + +function panelActivity(state: ChatState): PanelActivity { + return { + threadId: panelThreadId(state), + turnBusy: chatTurnBusy(state), + pending: hasPendingRequests(pendingRequestCountsFromQueues(state.requests)), + }; +} + +function panelActivityEquals(left: PanelActivity | null, right: PanelActivity): boolean { + return left !== null && left.threadId === right.threadId && left.turnBusy === right.turnBusy && left.pending === right.pending; } diff --git a/src/features/chat/panel/composer-controller.ts b/src/features/chat/panel/composer-controller.ts index aba10ea5..07bd392f 100644 --- a/src/features/chat/panel/composer-controller.ts +++ b/src/features/chat/panel/composer-controller.ts @@ -66,7 +66,6 @@ export interface ChatComposerControllerOptions { togglePlan: () => void; toggleAutoReview: () => void; toggleFast: () => void; - onDraftChange: () => void; onHeightChange: () => void; canFocus: () => boolean; onAttachmentError?: (message: string) => void; @@ -145,7 +144,6 @@ export class ChatComposerController { draft: text, ...(options.clearSuggestions === undefined ? {} : { clearSuggestions: options.clearSuggestions }), }); - this.options.onDraftChange(); if (options.focus && this.options.canFocus()) focusComposer(this.composer); } @@ -270,7 +268,6 @@ export class ChatComposerController { selected: suggestionState.selected, dismissedSignature: suggestionState.dismissedSignature, }); - this.options.onDraftChange(); } private inputSuggestionState(): { @@ -335,7 +332,6 @@ export class ChatComposerController { this.pendingSelection = collapsedComposerSelection(insertion.value, insertion.cursor); this.dispatch({ type: "composer/draft-set", draft: insertion.value, clearSuggestions: true }); - this.options.onDraftChange(); applyComposerInsertionToElement(this.composer, insertion.cursor); } @@ -398,7 +394,6 @@ export class ChatComposerController { }; this.pendingSelection = collapsedComposerSelection(insertion.value, insertion.cursor); this.dispatch({ type: "composer/attachment-save-started", saveId: id, draft: insertion.value }); - this.options.onDraftChange(); applyComposerInsertionToElement(this.composer, insertion.cursor); return pending; } @@ -425,7 +420,6 @@ export class ChatComposerController { this.pruneActiveNoteContextSnapshots(replacement.value); this.pruneSelectionContextSnapshots(replacement.value); this.dispatch({ type: "composer/attachment-save-settled", saveId: pending.id, draft: replacement.value }); - this.options.onDraftChange(); } private settlePendingAttachmentSave(pending: PendingAttachmentSave): void { @@ -434,7 +428,6 @@ export class ChatComposerController { const replacement = replacePendingAttachmentWithOriginalText(state.composer.draft, pending); this.pendingSelection = adjustedComposerSelection(composerSelectionSource(this.composer), state.composer.draft, replacement); this.dispatch({ type: "composer/attachment-save-settled", saveId: pending.id, draft: replacement.value }); - this.options.onDraftChange(); } private dismissSuggestions(): void { diff --git a/src/plugin-runtime.ts b/src/plugin-runtime.ts index 76299176..b0d3ced7 100644 --- a/src/plugin-runtime.ts +++ b/src/plugin-runtime.ts @@ -11,7 +11,6 @@ import { } from "./app-server/query/keys"; import { AppServerResourceStore, StaleAppServerResourceContextError } from "./app-server/query/resource-store"; import { VIEW_TYPE_CODEX_THREADS, VIEW_TYPE_CODEX_TURN_DIFF } from "./constants"; -import { hasPendingRequests } from "./domain/pending-requests/aggregate"; import { createThreadGoalOperationCoordinator } from "./features/chat/application/threads/goal-actions"; import type { ChatPanelClientSurface, @@ -152,7 +151,7 @@ export class CodexPanelRuntime implements AppServerClientAccess { focusThreadInOpenView: (threadId) => this.panels.focusThreadInOpenView(threadId), openTurnDiff: (state) => this.openTurnDiff(state), openSideChat: (sourceThreadId, sourceThreadTitle) => this.panels.openSideChat(sourceThreadId, sourceThreadTitle), - refreshThreadsViewLiveState: () => { + notifyPanelActivityChanged: () => { this.refreshThreadsViewLiveState(); }, }, @@ -324,8 +323,8 @@ export class CodexPanelRuntime implements AppServerClientAccess { return this.panels.getOpenPanelSnapshots().map((snapshot) => ({ threadId: snapshot.threadId, selected: snapshot.lastFocused, - pending: hasPendingRequests(snapshot.pendingRequests), - running: snapshot.turnLifecycle.kind !== "idle", + pending: snapshot.pending, + running: snapshot.turnBusy, })); } diff --git a/src/workspace/panel-coordinator.ts b/src/workspace/panel-coordinator.ts index 3358cb11..1bbe0d3b 100644 --- a/src/workspace/panel-coordinator.ts +++ b/src/workspace/panel-coordinator.ts @@ -1,7 +1,6 @@ import type { App, WorkspaceLeaf } from "obsidian"; import { VIEW_TYPE_CODEX_PANEL } from "../constants"; -import { hasPendingRequests, pendingRequestCounts } from "../domain/pending-requests/aggregate"; import type { ChatWorkspacePanelSnapshot, ChatWorkspacePanelSurface } from "../features/chat/host/contracts"; import { CodexChatView } from "../features/chat/host/view.obsidian"; import { parseChatPanelViewState } from "../features/chat/host/view-state"; @@ -661,12 +660,7 @@ export class WorkspacePanelCoordinator { } function isIdleEmptyPanelSnapshot(snapshot: ChatWorkspacePanelSnapshot): boolean { - return ( - snapshot.threadId === null && - snapshot.turnLifecycle.kind === "idle" && - !hasPendingRequests(snapshot.pendingRequests) && - !snapshot.hasComposerDraft - ); + return snapshot.threadId === null && !snapshot.turnBusy && !snapshot.pending && !snapshot.hasComposerDraft; } function focusedPanelViewId(leaf: WorkspaceLeaf | null): string | null { @@ -688,8 +682,8 @@ function restoredPanelSnapshot(leaf: WorkspaceLeaf, index: number): WorkspacePan return { viewId: `restored:${String(index)}:${threadId}`, threadId, - turnLifecycle: { kind: "idle" }, - pendingRequests: pendingRequestCounts({ pendingApprovals: 0, pendingUserInputs: 0, pendingMcpElicitations: 0 }), + turnBusy: false, + pending: false, hasComposerDraft: false, connected: false, lastFocused: false, diff --git a/tests/features/chat/application/connection/connection-actions.test.ts b/tests/features/chat/application/connection/connection-actions.test.ts index 793c0b77..566f739d 100644 --- a/tests/features/chat/application/connection/connection-actions.test.ts +++ b/tests/features/chat/application/connection/connection-actions.test.ts @@ -46,7 +46,6 @@ function createActionsHarness({ connected = false, canConnect = true } = {}) { setStatus: vi.fn(), addSystemMessage: vi.fn(), configuredCommand: () => "codex", - refreshLiveState: vi.fn(), isStaleConnectionError: () => false, isStaleResourceContextError: () => false, notifyConnectionFailed: vi.fn(), @@ -228,7 +227,6 @@ describe("ChatConnectionActions", () => { message: "Codex app-server stopped.", }); expect(host.resetThreadTurnPresence).toHaveBeenCalledWith(false); - expect(host.refreshLiveState).toHaveBeenCalledOnce(); expect(stateStore.getState()).toMatchObject({ threadList: { listedThreads: [{ id: "thread-1", title: "Thread 1" }], diff --git a/tests/features/chat/application/pending-requests/pending-request-actions.test.ts b/tests/features/chat/application/pending-requests/pending-request-actions.test.ts index 4d9c0372..2ed7069a 100644 --- a/tests/features/chat/application/pending-requests/pending-request-actions.test.ts +++ b/tests/features/chat/application/pending-requests/pending-request-actions.test.ts @@ -12,8 +12,8 @@ function expectPresent(value: T | null | undefined): T { } describe("PendingRequestActions", () => { - it("resolves user input from immutable draft state and refreshes the host", () => { - const { stateStore, responder, refreshLiveState, focusComposer, pendingRequests } = actionsHarness(); + it("resolves user input from immutable draft state and restores composer focus", () => { + const { stateStore, responder, focusComposer, pendingRequests } = actionsHarness(); const input = expectPresent(toPendingUserInput(userInputRequest())); stateStore.dispatch({ type: "request/user-input-queued", input }); stateStore.dispatch({ type: "request/user-input-draft-set", key: "7:direction", value: "Left" }); @@ -21,52 +21,45 @@ describe("PendingRequestActions", () => { pendingRequests.actions().resolveUserInput(input.requestId); expect(responder.resolveUserInput).toHaveBeenCalledWith(input.requestId, { direction: "Left" }); - expect(refreshLiveState).toHaveBeenCalledOnce(); expect(focusComposer).toHaveBeenCalledOnce(); - expect(expectPresent(refreshLiveState.mock.invocationCallOrder[0])).toBeLessThan( - expectPresent(focusComposer.mock.invocationCallOrder[0]), - ); }); it.each([ ["allows", "accept"], ["denies", "decline"], ] satisfies [string, ApprovalAction][])("%s a queued approval and commits the action", (_label, action) => { - const { stateStore, responder, refreshLiveState, focusComposer, pendingRequests } = actionsHarness(); + const { stateStore, responder, focusComposer, pendingRequests } = actionsHarness(); stateStore.dispatch({ type: "request/approval-queued", approval: approvalRequest() }); pendingRequests.actions().resolveApproval(1, action); expect(responder.resolveApproval).toHaveBeenCalledWith(1, action); - expect(refreshLiveState).toHaveBeenCalledOnce(); expect(focusComposer).toHaveBeenCalledOnce(); }); it("cancels queued user input and commits the action", () => { - const { stateStore, responder, refreshLiveState, focusComposer, pendingRequests } = actionsHarness(); + const { stateStore, responder, focusComposer, pendingRequests } = actionsHarness(); const input = expectPresent(toPendingUserInput(userInputRequest())); stateStore.dispatch({ type: "request/user-input-queued", input }); pendingRequests.actions().cancelUserInput(input.requestId); expect(responder.cancelUserInput).toHaveBeenCalledWith(input.requestId); - expect(refreshLiveState).toHaveBeenCalledOnce(); expect(focusComposer).toHaveBeenCalledOnce(); }); it("resolves a queued MCP elicitation and commits the action", () => { - const { stateStore, responder, refreshLiveState, focusComposer, pendingRequests } = actionsHarness(); + const { stateStore, responder, focusComposer, pendingRequests } = actionsHarness(); stateStore.dispatch({ type: "request/mcp-elicitation-queued", elicitation: mcpElicitationRequest() }); pendingRequests.actions().resolveMcpElicitation(9, "accept"); expect(responder.resolveMcpElicitation).toHaveBeenCalledWith(9, "accept"); - expect(refreshLiveState).toHaveBeenCalledOnce(); expect(focusComposer).toHaveBeenCalledOnce(); }); it("ignores actions for requests that disappeared before the action ran", () => { - const { stateStore, responder, refreshLiveState, focusComposer, pendingRequests } = actionsHarness(); + const { stateStore, responder, focusComposer, pendingRequests } = actionsHarness(); const input = expectPresent(toPendingUserInput(userInputRequest())); stateStore.dispatch({ type: "request/approval-queued", approval: approvalRequest() }); stateStore.dispatch({ type: "request/user-input-queued", input }); @@ -84,7 +77,6 @@ describe("PendingRequestActions", () => { expect(responder.resolveUserInput).not.toHaveBeenCalled(); expect(responder.cancelUserInput).not.toHaveBeenCalled(); expect(responder.resolveMcpElicitation).not.toHaveBeenCalled(); - expect(refreshLiveState).not.toHaveBeenCalled(); expect(focusComposer).not.toHaveBeenCalled(); }); @@ -147,15 +139,13 @@ function actionsHarness(composerHasFocus = vi.fn(() => false)) { resolveMcpElicitation: vi.fn(), }; const focusComposer = vi.fn(); - const refreshLiveState = vi.fn(); const pendingRequests = createPendingRequestActions({ stateStore, responder, composerHasFocus, focusComposer, - refreshLiveState, }); - return { stateStore, responder, focusComposer, refreshLiveState, pendingRequests }; + return { stateStore, responder, focusComposer, pendingRequests }; } function approvalRequest(): PendingApproval { diff --git a/tests/features/chat/application/threads/goal-actions.test.ts b/tests/features/chat/application/threads/goal-actions.test.ts index 98f91804..1dc9f1aa 100644 --- a/tests/features/chat/application/threads/goal-actions.test.ts +++ b/tests/features/chat/application/threads/goal-actions.test.ts @@ -20,7 +20,6 @@ describe("createGoalActions", () => { const stateStore = createChatStateStore(state); const currentGoal = goal(); const goalTransport = goalTransportFixture({ readThreadGoal: vi.fn().mockResolvedValue(currentGoal) }); - const refreshLiveState = vi.fn(); const actions = createGoalActions({ stateStore, goalTransport, @@ -28,13 +27,11 @@ describe("createGoalActions", () => { startThread: vi.fn().mockResolvedValue({ kind: "created-activated", threadId: "thread" }), addSystemMessage: vi.fn(), addGoalEvent: vi.fn(), - refreshLiveState, }); await actions.syncThreadGoal("thread"); expect(activeThreadState(stateStore.getState())?.goal).toEqual(currentGoal); - expect(refreshLiveState).toHaveBeenCalledOnce(); }); it("does not publish an old goal read after a shared mutation completes", async () => { @@ -52,7 +49,6 @@ describe("createGoalActions", () => { localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }), addSystemMessage: vi.fn(), addGoalEvent: vi.fn(), - refreshLiveState: vi.fn(), }; const goalOperations = createThreadGoalOperationCoordinator(); const sync = createThreadGoalSyncActions(host, goalOperations); @@ -89,7 +85,6 @@ describe("createGoalActions", () => { localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }), addSystemMessage, addGoalEvent: vi.fn(), - refreshLiveState: vi.fn(), }; const goalOperations = createThreadGoalOperationCoordinator(); const sync = createThreadGoalSyncActions(host, goalOperations); @@ -126,7 +121,6 @@ describe("createGoalActions", () => { localItemIds: createLocalIdSource({ nowMs: () => 1, seed: "goal" }), addSystemMessage: vi.fn(), addGoalEvent: vi.fn(), - refreshLiveState: vi.fn(), }; const sync = createThreadGoalSyncActions(host, goalOperations); const actions = createGoalActions( @@ -159,7 +153,6 @@ describe("createGoalActions", () => { startThread: vi.fn().mockResolvedValue({ kind: "created-activated", threadId: "thread" }), addSystemMessage, addGoalEvent: vi.fn(), - refreshLiveState: vi.fn(), }); await actions.syncThreadGoal("thread"); @@ -190,7 +183,6 @@ describe("createGoalActions", () => { startThread: vi.fn().mockResolvedValue({ kind: "created-activated", threadId: "thread" }), addSystemMessage, addGoalEvent, - refreshLiveState: vi.fn(), }); await actions.setObjective("thread", " Updated ", 250); @@ -226,7 +218,6 @@ describe("createGoalActions", () => { startThread: vi.fn().mockResolvedValue({ kind: "created-activated", threadId: "thread" }), addSystemMessage: vi.fn(), addGoalEvent: vi.fn(), - refreshLiveState: vi.fn(), }); const objectiveUpdate = actions.setObjective("thread", "Updated", null); @@ -266,7 +257,6 @@ describe("createGoalActions", () => { startThread: vi.fn().mockResolvedValue({ kind: "created-activated", threadId: "thread-a" }), addSystemMessage: vi.fn(), addGoalEvent: vi.fn(), - refreshLiveState: vi.fn(), }); const oldUpdate = actions.setObjective("thread-a", "Old", null); @@ -304,7 +294,6 @@ describe("createGoalActions", () => { startThread: vi.fn().mockResolvedValue({ kind: "created-activated", threadId: "thread" }), addSystemMessage: vi.fn(), addGoalEvent: vi.fn(), - refreshLiveState: vi.fn(), }, goalOperations, ); @@ -343,7 +332,6 @@ describe("createGoalActions", () => { startThread: vi.fn().mockResolvedValue({ kind: "created-activated", threadId: "side" }), addSystemMessage, addGoalEvent: vi.fn(), - refreshLiveState: vi.fn(), }); await expect(actions.setObjective("side", "Ship", null)).resolves.toBe(false); @@ -369,7 +357,6 @@ describe("createGoalActions", () => { startThread: vi.fn().mockResolvedValue({ kind: "created-activated", threadId: "thread" }), addSystemMessage, addGoalEvent: vi.fn(), - refreshLiveState: vi.fn(), }); const pending = actions.setStatus("thread", "paused"); @@ -396,7 +383,6 @@ describe("createGoalActions", () => { startThread: vi.fn().mockResolvedValue({ kind: "created-activated", threadId: "thread" }), addSystemMessage, addGoalEvent: vi.fn(), - refreshLiveState: vi.fn(), }); const pending = actions.clear("thread"); @@ -421,7 +407,6 @@ describe("createGoalActions", () => { startThread: vi.fn().mockResolvedValue({ kind: "created-activated", threadId: "thread" }), addSystemMessage: vi.fn(), addGoalEvent: vi.fn(), - refreshLiveState: vi.fn(), }); const pending = actions.clear("thread"); @@ -445,7 +430,6 @@ describe("createGoalActions", () => { startThread: vi.fn().mockResolvedValue({ kind: "created-activated", threadId: "thread" }), addSystemMessage, addGoalEvent: vi.fn(), - refreshLiveState: vi.fn(), }); const pending = actions.setObjective("thread", "Finish", null); @@ -472,7 +456,6 @@ describe("createGoalActions", () => { startThread: vi.fn().mockResolvedValue({ kind: "created-activated", threadId: "thread" }), addSystemMessage, addGoalEvent, - refreshLiveState: vi.fn(), }); await actions.setObjective("thread", "Finish", null); @@ -528,7 +511,6 @@ describe("createGoalActions", () => { startThread, addSystemMessage: vi.fn(), addGoalEvent: vi.fn(), - refreshLiveState: vi.fn(), }); await expect(actions.saveObjective(" Plan release ", null)).resolves.toBe(true); @@ -549,7 +531,6 @@ describe("createGoalActions", () => { startThread: vi.fn().mockResolvedValue({ kind: "created-not-activated", threadId: "thread-new" }), addSystemMessage, addGoalEvent: vi.fn(), - refreshLiveState: vi.fn(), }); await expect(actions.saveObjective("Plan release", null)).resolves.toBe(false); @@ -572,7 +553,6 @@ describe("createGoalActions", () => { startThread, addSystemMessage: vi.fn(), addGoalEvent: vi.fn(), - refreshLiveState: vi.fn(), }); const pending = actions.saveObjective("Plan release", null); @@ -626,7 +606,6 @@ describe("createGoalActions", () => { ensureRestoredThreadLoaded, addSystemMessage: vi.fn(), addGoalEvent: vi.fn(), - refreshLiveState: vi.fn(), }); await expect(actions.saveObjective("Resume work", null)).resolves.toBe(true); @@ -652,7 +631,6 @@ describe("createGoalActions", () => { startThread, addSystemMessage, addGoalEvent: vi.fn(), - refreshLiveState: vi.fn(), }); await expect(actions.saveObjective(" ", null)).resolves.toBe(false); @@ -678,7 +656,6 @@ describe("createGoalActions", () => { startThread: vi.fn().mockResolvedValue({ kind: "created-activated", threadId: "thread" }), addSystemMessage, addGoalEvent: vi.fn(), - refreshLiveState: vi.fn(), }); await actions.setObjective("thread", "Finish", null); @@ -705,7 +682,6 @@ describe("createGoalActions", () => { startThread: vi.fn().mockResolvedValue({ kind: "created-activated", threadId: "thread" }), addSystemMessage, addGoalEvent: vi.fn(), - refreshLiveState: vi.fn(), }); await actions.setObjective("thread", "Finish", null); @@ -730,7 +706,6 @@ describe("createGoalActions", () => { startThread: vi.fn().mockResolvedValue({ kind: "created-activated", threadId: "thread" }), addSystemMessage: vi.fn(), addGoalEvent: vi.fn(), - refreshLiveState: vi.fn(), }); await expect(actions.setObjective("thread", "Finish", null)).resolves.toBe(false); @@ -754,7 +729,6 @@ describe("createGoalActions", () => { startThread: vi.fn().mockResolvedValue({ kind: "created-activated", threadId: "thread" }), addSystemMessage: vi.fn(), addGoalEvent, - refreshLiveState: vi.fn(), }); await actions.setObjective("thread", "Updated", null); @@ -778,7 +752,6 @@ describe("createGoalActions", () => { startThread: vi.fn().mockResolvedValue({ kind: "created-activated", threadId: "thread" }), addSystemMessage, addGoalEvent, - refreshLiveState: vi.fn(), }); await actions.setStatus("thread", "active"); @@ -801,7 +774,6 @@ describe("createGoalActions", () => { startThread: vi.fn().mockResolvedValue({ kind: "created-activated", threadId: "thread" }), addSystemMessage, addGoalEvent: vi.fn(), - refreshLiveState: vi.fn(), }); await actions.syncThreadGoal("thread"); diff --git a/tests/features/chat/application/threads/resume-actions.test.ts b/tests/features/chat/application/threads/resume-actions.test.ts index 29dd9f34..9a23c5ff 100644 --- a/tests/features/chat/application/threads/resume-actions.test.ts +++ b/tests/features/chat/application/threads/resume-actions.test.ts @@ -54,7 +54,6 @@ function createActions(response: ThreadResumeSnapshot | null = activation("threa notifyActiveThreadIdentityChanged: vi.fn(), recordResumedThread: vi.fn(), addSystemMessage: vi.fn(), - refreshLiveState: vi.fn(), syncThreadGoal: vi.fn().mockResolvedValue(undefined), ...overrides, resumeTransport: overrides.resumeTransport ?? { ensureConnected: vi.fn().mockResolvedValue(true), resumeThread }, @@ -148,16 +147,6 @@ describe("ResumeActions", () => { expect(activeThreadId(stateStore.getState())).toBe("second"); }); - it("refreshes live state after resumed history and goal sync finish", async () => { - const { actions, host } = createActions(); - - await actions.resumeThread("thread"); - - expect(vi.mocked(host.refreshLiveState).mock.invocationCallOrder.at(-1)).toBeGreaterThan( - vi.mocked(host.syncThreadGoal).mock.invocationCallOrder[0] ?? 0, - ); - }); - it("does not switch threads while a different turn is busy", async () => { const { actions, host, resumeThread, stateStore } = createActions(); stateStore.dispatch({ diff --git a/tests/features/chat/host/session-runtime.test.ts b/tests/features/chat/host/session-runtime.test.ts index 05c5ff69..0f35a4ce 100644 --- a/tests/features/chat/host/session-runtime.test.ts +++ b/tests/features/chat/host/session-runtime.test.ts @@ -100,16 +100,10 @@ describe("ChatPanelSessionRuntime actions", () => { expect(stateStore.getState().threadList.listedThreads).toEqual([]); }); - it("fans out active-thread identity changes after starting a new thread", async () => { - const refreshLiveState = vi.fn(); + it("refreshes persisted view identity after starting a new thread", async () => { const refreshTabHeader = vi.fn(); const { runtime, stateStore } = sessionRuntimeFixture({ environment: { - plugin: { - workspace: { - refreshThreadsViewLiveState: refreshLiveState, - }, - }, view: { refreshTabHeader, }, @@ -133,7 +127,6 @@ describe("ChatPanelSessionRuntime actions", () => { await runtime.actions.startNewThread(); expect(refreshTabHeader).toHaveBeenCalledOnce(); - expect(refreshLiveState).toHaveBeenCalledOnce(); }); it("wires reconnect cleanup through the runtime toolbar action", async () => { @@ -169,11 +162,9 @@ describe("ChatPanelSessionRuntime actions", () => { vi.useFakeTimers(); const unsubscribeThreads = vi.fn(); const unsubscribeMetadata = vi.fn(); - const refreshLiveState = vi.fn(); const { runtime, stateStore, deferredTasks, threadStreamScrollBinding } = sessionRuntimeFixture({ environment: { plugin: { - workspace: { refreshThreadsViewLiveState: refreshLiveState }, threadCatalog: { observeActive: vi.fn(() => unsubscribeThreads) }, appServerQueries: { observeAppServerMetadataResources: vi.fn(() => unsubscribeMetadata), @@ -203,12 +194,10 @@ describe("ChatPanelSessionRuntime actions", () => { expect(dispatchScrollCommand).not.toHaveBeenCalled(); expect(unmount).toHaveBeenCalledOnce(); expect(disconnect).toHaveBeenCalledOnce(); - expect(refreshLiveState).toHaveBeenCalledOnce(); await vi.runAllTimersAsync(); expect(diagnostics).not.toHaveBeenCalled(); expect(warmup).not.toHaveBeenCalled(); - expect(refreshLiveState).toHaveBeenCalledTimes(2); }); function sessionRuntimeFixture(options: { environment?: PartialChatPanelEnvironment } = {}): { @@ -230,7 +219,6 @@ describe("ChatPanelSessionRuntime actions", () => { resumeWork, threadStreamScrollBinding, getClosing: () => false, - viewWindow: () => window, }); return { runtime, stateStore, resumeWork, deferredTasks, threadStreamScrollBinding }; } @@ -298,7 +286,7 @@ describe("ChatPanelSessionRuntime actions", () => { openThreadInNewView: vi.fn().mockResolvedValue(undefined), focusThreadInOpenView: vi.fn().mockResolvedValue(false), openTurnDiff: vi.fn().mockResolvedValue(undefined), - refreshThreadsViewLiveState: vi.fn(), + notifyPanelActivityChanged: vi.fn(), ...overrides.plugin?.workspace, openSideChat: overrides.plugin?.workspace?.openSideChat ?? vi.fn().mockResolvedValue(undefined), }, diff --git a/tests/features/chat/host/turn-bundle.test.ts b/tests/features/chat/host/turn-bundle.test.ts index 852f9d4e..870ce83b 100644 --- a/tests/features/chat/host/turn-bundle.test.ts +++ b/tests/features/chat/host/turn-bundle.test.ts @@ -94,7 +94,6 @@ function turnBundleFixture(options: { stateStore?: ReturnType { connectCalls: 0, connected: false, onNotification: null as ((notification: ServerNotification) => void) | null, + onServerRequest: null as + | ((request: ServerRequest, responder: { respond(result: unknown): void; reject(code: number, message: string): void }) => void) + | null, onExit: null as (() => void) | null, }; @@ -48,6 +51,7 @@ const connectionMock = vi.hoisted(() => { state.connectCalls = 0; state.connected = false; state.onNotification = null; + state.onServerRequest = null; state.onExit = null; }, }; @@ -120,6 +124,7 @@ vi.mock("../../../../src/app-server/connection/connection-manager", () => { private publishHandlers(handlers: NonNullable): void { const context = { codexPath: this.codexPath(), cwd: this.cwd }; connectionMock.state.onNotification = (notification) => handlers.onNotification(notification, context); + connectionMock.state.onServerRequest = (request, responder) => handlers.onServerRequest(request, responder); connectionMock.state.onExit = () => handlers.onExit(context); } } @@ -621,6 +626,39 @@ describe("CodexChatView connection lifecycle", () => { expect(requestMethods(client)).not.toContain("thread/list"); }); + it("notifies Threads immediately and after the workspace settles when the panel closes", async () => { + const notifyPanelActivityChanged = vi.fn(); + connectionMock.state.client = connectedClient(); + const view = await chatView({ host: chatHost({ notifyPanelActivityChanged }) }); + + await view.onOpen(); + await view.surface.openThread("thread-1"); + notifyPanelActivityChanged.mockClear(); + await view.onClose(); + + expect(notifyPanelActivityChanged).toHaveBeenCalledOnce(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + expect(notifyPanelActivityChanged).toHaveBeenCalledTimes(2); + + connectionMock.state.onNotification?.({ + method: "turn/started", + params: { + threadId: "thread-1", + turn: { + id: "late-turn", + status: "inProgress", + startedAt: 1, + completedAt: null, + durationMs: null, + error: null, + itemsView: "full", + items: [], + }, + }, + } satisfies Extract); + expect(notifyPanelActivityChanged).toHaveBeenCalledTimes(2); + }); + it("ignores stale connection work after the app-server exits during metadata loading", async () => { const config = deferred(); const client = connectedClient({ @@ -733,7 +771,7 @@ describe("CodexChatView connection lifecycle", () => { expect(view.getState()).toEqual({ version: 1, threadId: "thread-2", threadTitle: "Restored thread 2" }); expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: "thread-2", - turnLifecycle: { kind: "idle" }, + turnBusy: false, hasComposerDraft: false, }); expect(composerElement(view).value).toBe(""); @@ -866,7 +904,7 @@ describe("CodexChatView connection lifecycle", () => { clientUserMessageId: expect.stringMatching(/^local-user-\d+-[A-Za-z0-9_-]+-[a-z0-9]+$/), }); }); - expect(view.surface.openPanelSnapshot()).toMatchObject({ turnLifecycle: { kind: "starting" } }); + expect(view.surface.openPanelSnapshot()).toMatchObject({ turnBusy: true }); expect(view.getState()).toEqual({ version: 1, threadId: "thread-1", threadTitle: "Restored thread" }); connectionMock.state.onNotification?.({ method: "turn/started", @@ -884,7 +922,83 @@ describe("CodexChatView connection lifecycle", () => { }, }, } satisfies Extract); - expect(view.surface.openPanelSnapshot()).toMatchObject({ turnLifecycle: { kind: "running", turnId: "turn-1" } }); + expect(view.surface.openPanelSnapshot()).toMatchObject({ turnBusy: true }); + }); + + it("notifies Threads only when panel activity changes", async () => { + const notifyPanelActivityChanged = vi.fn(); + const client = connectedClient(); + connectionMock.state.client = client; + const view = await chatView({ host: chatHost({ notifyPanelActivityChanged }) }); + + await view.onOpen(); + expect(notifyPanelActivityChanged).toHaveBeenCalledOnce(); + + await view.surface.openThread("thread-1"); + notifyPanelActivityChanged.mockClear(); + + view.surface.setComposerText("hello"); + expect(notifyPanelActivityChanged).not.toHaveBeenCalled(); + + await submitComposerByEnter(view); + expect(notifyPanelActivityChanged).toHaveBeenCalledOnce(); + expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: "thread-1", turnBusy: true, pending: false }); + + notifyPanelActivityChanged.mockClear(); + connectionMock.state.onNotification?.({ + method: "turn/started", + params: { + threadId: "thread-1", + turn: { + id: "turn-1", + status: "inProgress", + startedAt: 1, + completedAt: null, + durationMs: null, + error: null, + itemsView: "full", + items: [], + }, + }, + } satisfies Extract); + expect(notifyPanelActivityChanged).not.toHaveBeenCalled(); + + connectionMock.state.onServerRequest?.( + { + id: 7, + method: "item/tool/requestUserInput", + params: { + threadId: "thread-1", + turnId: "turn-1", + itemId: "input-1", + questions: [{ id: "note", header: "Note", question: "What now?", isOther: false, isSecret: false, options: null }], + autoResolutionMs: null, + }, + }, + { respond: vi.fn(), reject: vi.fn() }, + ); + expect(notifyPanelActivityChanged).toHaveBeenCalledOnce(); + expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: "thread-1", turnBusy: true, pending: true }); + + notifyPanelActivityChanged.mockClear(); + connectionMock.state.onNotification?.({ + method: "turn/completed", + params: { + threadId: "thread-1", + turn: { + id: "turn-1", + status: "completed", + startedAt: 1, + completedAt: 2, + durationMs: 1, + error: null, + itemsView: "full", + items: [], + }, + }, + } satisfies Extract); + expect(notifyPanelActivityChanged).toHaveBeenCalledOnce(); + expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: "thread-1", turnBusy: false, pending: true }); }); it("requests a workspace layout save after resuming a thread", async () => { @@ -978,7 +1092,7 @@ describe("CodexChatView connection lifecycle", () => { await view.surface.openThread("other"); expect(requestMethods(client).filter((method) => method === "thread/unsubscribe")).toEqual([]); - expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: "child", turnLifecycle: { kind: "running", turnId: "turn-child" } }); + expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: "child", turnBusy: true }); }); it("resets to an unstarted empty chat without starting a thread", async () => { @@ -992,7 +1106,7 @@ describe("CodexChatView connection lifecycle", () => { expect(requestMethods(client)).not.toContain("thread/start"); expect(view.getState()).toEqual({ version: 1 }); - expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: null, turnLifecycle: { kind: "idle" }, hasComposerDraft: false }); + expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: null, turnBusy: false, hasComposerDraft: false }); expect(requestSaveLayout).toHaveBeenCalledTimes(2); }); @@ -1002,7 +1116,7 @@ describe("CodexChatView connection lifecycle", () => { expect(view.getState()).toEqual({ version: 1 }); expect(view.getDisplayText()).not.toBe("Side chat"); - expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: null, turnLifecycle: { kind: "idle" }, hasComposerDraft: false }); + expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: null, turnBusy: false, hasComposerDraft: false }); expect(view.containerEl.textContent).not.toContain("This side conversation is no longer available."); }); @@ -1456,7 +1570,7 @@ interface ChatHostFixtureOverrides { openThreadInNewView?: CodexChatHost["workspace"]["openThreadInNewView"]; focusThreadInOpenView?: CodexChatHost["workspace"]["focusThreadInOpenView"]; openTurnDiff?: CodexChatHost["workspace"]["openTurnDiff"]; - refreshThreadsViewLiveState?: CodexChatHost["workspace"]["refreshThreadsViewLiveState"]; + notifyPanelActivityChanged?: CodexChatHost["workspace"]["notifyPanelActivityChanged"]; openSideChat?: CodexChatHost["workspace"]["openSideChat"]; applyThreadCatalogEvent?: CodexChatHost["threadCatalog"]["apply"]; refreshActive?: CodexChatHost["threadCatalog"]["refreshActive"]; @@ -1605,7 +1719,7 @@ function chatHost(overrides: ChatHostFixtureOverrides = {}): TestCodexChatHost { openThreadInNewView: overrides.openThreadInNewView ?? vi.fn(), focusThreadInOpenView: overrides.focusThreadInOpenView ?? vi.fn().mockResolvedValue(false), openTurnDiff: overrides.openTurnDiff ?? vi.fn(), - refreshThreadsViewLiveState: overrides.refreshThreadsViewLiveState ?? vi.fn(), + notifyPanelActivityChanged: overrides.notifyPanelActivityChanged ?? vi.fn(), openSideChat: overrides.openSideChat ?? vi.fn().mockResolvedValue(undefined), }, appServerQueries: { diff --git a/tests/features/chat/panel/composer-controller.test.ts b/tests/features/chat/panel/composer-controller.test.ts index 54b67c66..e529583a 100644 --- a/tests/features/chat/panel/composer-controller.test.ts +++ b/tests/features/chat/panel/composer-controller.test.ts @@ -101,7 +101,6 @@ function composerControllerFixture( togglePlan: vi.fn(), toggleAutoReview: vi.fn(), toggleFast: vi.fn(), - onDraftChange: vi.fn(), onHeightChange: vi.fn(), canFocus: () => true, ...options.controller, @@ -248,7 +247,6 @@ describe("ChatComposerController", () => { togglePlan: vi.fn(), toggleAutoReview: vi.fn(), toggleFast: vi.fn(), - onDraftChange: vi.fn(), canFocus: () => true, onHeightChange: vi.fn(), }); @@ -413,7 +411,6 @@ describe("ChatComposerController", () => { togglePlan: vi.fn(), toggleAutoReview: vi.fn(), toggleFast: vi.fn(), - onDraftChange: vi.fn(), canFocus: () => true, onHeightChange: vi.fn(), }); @@ -474,7 +471,6 @@ describe("ChatComposerController", () => { togglePlan: vi.fn(), toggleAutoReview: vi.fn(), toggleFast: vi.fn(), - onDraftChange: vi.fn(), canFocus: () => true, onHeightChange: vi.fn(), }); @@ -627,7 +623,6 @@ describe("ChatComposerController", () => { togglePlan: vi.fn(), toggleAutoReview: vi.fn(), toggleFast: vi.fn(), - onDraftChange: vi.fn(), canFocus: () => true, onHeightChange: vi.fn(), }); @@ -689,7 +684,6 @@ describe("ChatComposerController", () => { togglePlan: vi.fn(), toggleAutoReview: vi.fn(), toggleFast: vi.fn(), - onDraftChange: vi.fn(), canFocus: () => true, onHeightChange: vi.fn(), }); @@ -750,7 +744,6 @@ describe("ChatComposerController", () => { togglePlan: vi.fn(), toggleAutoReview: vi.fn(), toggleFast: vi.fn(), - onDraftChange: vi.fn(), canFocus: () => true, onHeightChange: vi.fn(), }); @@ -972,7 +965,6 @@ describe("ChatComposerController", () => { togglePlan: vi.fn(), toggleAutoReview: vi.fn(), toggleFast: vi.fn(), - onDraftChange: vi.fn(), canFocus: () => true, onHeightChange: vi.fn(), }); @@ -1032,7 +1024,6 @@ describe("ChatComposerController", () => { togglePlan: vi.fn(), toggleAutoReview: vi.fn(), toggleFast: vi.fn(), - onDraftChange: vi.fn(), canFocus: () => true, onHeightChange: vi.fn(), }); @@ -1078,7 +1069,6 @@ describe("ChatComposerController", () => { togglePlan: vi.fn(), toggleAutoReview: vi.fn(), toggleFast: vi.fn(), - onDraftChange: vi.fn(), canFocus: () => true, onHeightChange: vi.fn(), }); @@ -1130,7 +1120,6 @@ describe("ChatComposerController", () => { togglePlan: vi.fn(), toggleAutoReview: vi.fn(), toggleFast: vi.fn(), - onDraftChange: vi.fn(), canFocus: () => true, onHeightChange: vi.fn(), }); @@ -1189,7 +1178,6 @@ describe("ChatComposerController", () => { togglePlan: vi.fn(), toggleAutoReview: vi.fn(), toggleFast: vi.fn(), - onDraftChange: vi.fn(), canFocus: () => true, onHeightChange: vi.fn(), }); @@ -1266,7 +1254,6 @@ describe("ChatComposerController", () => { togglePlan: vi.fn(), toggleAutoReview: vi.fn(), toggleFast: vi.fn(), - onDraftChange: vi.fn(), canFocus: () => true, onHeightChange: vi.fn(), }); @@ -1315,7 +1302,6 @@ describe("ChatComposerController", () => { togglePlan: vi.fn(), toggleAutoReview: vi.fn(), toggleFast: vi.fn(), - onDraftChange: vi.fn(), canFocus: () => true, onHeightChange: vi.fn(), }); diff --git a/tests/features/chat/panel/shell.test.tsx b/tests/features/chat/panel/shell.test.tsx index 610ecb4f..874557bb 100644 --- a/tests/features/chat/panel/shell.test.tsx +++ b/tests/features/chat/panel/shell.test.tsx @@ -99,7 +99,6 @@ describe("ChatPanelShell", () => { togglePlan: vi.fn(), toggleAutoReview: vi.fn(), toggleFast: vi.fn(), - onDraftChange: vi.fn(), canFocus: () => true, onHeightChange: vi.fn(), }); diff --git a/tests/support/plugin-fixtures.ts b/tests/support/plugin-fixtures.ts index 20fc96a0..2a1978f7 100644 --- a/tests/support/plugin-fixtures.ts +++ b/tests/support/plugin-fixtures.ts @@ -128,7 +128,7 @@ function chatHostFixture(): CodexChatHost { openThreadInNewView: vi.fn(), focusThreadInOpenView: vi.fn(), openTurnDiff: vi.fn(), - refreshThreadsViewLiveState: vi.fn(), + notifyPanelActivityChanged: vi.fn(), openSideChat: vi.fn(), }, appServerQueries: { @@ -181,17 +181,11 @@ export function thread(id: string): Thread { export function panelSnapshot(overrides: PanelSnapshotFixtureOverrides = {}): ReturnType { const { pendingApprovals, pendingUserInputs, pendingMcpElicitations, ...snapshotOverrides } = overrides; - const pendingRequests = snapshotOverrides.pendingRequests ?? { - approvals: pendingApprovals ?? 0, - userInputs: pendingUserInputs ?? 0, - mcpElicitations: pendingMcpElicitations ?? 0, - actionable: (pendingApprovals ?? 0) + (pendingUserInputs ?? 0) + (pendingMcpElicitations ?? 0), - }; return { viewId: "view", threadId: "thread", - turnLifecycle: { kind: "idle" }, - pendingRequests, + turnBusy: false, + pending: (pendingApprovals ?? 0) + (pendingUserInputs ?? 0) + (pendingMcpElicitations ?? 0) > 0, hasComposerDraft: false, connected: true, ...snapshotOverrides, diff --git a/tests/workspace/panel-coordinator.test.ts b/tests/workspace/panel-coordinator.test.ts index 2fb13ce2..59bee509 100644 --- a/tests/workspace/panel-coordinator.test.ts +++ b/tests/workspace/panel-coordinator.test.ts @@ -37,7 +37,7 @@ describe("WorkspacePanelCoordinator", () => { const coordinator = panels(await pluginWithLeaves([restoredLeaf])); expect(coordinator.getOpenPanelSnapshots()).toMatchObject([ - { threadId: "thread-1", connected: false, lastFocused: false, pendingRequests: { actionable: 0 } }, + { threadId: "thread-1", connected: false, lastFocused: false, turnBusy: false, pending: false }, ]); });