Repository navigation
fix(web): allow copying pending question option text #23197
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
4fc8ee4
89e3226
f1ed10f
0add353
0ed9d4b
6498345
79a11bb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -85,6 +85,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( | |||||||||||||||
| const canRespond = prompt.responseCapability !== "not_resumable"; | ||||||||||||||||
| const responseDisabled = disabled || isResponding || !canRespond; | ||||||||||||||||
| const autoAdvanceTimerRef = useRef<number | null>(null); | ||||||||||||||||
| const spacePressedOptionRef = useRef<HTMLElement | null>(null); | ||||||||||||||||
| const advance = useStableCallback(onAdvance); | ||||||||||||||||
| const [optimisticSingleSelect, setOptimisticSingleSelect] = useState<{ | ||||||||||||||||
| questionId: string; | ||||||||||||||||
|
|
@@ -250,7 +251,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( | |||||||||||||||
| isOptimisticallySelected || draftSelectedOptions?.[index] === true; | ||||||||||||||||
| const shortcutKey = index < 9 ? index + 1 : null; | ||||||||||||||||
| const className = cn( | ||||||||||||||||
| "group flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left outline-none transition-colors duration-150 focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-primary/25", | ||||||||||||||||
| "group flex w-full select-text items-center gap-2 rounded-md px-2.5 py-2 text-left outline-none transition-colors duration-150 focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-primary/25", | ||||||||||||||||
| isSelected | ||||||||||||||||
| ? "bg-muted/55 text-foreground" | ||||||||||||||||
| : "bg-transparent text-foreground/85 hover:bg-muted/30", | ||||||||||||||||
|
|
@@ -278,18 +279,55 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( | |||||||||||||||
| ) : null} | ||||||||||||||||
| </> | ||||||||||||||||
| ); | ||||||||||||||||
| // Chromium cannot start a text selection inside a native <button>, so the | ||||||||||||||||
| // option is a button-role div whose label and description stay copyable. | ||||||||||||||||
| return ( | ||||||||||||||||
| <button | ||||||||||||||||
| <div | ||||||||||||||||
| key={`${activeQuestion.id}:${optionValue}`} | ||||||||||||||||
| type="button" | ||||||||||||||||
| disabled={disabled || isResponding} | ||||||||||||||||
| onClick={() => { | ||||||||||||||||
| role="button" | ||||||||||||||||
| tabIndex={disabled || isResponding ? -1 : 0} | ||||||||||||||||
| aria-disabled={disabled || isResponding} | ||||||||||||||||
| onClick={(event) => { | ||||||||||||||||
| // A drag that selected text in the option ends in a click; it must not answer. | ||||||||||||||||
| // Assistive technology activates with detail 0 and still answers. | ||||||||||||||||
| const selection = window.getSelection(); | ||||||||||||||||
| if ( | ||||||||||||||||
| event.detail > 0 && | ||||||||||||||||
| selection && | ||||||||||||||||
| !selection.isCollapsed && | ||||||||||||||||
| selection.containsNode(event.currentTarget, true) | ||||||||||||||||
| ) { | ||||||||||||||||
| return; | ||||||||||||||||
| } | ||||||||||||||||
| handleOptionSelection(activeQuestion.id, optionValue); | ||||||||||||||||
| }} | ||||||||||||||||
| // Native button keys: Enter answers on keydown, Space on keyup, and | ||||||||||||||||
| // blurring while Space is held cancels it. Modified keys stay with global | ||||||||||||||||
| // shortcuts such as Mod+Shift+Enter. | ||||||||||||||||
| onKeyDown={(event) => { | ||||||||||||||||
| if (event.metaKey || event.ctrlKey || event.altKey) return; | ||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Exclude Shift+Enter from option activation. When an option has focus, Shift+Enter calls Proposed fix- if (event.metaKey || event.ctrlKey || event.altKey) return;
+ if (
+ event.metaKey ||
+ event.ctrlKey ||
+ event.altKey ||
+ (event.key === "Enter" && event.shiftKey)
+ ) return;📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||
| if (event.key === "Enter") { | ||||||||||||||||
| event.preventDefault(); | ||||||||||||||||
| handleOptionSelection(activeQuestion.id, optionValue); | ||||||||||||||||
| } else if (event.key === " ") { | ||||||||||||||||
| event.preventDefault(); | ||||||||||||||||
| if (!event.repeat) spacePressedOptionRef.current = event.currentTarget; | ||||||||||||||||
| } | ||||||||||||||||
| }} | ||||||||||||||||
| onKeyUp={(event) => { | ||||||||||||||||
| if (event.key !== " ") return; | ||||||||||||||||
| const pressed = spacePressedOptionRef.current === event.currentTarget; | ||||||||||||||||
| spacePressedOptionRef.current = null; | ||||||||||||||||
| if (event.metaKey || event.ctrlKey || event.altKey) return; | ||||||||||||||||
| if (pressed) handleOptionSelection(activeQuestion.id, optionValue); | ||||||||||||||||
| }} | ||||||||||||||||
| onBlur={() => { | ||||||||||||||||
| spacePressedOptionRef.current = null; | ||||||||||||||||
| }} | ||||||||||||||||
| className={className} | ||||||||||||||||
| > | ||||||||||||||||
| {content} | ||||||||||||||||
| </button> | ||||||||||||||||
| </div> | ||||||||||||||||
| ); | ||||||||||||||||
| })} | ||||||||||||||||
| </div> | ||||||||||||||||
|
|
||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| # Pending question evidence fixtures | ||
|
|
||
| Run `node apps/web/test/fixtures/pending-user-input/request.mjs multi` or | ||
| `node apps/web/test/fixtures/pending-user-input/request.mjs single` from the repo | ||
| root. Paste the output into a Claude thread in an isolated development server. | ||
| The provider must only ask the question; it must not execute the example command. | ||
|
|
||
| `multi` exercises wrapped labels and descriptions, inline question code, an empty | ||
| description, and a description identical to its label. Select **Wait** for the | ||
| matched stills, leaving the other options unselected. Keep the request pending | ||
| while testing selection, clipboard paste, ordinary clicks, keyboard activation, | ||
| and collapse/reopen. `single` has a nonterminal and terminal question. Reissue it | ||
| for each activation method because the terminal answer consumes the request. | ||
|
|
||
| These are public synthetic inputs, not database rows or provider credentials. | ||
| Provider-generated request/question identifiers can differ. Claude's four-option | ||
| limit prevents this live fixture from covering the ninth/tenth option. Permission | ||
| disabled, in-flight responding, and nonresumable states require separate real | ||
| runtime conditions; changing these inputs does not establish those conditions. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| { | ||
| "multi": [ | ||
| { | ||
| "id": "selection-test", | ||
| "header": "Selection test", | ||
| "question": "Which command should handle ticket T3-16821? Inspect `git status --short` before proceeding.", | ||
| "multiSelect": true, | ||
| "options": [ | ||
| { | ||
| "label": "Inspect a long command and its harmless temporary working directory", | ||
| "description": "Run git status --short in /tmp/repro\nKeep ticket T3-16821 pending until review is complete." | ||
| }, | ||
| { | ||
| "label": "Wait", | ||
| "description": "Leave ticket T3-16821 pending" | ||
| }, | ||
| { | ||
| "label": "No description", | ||
| "description": "" | ||
| }, | ||
| { | ||
| "label": "Same description", | ||
| "description": "Same description" | ||
| } | ||
| ] | ||
| } | ||
| ], | ||
| "single": [ | ||
| { | ||
| "id": "single-first", | ||
| "header": "First question", | ||
| "question": "Which command should handle ticket T3-16821? Inspect `git status --short` before proceeding.", | ||
| "multiSelect": false, | ||
| "options": [ | ||
| { | ||
| "label": "Inspect a long command and its harmless temporary working directory", | ||
| "description": "Run git status --short in /tmp/repro\nKeep ticket T3-16821 pending until review is complete." | ||
| }, | ||
| { | ||
| "label": "Wait", | ||
| "description": "Leave ticket T3-16821 pending" | ||
| }, | ||
| { | ||
| "label": "No description", | ||
| "description": "" | ||
| }, | ||
| { | ||
| "label": "Same description", | ||
| "description": "Same description" | ||
| } | ||
| ] | ||
| }, | ||
| { | ||
| "id": "single-final", | ||
| "header": "Final question", | ||
| "question": "Which harmless choice should finish ticket T3-16821?", | ||
| "multiSelect": false, | ||
| "options": [ | ||
| { | ||
| "label": "Inspect a long command and its harmless temporary working directory", | ||
| "description": "Run git status --short in /tmp/repro\nKeep ticket T3-16821 pending until review is complete." | ||
| }, | ||
| { | ||
| "label": "Wait", | ||
| "description": "Leave ticket T3-16821 pending" | ||
| }, | ||
| { | ||
| "label": "No description", | ||
| "description": "" | ||
| }, | ||
| { | ||
| "label": "Same description", | ||
| "description": "Same description" | ||
| } | ||
| ] | ||
| } | ||
| ] | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| import * as NodeFS from "node:fs"; | ||
|
|
||
| const fixtures = JSON.parse( | ||
| NodeFS.readFileSync(new URL("./questions.json", import.meta.url), "utf8"), | ||
| ); | ||
| const shape = process.argv[2] ?? "multi"; | ||
| if (shape !== "multi" && shape !== "single") { | ||
| throw new Error("Usage: node request.mjs [multi|single]"); | ||
| } | ||
|
|
||
| console.log( | ||
| "Call only AskUserQuestion with the questions below. Do not execute commands, edit files, " + | ||
| "or use other tools. Preserve the text exactly, including the description newline. " + | ||
| "The id fields are fixture identifiers; omit them if the provider schema disallows them. " + | ||
| "Leave the request pending. When answered, acknowledge the choices without taking action.\n\n" + | ||
| JSON.stringify({ questions: fixtures[shape] }, null, 2), | ||
| ); |
Uh oh!
There was an error while loading. Please reload this page.