mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Extract pending request controller
This commit is contained in:
parent
16f73703e7
commit
390b8b2333
3 changed files with 178 additions and 73 deletions
92
src/features/chat/pending-request-controller.ts
Normal file
92
src/features/chat/pending-request-controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
71
tests/features/chat/pending-request-controller.test.ts
Normal file
71
tests/features/chat/pending-request-controller.test.ts
Normal 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" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue