refactor(chat): derive Threads activity from panel state

This commit is contained in:
murashit 2026-07-18 22:54:46 +09:00
parent cf0548a0d0
commit f93570848f
26 changed files with 201 additions and 226 deletions

View file

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

View file

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

View file

@ -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();
}

View file

@ -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;
}

View file

@ -21,7 +21,6 @@ export interface ResumeActionsHost {
notifyActiveThreadIdentityChanged: () => void;
recordResumedThread: (thread: Thread) => void;
addSystemMessage: (text: string) => void;
refreshLiveState: () => void;
syncThreadGoal: (threadId: string) => Promise<void>;
recoverTokenUsageFromRollout?: (path: string) => Promise<ThreadTokenUsage | null>;
}
@ -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);
}

View file

@ -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) => {

View file

@ -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: () => {

View file

@ -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)),

View file

@ -54,7 +54,6 @@ interface ChatPanelTurnInput {
reconnect: () => Promise<void>;
runtimeProjection: ChatPanelRuntimeProjection;
refreshDiagnostics: () => Promise<void>;
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),

View file

@ -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<void>;
focusThreadInOpenView(threadId: string): Promise<boolean>;
openTurnDiff(state: TurnDiffViewState): Promise<void>;
refreshThreadsViewLiveState(): void;
notifyPanelActivityChanged(): void;
openSideChat(sourceThreadId: string, sourceThreadTitle: string | null): Promise<void>;
}
@ -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;
}

View file

@ -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<void>;
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();

View file

@ -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<void> {
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;
}

View file

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

View file

@ -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,
}));
}

View file

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

View file

@ -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" }],

View file

@ -12,8 +12,8 @@ function expectPresent<T>(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 {

View file

@ -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");

View file

@ -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({

View file

@ -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),
},

View file

@ -94,7 +94,6 @@ function turnBundleFixture(options: { stateStore?: ReturnType<typeof createChatS
reconnect: vi.fn(),
runtimeProjection,
refreshDiagnostics,
refreshLiveState: vi.fn(),
notifyActiveThreadIdentityChanged: vi.fn(),
} as never,
);

View file

@ -1,7 +1,7 @@
// @vitest-environment jsdom
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ServerNotification } from "../../../../src/app-server/connection/rpc-messages";
import type { ServerNotification, ServerRequest } from "../../../../src/app-server/connection/rpc-messages";
import { modelMetadataFromCatalogModels } from "../../../../src/app-server/protocol/catalog";
import type { ThreadRecord } from "../../../../src/app-server/protocol/thread";
import type { ObservedResult } from "../../../../src/app-server/query/observed-result";
@ -38,6 +38,9 @@ const connectionMock = vi.hoisted(() => {
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<ConnectionManager["handlers"]>): 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<ServerNotification, { method: "turn/started" }>);
expect(notifyPanelActivityChanged).toHaveBeenCalledTimes(2);
});
it("ignores stale connection work after the app-server exits during metadata loading", async () => {
const config = deferred<unknown>();
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<ServerNotification, { method: "turn/started" }>);
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<ServerNotification, { method: "turn/started" }>);
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<ServerNotification, { method: "turn/completed" }>);
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: {

View file

@ -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(),
});

View file

@ -99,7 +99,6 @@ describe("ChatPanelShell", () => {
togglePlan: vi.fn(),
toggleAutoReview: vi.fn(),
toggleFast: vi.fn(),
onDraftChange: vi.fn(),
canFocus: () => true,
onHeightChange: vi.fn(),
});

View file

@ -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<CodexChatView["surface"]["openPanelSnapshot"]> {
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,

View file

@ -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 },
]);
});