Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# TypeScript sources are text, always. multi-select-action.ts uses a NUL as a map-key separator, and
# a single such byte makes git classify the whole file as binary — which silently hid every change to
# the race guard from `git show` and from PR review. The byte is now a source escape, but this keeps
# any future one from costing reviewability.
*.ts diff
*.tsx diff
61 changes: 60 additions & 1 deletion web/src/components/multi-select-block.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ describe("MultiSelectBlock — checkbox screen", () => {
// A tap locks every control until the (awaited) handler resolves; vi.fn() resolves synchronously,
// so subsequent taps go through in order.
await user.click(screen.getByRole("button", { name: /^Submit$/ }));
expect(onAction).toHaveBeenLastCalledWith({ kind: "submit" });
expect(onAction).toHaveBeenLastCalledWith({ kind: "advance" });

await user.click(screen.getByRole("button", { name: /Chat about this/ }));
expect(onAction).toHaveBeenLastCalledWith({ kind: "escape" });
Expand Down Expand Up @@ -91,3 +91,62 @@ describe("MultiSelectBlock — review screen", () => {
expect(onAction).toHaveBeenLastCalledWith({ kind: "cancel" });
});
});

// A checkbox question that is one STEP of a wizard. Everything the parser lifts for this shape —
// the chips, the Left/Right navigation, and the advance row's literal label — has to reach the DOM,
// and none of it is exercised by the single-question fixtures (their `steps` is null).
describe("MultiSelectBlock — as a step of a wizard", () => {
it("renders the stepper, and names the advance control what the terminal calls it", () => {
const model = fixtureModel("claude--wizard-multiselect-q1.txt");
render(<MultiSelectBlock multi={model} onAction={vi.fn()} />);

const steps = screen.getByRole("list", { name: "Questions" });
expect(within(steps).getAllByRole("listitem").map((li) => li.textContent)).toEqual([
"Toppings",
"Crust",
"Submit",
]);
// Not "Submit": this is question 1 of 2, and the button must not claim to finish the dialog.
expect(screen.getByRole("button", { name: "Next" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Submit" })).not.toBeInTheDocument();
});

it("says Submit on the last step", () => {
const model = fixtureModel("claude--wizard-multiselect-final.txt");
render(<MultiSelectBlock multi={model} onAction={vi.fn()} />);
expect(screen.getByRole("button", { name: "Submit" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Next" })).not.toBeInTheDocument();
});

it("marks the current question and announces where you are", () => {
const model = fixtureModel("claude--wizard-multiselect-final.txt");
render(<MultiSelectBlock multi={model} onAction={vi.fn()} />);
// aria-current alone moves silently between list items, so the position is also spoken.
expect(screen.getByRole("status")).toHaveTextContent("Step 2 of 3, Extras");
const current = within(screen.getByRole("list", { name: "Questions" }))
.getAllByRole("listitem")
.filter((li) => li.getAttribute("aria-current") === "step");
expect(current.map((li) => li.textContent)).toEqual(["Extras"]);
});

it("navigates between questions with the wizard's own keys", async () => {
const user = userEvent.setup();
const onAction = vi.fn();
render(<MultiSelectBlock multi={fixtureModel("claude--wizard-multiselect-final.txt")} onAction={onAction} />);

await user.click(screen.getByRole("button", { name: "Previous step" }));
expect(onAction).toHaveBeenLastCalledWith({ kind: "nav", keys: ["Left"] });
await user.click(screen.getByRole("button", { name: "Next step" }));
expect(onAction).toHaveBeenLastCalledWith({ kind: "nav", keys: ["Right"] });
});

it("disables Back on the first question — there is nothing to its left", () => {
render(<MultiSelectBlock multi={fixtureModel("claude--wizard-multiselect-q1.txt")} onAction={vi.fn()} />);
expect(screen.getByRole("button", { name: "Previous step" })).toBeDisabled();
});

it("shows no stepper for a standalone single-question dialog", () => {
render(<MultiSelectBlock multi={fixtureModel("claude--select-multiselect-single.txt")} onAction={vi.fn()} />);
expect(screen.queryByRole("list", { name: "Questions" })).not.toBeInTheDocument();
});
});
26 changes: 22 additions & 4 deletions web/src/components/multi-select-block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { cn } from "@/lib/utils";
import type { MultiSelectModel } from "@/lib/blocks";
import type { MultiSelectIntent } from "@/lib/multi-select-action";
import { KeyBadge, optionSurface, PromptPanel, QuestionHeading } from "@/components/option-button";
import { WizardStepper } from "@/components/wizard-stepper";
import { WIZARD_BACK_KEYS, WIZARD_NEXT_KEYS } from "@/lib/harness/claude/wizard";

export interface MultiSelectBlockProps {
/** The detected multi-select dialog (checkbox screen or review screen). */
Expand Down Expand Up @@ -66,6 +68,17 @@ function CheckboxPhase({
}) {
return (
<PromptPanel ariaLabel={multi.question}>
{multi.steps && (
<WizardStepper
steps={multi.steps}
locked={locked}
busyBack={sending === "nav-back"}
busyNext={sending === "nav-next"}
busyIcon={spinnerSm}
onBack={() => onPress("nav-back", { kind: "nav", keys: WIZARD_BACK_KEYS })}
onNext={() => onPress("nav-next", { kind: "nav", keys: WIZARD_NEXT_KEYS })}
/>
)}
<QuestionHeading>{multi.question}</QuestionHeading>
<div className="flex flex-col gap-1">
{multi.options.map((option) => {
Expand Down Expand Up @@ -111,15 +124,20 @@ function CheckboxPhase({
})}
</div>

{/* Submit advances to the review screen — the handler drives the closed-loop macro. */}
{/* The advance row — "Submit" on the last question, "Next" before it. The handler drives the
closed-loop macro that walks the pointer onto it and verifies before pressing Enter. */}
<button
type="button"
disabled={locked}
onClick={() => onPress("submit", { kind: "submit" })}
// The name is on the ATTRIBUTE, not just the text: this same button element is renamed
// "Next" → "Submit" underneath a user who may already have it focused, and a plain
// child-text swap on a focused control is not reliably re-announced.
aria-label={multi.advanceLabel}
onClick={() => onPress("advance", { kind: "advance" })}
className="flex w-full items-center justify-center gap-2 rounded-lg border border-primary/60 bg-primary/15 px-3 py-2 text-sm font-medium text-foreground transition-colors active:bg-primary/25 disabled:opacity-60"
>
{sending === "submit" ? spinnerSm : null}
Submit
{sending === "advance" ? spinnerSm : null}
{multi.advanceLabel}
</button>

{/* "Chat about this" ABORTS the tool — de-emphasised, apart from the answers (like the wizard). */}
Expand Down
54 changes: 10 additions & 44 deletions web/src/components/preview-select-block.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { useState } from "react";
import {
Check,
ChevronLeft,
ChevronRight,
Loader2,
Pencil,
StickyNote,
Trash2,
} from "lucide-react";

import { WizardStepper } from "@/components/wizard-stepper";
import { cn } from "@/lib/utils";
import type { PreviewOption, PreviewSelectModel } from "@/lib/blocks";
import {
Expand Down Expand Up @@ -75,7 +75,6 @@ export function PreviewSelectBlock({ preview, onAction, disabled }: PreviewSelec
}

const wizard = preview.steps !== null;
const atFirstQuestion = wizard && (preview.steps![0]?.current ?? false);
const pointedLabel = preview.options.find((o) => o.pointed)?.label;
const busyIcon = (
<Loader2 className="size-3.5 shrink-0 animate-spin text-muted-foreground" aria-label="Sending" />
Expand All @@ -87,48 +86,15 @@ export function PreviewSelectBlock({ preview, onAction, disabled }: PreviewSelec
a preview question is just one step of the same dialog. Single-question dialogs keep
their question/chip line in the raw mirror above, so neither renders here. */}
{wizard && (
<div className="flex items-center gap-1.5">
<button
type="button"
aria-label="Previous step"
disabled={locked || atFirstQuestion}
onClick={() => press("nav-back", { kind: "nav", keys: WIZARD_BACK_KEYS })}
className="flex size-7 shrink-0 items-center justify-center rounded-md border border-border/70 text-muted-foreground transition-colors active:bg-muted disabled:opacity-50"
>
{sending === "nav-back" ? busyIcon : <ChevronLeft className="size-4" />}
</button>
<ol className="flex min-w-0 flex-1 flex-wrap items-center gap-1">
{preview.steps!.map((step, i) => (
<li
key={i}
aria-current={step.current ? "step" : undefined}
className={cn(
"flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] leading-tight",
step.current
? "border-primary/60 bg-primary/15 font-medium text-foreground"
: "border-border/60 text-muted-foreground",
)}
>
{step.answered ? (
<Check className="size-3 shrink-0 text-primary" aria-label="Answered" />
) : null}
<span className="truncate">{step.label}</span>
</li>
))}
<li className="flex items-center gap-1 rounded-full border border-border/60 px-2 py-0.5 text-[11px] leading-tight text-muted-foreground">
<span>Submit</span>
</li>
</ol>
<button
type="button"
aria-label="Next step"
disabled={locked}
onClick={() => press("nav-next", { kind: "nav", keys: WIZARD_NEXT_KEYS })}
className="flex size-7 shrink-0 items-center justify-center rounded-md border border-border/70 text-muted-foreground transition-colors active:bg-muted disabled:opacity-50"
>
{sending === "nav-next" ? busyIcon : <ChevronRight className="size-4" />}
</button>
</div>
<WizardStepper
steps={preview.steps!}
locked={locked}
busyBack={sending === "nav-back"}
busyNext={sending === "nav-next"}
busyIcon={busyIcon}
onBack={() => press("nav-back", { kind: "nav", keys: WIZARD_BACK_KEYS })}
onNext={() => press("nav-next", { kind: "nav", keys: WIZARD_NEXT_KEYS })}
/>
)}
{wizard && <QuestionHeading>{preview.question}</QuestionHeading>}
{!wizard && <OptionGroupCaption>Choose an option</OptionGroupCaption>}
Expand Down
64 changes: 13 additions & 51 deletions web/src/components/wizard-block.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useState } from "react";
import { AlertTriangle, Check, ChevronLeft, ChevronRight, Loader2 } from "lucide-react";
import { AlertTriangle, Check, Loader2 } from "lucide-react";

import { cn } from "@/lib/utils";
import { WizardStepper } from "@/components/wizard-stepper";
import type { WizardModel, WizardOption } from "@/lib/blocks";
import { OptionButton, PromptPanel, QuestionHeading } from "@/components/option-button";
import {
Expand Down Expand Up @@ -49,61 +49,23 @@ export function WizardBlock({ wizard, onAction, disabled }: WizardBlockProps) {
// review step are no-ops, so disable those arrows rather than send a keystroke that does nothing.
// When no chip reads as current (an unknown theme's highlight), both stay enabled — the TUI still
// clamps, and keeping nav available is the safer degradation.
const atFirstQuestion = !review && (wizard.steps[0]?.current ?? false);
const busyIcon = <Loader2 className="size-3.5 shrink-0 animate-spin text-muted-foreground" aria-label="Sending" />;

return (
<PromptPanel ariaLabel={review ? "Review your answers" : wizard.question}>
{/* Stepper: one chip per question plus the fixed Submit step, flanked by the same back/next
navigation the TUI drives with ←/→ (each tap sends exactly that one key). */}
<div className="flex items-center gap-1.5">
<button
type="button"
aria-label="Previous step"
disabled={locked || atFirstQuestion}
onClick={() => press("back", WIZARD_BACK_KEYS)}
className="flex size-7 shrink-0 items-center justify-center rounded-md border border-border/70 text-muted-foreground transition-colors active:bg-muted disabled:opacity-50"
>
{sending === "back" ? busyIcon : <ChevronLeft className="size-4" />}
</button>
<ol className="flex min-w-0 flex-1 flex-wrap items-center gap-1">
{wizard.steps.map((step, i) => (
<li
key={i}
aria-current={step.current ? "step" : undefined}
className={cn(
"flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] leading-tight",
step.current
? "border-primary/60 bg-primary/15 font-medium text-foreground"
: "border-border/60 text-muted-foreground",
)}
>
{step.answered ? <Check className="size-3 shrink-0 text-primary" aria-label="Answered" /> : null}
<span className="truncate">{step.label}</span>
</li>
))}
<li
aria-current={review ? "step" : undefined}
className={cn(
"flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] leading-tight",
review
? "border-primary/60 bg-primary/15 font-medium text-foreground"
: "border-border/60 text-muted-foreground",
)}
>
<span>Submit</span>
</li>
</ol>
<button
type="button"
aria-label="Next step"
disabled={locked || review}
onClick={() => press("next", WIZARD_NEXT_KEYS)}
className="flex size-7 shrink-0 items-center justify-center rounded-md border border-border/70 text-muted-foreground transition-colors active:bg-muted disabled:opacity-50"
>
{sending === "next" ? busyIcon : <ChevronRight className="size-4" />}
</button>
</div>
<WizardStepper
steps={wizard.steps}
locked={locked}
submitCurrent={review}
nextDisabled={review}
busyBack={sending === "back"}
busyNext={sending === "next"}
busyIcon={busyIcon}
onBack={() => press("back", WIZARD_BACK_KEYS)}
onNext={() => press("next", WIZARD_NEXT_KEYS)}
/>

{wizard.phase === "question" ? (
<QuestionStep
Expand Down
Loading
Loading