mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Improve pending request focus and Plan inputs
This commit is contained in:
parent
1b05e74e94
commit
9d48221d64
4 changed files with 276 additions and 7 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) => [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue