Skip to content

Commit 8b34b41

Browse files
committed
Merge 17e4072 into feat/chat-input-icon-slots
Keep ChatInput onSendHold with attachIcon/sendIcon and leading/trailing tools.
2 parents 36b803a + 17e4072 commit 8b34b41

10 files changed

Lines changed: 427 additions & 12 deletions

‎package.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,10 @@
443443
"types": "./dist/ui/section.d.ts",
444444
"default": "./dist/ui/section.js"
445445
},
446+
"./ui/segmented-control": {
447+
"types": "./dist/ui/segmented-control.d.ts",
448+
"default": "./dist/ui/segmented-control.js"
449+
},
446450
"./ui/select": {
447451
"types": "./dist/ui/select.d.ts",
448452
"default": "./dist/ui/select.js"

‎src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ export * from "./ui/research-body.js";
9696
export * from "./ui/rich-empty-state.js";
9797
export * from "./ui/run-now-button.js";
9898
export * from "./ui/section.js";
99+
export * from "./ui/segmented-control.js";
99100
export * from "./ui/select.js";
100101
export * from "./ui/selection-checkbox.js";
101102
export * from "./ui/settings-panel.js";

‎src/ui/chat-input.test.tsx‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,27 @@ describe("ChatInput send", () => {
171171
expect(onSend).not.toHaveBeenCalled();
172172
unmount();
173173
});
174+
175+
test("holding send fires onSendHold and swallows the trailing click", async () => {
176+
const onSend = mock();
177+
const onSendHold = mock();
178+
const { send, unmount } = mount({ value: "hello", onSend, onSendHold, sendHoldMs: 20 });
179+
const button = send();
180+
if (button === null) throw new Error("send button missing");
181+
act(() => {
182+
button.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
183+
});
184+
await act(async () => {
185+
await new Promise((resolve) => setTimeout(resolve, 40));
186+
});
187+
expect(onSendHold).toHaveBeenCalledTimes(1);
188+
act(() => {
189+
button.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
190+
button.click();
191+
});
192+
expect(onSend).not.toHaveBeenCalled();
193+
unmount();
194+
});
174195
});
175196

176197
describe("ChatInput compound slots", () => {

‎src/ui/chat-input.tsx‎

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,16 @@ export type ChatInputProps = {
2020
readonly onSend: () => void;
2121
/** Shown while the agent is replying. Its presence turns send into stop. */
2222
readonly onStop?: () => void;
23+
/**
24+
* Holding the send button fires this instead of sending — the heavier
25+
* alternative gesture (send back, send-and-…) that shares the button
26+
* because it is a send, not a separate action. The click that ends the
27+
* hold is swallowed so it never sends on release. There is no keyboard
28+
* hold, so whatever this opens must be reachable another way too.
29+
*/
30+
readonly onSendHold?: () => void;
31+
/** How long a press counts as a hold. Defaults to 450ms. */
32+
readonly sendHoldMs?: number;
2333
readonly working?: boolean;
2434
readonly placeholder?: string;
2535
readonly disabled?: boolean;
@@ -62,6 +72,9 @@ export type ChatInputSubmitProps = Omit<ComponentProps<"button">, "type" | "chil
6272
readonly sendIcon?: ReactNode;
6373
/** When false, send is disabled. Omit to leave enablement to `disabled`. */
6474
readonly canSend?: boolean;
75+
readonly onSendHold?: () => void;
76+
/** How long a press counts as a hold. Defaults to 450ms. */
77+
readonly sendHoldMs?: number;
6578
};
6679

6780
export type ChatInputAttachProps = {
@@ -100,12 +113,17 @@ const toolButtonClass =
100113
* Callers that need extra footer controls (dictate, model picker, …) pass
101114
* `leadingTools` / `trailingTools`, or compose the named slots instead of
102115
* this all-in-one. Omit the extras and the composer looks as it did.
116+
*
117+
* Holding send fires `onSendHold` instead of sending. The click that ends the
118+
* hold is swallowed so it never sends on release.
103119
*/
104120
export function ChatInput({
105121
value,
106122
onValueChange,
107123
onSend,
108124
onStop,
125+
onSendHold,
126+
sendHoldMs = 450,
109127
working = false,
110128
placeholder = "Send a message…",
111129
disabled = false,
@@ -120,8 +138,14 @@ export function ChatInput({
120138
className,
121139
}: ChatInputProps) {
122140
const canSend = value.trim().length > 0 && !disabled;
141+
// Set when a hold fires so the click that ends the press does not send.
142+
const heldRef = useRef(false);
123143

124144
const submit = () => {
145+
if (heldRef.current) {
146+
heldRef.current = false;
147+
return;
148+
}
125149
if (!canSend) return;
126150
onSend();
127151
};
@@ -172,7 +196,21 @@ export function ChatInput({
172196
{leadingTools}
173197
</ChatInputTools>
174198
{trailingTools}
175-
<ChatInputSubmit onStop={onStop} working={working} sendIcon={sendIcon} canSend={canSend} />
199+
<ChatInputSubmit
200+
onStop={onStop}
201+
working={working}
202+
sendIcon={sendIcon}
203+
canSend={canSend}
204+
sendHoldMs={sendHoldMs}
205+
{...(onSendHold === undefined
206+
? {}
207+
: {
208+
onSendHold: () => {
209+
heldRef.current = true;
210+
onSendHold();
211+
},
212+
})}
213+
/>
176214
</ChatInputFooter>
177215
</ChatInputRoot>
178216
);
@@ -339,18 +377,30 @@ export function ChatInputAttach({
339377
);
340378
}
341379

342-
/** Send, or stop while the agent is replying. */
380+
/** Send, or stop while the agent is replying. Holding send fires `onSendHold`. */
343381
export function ChatInputSubmit({
344382
onStop,
345383
working = false,
346384
sendIcon = defaultSendIcon,
347385
canSend,
348386
disabled,
349387
className,
388+
onSendHold,
389+
sendHoldMs = 450,
350390
...props
351391
}: ChatInputSubmitProps) {
352392
const showStop = working && onStop !== undefined;
353-
const sendDisabled = disabled === true || canSend === false;
393+
const sendDisabled = disabled === true || (canSend === false && onSendHold === undefined);
394+
const holdTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
395+
396+
const releaseHold = () => {
397+
if (holdTimer.current !== null) {
398+
clearTimeout(holdTimer.current);
399+
holdTimer.current = null;
400+
}
401+
};
402+
403+
useEffect(() => releaseHold, []);
354404

355405
if (showStop) {
356406
return (
@@ -377,6 +427,20 @@ export function ChatInputSubmit({
377427
data-slot="chat-input-submit"
378428
disabled={sendDisabled}
379429
aria-label={props["aria-label"] ?? "Send message"}
430+
{...(onSendHold === undefined
431+
? {}
432+
: {
433+
onPointerDown: () => {
434+
releaseHold();
435+
holdTimer.current = setTimeout(() => {
436+
holdTimer.current = null;
437+
onSendHold();
438+
}, sendHoldMs);
439+
},
440+
onPointerUp: releaseHold,
441+
onPointerLeave: releaseHold,
442+
onPointerCancel: releaseHold,
443+
})}
380444
className={cn(
381445
"grid size-9 shrink-0 place-items-center rounded-md bg-primary text-primary-foreground transition-colors hover:bg-primary-active disabled:opacity-40",
382446
className,

‎src/ui/horizontal-stepper.tsx‎

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,20 @@ function pillClass(status: WorkflowStepStatus): string {
1818

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

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

49+
if (variant === "segments") {
50+
const segmentClass = (step: WorkflowStep) =>
51+
cn(
52+
"h-0.5 rounded-full transition-[width,background-color] duration-300",
53+
step.status === "current" ? "w-6" : "w-4",
54+
step.status === "completed"
55+
? "bg-foreground"
56+
: step.status === "failed"
57+
? "bg-destructive"
58+
: step.status === "current"
59+
? "bg-primary"
60+
: "bg-border",
61+
);
62+
return (
63+
<ol
64+
aria-label="Workflow progress"
65+
className={cn("flex items-center gap-1", className)}
66+
>
67+
{steps.map((step) => (
68+
<li
69+
key={step.number}
70+
ref={step.status === "current" ? currentRef : undefined}
71+
aria-current={step.status === "current" ? "step" : undefined}
72+
className="flex"
73+
>
74+
{step.status === "completed" && onStepClick ? (
75+
<button
76+
type="button"
77+
title={step.label}
78+
aria-label={step.label}
79+
onClick={() => onStepClick(step)}
80+
className={cn(segmentClass(step), "cursor-pointer py-1 -my-1 bg-clip-content hover:bg-primary")}
81+
>
82+
<span className="sr-only">{step.label}</span>
83+
</button>
84+
) : (
85+
<span title={step.label} aria-hidden="true" className={segmentClass(step)}>
86+
<span className="sr-only">{step.label}</span>
87+
</span>
88+
)}
89+
</li>
90+
))}
91+
</ol>
92+
);
93+
}
94+
3695
return (
3796
<div className={cn("border-b border-border bg-card px-6 py-5", className)}>
3897
<ol

‎src/ui/notifications-bell.tsx‎

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ export type NotificationsBellProps = {
1212
readonly children: ReactNode;
1313
/** Counts above this render as "N+" so the marker cannot grow unbounded. */
1414
readonly maxCount?: number;
15+
/**
16+
* `count` shows the number; `dot` shows a bare mark — for hosts where the
17+
* count is noise (a queue that is either empty or not) but "something is
18+
* waiting" is not.
19+
*/
20+
readonly marker?: "count" | "dot";
1521
/** Controlled open state. Pair with `onOpenChange` to lift it to a parent. */
1622
readonly open?: boolean;
1723
readonly onOpenChange?: (open: boolean) => void;
@@ -36,6 +42,7 @@ export function NotificationsBell({
3642
count,
3743
children,
3844
maxCount = 99,
45+
marker = "count",
3946
open: openProp,
4047
onOpenChange,
4148
className,
@@ -68,12 +75,16 @@ export function NotificationsBell({
6875
>
6976
<Bell className="size-4" aria-hidden />
7077
{count > 0 ? (
71-
<span
72-
aria-hidden
73-
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"
74-
>
75-
{count > maxCount ? `${maxCount}+` : count}
76-
</span>
78+
marker === "dot" ? (
79+
<span aria-hidden className="absolute top-1.5 right-1.5 size-2 rounded-full bg-primary" />
80+
) : (
81+
<span
82+
aria-hidden
83+
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"
84+
>
85+
{count > maxCount ? `${maxCount}+` : count}
86+
</span>
87+
)
7788
) : null}
7889
</button>
7990

‎src/ui/segmented-control.test.tsx‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { act, createElement } from "react";
3+
import type { ComponentProps } from "react";
4+
import { createRoot } from "react-dom/client";
5+
6+
import { SegmentedControl } from "./segmented-control.js";
7+
8+
type Id = "light" | "system" | "dark";
9+
type Props = ComponentProps<typeof SegmentedControl<Id>>;
10+
11+
const OPTIONS: Props["options"] = [
12+
{ id: "light", label: "Light" },
13+
{ id: "system", label: "System" },
14+
{ id: "dark", label: "Dark" },
15+
];
16+
17+
function mount(props: Partial<Props> = {}) {
18+
const container = document.createElement("div");
19+
document.body.appendChild(container);
20+
const root = createRoot(container);
21+
const full: Props = { options: OPTIONS, value: "system", onValueChange: () => {}, label: "Theme", ...props };
22+
act(() => {
23+
root.render(createElement(SegmentedControl<Id>, full));
24+
});
25+
return {
26+
group: () => container.querySelector("[role=group]") as HTMLElement,
27+
buttons: () => Array.from(container.querySelectorAll("button")),
28+
container,
29+
unmount: () => root.unmount(),
30+
};
31+
}
32+
33+
describe("SegmentedControl", () => {
34+
test("names the set and marks the current segment pressed", () => {
35+
const { group, buttons, unmount } = mount();
36+
expect(group().getAttribute("aria-label")).toBe("Theme");
37+
const pressed = buttons().map((b) => b.getAttribute("aria-pressed"));
38+
expect(pressed).toEqual(["false", "true", "false"]);
39+
unmount();
40+
});
41+
42+
test("pressing a segment reports its id", () => {
43+
let received: Id | undefined;
44+
const { buttons, unmount } = mount({ onValueChange: (id) => { received = id; } });
45+
act(() => {
46+
buttons()[2]?.click();
47+
});
48+
expect(received).toBe("dark");
49+
unmount();
50+
});
51+
52+
test("pressing the current segment still reports — the host decides idempotence", () => {
53+
let received: Id | undefined;
54+
const { buttons, unmount } = mount({ onValueChange: (id) => { received = id; } });
55+
act(() => {
56+
buttons()[1]?.click();
57+
});
58+
expect(received).toBe("system");
59+
unmount();
60+
});
61+
});

0 commit comments

Comments
 (0)