Improve pending request focus and Plan inputs

This commit is contained in:
murashit 2026-05-28 12:53:48 +09:00
parent 1b05e74e94
commit 9d48221d64
4 changed files with 276 additions and 7 deletions

View file

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

View file

@ -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<string>,
actions: PendingRequestMessageActions,
autoFocus = false,
): ReactNode {
return (
<PendingRequestMessage
@ -41,6 +42,7 @@ export function pendingRequestMessageNode(
drafts={drafts}
openDetails={openDetails}
actions={actions}
autoFocus={autoFocus}
/>
);
}
@ -51,16 +53,23 @@ function PendingRequestMessage({
drafts,
openDetails,
actions,
autoFocus,
}: {
approvals: readonly PendingApproval[];
pendingUserInputs: readonly PendingUserInput[];
drafts: PendingRequestMessageDrafts;
openDetails: ReadonlySet<string>;
actions: PendingRequestMessageActions;
autoFocus: boolean;
}): ReactNode {
const requestRef = useRef<HTMLDivElement | null>(null);
useLayoutEffect(() => {
if (!autoFocus) return;
focusPendingRequestControl(requestRef.current);
}, [autoFocus]);
if (approvals.length === 0 && pendingUserInputs.length === 0) return null;
return (
<div className={createWorkMessageClassName("codex-panel__pending-request-message", "warning")}>
<div ref={requestRef} className={createWorkMessageClassName("codex-panel__pending-request-message", "warning")}>
<div className="codex-panel__message-role">Request</div>
{approvals.map((approval) => (
<ApprovalCard key={String(approval.requestId)} approval={approval} openDetails={openDetails} actions={actions} />
@ -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<HTMLElement>(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<string>;
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 (
<label className="codex-panel__user-input-option">
<input
@ -270,20 +311,32 @@ function OtherUserInputOption({
type="radio"
name={groupName}
value="__other__"
checked={current === otherValue && otherValue.length > 0}
checked={otherSelected}
onChange={(event) => {
if (event.currentTarget.checked) actions.setUserInputDraft(draftKey, drafts.values.get(otherKey) ?? "");
if (event.currentTarget.checked) selectOther();
}}
/>
<span className="codex-panel__user-input-option-label">Other</span>
<input
className="codex-panel__user-input-text codex-panel__user-input-other-text"
type="text"
value={otherValue}
value={inputValue}
tabIndex={otherSelected ? 0 : -1}
placeholder="Other answer"
onFocus={selectOther}
onCompositionStart={() => {
composingRef.current = true;
selectOther();
}}
onCompositionEnd={(event) => {
composingRef.current = false;
setInputValue(event.currentTarget.value);
commitOtherValue(event.currentTarget.value);
}}
onChange={(event) => {
actions.setUserInputDraft(otherKey, event.currentTarget.value);
actions.setUserInputDraft(draftKey, event.currentTarget.value);
setInputValue(event.currentTarget.value);
const nativeEvent = event.nativeEvent as Event & { isComposing?: boolean };
if (nativeEvent.isComposing !== true && !composingRef.current) commitOtherValue(event.currentTarget.value);
}}
/>
</label>

View file

@ -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<string, string> {
return Object.fromEntries(
input.params.questions.map((question) => [

View file

@ -27,6 +27,19 @@ function expectPresent<T>(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<typeof rawMessageStreamBlocks>[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<string, string>();
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<HTMLInputElement>(".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<HTMLInputElement>(".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<string, string>();
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<HTMLInputElement>(".codex-panel__user-input-other-text")?.tabIndex).toBe(-1);
expectPresent(parent.querySelectorAll<HTMLInputElement>(".codex-panel__user-input-radio").item(1)).click();
render();
expect(parent.querySelector<HTMLInputElement>(".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<string, string>();
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<HTMLInputElement>(".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,