From 390b8b23330158bee8b6e7e7bf53d6c3afca656a Mon Sep 17 00:00:00 2001 From: murashit Date: Fri, 29 May 2026 05:48:45 +0900 Subject: [PATCH] Extract pending request controller --- .../chat/pending-request-controller.ts | 92 +++++++++++++++++++ src/features/chat/view.ts | 88 +++--------------- .../chat/pending-request-controller.test.ts | 71 ++++++++++++++ 3 files changed, 178 insertions(+), 73 deletions(-) create mode 100644 src/features/chat/pending-request-controller.ts create mode 100644 tests/features/chat/pending-request-controller.test.ts diff --git a/src/features/chat/pending-request-controller.ts b/src/features/chat/pending-request-controller.ts new file mode 100644 index 00000000..1cad1c48 --- /dev/null +++ b/src/features/chat/pending-request-controller.ts @@ -0,0 +1,92 @@ +import type { ReactNode } from "react"; + +import type { ApprovalAction, PendingApproval } from "./approvals/model"; +import type { ChatAction, ChatState, ChatStateStore } from "./chat-state"; +import type { ChatController } from "./chat-controller"; +import { pendingRequestFocusSignature, userInputDraftKey, userInputOtherDraftKey } from "./request-state"; +import { pendingRequestMessageNode } from "./ui/pending-request-message"; +import { answersForPendingUserInput, type PendingUserInput } from "./user-input/model"; + +export interface PendingRequestControllerHost { + stateStore: ChatStateStore; + controller: ChatController; + composerHasFocus: () => boolean; + refreshLiveState: () => void; + render: () => void; +} + +export class PendingRequestController { + private lastFocusSignature = ""; + + constructor(private readonly host: PendingRequestControllerHost) {} + + private get state(): ChatState { + return this.host.stateStore.getState(); + } + + private dispatch(action: ChatAction): void { + this.host.stateStore.dispatch(action); + } + + renderNode(): ReactNode { + return pendingRequestMessageNode( + this.state.approvals, + this.state.pendingUserInputs, + { + values: this.state.userInputDrafts, + draftKey: userInputDraftKey, + otherDraftKey: userInputOtherDraftKey, + }, + this.state.openDetails, + { + resolveApproval: (approval, action) => { + this.resolveApproval(approval, action); + }, + resolveUserInput: (input) => { + this.resolveUserInput(input); + }, + cancelUserInput: (input) => { + this.cancelUserInput(input); + }, + setOpenDetail: (key, open) => { + this.dispatch({ type: "ui/detail-open-set", key, open }); + }, + setUserInputDraft: (key, value) => { + this.dispatch({ type: "request/user-input-draft-set", key, value }); + }, + }, + this.consumeAutoFocus(), + ); + } + + resolveApproval(approval: PendingApproval, action: ApprovalAction): void { + this.host.controller.resolveApproval(approval, action); + this.commitRequestAction(); + } + + resolveUserInput(input: PendingUserInput): void { + this.host.controller.resolveUserInput(input, answersForPendingUserInput(input, this.state.userInputDrafts)); + this.commitRequestAction(); + } + + cancelUserInput(input: PendingUserInput): void { + this.host.controller.cancelUserInput(input); + this.commitRequestAction(); + } + + private commitRequestAction(): void { + this.host.refreshLiveState(); + this.host.render(); + } + + private consumeAutoFocus(): boolean { + const signature = pendingRequestFocusSignature(this.state.approvals, this.state.pendingUserInputs); + if (!signature) { + this.lastFocusSignature = ""; + return false; + } + if (signature === this.lastFocusSignature) return false; + this.lastFocusSignature = signature; + return this.host.composerHasFocus(); + } +} diff --git a/src/features/chat/view.ts b/src/features/chat/view.ts index f91a948a..0cfc76f0 100644 --- a/src/features/chat/view.ts +++ b/src/features/chat/view.ts @@ -2,7 +2,6 @@ import { ItemView, Notice, type ViewStateResult, type WorkspaceLeaf } from "obsi import type { AppServerClient } from "../../app-server/client"; import { ConnectionManager, StaleConnectionError } from "../../app-server/connection-manager"; -import type { ApprovalAction, PendingApproval } from "./approvals/model"; import type { SlashCommandName } from "./composer/slash-commands"; import { parseSlashCommand } from "./composer/suggestions"; import { VIEW_TYPE_CODEX_PANEL } from "../../constants"; @@ -21,14 +20,8 @@ import { mcpStatusLines } from "./mcp-status"; import { ChatAppServerController } from "./chat-app-server-controller"; import { ThreadHistoryLoader } from "./thread-history"; import { ThreadRenameController } from "./thread-rename"; -import { - pendingRequestFocusSignature, - pendingRequestsSignature as requestStateSignature, - userInputDraftKey, - userInputOtherDraftKey, -} from "./request-state"; +import { pendingRequestsSignature as requestStateSignature } from "./request-state"; import type { CodexPanelSettings } from "../../settings/model"; -import { answersForPendingUserInput, type PendingUserInput } from "./user-input/model"; import { ChatComposerController } from "./chat-composer-controller"; import { activeTurnId, @@ -47,7 +40,6 @@ import { REFERENCED_THREAD_TURN_LIMIT, type ReferencedThreadDisplay, } from "../../domain/threads/reference"; -import { pendingRequestMessageNode } from "./ui/pending-request-message"; import { renderToolbar, type ToolbarViewModel } from "./ui/toolbar"; import { renderChatPanelShell, unmountChatPanelShell } from "./ui/shell"; import type { ChatTurnDiffViewState } from "./ui/turn-diff"; @@ -91,6 +83,7 @@ import { shouldAcknowledgeTurnStart, } from "./turn-submission"; import { resumedThreadAction, type ResumedThreadActionParams } from "./thread-resume"; +import { PendingRequestController } from "./pending-request-controller"; export interface CodexChatHost { readonly settings: CodexPanelSettings; @@ -119,6 +112,7 @@ export class CodexChatView extends ItemView { private readonly runtimeSettings: ChatRuntimeSettingsController; private readonly restoredThread: RestoredThreadController; private readonly threadRename: ThreadRenameController; + private readonly pendingRequests: PendingRequestController; private readonly chatState = createChatStateStore(); private readonly viewId = `codex-panel-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; private readonly deferredTasks: ChatViewDeferredTasks; @@ -131,7 +125,6 @@ export class CodexChatView extends ItemView { private opened = false; private closing = false; private nextMessageScrollIntent: ChatMessageScrollIntent = "auto"; - private lastPendingRequestFocusSignature = ""; constructor( leaf: WorkspaceLeaf, @@ -157,7 +150,7 @@ export class CodexChatView extends ItemView { implementPlan: (item) => void this.implementPlan(item), openTurnDiff: (state) => void this.plugin.openTurnDiff(state), pendingRequestsSignature: () => this.pendingRequestsSignature(), - renderPendingRequests: () => this.pendingRequestMessageNode(), + renderPendingRequests: () => this.pendingRequests.renderNode(), }); this.composerController = new ChatComposerController({ app: this.app, @@ -229,6 +222,17 @@ export class CodexChatView extends ItemView { respondToServerRequest: (requestId, result) => this.respondToServerRequest(requestId, result), rejectServerRequest: (requestId, code, message) => this.rejectServerRequest(requestId, code, message), }); + this.pendingRequests = new PendingRequestController({ + stateStore: this.chatState, + controller: this.controller, + composerHasFocus: () => this.composerController.hasFocus(), + refreshLiveState: () => { + this.plugin.refreshThreadsViewLiveState(); + }, + render: () => { + this.render(); + }, + }); this.appServer = new ChatAppServerController({ stateStore: this.chatState, vaultPath: this.plugin.vaultPath, @@ -937,12 +941,6 @@ export class CodexChatView extends ItemView { await this.runtimeSettings.setRequestedReasoningEffortFromUi(effort); } - private async resolveApproval(approval: PendingApproval, action: ApprovalAction): Promise { - this.controller.resolveApproval(approval, action); - this.plugin.refreshThreadsViewLiveState(); - this.render(); - } - private respondToServerRequest(requestId: Parameters[0], result: unknown): boolean { try { this.client?.respondToServerRequest(requestId, result); @@ -961,18 +959,6 @@ export class CodexChatView extends ItemView { } } - private async resolveUserInput(input: PendingUserInput): Promise { - this.controller.resolveUserInput(input, this.answersForUserInput(input)); - this.plugin.refreshThreadsViewLiveState(); - this.render(); - } - - private async cancelUserInput(input: PendingUserInput): Promise { - this.controller.cancelUserInput(input); - this.plugin.refreshThreadsViewLiveState(); - this.render(); - } - private systemItem(text: string): DisplayItem { return createSystemItem(`system-${String(Date.now())}-${Math.random().toString(36).slice(2)}`, text); } @@ -1338,50 +1324,6 @@ export class CodexChatView extends ItemView { return runtimeSnapshotForChatState({ state }); } - private pendingRequestMessageNode() { - return pendingRequestMessageNode( - this.state.approvals, - this.state.pendingUserInputs, - { - values: this.state.userInputDrafts, - draftKey: userInputDraftKey, - otherDraftKey: userInputOtherDraftKey, - }, - this.state.openDetails, - { - resolveApproval: (approval, action) => void this.resolveApproval(approval, action), - resolveUserInput: (input) => void this.resolveUserInput(input), - cancelUserInput: (input) => void this.cancelUserInput(input), - setOpenDetail: (key, open) => { - this.dispatch({ type: "ui/detail-open-set", key, open }); - }, - setUserInputDraft: (key, value) => { - this.dispatch({ type: "request/user-input-draft-set", key, value }); - }, - }, - this.consumePendingRequestAutoFocus(), - ); - } - - private consumePendingRequestAutoFocus(): boolean { - const signature = this.pendingRequestFocusSignature(); - if (!signature) { - this.lastPendingRequestFocusSignature = ""; - return false; - } - if (signature === this.lastPendingRequestFocusSignature) return false; - this.lastPendingRequestFocusSignature = signature; - return this.composerController.hasFocus(); - } - - private pendingRequestFocusSignature(): string { - return pendingRequestFocusSignature(this.state.approvals, this.state.pendingUserInputs); - } - - private answersForUserInput(input: PendingUserInput): Record { - return answersForPendingUserInput(input, this.state.userInputDrafts); - } - private queueMessagesBottomScroll(): void { this.dispatch({ type: "ui/messages-pinned-set", pinned: true }); this.nextMessageScrollIntent = "force-bottom"; diff --git a/tests/features/chat/pending-request-controller.test.ts b/tests/features/chat/pending-request-controller.test.ts new file mode 100644 index 00000000..bffd6cb2 --- /dev/null +++ b/tests/features/chat/pending-request-controller.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from "vitest"; + +import { ChatController } from "../../../src/features/chat/chat-controller"; +import { createChatState, createChatStateStore } from "../../../src/features/chat/chat-state"; +import { PendingRequestController } from "../../../src/features/chat/pending-request-controller"; +import { toPendingUserInput } from "../../../src/features/chat/user-input/model"; +import type { ServerRequest } from "../../../src/generated/app-server/ServerRequest"; + +function expectPresent(value: T | null | undefined): T { + if (value === null || value === undefined) throw new Error("Expected value to be present"); + return value; +} + +describe("PendingRequestController", () => { + it("resolves user input from immutable draft state and refreshes the host", () => { + const stateStore = createChatStateStore(createChatState()); + const respondToServerRequest = vi.fn().mockReturnValue(true); + const refreshLiveState = vi.fn(); + const render = vi.fn(); + const controller = new ChatController(stateStore, { + refreshThreads: vi.fn(), + refreshSkills: vi.fn(), + publishAppServerMetadata: vi.fn(), + maybeNameThread: vi.fn(), + notifyThreadArchived: vi.fn(), + notifyThreadRenamed: vi.fn(), + recordMcpStartupStatus: vi.fn(), + respondToServerRequest, + rejectServerRequest: vi.fn(), + }); + const pendingRequests = new PendingRequestController({ + stateStore, + controller, + composerHasFocus: () => false, + refreshLiveState, + render, + }); + 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" }); + + pendingRequests.resolveUserInput(input); + + expect(respondToServerRequest).toHaveBeenCalledWith(7, { answers: { direction: { answers: ["Left"] } } }); + expect(stateStore.getState().pendingUserInputs).toEqual([]); + expect(refreshLiveState).toHaveBeenCalledOnce(); + expect(render).toHaveBeenCalledOnce(); + }); +}); + +function userInputRequest(): ServerRequest { + return { + id: 7, + method: "item/tool/requestUserInput", + params: { + threadId: "thread", + turnId: "turn", + itemId: "item", + questions: [ + { + id: "direction", + header: "Direction", + question: "Which way?", + isOther: true, + isSecret: false, + options: [{ label: "Recommended", description: "Use the default path" }], + }, + ], + }, + }; +}