Skip to content
Merged
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
8 changes: 7 additions & 1 deletion content/solid/ask-card.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,10 @@ import { AskCard } from "@/components/ui/ask-card"

## After the answer

Once the conversation has moved on, leave `onAnswer` out. The choices are then no longer offered, and the card stays as a record of what was asked.
Once the conversation has moved on, leave `onAnswer` out. The choices are then no longer offered, and the card stays as a record of what was asked. Give `chosen` the answer and it is a record of what was said too: that choice keeps its pane, the others fade to words.

```tsx
<AskCard question={question} options={options} onAnswer={answered ? undefined : send} chosen={answered} />
```

The choices are not disabled once answered, only inert: a disabled button drops the keyboard to the page on the very press that answers.
2 changes: 2 additions & 0 deletions content/solid/composer.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ continue to apply to the outer wrapper.

The send mark has no block behind it in any state: its brightness says what it will do. With `running`, it is the quiet square that stops the turn, until something is typed, which is then sent behind it.

The mark is never disabled. With nothing to send and nothing to stop it is dim and does nothing, but it stays where it is, so the keyboard on it is not dropped to the page as the text goes or the turn ends. Sent or stopped from the keyboard, the focus returns to the text; the pointer never takes it away. While the text is being written, the pane's hairline is drawn in the focus line.

<ComponentPreview name="composer-running" />

## Footer
Expand Down
4 changes: 4 additions & 0 deletions content/solid/tag.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,7 @@ import { Tag } from "@/components/ui/tag"
## States

`progress` between 0 and 1 fills the tag from the left. `error` turns it to the colour of failure and says why on hover. `armed` is the same pane lit a step brighter, for the tag the next Backspace will remove.

## Removed

