mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
feat(agent-mode): custom "Other" response option in the AskUserQuestion card (#2653)
* feat(agent-mode): add custom "Other" response to AskUserQuestion card
Each question in the inline AskUserQuestion card now offers an "Other" row (radio for single-select, checkbox for multi-select) that reveals a free-form textarea, mirroring Claude Code CLI. The typed text becomes the answer (single-select) or is appended to the checked labels (multi-select). Confined to the UI component — the answer stays a plain string, so no type/bridge/backend changes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test(agent-mode): cover custom "Other" response in AskUserQuestion card
Five RTL cases: single-select Other→trimmed text, multi-select presets+Other→joined, empty Other disables Submit, Cancel→{}, and a single-select preset regression.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
4cfea5761d
commit
9fc6153ae6
2 changed files with 238 additions and 23 deletions
118
src/agentMode/ui/AskUserQuestionCard.test.tsx
Normal file
118
src/agentMode/ui/AskUserQuestionCard.test.tsx
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { AskUserQuestionCard } from "@/agentMode/ui/AskUserQuestionCard";
|
||||
import type { AskUserQuestionPrompt, SessionId } from "@/agentMode/session/types";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
const SESSION_ID = "s1" as SessionId;
|
||||
const REQUEST_ID = "req-1";
|
||||
|
||||
function makeRequest(questions: AskUserQuestionPrompt["questions"]): AskUserQuestionPrompt {
|
||||
return { sessionId: SESSION_ID, requestId: REQUEST_ID, questions };
|
||||
}
|
||||
|
||||
function renderCard(request: AskUserQuestionPrompt, onResolve: jest.Mock) {
|
||||
return render(<AskUserQuestionCard request={request} onResolve={onResolve} />);
|
||||
}
|
||||
|
||||
// The "Other" row's accessible name is its label plus the "Type your own
|
||||
// response" description (jsdom concatenates them with no separator), so anchor
|
||||
// on the leading "Other" rather than an exact match. Preset labels here are
|
||||
// A/B/C, so this can't collide with a preset.
|
||||
function getOtherControl(role: "radio" | "checkbox"): HTMLElement {
|
||||
return screen.getByRole(role, { name: /^other/i });
|
||||
}
|
||||
|
||||
const submitButton = (): HTMLElement => screen.getByRole("button", { name: /submit/i });
|
||||
const cancelButton = (): HTMLElement => screen.getByRole("button", { name: /cancel/i });
|
||||
const otherTextarea = (): HTMLElement => screen.getByPlaceholderText(/type your response/i);
|
||||
|
||||
describe("AskUserQuestionCard custom 'Other' response", () => {
|
||||
it("single-select 'Other' → the trimmed typed text is the answer", () => {
|
||||
const onResolve = jest.fn();
|
||||
const request = makeRequest([
|
||||
{
|
||||
question: "When do we ship?",
|
||||
options: [{ label: "A" }, { label: "B" }],
|
||||
},
|
||||
]);
|
||||
renderCard(request, onResolve);
|
||||
|
||||
fireEvent.click(getOtherControl("radio"));
|
||||
// Surrounding whitespace proves the answer is trimmed on submit.
|
||||
fireEvent.change(otherTextarea(), { target: { value: " ship it Friday " } });
|
||||
fireEvent.click(submitButton());
|
||||
|
||||
expect(onResolve).toHaveBeenCalledTimes(1);
|
||||
expect(onResolve).toHaveBeenCalledWith(REQUEST_ID, { "When do we ship?": "ship it Friday" });
|
||||
});
|
||||
|
||||
it("multi-select presets + 'Other' → checked labels and trimmed text joined with ', '", () => {
|
||||
const onResolve = jest.fn();
|
||||
const request = makeRequest([
|
||||
{
|
||||
question: "Pick tasks",
|
||||
multiSelect: true,
|
||||
options: [{ label: "A" }, { label: "B" }, { label: "C" }],
|
||||
},
|
||||
]);
|
||||
renderCard(request, onResolve);
|
||||
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: /^A$/ }));
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: /^C$/ }));
|
||||
fireEvent.click(getOtherControl("checkbox"));
|
||||
fireEvent.change(otherTextarea(), { target: { value: " rollback plan " } });
|
||||
fireEvent.click(submitButton());
|
||||
|
||||
expect(onResolve).toHaveBeenCalledTimes(1);
|
||||
expect(onResolve).toHaveBeenCalledWith(REQUEST_ID, { "Pick tasks": "A, C, rollback plan" });
|
||||
});
|
||||
|
||||
it("disables Submit while 'Other' is armed with empty text, enabling it once text is typed", () => {
|
||||
const onResolve = jest.fn();
|
||||
const request = makeRequest([
|
||||
{
|
||||
question: "When do we ship?",
|
||||
options: [{ label: "A" }, { label: "B" }],
|
||||
},
|
||||
]);
|
||||
renderCard(request, onResolve);
|
||||
|
||||
fireEvent.click(getOtherControl("radio"));
|
||||
expect((submitButton() as HTMLButtonElement).disabled).toBe(true);
|
||||
|
||||
fireEvent.change(otherTextarea(), { target: { value: "x" } });
|
||||
expect((submitButton() as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("Cancel resolves with an empty answer map", () => {
|
||||
const onResolve = jest.fn();
|
||||
const request = makeRequest([
|
||||
{
|
||||
question: "When do we ship?",
|
||||
options: [{ label: "A" }, { label: "B" }],
|
||||
},
|
||||
]);
|
||||
renderCard(request, onResolve);
|
||||
|
||||
fireEvent.click(cancelButton());
|
||||
|
||||
expect(onResolve).toHaveBeenCalledWith(REQUEST_ID, {});
|
||||
});
|
||||
|
||||
it("regression: single-select preset still resolves with the chosen label", () => {
|
||||
const onResolve = jest.fn();
|
||||
const request = makeRequest([
|
||||
{
|
||||
question: "When do we ship?",
|
||||
options: [{ label: "A" }, { label: "B" }],
|
||||
},
|
||||
]);
|
||||
renderCard(request, onResolve);
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: /^A$/ }));
|
||||
fireEvent.click(submitButton());
|
||||
|
||||
expect(onResolve).toHaveBeenCalledTimes(1);
|
||||
expect(onResolve).toHaveBeenCalledWith(REQUEST_ID, { "When do we ship?": "A" });
|
||||
});
|
||||
});
|
||||
|
|
@ -13,8 +13,18 @@ interface AskUserQuestionCardProps {
|
|||
onResolve: (requestId: string, answers: AgentQuestionAnswers) => void;
|
||||
}
|
||||
|
||||
/** A single-select pick is "answered" once a label is chosen; multi-select is always satisfiable. */
|
||||
function isAnswered(question: AgentQuestion, selection: string | Set<string> | undefined): boolean {
|
||||
/**
|
||||
* Whether a question has enough input to submit. Mirrors Claude Code's "Other"
|
||||
* affordance: an active "Other" row is only satisfied once its free-form text
|
||||
* is non-empty, so it can gate Submit independently of the preset options.
|
||||
*/
|
||||
function isAnswered(
|
||||
question: AgentQuestion,
|
||||
selection: string | Set<string> | undefined,
|
||||
otherActive: boolean,
|
||||
customText: string
|
||||
): boolean {
|
||||
if (otherActive) return customText.trim().length > 0;
|
||||
if (question.multiSelect) return true;
|
||||
return typeof selection === "string" && selection !== "";
|
||||
}
|
||||
|
|
@ -31,6 +41,10 @@ function isAnswered(question: AgentQuestion, selection: string | Set<string> | u
|
|||
* SDK's single-response contract. Submitting routes the answers map through the
|
||||
* ask-question prompter's happy path; Cancel resolves with `{}`, which the
|
||||
* bridge maps to the "User cancelled the question" deny.
|
||||
*
|
||||
* Each question also offers an "Other" row that reveals a free-form textarea,
|
||||
* so the user can answer when none of the agent's options fit — the typed text
|
||||
* is folded into the same plain-string answer the presets produce.
|
||||
*/
|
||||
export const AskUserQuestionCard: React.FC<AskUserQuestionCardProps> = ({ request, onResolve }) => {
|
||||
const { questions, requestId } = request;
|
||||
|
|
@ -38,10 +52,17 @@ export const AskUserQuestionCard: React.FC<AskUserQuestionCardProps> = ({ reques
|
|||
const [activeTab, setActiveTab] = useState(0);
|
||||
// Per-question selection: a single label for radio, a Set of labels for checkbox.
|
||||
const [selections, setSelections] = useState<Record<number, string | Set<string>>>({});
|
||||
// Per-question "Other" state, kept out of the option-label value space so a
|
||||
// custom answer can never collide with a real option label.
|
||||
const [otherActive, setOtherActive] = useState<Record<number, boolean>>({});
|
||||
const [customTexts, setCustomTexts] = useState<Record<number, string>>({});
|
||||
|
||||
// Gate Submit until every single-select question has a pick. Multi-select
|
||||
// questions may be left empty (the user can decline every option).
|
||||
const canSubmit = questions.every((q, idx) => isAnswered(q, selections[idx]));
|
||||
// Gate Submit until every single-select question has a pick (or a non-empty
|
||||
// "Other"). Multi-select questions may be left empty unless "Other" is armed,
|
||||
// in which case its text must be filled.
|
||||
const canSubmit = questions.every((q, idx) =>
|
||||
isAnswered(q, selections[idx], otherActive[idx] ?? false, customTexts[idx] ?? "")
|
||||
);
|
||||
|
||||
const submit = (): void => {
|
||||
if (busy || !canSubmit) return;
|
||||
|
|
@ -50,10 +71,15 @@ export const AskUserQuestionCard: React.FC<AskUserQuestionCardProps> = ({ reques
|
|||
for (let i = 0; i < questions.length; i++) {
|
||||
const q = questions[i];
|
||||
const sel = selections[i];
|
||||
const other = otherActive[i] ?? false;
|
||||
const text = (customTexts[i] ?? "").trim();
|
||||
if (q.multiSelect) {
|
||||
answers[q.question] = sel instanceof Set ? Array.from(sel).join(", ") : "";
|
||||
const labels = sel instanceof Set ? Array.from(sel) : [];
|
||||
if (other && text) labels.push(text);
|
||||
answers[q.question] = labels.join(", ");
|
||||
} else {
|
||||
answers[q.question] = typeof sel === "string" ? sel : "";
|
||||
// "Other" wins over any stale preset (radio exclusivity clears it anyway).
|
||||
answers[q.question] = other ? text : typeof sel === "string" ? sel : "";
|
||||
}
|
||||
}
|
||||
onResolve(requestId, answers);
|
||||
|
|
@ -69,6 +95,34 @@ export const AskUserQuestionCard: React.FC<AskUserQuestionCardProps> = ({ reques
|
|||
const active = questions[activeTab] ?? questions[0];
|
||||
const activeIdx = questions[activeTab] ? activeTab : 0;
|
||||
|
||||
// Choosing a preset. Single-select picks one label and disarms "Other";
|
||||
// multi-select toggles the label in its Set and leaves "Other" alone.
|
||||
const togglePreset = (label: string): void => {
|
||||
if (active.multiSelect) {
|
||||
setSelections((prev) => {
|
||||
const cur = prev[activeIdx];
|
||||
const next = new Set(cur instanceof Set ? cur : []);
|
||||
if (next.has(label)) next.delete(label);
|
||||
else next.add(label);
|
||||
return { ...prev, [activeIdx]: next };
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSelections((prev) => ({ ...prev, [activeIdx]: label }));
|
||||
setOtherActive((prev) => ({ ...prev, [activeIdx]: false }));
|
||||
};
|
||||
|
||||
// Choosing "Other". Single-select arms it exclusively (clearing the radio
|
||||
// pick); multi-select toggles it alongside any checked presets.
|
||||
const toggleOther = (): void => {
|
||||
if (active.multiSelect) {
|
||||
setOtherActive((prev) => ({ ...prev, [activeIdx]: !(prev[activeIdx] ?? false) }));
|
||||
return;
|
||||
}
|
||||
setOtherActive((prev) => ({ ...prev, [activeIdx]: true }));
|
||||
setSelections((prev) => ({ ...prev, [activeIdx]: "" }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tw-mx-3 tw-my-2 tw-w-[calc(100%-1.5rem)] tw-rounded-md tw-border tw-border-solid tw-border-border tw-bg-secondary">
|
||||
<div className="copilot-divider-b tw-flex tw-items-center tw-gap-2 tw-px-3 tw-py-2">
|
||||
|
|
@ -113,19 +167,13 @@ export const AskUserQuestionCard: React.FC<AskUserQuestionCardProps> = ({ reques
|
|||
question={active}
|
||||
name={`askq-${requestId}-${activeIdx}`}
|
||||
selection={selections[activeIdx]}
|
||||
otherActive={otherActive[activeIdx] ?? false}
|
||||
customText={customTexts[activeIdx] ?? ""}
|
||||
disabled={busy}
|
||||
onToggle={(label) =>
|
||||
setSelections((prev) => {
|
||||
if (active.multiSelect) {
|
||||
const cur = prev[activeIdx];
|
||||
const next = new Set(cur instanceof Set ? cur : []);
|
||||
if (next.has(label)) next.delete(label);
|
||||
else next.add(label);
|
||||
return { ...prev, [activeIdx]: next };
|
||||
}
|
||||
return { ...prev, [activeIdx]: label };
|
||||
})
|
||||
}
|
||||
onTogglePreset={togglePreset}
|
||||
onToggleOther={toggleOther}
|
||||
onCustomTextChange={(text) => setCustomTexts((prev) => ({ ...prev, [activeIdx]: text }))}
|
||||
onSubmitShortcut={submit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -146,8 +194,14 @@ interface QuestionPanelProps {
|
|||
/** Radio-group name; namespaced by requestId + index so cards don't collide. */
|
||||
name: string;
|
||||
selection: string | Set<string> | undefined;
|
||||
otherActive: boolean;
|
||||
customText: string;
|
||||
disabled: boolean;
|
||||
onToggle: (label: string) => void;
|
||||
onTogglePreset: (label: string) => void;
|
||||
onToggleOther: () => void;
|
||||
onCustomTextChange: (text: string) => void;
|
||||
/** Cmd/Ctrl+Enter in the textarea; the parent guards on `canSubmit`. */
|
||||
onSubmitShortcut: () => void;
|
||||
}
|
||||
|
||||
/** The active question's prompt text plus its single- or multi-select option list. */
|
||||
|
|
@ -155,9 +209,15 @@ const QuestionPanel: React.FC<QuestionPanelProps> = ({
|
|||
question,
|
||||
name,
|
||||
selection,
|
||||
otherActive,
|
||||
customText,
|
||||
disabled,
|
||||
onToggle,
|
||||
onTogglePreset,
|
||||
onToggleOther,
|
||||
onCustomTextChange,
|
||||
onSubmitShortcut,
|
||||
}) => {
|
||||
const control = question.multiSelect ? "checkbox" : "radio";
|
||||
return (
|
||||
<div role="tabpanel" className="tw-flex tw-flex-col tw-gap-2">
|
||||
<div className="tw-text-sm">{question.question}</div>
|
||||
|
|
@ -177,11 +237,11 @@ const QuestionPanel: React.FC<QuestionPanelProps> = ({
|
|||
native checkboxes/radios, which was throwing off alignment. */}
|
||||
<span className="tw-flex tw-h-5 tw-shrink-0 tw-items-center">
|
||||
<input
|
||||
type={question.multiSelect ? "checkbox" : "radio"}
|
||||
type={control}
|
||||
name={name}
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={() => onToggle(opt.label)}
|
||||
onChange={() => onTogglePreset(opt.label)}
|
||||
className="tw-m-0"
|
||||
/>
|
||||
</span>
|
||||
|
|
@ -194,7 +254,44 @@ const QuestionPanel: React.FC<QuestionPanelProps> = ({
|
|||
</label>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* "Other" escape hatch: shares the radio group name so single-select
|
||||
grouping stays native, and reveals a free-form textarea when armed. */}
|
||||
<label className="tw-flex tw-cursor-pointer tw-items-start tw-gap-2 tw-rounded tw-px-2 tw-py-1.5 hover:tw-bg-modifier-hover">
|
||||
<span className="tw-flex tw-h-5 tw-shrink-0 tw-items-center">
|
||||
<input
|
||||
type={control}
|
||||
name={name}
|
||||
checked={otherActive}
|
||||
disabled={disabled}
|
||||
onChange={onToggleOther}
|
||||
className="tw-m-0"
|
||||
/>
|
||||
</span>
|
||||
<div className="tw-min-w-0">
|
||||
<div className="tw-text-sm tw-leading-5">Other</div>
|
||||
<div className="tw-text-xs tw-text-muted">Type your own response</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{otherActive ? (
|
||||
<textarea
|
||||
className="tw-min-h-9 tw-w-full tw-resize-y tw-rounded tw-border tw-border-solid tw-border-border tw-bg-primary tw-px-2 tw-py-1 tw-text-sm tw-text-normal tw-outline-none focus:tw-border-border-focus"
|
||||
placeholder="Type your response…"
|
||||
value={customText}
|
||||
disabled={disabled}
|
||||
autoFocus
|
||||
onChange={(e) => onCustomTextChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
onSubmitShortcut();
|
||||
}
|
||||
}}
|
||||
rows={2}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue