authorgravatar for 6890442+Jardo-51@users.noreply.github.comJaroslav Brtis <6890442+Jardo-51@users.noreply.github.com> 2026-08-15 14:07:09+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-15 12:07:09+00:00
log40ab7bf32a81a66b20571ed280dc238c2276dc61
treefcb9e0be6b7f57c2a249413efdeba6658595018c
parent7c55e86320aac9c68ae53a7bc15682b7e14f98bf
signature Signed by PGP key B5690EEEBB952194

feat(web): collapse the question prompt from its header (#6773)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

2 files changed, 186 insertions(+), 71 deletions(-)

apps/web/src/components/chat/ComposerPendingUserInputPanel.test.tsx created+61
......@@ -0,0 +1,61 @@
1import { ApprovalRequestId } from "@t3tools/contracts";
2import { renderToStaticMarkup } from "react-dom/server";
3import { describe, expect, it } from "vite-plus/test";
4
5import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel";
6import type { PendingUserInput } from "../../session-logic";
7
8const prompt: PendingUserInput = {
9 requestId: ApprovalRequestId.make("request-1"),
10 createdAt: "2026-08-15T00:00:00.000Z",
11 questions: [
12 {
13 id: "question-1",
14 header: "Approach",
15 question: "Which approach should the migration take?",
16 options: [
17 { label: "Incremental", description: "Move one module at a time" },
18 { label: "Big bang", description: "Move everything in one release" },
19 ],
20 multiSelect: false,
21 },
22 ],
23};
24
25function renderPanel() {
26 return renderToStaticMarkup(
27 <ComposerPendingUserInputPanel
28 pendingUserInputs={[prompt]}
29 respondingRequestIds={[]}
30 answers={{}}
31 questionIndex={0}
32 onToggleOption={() => {}}
33 onAdvance={() => {}}
34 />,
35 );
36}
37
38describe("ComposerPendingUserInputPanel", () => {
39 it("renders the header as a disclosure control for the question body", () => {
40 const markup = renderPanel();
41
42 const toggle = markup.match(/<button[^>]*data-pending-user-input-toggle="[^"]*"[^>]*>/)?.[0];
43 expect(toggle).toBeDefined();
44 expect(toggle).toContain('data-pending-user-input-toggle="expanded"');
45 expect(toggle).toContain('aria-expanded="true"');
46 expect(toggle).toContain('type="button"');
47
48 const controlledId = toggle?.match(/aria-controls="([^"]+)"/)?.[1];
49 expect(controlledId).toBeDefined();
50 expect(markup).toMatch(new RegExp(`<div[^>]*\\sid="${controlledId}"`));
51 });
52
53 it("starts expanded so the question and its options are visible", () => {
54 const markup = renderPanel();
55
56 expect(markup).toContain("Approach");
57 expect(markup).toContain("Which approach should the migration take?");
58 expect(markup).toContain("Incremental");
59 expect(markup).toContain("Big bang");
60 });
61});
apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx+125-71
......@@ -5,7 +5,8 @@ import {
55 derivePendingUserInputProgress,
66 type PendingUserInputDraftAnswer,
77} from "../../pendingUserInput";
8import { CheckIcon } from "lucide-react";
8import { CheckIcon, ChevronDownIcon } from "lucide-react";
9import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";
910import { cn } from "~/lib/utils";
1011
1112interface PendingUserInputPanelProps {
......@@ -65,6 +66,14 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
6566 questionId: string;
6667 optionLabel: string;
6768 } | null>(null);
69 // Collapsing hides everything but the header so a tall prompt stops covering
70 // the thread the user is trying to read. Scoped to a single question: the card
71 // is keyed by request id so the next prompt starts expanded, and storing the
72 // collapsed question's id (rather than a bare flag) reopens the card when the
73 // prompt advances to its next question, which can happen without a click —
74 // sending from the composer advances the active question.
75 const [collapsedQuestionId, setCollapsedQuestionId] = useState<string | null>(null);
76 const isCollapsed = collapsedQuestionId !== null && collapsedQuestionId === activeQuestion?.id;
6877
6978 useEffect(() => {
7079 onAdvanceRef.current = onAdvance;
......@@ -118,9 +127,10 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
118127
119128 // Keyboard shortcut: number keys 1-9 select corresponding options when focus is
120129 // outside editable fields. Multi-select prompts toggle options in place; single-
121 // select prompts keep the existing auto-advance behavior.
130 // select prompts keep the existing auto-advance behavior. Collapsed prompts opt
131 // out, since the numbers they refer to are not on screen.
122132 useEffect(() => {
123 if (!activeQuestion || isResponding) return;
133 if (!activeQuestion || isResponding || isCollapsed) return;
124134 const handler = (event: globalThis.KeyboardEvent) => {
125135 if (event.metaKey || event.ctrlKey || event.altKey) return;
126136 const target = event.target;
......@@ -144,7 +154,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
144154 };
145155 document.addEventListener("keydown", handler);
146156 return () => document.removeEventListener("keydown", handler);
147 }, [activeQuestion, isResponding]);
157 }, [activeQuestion, isCollapsed, isResponding]);
148158
149159 if (!activeQuestion) {
150160 return null;
......@@ -153,75 +163,119 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
153163 const customAnswerActive = progress.customAnswer.trim().length > 0;
154164
155165 return (
156 <div className="px-4 py-3 sm:px-5">
157 <div className="mb-2 flex items-center gap-3">
158 <span className="text-secondary-label text-[11px] font-semibold tracking-widest uppercase">
159 {activeQuestion.header}
160 </span>
161 {prompt.questions.length > 1 ? (
162 <span className="flex h-5 items-center rounded-md bg-muted/60 px-1.5 text-secondary-label text-[10px] font-medium tabular-nums">
163 {questionIndex + 1}/{prompt.questions.length}
166 <Collapsible
167 className="py-3"
168 open={!isCollapsed}
169 onOpenChange={(open) => {
170 setCollapsedQuestionId(open ? null : activeQuestion.id);
171 }}
172 >
173 {/* The trigger's wrapper is inset less than the card's text column, and
174 the trigger pays the difference back as padding: the hover background
175 and focus ring bleed 10px past that column on both sides, while the
176 header label and the chevron still line up with the left and right
177 edges of the question text below. The negative block margin keeps the
178 taller hit area from pushing the panel down. */}
179 <div className="px-1.5 sm:px-2.5">
180 <CollapsibleTrigger
181 title={
182 isCollapsed ? "Show the question and its options" : "Hide the question and its options"
183 }
184 data-pending-user-input-toggle={isCollapsed ? "collapsed" : "expanded"}
185 className="group -my-1 flex w-full items-center gap-3 rounded-md px-2.5 py-1.5 text-left outline-none transition-colors duration-150 hover:bg-muted/40 focus-visible:ring-1 focus-visible:ring-primary/25"
186 >
187 <span className="text-secondary-label text-[11px] font-semibold tracking-widest uppercase group-hover:text-foreground">
188 {activeQuestion.header}
164189 </span>
165 ) : null}
190 {prompt.questions.length > 1 ? (
191 <span className="flex h-5 items-center rounded-md bg-muted/60 px-1.5 text-secondary-label text-[10px] font-medium tabular-nums">
192 {questionIndex + 1}/{prompt.questions.length}
193 </span>
194 ) : null}
195 {/* Collapsed, the header is otherwise just a section label and a
196 counter, so the question itself is echoed here as a one-line
197 reminder of what is being asked. */}
198 {isCollapsed ? (
199 <span className="min-w-0 flex-1 truncate text-secondary-label text-xs">
200 {activeQuestion.question}
201 </span>
202 ) : null}
203 {/* The chevron points at the body: down while it is open below the
204 header, up while it is collapsed into it. */}
205 <ChevronDownIcon
206 aria-hidden="true"
207 className={cn(
208 "ml-auto size-3.5 shrink-0 text-secondary-label transition-transform duration-150 group-hover:text-foreground",
209 isCollapsed && "rotate-180",
210 )}
211 />
212 </CollapsibleTrigger>
166213 </div>
167 <p className="text-sm text-foreground/90">{activeQuestion.question}</p>
168 {activeQuestion.multiSelect ? (
169 <p className="mt-1 text-secondary-label text-xs">Select one or more options.</p>
170 ) : null}
171 <div className="mt-3 space-y-1.5">
172 {activeQuestion.options.map((option, index) => {
173 const isOptimisticallySelected =
174 optimisticSingleSelect?.questionId === activeQuestion.id &&
175 optimisticSingleSelect.optionLabel === option.label;
176 const isSelected =
177 isOptimisticallySelected ||
178 (!customAnswerActive && progress.selectedOptionLabels.includes(option.label));
179 const shortcutKey = index < 9 ? index + 1 : null;
180 const className = cn(
181 "group flex w-full items-center gap-3 rounded-lg border px-3 py-2 text-left outline-none transition-all duration-150 focus-visible:border-primary/40 focus-visible:ring-1 focus-visible:ring-primary/25",
182 isSelected
183 ? "border-primary/30 bg-primary/8 text-foreground"
184 : "border-transparent bg-muted/22 text-foreground/85 hover:border-border/45 hover:bg-muted/34",
185 isResponding && "opacity-50 cursor-not-allowed",
186 !isResponding && "cursor-pointer",
187 );
188 const content = (
189 <>
190 <div className="min-w-0 flex-1 flex flex-col gap-0.5">
191 <span className="text-sm font-medium">{option.label}</span>
192 {option.description && option.description !== option.label ? (
193 <span className="text-secondary-label text-xs">{option.description}</span>
194 ) : null}
195 </div>
196 {isSelected ? (
197 <CheckIcon className="size-3.5 shrink-0 text-primary" />
198 ) : shortcutKey !== null ? (
199 <kbd
200 className={cn(
201 "flex size-5 shrink-0 items-center justify-center rounded border border-border/50 text-[11px] font-medium tabular-nums transition-colors duration-150",
202 "bg-background/35 text-secondary-label group-hover:border-border/70 group-hover:text-foreground",
203 )}
214 {/* The panel carries the horizontal padding itself: it clips its content
215 while the height animates, so the option buttons have to sit inside
216 that padding or their focus rings get shaved off at the edges. */}
217 <CollapsiblePanel className="px-4 sm:px-5">
218 <div className="pt-2 pb-0.5">
219 <p className="text-sm text-foreground/90">{activeQuestion.question}</p>
220 {activeQuestion.multiSelect ? (
221 <p className="mt-1 text-secondary-label text-xs">Select one or more options.</p>
222 ) : null}
223 <div className="mt-3 space-y-1.5">
224 {activeQuestion.options.map((option, index) => {
225 const isOptimisticallySelected =
226 optimisticSingleSelect?.questionId === activeQuestion.id &&
227 optimisticSingleSelect.optionLabel === option.label;
228 const isSelected =
229 isOptimisticallySelected ||
230 (!customAnswerActive && progress.selectedOptionLabels.includes(option.label));
231 const shortcutKey = index < 9 ? index + 1 : null;
232 const className = cn(
233 "group flex w-full items-center gap-3 rounded-lg border px-3 py-2 text-left outline-none transition-all duration-150 focus-visible:border-primary/40 focus-visible:ring-1 focus-visible:ring-primary/25",
234 isSelected
235 ? "border-primary/30 bg-primary/8 text-foreground"
236 : "border-transparent bg-muted/22 text-foreground/85 hover:border-border/45 hover:bg-muted/34",
237 isResponding && "opacity-50 cursor-not-allowed",
238 !isResponding && "cursor-pointer",
239 );
240 const content = (
241 <>
242 <div className="min-w-0 flex-1 flex flex-col gap-0.5">
243 <span className="text-sm font-medium">{option.label}</span>
244 {option.description && option.description !== option.label ? (
245 <span className="text-secondary-label text-xs">{option.description}</span>
246 ) : null}
247 </div>
248 {isSelected ? (
249 <CheckIcon className="size-3.5 shrink-0 text-primary" />
250 ) : shortcutKey !== null ? (
251 <kbd
252 className={cn(
253 "flex size-5 shrink-0 items-center justify-center rounded border border-border/50 text-[11px] font-medium tabular-nums transition-colors duration-150",
254 "bg-background/35 text-secondary-label group-hover:border-border/70 group-hover:text-foreground",
255 )}
256 >
257 {shortcutKey}
258 </kbd>
259 ) : null}
260 </>
261 );
262 return (
263 <button
264 key={`${activeQuestion.id}:${option.label}`}
265 type="button"
266 disabled={isResponding}
267 onClick={() => {
268 handleOptionSelection(activeQuestion.id, option.label);
269 }}
270 className={className}
204271 >
205 {shortcutKey}
206 </kbd>
207 ) : null}
208 </>
209 );
210 return (
211 <button
212 key={`${activeQuestion.id}:${option.label}`}
213 type="button"
214 disabled={isResponding}
215 onClick={() => {
216 handleOptionSelection(activeQuestion.id, option.label);
217 }}
218 className={className}
219 >
220 {content}
221 </button>
222 );
223 })}
224 </div>
225 </div>
272 {content}
273 </button>
274 );
275 })}
276 </div>
277 </div>
278 </CollapsiblePanel>
279 </Collapsible>
226280 );
227281});