Extract pending request controller

This commit is contained in:
murashit 2026-05-29 05:48:45 +09:00
parent 16f73703e7
commit 390b8b2333
3 changed files with 178 additions and 73 deletions

View file

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

View file

@ -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<void> {
this.controller.resolveApproval(approval, action);
this.plugin.refreshThreadsViewLiveState();
this.render();
}
private respondToServerRequest(requestId: Parameters<AppServerClient["respondToServerRequest"]>[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<void> {
this.controller.resolveUserInput(input, this.answersForUserInput(input));
this.plugin.refreshThreadsViewLiveState();
this.render();
}
private async cancelUserInput(input: PendingUserInput): Promise<void> {
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<string, string> {
return answersForPendingUserInput(input, this.state.userInputDrafts);
}
private queueMessagesBottomScroll(): void {
this.dispatch({ type: "ui/messages-pinned-set", pinned: true });
this.nextMessageScrollIntent = "force-bottom";

View file

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