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
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,10 @@
"types": "./dist/ui/section.d.ts",
"default": "./dist/ui/section.js"
},
"./ui/segmented-control": {
"types": "./dist/ui/segmented-control.d.ts",
"default": "./dist/ui/segmented-control.js"
},
"./ui/select": {
"types": "./dist/ui/select.d.ts",
"default": "./dist/ui/select.js"
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export * from "./ui/research-body.js";
export * from "./ui/rich-empty-state.js";
export * from "./ui/run-now-button.js";
export * from "./ui/section.js";
export * from "./ui/segmented-control.js";
export * from "./ui/select.js";
export * from "./ui/selection-checkbox.js";
export * from "./ui/settings-panel.js";
Expand Down
49 changes: 48 additions & 1 deletion src/ui/chat-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@ export type ChatInputProps = {
readonly onSend: () => void;
/** Shown while the agent is replying. Its presence turns send into stop. */
readonly onStop?: () => void;
/**
* Holding the send button fires this instead of sending — the heavier
* alternative gesture (send back, send-and-…) that shares the button
* because it is a send, not a separate action. The click that ends the
* hold is swallowed so it never sends on release. There is no keyboard
* hold, so whatever this opens must be reachable another way too.
*/
readonly onSendHold?: () => void;
/** How long a press counts as a hold. Defaults to 450ms. */
readonly sendHoldMs?: number;
readonly working?: boolean;
readonly placeholder?: string;
readonly disabled?: boolean;
Expand Down Expand Up @@ -48,6 +58,8 @@ export function ChatInput({
onValueChange,
onSend,
onStop,
onSendHold,
sendHoldMs = 450,
working = false,
placeholder = "Send a message…",
disabled = false,
Expand All @@ -68,14 +80,30 @@ export function ChatInput({
node.style.height = `${Math.min(node.scrollHeight, MAX_ROWS_PX)}px`;
}, [value]);

useEffect(() => releaseHold, []);

const canSend = value.trim().length > 0 && !disabled;
const showStop = working && onStop !== undefined;
const holdTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// Set when a hold fires so the click that ends the press does not send.
const heldRef = useRef(false);

const submit = () => {
if (heldRef.current) {
heldRef.current = false;
return;
}
if (!canSend) return;
onSend();
};

const releaseHold = () => {
if (holdTimer.current !== null) {
clearTimeout(holdTimer.current);
holdTimer.current = null;
}
};

return (
<form
className={cn("flex flex-col gap-2 rounded-lg border border-input bg-background p-2", className)}
Expand Down Expand Up @@ -163,8 +191,27 @@ export function ChatInput({
) : (
<button
type="submit"
disabled={!canSend}
disabled={!canSend && onSendHold === undefined}
aria-label="Send message"
{...(onSendHold === undefined
? {}
: {
onPointerDown: () => {
releaseHold();
// A hold released off the button never produces the
// click that consumes this — reset it per press so it
// cannot swallow a later normal send.
heldRef.current = false;
holdTimer.current = setTimeout(() => {
heldRef.current = true;
holdTimer.current = null;
onSendHold();
}, sendHoldMs);
},
onPointerUp: releaseHold,
onPointerLeave: releaseHold,
onPointerCancel: releaseHold,
})}
className="grid size-9 shrink-0 place-items-center rounded-md bg-primary text-primary-foreground transition-colors hover:bg-primary-active disabled:opacity-40"
>
<ArrowUp className="size-4" aria-hidden />
Expand Down
61 changes: 60 additions & 1 deletion src/ui/horizontal-stepper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,20 @@ function pillClass(status: WorkflowStepStatus): string {

export type HorizontalStepperProps = {
readonly steps: readonly WorkflowStep[];
/**
* `pills` is the default rail: numbered pills with connecting fills and
* labels. `segments` is the whisper track — hairline segments, done in ink,
* current in accent and stretched — for dense chrome like a top bar, where
* the step's name is already carried by the surface beneath it.
*/
readonly variant?: "pills" | "segments";
readonly className?: string;
/**
* Makes completed steps clickable — a finished step is a place you can go
* back to, not just a mark of progress. Pending and current steps stay
* inert: there is nothing behind them to open.
*/
readonly onStepClick?: (step: WorkflowStep) => void;
};

/**
Expand All @@ -28,11 +41,57 @@ export type HorizontalStepperProps = {
* change, so a run with more steps than fit on screen never leaves the
* active one off to the side unannounced.
*/
export function HorizontalStepper({ steps, className }: HorizontalStepperProps) {
export function HorizontalStepper({ steps, variant = "pills", className, onStepClick }: HorizontalStepperProps) {
const currentStep = steps.find((step) => step.status === "current");
const currentRef = useScrollCurrentIntoView<HTMLLIElement>(currentStep?.number ?? -1);
const compress = steps.length > LABEL_VISIBLE_STEP_THRESHOLD;

if (variant === "segments") {
const segmentClass = (step: WorkflowStep) =>
cn(
"h-0.5 rounded-full transition-[width,background-color] duration-300",
step.status === "current" ? "w-6" : "w-4",
step.status === "completed"
? "bg-foreground"
: step.status === "failed"
? "bg-destructive"
: step.status === "current"
? "bg-primary"
: "bg-border",
);
return (
<ol
aria-label="Workflow progress"
className={cn("flex items-center gap-1", className)}
>
{steps.map((step) => (
<li
key={step.number}
ref={step.status === "current" ? currentRef : undefined}
aria-current={step.status === "current" ? "step" : undefined}
className="flex"
>
{step.status === "completed" && onStepClick ? (
<button
type="button"
title={step.label}
aria-label={step.label}
onClick={() => onStepClick(step)}
className={cn(segmentClass(step), "cursor-pointer py-1 -my-1 bg-clip-content hover:bg-primary")}
>
<span className="sr-only">{step.label}</span>
</button>
) : (
<span title={step.label} aria-hidden="true" className={segmentClass(step)}>
<span className="sr-only">{step.label}</span>
</span>
)}
</li>
))}
</ol>
);
}

return (
<div className={cn("border-b border-border bg-card px-6 py-5", className)}>
<ol
Expand Down
23 changes: 17 additions & 6 deletions src/ui/notifications-bell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ export type NotificationsBellProps = {
readonly children: ReactNode;
/** Counts above this render as "N+" so the marker cannot grow unbounded. */
readonly maxCount?: number;
/**
* `count` shows the number; `dot` shows a bare mark — for hosts where the
* count is noise (a queue that is either empty or not) but "something is
* waiting" is not.
*/
readonly marker?: "count" | "dot";
/** Controlled open state. Pair with `onOpenChange` to lift it to a parent. */
readonly open?: boolean;
readonly onOpenChange?: (open: boolean) => void;
Expand All @@ -36,6 +42,7 @@ export function NotificationsBell({
count,
children,
maxCount = 99,
marker = "count",
open: openProp,
onOpenChange,
className,
Expand Down Expand Up @@ -68,12 +75,16 @@ export function NotificationsBell({
>
<Bell className="size-4" aria-hidden />
{count > 0 ? (
<span
aria-hidden
className="absolute top-1 right-1 min-w-4 rounded-full bg-primary px-1 text-center font-mono text-[10px] leading-4 text-primary-foreground"
>
{count > maxCount ? `${maxCount}+` : count}
</span>
marker === "dot" ? (
<span aria-hidden className="absolute top-1.5 right-1.5 size-2 rounded-full bg-primary" />
) : (
<span
aria-hidden
className="absolute top-1 right-1 min-w-4 rounded-full bg-primary px-1 text-center font-mono text-[10px] leading-4 text-primary-foreground"
>
{count > maxCount ? `${maxCount}+` : count}
</span>
)
) : null}
</button>

Expand Down
61 changes: 61 additions & 0 deletions src/ui/segmented-control.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, expect, test } from "bun:test";
import { act, createElement } from "react";
import type { ComponentProps } from "react";
import { createRoot } from "react-dom/client";

import { SegmentedControl } from "./segmented-control.js";

type Id = "light" | "system" | "dark";
type Props = ComponentProps<typeof SegmentedControl<Id>>;

const OPTIONS: Props["options"] = [
{ id: "light", label: "Light" },
{ id: "system", label: "System" },
{ id: "dark", label: "Dark" },
];

function mount(props: Partial<Props> = {}) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
const full: Props = { options: OPTIONS, value: "system", onValueChange: () => {}, label: "Theme", ...props };
act(() => {
root.render(createElement(SegmentedControl<Id>, full));
});
return {
group: () => container.querySelector("[role=group]") as HTMLElement,
buttons: () => Array.from(container.querySelectorAll("button")),
container,
unmount: () => root.unmount(),
};
}

describe("SegmentedControl", () => {
test("names the set and marks the current segment pressed", () => {
const { group, buttons, unmount } = mount();
expect(group().getAttribute("aria-label")).toBe("Theme");
const pressed = buttons().map((b) => b.getAttribute("aria-pressed"));
expect(pressed).toEqual(["false", "true", "false"]);
unmount();
});

test("pressing a segment reports its id", () => {
let received: Id | undefined;
const { buttons, unmount } = mount({ onValueChange: (id) => { received = id; } });
act(() => {
buttons()[2]?.click();
});
expect(received).toBe("dark");
unmount();
});

test("pressing the current segment still reports — the host decides idempotence", () => {
let received: Id | undefined;
const { buttons, unmount } = mount({ onValueChange: (id) => { received = id; } });
act(() => {
buttons()[1]?.click();
});
expect(received).toBe("system");
unmount();
});
});
58 changes: 58 additions & 0 deletions src/ui/segmented-control.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { cn } from "../lib/utils.js";

export type SegmentedControlOption<Id extends string> = {
readonly id: Id;
readonly label: string;
};

export type SegmentedControlProps<Id extends string> = {
readonly options: readonly SegmentedControlOption<Id>[];
readonly value: Id;
readonly onValueChange: (value: Id) => void;
/** Names the set for assistive tech: "Theme". */
readonly label: string;
readonly className?: string;
};

/**
* A small set of mutually exclusive choices as joined segments — theme,
* protocol, density. `role="group"` with `aria-pressed` toggles rather than a
* radiogroup: these change something immediately (same reasoning as
* ViewToggle), and radios promise form semantics that do not apply.
*
* Stateless — the host owns the value, because the choice usually outlives the
* component and gets persisted.
*/
export function SegmentedControl<Id extends string>({
options,
value,
onValueChange,
label,
className,
}: SegmentedControlProps<Id>) {
return (
<div
role="group"
aria-label={label}
className={cn("flex items-center gap-0.5 rounded-md border border-border p-0.5", className)}
>
{options.map((option) => {
const active = option.id === value;
return (
<button
key={option.id}
type="button"
aria-pressed={active}
onClick={() => onValueChange(option.id)}
className={cn(
"rounded-sm px-3 py-1 font-mono text-xs transition-colors",
active ? "bg-muted font-medium text-foreground" : "text-muted-foreground hover:text-foreground",
)}
>
{option.label}
</button>
);
})}
</div>
);
}
Loading
Loading