diff --git a/src/features/chat/chat-composer-controller.ts b/src/features/chat/chat-composer-controller.ts index a66ed31f..dadf0b0f 100644 --- a/src/features/chat/chat-composer-controller.ts +++ b/src/features/chat/chat-composer-controller.ts @@ -137,6 +137,10 @@ export class ChatComposerController { this.composer?.focus({ preventScroll: true }); } + hasFocus(): boolean { + return this.composer !== null && this.composer.ownerDocument.activeElement === this.composer; + } + dispose(): void { unmountReactRoot(this.suggestionsEl); this.composer = null; diff --git a/src/features/chat/ui/pending-request-message.tsx b/src/features/chat/ui/pending-request-message.tsx index 533fc5ba..48904c79 100644 --- a/src/features/chat/ui/pending-request-message.tsx +++ b/src/features/chat/ui/pending-request-message.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from "react"; +import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react"; import { approvalActionOptions, @@ -33,6 +33,7 @@ export function pendingRequestMessageNode( drafts: PendingRequestMessageDrafts, openDetails: ReadonlySet, actions: PendingRequestMessageActions, + autoFocus = false, ): ReactNode { return ( ); } @@ -51,16 +53,23 @@ function PendingRequestMessage({ drafts, openDetails, actions, + autoFocus, }: { approvals: readonly PendingApproval[]; pendingUserInputs: readonly PendingUserInput[]; drafts: PendingRequestMessageDrafts; openDetails: ReadonlySet; actions: PendingRequestMessageActions; + autoFocus: boolean; }): ReactNode { + const requestRef = useRef(null); + useLayoutEffect(() => { + if (!autoFocus) return; + focusPendingRequestControl(requestRef.current); + }, [autoFocus]); if (approvals.length === 0 && pendingUserInputs.length === 0) return null; return ( -
+
Request
{approvals.map((approval) => ( @@ -72,6 +81,22 @@ function PendingRequestMessage({ ); } +function focusPendingRequestControl(container: HTMLElement | null): void { + if (!container) return; + for (const selector of [ + ".codex-panel__user-input-radio:checked", + ".codex-panel__user-input-text", + ".codex-panel__user-input-radio", + ".codex-panel__pending-request-button.mod-cta", + ".codex-panel__pending-request-button", + ]) { + const target = container.querySelector(selector); + if (!target) continue; + target.focus({ preventScroll: true }); + return; + } +} + function ApprovalCard({ approval, openDetails, @@ -222,6 +247,7 @@ function UserInputQuestions({ questionId={question.id} groupName={`codex-panel-${String(input.requestId)}-${question.id}`} current={current} + optionLabels={new Set(question.options.map((option) => option.label))} drafts={drafts} actions={actions} /> @@ -250,6 +276,7 @@ function OtherUserInputOption({ questionId, groupName, current, + optionLabels, drafts, actions, }: { @@ -257,12 +284,26 @@ function OtherUserInputOption({ questionId: string; groupName: string; current: string; + optionLabels: ReadonlySet; drafts: PendingRequestMessageDrafts; actions: PendingRequestMessageActions; }): ReactNode { const draftKey = drafts.draftKey(input.requestId, questionId); const otherKey = drafts.otherDraftKey(input.requestId, questionId); const otherValue = drafts.values.get(otherKey) ?? ""; + const [inputValue, setInputValue] = useState(otherValue); + const composingRef = useRef(false); + const otherSelected = drafts.values.has(draftKey) && current === otherValue && !optionLabels.has(current); + useEffect(() => { + if (!composingRef.current) setInputValue(otherValue); + }, [otherValue]); + const selectOther = () => { + actions.setUserInputDraft(draftKey, otherValue); + }; + const commitOtherValue = (value: string) => { + actions.setUserInputDraft(otherKey, value); + actions.setUserInputDraft(draftKey, value); + }; return ( diff --git a/src/features/chat/view.ts b/src/features/chat/view.ts index 31763d90..89f66ef9 100644 --- a/src/features/chat/view.ts +++ b/src/features/chat/view.ts @@ -122,6 +122,7 @@ export class CodexChatView extends ItemView { private opened = false; private closing = false; private nextMessageScrollIntent: ChatMessageScrollIntent = "auto"; + private lastPendingRequestFocusSignature = ""; private scheduledSessionWarmupTimer: number | null = null; constructor( @@ -1628,9 +1629,28 @@ export class CodexChatView extends ItemView { 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 { + const approvals = this.state.approvals.map((approval) => ({ id: approval.requestId, method: approval.method })); + const inputs = this.state.pendingUserInputs.map((input) => ({ id: input.requestId, method: input.method })); + if (approvals.length === 0 && inputs.length === 0) return ""; + return JSON.stringify({ approvals, inputs }); + } + private answersForUserInput(input: PendingUserInput): Record { return Object.fromEntries( input.params.questions.map((question) => [ diff --git a/tests/features/chat/ui/message-stream.test.tsx b/tests/features/chat/ui/message-stream.test.tsx index bd5b5bdd..3e9ac405 100644 --- a/tests/features/chat/ui/message-stream.test.tsx +++ b/tests/features/chat/ui/message-stream.test.tsx @@ -27,6 +27,19 @@ function expectPresent(value: T | null | undefined): T { return value; } +function setNativeInputValue(input: HTMLInputElement, value: string): void { + const valueDescriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value"); + if (!valueDescriptor?.set) throw new Error("Missing input value setter"); + valueDescriptor.set.call(input, value); +} + +function dispatchComposingInputValue(input: HTMLInputElement, value: string): void { + setNativeInputValue(input, value); + const event = new Event("input", { bubbles: true }); + Object.defineProperty(event, "isComposing", { value: true }); + input.dispatchEvent(event); +} + function testMessageStreamBlock(key: string, node: ReactNode): ReturnType[number] { return { key, node }; } @@ -2089,6 +2102,88 @@ describe("pending request renderer decisions", () => { expect(radios.map((radio) => radio.checked)).toEqual([false, true]); }); + it("keeps the other Plan mode radio selected when the custom answer is empty", () => { + const parent = document.createElement("div"); + const drafts = new Map(); + const input = pendingOtherUserInput(); + const draftKey = (requestId: PendingUserInput["requestId"], questionId: string) => `${String(requestId)}:${questionId}`; + const otherDraftKey = (requestId: PendingUserInput["requestId"], questionId: string) => `${String(requestId)}:${questionId}:other`; + const actions = pendingRequestActions({ + setUserInputDraft: vi.fn((key: string, value: string) => { + drafts.set(key, value); + }), + }); + const render = () => { + renderPendingRequestNode(parent, [], [input], { values: drafts, draftKey, otherDraftKey }, new Set(), actions); + }; + + render(); + const radios = [...parent.querySelectorAll(".codex-panel__user-input-radio")]; + expect(radios.map((radio) => radio.checked)).toEqual([true, false]); + + expectPresent(radios.at(1)).click(); + + expect(actions.setUserInputDraft).toHaveBeenCalledWith("99:scope", ""); + + render(); + const rerenderedRadios = [...parent.querySelectorAll(".codex-panel__user-input-radio")]; + expect(rerenderedRadios.map((radio) => radio.checked)).toEqual([false, true]); + }); + + it("keeps unselected other Plan mode text out of tab order", () => { + const parent = document.createElement("div"); + const drafts = new Map(); + const input = pendingOtherUserInput(); + const draftKey = (requestId: PendingUserInput["requestId"], questionId: string) => `${String(requestId)}:${questionId}`; + const otherDraftKey = (requestId: PendingUserInput["requestId"], questionId: string) => `${String(requestId)}:${questionId}:other`; + const actions = pendingRequestActions({ + setUserInputDraft: vi.fn((key: string, value: string) => { + drafts.set(key, value); + }), + }); + const render = () => { + renderPendingRequestNode(parent, [], [input], { values: drafts, draftKey, otherDraftKey }, new Set(), actions); + }; + + render(); + + expect(parent.querySelector(".codex-panel__user-input-other-text")?.tabIndex).toBe(-1); + + expectPresent(parent.querySelectorAll(".codex-panel__user-input-radio").item(1)).click(); + render(); + + expect(parent.querySelector(".codex-panel__user-input-other-text")?.tabIndex).toBe(0); + }); + + it("does not commit other Plan mode IME preedit text as the answer", () => { + const parent = document.createElement("div"); + const drafts = new Map(); + const input = pendingOtherUserInput(); + const draftKey = (requestId: PendingUserInput["requestId"], questionId: string) => `${String(requestId)}:${questionId}`; + const otherDraftKey = (requestId: PendingUserInput["requestId"], questionId: string) => `${String(requestId)}:${questionId}:other`; + const actions = pendingRequestActions({ + setUserInputDraft: vi.fn((key: string, value: string) => { + drafts.set(key, value); + }), + }); + + renderPendingRequestNode(parent, [], [input], { values: drafts, draftKey, otherDraftKey }, new Set(), actions); + const otherInput = expectPresent(parent.querySelector(".codex-panel__user-input-other-text")); + + otherInput.dispatchEvent(new Event("compositionstart", { bubbles: true })); + dispatchComposingInputValue(otherInput, "にほん"); + + expect(actions.setUserInputDraft).toHaveBeenCalledWith("99:scope", ""); + expect(actions.setUserInputDraft).not.toHaveBeenCalledWith("99:scope:other", "にほん"); + expect(actions.setUserInputDraft).not.toHaveBeenCalledWith("99:scope", "にほん"); + + setNativeInputValue(otherInput, "日本"); + otherInput.dispatchEvent(new Event("compositionend", { bubbles: true })); + + expect(actions.setUserInputDraft).toHaveBeenCalledWith("99:scope:other", "日本"); + expect(actions.setUserInputDraft).toHaveBeenCalledWith("99:scope", "日本"); + }); + it("renders pending approvals and Plan mode questions in the same request block", () => { const parent = document.createElement("div"); const approval = pendingApproval(); @@ -2125,6 +2220,87 @@ describe("pending request renderer decisions", () => { ]); }); + it("focuses Plan mode input when pending requests ask for autofocus", () => { + const parent = document.createElement("div"); + document.body.appendChild(parent); + const input = pendingFreeformUserInput(); + + try { + renderPendingRequestNode( + parent, + [pendingApproval()], + [input], + { + values: new Map(), + draftKey: (requestId, questionId) => `${String(requestId)}:${questionId}`, + otherDraftKey: (requestId, questionId) => `${String(requestId)}:${questionId}:other`, + }, + new Set(), + pendingRequestActions(), + true, + ); + + expect(document.activeElement).toBe(parent.querySelector(".codex-panel__user-input-text")); + } finally { + unmountReactRoot(parent); + parent.remove(); + } + }); + + it("focuses the selected Plan mode option before the other text field", () => { + const parent = document.createElement("div"); + document.body.appendChild(parent); + const input = pendingOtherUserInput(); + + try { + renderPendingRequestNode( + parent, + [], + [input], + { + values: new Map(), + draftKey: (requestId, questionId) => `${String(requestId)}:${questionId}`, + otherDraftKey: (requestId, questionId) => `${String(requestId)}:${questionId}:other`, + }, + new Set(), + pendingRequestActions(), + true, + ); + + expect(document.activeElement).toBe(parent.querySelector(".codex-panel__user-input-radio:checked")); + expect(document.activeElement).not.toBe(parent.querySelector(".codex-panel__user-input-other-text")); + } finally { + unmountReactRoot(parent); + parent.remove(); + } + }); + + it("focuses the approval action when pending approval asks for autofocus", () => { + const parent = document.createElement("div"); + document.body.appendChild(parent); + + try { + renderPendingRequestNode( + parent, + [pendingApproval()], + [], + { + values: new Map(), + draftKey: (requestId, questionId) => `${String(requestId)}:${questionId}`, + otherDraftKey: (requestId, questionId) => `${String(requestId)}:${questionId}:other`, + }, + new Set(), + pendingRequestActions(), + true, + ); + + expect(document.activeElement).toBe(parent.querySelector(".codex-panel__pending-request-button.mod-cta")); + } finally { + unmountReactRoot(parent); + parent.remove(); + } + }); + it("renders command approval buttons from app-server available decisions", () => { const parent = document.createElement("div"); const approval: PendingApproval = { @@ -2415,6 +2591,22 @@ function pendingOtherUserInput(): PendingUserInput { }; } +function pendingFreeformUserInput(): PendingUserInput { + const input = pendingUserInput(); + return { + ...input, + params: { + ...input.params, + questions: [ + { + ...expectPresent(input.params.questions[0]), + options: null, + }, + ], + }, + }; +} + function pendingApproval(): PendingApproval { return { requestId: 42,