A tag removed on its own x hands the keyboard on before it goes: to the next tag's x, else the one before, else the text of the composer it is in. Nothing is dropped to the page.
2 changes: 2 additions & 0 deletions content/solid/tool-steps.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ const steps: Step[] = [

`StepList` is the opened list on its own. A row opens to the call itself, or to its diff when it changed a file.

A group keeps the state of its rows. One call is one line; when a second joins it the group forms round the first, and if that call was open the group opens with it, with the keyboard still on the row it was on.

<ComponentPreview name="tool-steps-list" align="start" />

## API Reference
Expand Down
2 changes: 1 addition & 1 deletion content/solid/turn-status.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import { TurnStatus } from "@/components/ui/turn-status"

## With steps

Given children, the line is a button and opens to show them: the calls of the step in hand.
Given children, the line is a button and opens to show them: the calls of the step in hand. Once it has had children it stays a button, with nothing to open between steps, so the keyboard on it is not dropped as a step ends.
The activity label is announced by a separate status region, so the button keeps
its native role and updates to the clock and token count do not repeat the announcement.
When `label` is omitted or blank, the activity name supplies the announcement and
Expand Down
39 changes: 27 additions & 12 deletions src/components/ui/ask-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,38 +6,53 @@ import { cn } from "@/lib/utils"
* A decision the agent has put to the user: the question, and its choices as
* buttons when it gave any. Once the conversation has moved on, leave
* `onAnswer` out: the choices are then no longer offered, and the card stays
* as a record of what was asked.
* as a record of what was asked — and, given `chosen`, of what was said: that
* choice keeps its pane, the others fade to words.
*/
function AskCard({
question,
options = [],
onAnswer,
chosen,
eyebrow = "Needs your decision",
className,
}: {
question: string
options?: string[]
onAnswer?: (option: string) => void
/** The option that was taken, once one was. */
chosen?: string
eyebrow?: string
className?: string
}) {
const answered = !onAnswer
return (
<div data-slot="ask-card" className={cn("glass-panel rounded-md px-3.5 py-3", className)}>
<p className="text-[11.5px] text-status-interrupted">{eyebrow}</p>
<p className="mt-1 text-[14px] leading-relaxed whitespace-pre-wrap">{question}</p>
{options.length > 0 && (
<div className="mt-2.5 flex flex-wrap gap-1.5">
{options.map((option) => (
<button
key={option}
type="button"
disabled={!onAnswer}
onClick={() => onAnswer?.(option)}
className="glass-chosen h-7 rounded-sm px-2.5 text-[12.5px] text-foreground/90 transition-colors outline-none hover:bg-foreground/[0.12] hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/60 disabled:pointer-events-none disabled:opacity-50"
>
{option}
</button>
))}
{options.map((option) => {
const taken = option === chosen
return (
// Not disabled once answered: that drops the keyboard to the page
// on the very press that answers. Inert, and still reachable.
<button
key={option}
type="button"
aria-disabled={answered || undefined}
aria-current={taken || undefined}
onClick={() => onAnswer?.(option)}
className={cn(
"h-7 rounded-sm px-2.5 text-[12.5px] transition-colors outline-none",
answered && !taken ? "cursor-default text-muted-foreground/70" : "glass-chosen text-foreground/90",
answered ? "cursor-default" : "hover:bg-foreground/[0.12] hover:text-foreground"
)}
>
{option}
</button>
)
})}
</div>
)}
</div>
Expand Down
26 changes: 21 additions & 5 deletions src/components/ui/composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ function Composer({
// which is then queued behind the turn like any other message.
const stops = running && !ready
const Icon = stops ? Square : ArrowUp
// With nothing to send and nothing to stop, the mark does nothing. It is
// not disabled for that: a disabled button drops the keyboard to the page
// at the moment the text is sent or the turn ends, which is when the
// keyboard was last on it.
const inert = !stops && !ready

const resize = React.useCallback(() => {
const el = textarea.current
Expand Down Expand Up @@ -102,7 +107,9 @@ function Composer({
<div
data-slot="composer"
className={cn(
"group/composer relative rounded-xl bg-card py-2 pr-1 pl-2 transition-colors focus-within:border-ring/60",
// The pane has no border for a focus colour to show on; while the text
// is being written, its hairline is drawn again in the focus line.
"group/composer relative rounded-xl bg-card py-2 pr-1 pl-2 transition-colors focus-within:outline-1 focus-within:-outline-offset-1 focus-within:outline-(--focus-line)",
className
)}
{...props}
Expand Down Expand Up @@ -153,15 +160,23 @@ function Composer({
<Button
size="icon"
aria-label={stops ? stopLabel : sendLabel}
disabled={!stops && !ready}
onClick={stops ? onStop : submit}
aria-disabled={inert || undefined}
// The pointer sends; the keyboard stays in the text, as in any composer.
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
if (inert) return
if (stops) onStop?.()
else submit()
// Sent or stopped from the keyboard, the next thing to do is write.
textarea.current?.focus()
}}
className={cn(
"shrink-0 rounded-md bg-transparent transition-colors hover:bg-accent",
"shrink-0 rounded-md bg-transparent transition-colors hover:bg-accent aria-disabled:cursor-default aria-disabled:active:translate-y-0",
stops
? "text-muted-foreground hover:text-foreground"
: ready
? "text-foreground"
: "text-muted-foreground/50 hover:bg-transparent disabled:opacity-100"
: "text-muted-foreground/50 hover:bg-transparent"
)}
>
<Icon className={cn("size-4", stops && "size-2.5 fill-current")} />
Expand Down Expand Up @@ -217,6 +232,7 @@ function ComposerPick<T extends string>({
return (
<DropdownMenu>
<DropdownMenuTrigger
data-slot="composer-pick"
aria-label={label}
className="glass-held flex h-6 items-center gap-1 rounded-sm px-1.5 text-[12px] text-muted-foreground transition-colors outline-none hover:text-foreground focus-visible:text-foreground aria-expanded:text-foreground"
>
Expand Down
1 change: 1 addition & 0 deletions src/components/ui/context-ring.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ function ContextRing({
<PopoverContent side="top" align="end" sideOffset={8} className="w-72 gap-2 p-3">
<button
type="button"
data-slot="context-ring-detail"
disabled={!parts.length}
aria-expanded={detailed}
onClick={() => setDetailed((open) => !open)}
Expand Down
26 changes: 26 additions & 0 deletions src/components/ui/tag.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ function Tag({
removeLabel = "Remove",
className,
children,
ref,
...props
}: React.ComponentProps<"span"> & {
icon?: React.ReactNode
Expand All @@ -35,8 +36,33 @@ function Tag({
}) {
const going = progress !== undefined && !error
const percent = Math.round((progress ?? 0) * 100)

// A tag removed on its own x takes the keyboard with it: the page has the
// focus, and the next Tab starts from its top. The cleanup runs while the
// tag is still in the page, so it can hand the focus on first — to the next
// tag's x, else the one before, else the text the tags are attached to.
const root = React.useRef<HTMLSpanElement | null>(null)
React.useLayoutEffect(() => {
const el = root.current
return () => {
if (!el || !el.contains(document.activeElement)) return
const tags = el.parentElement ? [...el.parentElement.querySelectorAll<HTMLElement>('[data-slot="tag"]')] : []
const at = tags.indexOf(el)
const neighbour = tags[at + 1] ?? tags[at - 1]
const next =
neighbour?.querySelector<HTMLElement>("button") ??
el.closest('[data-slot="composer"]')?.querySelector<HTMLElement>("textarea")
next?.focus()
}
}, [])

return (
<span
ref={(node) => {
root.current = node
if (typeof ref === "function") return ref(node)
if (ref) ref.current = node
}}
data-slot="tag"
title={error}
className={cn(
Expand Down
103 changes: 95 additions & 8 deletions src/components/ui/tool-steps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,19 +55,65 @@ function OutputBlock({
)
}

/** One call on one line; opened, the call itself — or its diff, when it changed a file. */
function StepRow({ step, labels }: { step: Step; labels?: Partial<StepLabels> }) {
const [open, setOpen] = React.useState(false)
/**
* The row the keyboard was on as it was taken down, if any. A row made again
* in the same moment for the same call — the one call of a turn becoming the
* first of a group — takes the focus back, so a keyboard reading a call is
* not thrown to the top of the page. Forgotten right after, so a row made
* later for the same call does not take it.
*/
let focusedRowId: string | null = null

/**
* One call on one line; opened, the call itself — or its diff, when it changed
* a file. Open on its own, or as told (`open`, `onOpenChange`) by a group that
* keeps the state of its rows.
*/
function StepRow({
step,
labels,
open: openProp,
onOpenChange,
}: {
step: Step
labels?: Partial<StepLabels>
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const [openState, setOpenState] = React.useState(false)
const open = openProp ?? openState
const words = { ...defaultLabels, ...labels }
const canOpen = Boolean(step.patch || step.detail || step.output || step.error)
const button = React.useRef<HTMLButtonElement>(null)

React.useLayoutEffect(() => {
const el = button.current
if (focusedRowId === step.id) {
focusedRowId = null
el?.focus()
}
return () => {
// Runs while the row is still in the page, so the focus can be seen.
if (el && el === document.activeElement) {
focusedRowId = step.id
queueMicrotask(() => {
if (focusedRowId === step.id) focusedRowId = null
})
}
}
}, [step.id])

return (
<div data-slot="step-row" className="flex flex-col gap-1.5">
<button
ref={button}
type="button"
aria-expanded={canOpen ? open : undefined}
disabled={!canOpen}
onClick={() => setOpen((value) => !value)}
onClick={() => {
setOpenState(!open)
onOpenChange?.(!open)
}}
className={cn(lineClass, step.status === "running" && "animate-pulse motion-reduce:animate-none")}
>
<span className="truncate text-[12.5px]">{step.label}</span>
Expand Down Expand Up @@ -96,12 +142,30 @@ type StepLabels = { detail: string; output: string; error: string }
const defaultLabels: StepLabels = { detail: "Input", output: "Output", error: "Error" }

/** Calls as a list of what each one did; a row opens to the call itself. */
function StepList({ steps, labels, className }: { steps: Step[]; labels?: Partial<StepLabels>; className?: string }) {
function StepList({
steps,
labels,
className,
openRows,
onRowOpenChange,
}: {
steps: Step[]
labels?: Partial<StepLabels>
className?: string
/** Which rows are open, by call; without it each row keeps its own. */
openRows?: Record<string, boolean>
onRowOpenChange?: (id: string, open: boolean) => void
}) {
return (
<div data-slot="step-list" className={cn("glass-panel flex flex-col divide-y divide-border rounded-md", className)}>
{steps.map((step) => (
<div key={step.id} className="px-2.5 py-1.5">
<StepRow step={step} labels={labels} />
<StepRow
step={step}
labels={labels}
open={openRows ? (openRows[step.id] ?? false) : undefined}
onOpenChange={onRowOpenChange && ((open) => onRowOpenChange(step.id, open))}
/>
</div>
))}
</div>
Expand Down Expand Up @@ -137,10 +201,33 @@ function StepGroup({
const running = steps.some((step) => step.status === "running")
const [open, setOpen] = React.useState(defaultOpen)
const stats = totalStats(steps)
// Which rows are open, by call, held here rather than in the rows: a row
// is made again when the group forms round it, and must not forget.
const [openRows, setOpenRows] = React.useState<Record<string, boolean>>({})
const setRowOpen = (id: string, value: boolean) => setOpenRows((rows) => ({ ...rows, [id]: value }))

// When a second call joins the first, the group opens if that call was
// open, so what was being read stays in view instead of folding away.
const single = steps.length === 1 ? steps[0].id : null
const [lastSingle, setLastSingle] = React.useState(single)
if (single !== lastSingle) {
setLastSingle(single)
if (lastSingle !== null && steps.length > 1 && openRows[lastSingle]) setOpen(true)
}

// Finishing the same call must not close its details or remove its focused
// button. A different call starts closed instead of inheriting that state.
if (steps.length === 1) return <StepRow key={steps[0].id} step={steps[0]} labels={labels} />
if (single !== null) {
return (
<StepRow
key={single}
step={steps[0]}
labels={labels}
open={openRows[single] ?? false}
onOpenChange={(value) => setRowOpen(single, value)}
/>
)
}

return (
<div data-slot="step-group" className="flex flex-col gap-1.5">
Expand All @@ -151,7 +238,7 @@ function StepGroup({
{stats && <DiffStats {...stats} />}
<ChevronRight className={cn("size-3.5 shrink-0 transition-transform", open && "rotate-90")} />
</button>
{open && <StepList steps={steps} labels={labels} />}
{open && <StepList steps={steps} labels={labels} openRows={openRows} onRowOpenChange={setRowOpen} />}
</div>
)
}
Expand Down
Loading
Loading