@candor-ui/react 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -32,9 +32,10 @@ export type ComposerState = "ready" | "sending" | "streaming" | {
32
32
  *
33
33
  * Enter sends, Shift+Enter breaks a line. While an agent replies the send button
34
34
  * becomes Stop. A locked composer says why; an offline one says so. With
35
- * `onAttach`, files can also be pasted or dropped onto it.
35
+ * `onAttach`, files can also be pasted or dropped onto it. `tools` adds your
36
+ * own controls to the toolbar, such as a dictation button.
36
37
  */
37
- declare function Composer({ value, onChange, onSubmit, onStop, state, scope, onScopeClick, mentions, commands, references, onReferencesChange, attachments, onAttach, accept, onRemoveAttachment, placeholder: placeholderProp, label: labelProp, className, }: {
38
+ declare function Composer({ value, onChange, onSubmit, onStop, state, scope, onScopeClick, mentions, commands, references, onReferencesChange, attachments, onAttach, accept, onRemoveAttachment, placeholder: placeholderProp, label: labelProp, tools, className, }: {
38
39
  /** The text in the box. */
39
40
  value: string;
40
41
  /** Called with the new text as the person types. */
@@ -72,6 +73,8 @@ declare function Composer({ value, onChange, onSubmit, onStop, state, scope, onS
72
73
  placeholder?: string;
73
74
  /** The accessible name of the text box; "Message" by default. */
74
75
  label?: string;
76
+ /** Your own controls in the toolbar, after the attach button: a dictation button, a model picker. */
77
+ tools?: React.ReactNode;
75
78
  className?: string;
76
79
  }): React.JSX.Element;
77
80
  export { Composer };
@@ -47,9 +47,10 @@ const hasFiles = (e) => Array.from(e.dataTransfer?.types ?? []).includes("Files"
47
47
  *
48
48
  * Enter sends, Shift+Enter breaks a line. While an agent replies the send button
49
49
  * becomes Stop. A locked composer says why; an offline one says so. With
50
- * `onAttach`, files can also be pasted or dropped onto it.
50
+ * `onAttach`, files can also be pasted or dropped onto it. `tools` adds your
51
+ * own controls to the toolbar, such as a dictation button.
51
52
  */
52
- function Composer({ value, onChange, onSubmit, onStop, state = "ready", scope, onScopeClick, mentions = [], commands = [], references = [], onReferencesChange, attachments = [], onAttach, accept, onRemoveAttachment, placeholder: placeholderProp, label: labelProp, className, }) {
53
+ function Composer({ value, onChange, onSubmit, onStop, state = "ready", scope, onScopeClick, mentions = [], commands = [], references = [], onReferencesChange, attachments = [], onAttach, accept, onRemoveAttachment, placeholder: placeholderProp, label: labelProp, tools, className, }) {
53
54
  const kindOf = useEntityKinds();
54
55
  const t = useStrings("composer", composerStrings);
55
56
  const placeholder = placeholderProp ?? t("placeholder");
@@ -211,6 +212,6 @@ function Composer({ value, onChange, onSubmit, onStop, state = "ready", scope, o
211
212
  if (files.length)
212
213
  onAttach(files);
213
214
  e.target.value = "";
214
- } }), _jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsx("button", { type: "button", "aria-label": t("attachFiles"), disabled: Boolean(locked), onClick: () => fileInput.current?.click(), className: "flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors duration-150 hover:bg-accent hover:text-foreground disabled:opacity-40", children: _jsx(Paperclip, { className: "size-4" }) }) }), _jsx(TooltipContent, { children: t("attachFiles") })] })] })), scope && (_jsxs("button", { type: "button", onClick: onScopeClick, disabled: !onScopeClick, className: cn("inline-flex min-w-0 items-center gap-1.5 rounded-lg px-2 py-1 text-xs transition-colors duration-150 enabled:hover:bg-accent", production ? "bg-warning-weak text-warning-text enabled:hover:bg-warning-weak" : "text-muted-foreground"), children: [production ? _jsx(TriangleAlert, { "aria-hidden": true, className: "size-3 shrink-0" }) : _jsx("span", { "aria-hidden": true, className: "size-1.5 shrink-0 rounded-full bg-success" }), _jsxs("span", { className: "truncate", children: [scope.environment, " \u00B7 ", production ? t("production") : (scope.detail ?? t("nonProduction"))] })] })), _jsxs("span", { className: "ms-auto hidden text-2xs text-faint-foreground @lg:inline", children: [_jsx("kbd", { className: "font-sans", children: "\u21B5" }), " ", t("hintSend"), " \u00B7 ", _jsx("kbd", { className: "font-sans", children: "\u21E7\u21B5" }), " ", t("hintNewLine"), " \u00B7 ", _jsx("kbd", { className: "font-sans", children: "@" }), " ", t("hintMention")] }), state === "streaming" && onStop ? (_jsx("button", { type: "button", "aria-label": t("stop"), onClick: onStop, className: "ms-auto flex size-8 items-center justify-center rounded-lg bg-foreground text-background transition-transform duration-150 active:scale-95 @lg:ms-1", children: _jsx(Square, { className: "size-3 fill-current" }) })) : (_jsx("button", { type: "button", "aria-label": t("send"), onClick: submit, disabled: !canSend, "aria-busy": state === "sending", className: "ms-auto flex size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground transition-[background-color,opacity,transform] duration-150 enabled:hover:bg-primary-hover active:scale-95 disabled:opacity-30 @lg:ms-1", children: state === "sending" ? _jsx(LoaderCircle, { className: "size-4 animate-spin" }) : _jsx(ArrowUp, { className: "size-4", strokeWidth: 2.5 }) }))] })] })] }));
215
+ } }), _jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsx("button", { type: "button", "aria-label": t("attachFiles"), disabled: Boolean(locked), onClick: () => fileInput.current?.click(), className: "flex size-8 items-center justify-center rounded-lg text-muted-foreground transition-colors duration-150 hover:bg-accent hover:text-foreground disabled:opacity-40", children: _jsx(Paperclip, { className: "size-4" }) }) }), _jsx(TooltipContent, { children: t("attachFiles") })] })] })), tools, scope && (_jsxs("button", { type: "button", onClick: onScopeClick, disabled: !onScopeClick, className: cn("inline-flex min-w-0 items-center gap-1.5 rounded-lg px-2 py-1 text-xs transition-colors duration-150 enabled:hover:bg-accent", production ? "bg-warning-weak text-warning-text enabled:hover:bg-warning-weak" : "text-muted-foreground"), children: [production ? _jsx(TriangleAlert, { "aria-hidden": true, className: "size-3 shrink-0" }) : _jsx("span", { "aria-hidden": true, className: "size-1.5 shrink-0 rounded-full bg-success" }), _jsxs("span", { className: "truncate", children: [scope.environment, " \u00B7 ", production ? t("production") : (scope.detail ?? t("nonProduction"))] })] })), _jsxs("span", { className: "ms-auto hidden text-2xs text-faint-foreground @lg:inline", children: [_jsx("kbd", { className: "font-sans", children: "\u21B5" }), " ", t("hintSend"), " \u00B7 ", _jsx("kbd", { className: "font-sans", children: "\u21E7\u21B5" }), " ", t("hintNewLine"), " \u00B7 ", _jsx("kbd", { className: "font-sans", children: "@" }), " ", t("hintMention")] }), state === "streaming" && onStop ? (_jsx("button", { type: "button", "aria-label": t("stop"), onClick: onStop, className: "ms-auto flex size-8 items-center justify-center rounded-lg bg-foreground text-background transition-transform duration-150 active:scale-95 @lg:ms-1", children: _jsx(Square, { className: "size-3 fill-current" }) })) : (_jsx("button", { type: "button", "aria-label": t("send"), onClick: submit, disabled: !canSend, "aria-busy": state === "sending", className: "ms-auto flex size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground transition-[background-color,opacity,transform] duration-150 enabled:hover:bg-primary-hover active:scale-95 disabled:opacity-30 @lg:ms-1", children: state === "sending" ? _jsx(LoaderCircle, { className: "size-4 animate-spin" }) : _jsx(ArrowUp, { className: "size-4", strokeWidth: 2.5 }) }))] })] })] }));
215
216
  }
216
217
  export { Composer };
@@ -9,8 +9,12 @@ export declare const threadStrings: {
9
9
  * bottom and stops following the moment you scroll up to read, offering a
10
10
  * "Jump to latest" button instead. Pass `empty` for the first-run state and
11
11
  * the composer as `footer`.
12
+ *
13
+ * For long histories, `virtualize` renders only the messages in or near view
14
+ * (each child is one item, keyed by its `key`). The column stays a `log`, the
15
+ * message holding focus stays rendered, and Jump to latest works the same.
12
16
  */
13
- declare function Thread({ children, empty, footer, className, contentClassName, }: {
17
+ declare function Thread({ children, empty, footer, className, contentClassName, virtualize, }: {
14
18
  children?: React.ReactNode;
15
19
  /** Shown before the first message. */
16
20
  empty?: React.ReactNode;
@@ -19,5 +23,7 @@ declare function Thread({ children, empty, footer, className, contentClassName,
19
23
  className?: string;
20
24
  /** Classes for the message column. */
21
25
  contentClassName?: string;
26
+ /** Render only the messages in or near view, for histories of hundreds of messages. Give each child a `key`. Defaults to false. */
27
+ virtualize?: boolean;
22
28
  }): React.JSX.Element;
23
29
  export { Thread };
@@ -1,9 +1,10 @@
1
1
  "use client";
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import * as React from "react";
4
4
  import { ArrowDown } from "lucide-react";
5
5
  import { cn } from "../lib/utils.js";
6
6
  import { useStrings } from "../blocks/locale.js";
7
+ import { useVirtualList } from "../lib/use-virtual-list.js";
7
8
  /** Thread's fixed words; override them under `thread` in LocaleProvider's `strings`. */
8
9
  export const threadStrings = {
9
10
  messages: "Messages",
@@ -14,8 +15,12 @@ export const threadStrings = {
14
15
  * bottom and stops following the moment you scroll up to read, offering a
15
16
  * "Jump to latest" button instead. Pass `empty` for the first-run state and
16
17
  * the composer as `footer`.
18
+ *
19
+ * For long histories, `virtualize` renders only the messages in or near view
20
+ * (each child is one item, keyed by its `key`). The column stays a `log`, the
21
+ * message holding focus stays rendered, and Jump to latest works the same.
17
22
  */
18
- function Thread({ children, empty, footer, className, contentClassName, }) {
23
+ function Thread({ children, empty, footer, className, contentClassName, virtualize = false, }) {
19
24
  const t = useStrings("thread", threadStrings);
20
25
  const scroller = React.useRef(null);
21
26
  const content = React.useRef(null);
@@ -58,7 +63,23 @@ function Thread({ children, empty, footer, className, contentClassName, }) {
58
63
  lastTop.current = top;
59
64
  };
60
65
  const hasMessages = React.Children.count(children) > 0;
61
- return (_jsxs("div", { "data-slot": "thread", className: cn("flex min-h-0 flex-col", className), children: [_jsxs("div", { className: "relative min-h-0 flex-1", children: [_jsx("div", { ref: scroller, onScroll: onScroll, className: "h-full overflow-y-auto overscroll-contain", children: _jsx("div", { ref: content, role: "log", "aria-label": t("messages"), className: cn("mx-auto flex w-full max-w-(--cd-thread-width) flex-col gap-7 px-4 py-6", contentClassName), children: hasMessages ? children : empty }) }), !pinned && hasMessages && (_jsxs("button", { type: "button", onClick: () => {
66
+ const items = virtualize ? React.Children.toArray(children) : [];
67
+ const keyOf = (i) => (React.isValidElement(items[i]) ? (items[i].key ?? i) : i);
68
+ // The message holding focus stays rendered, so focus isn't lost when it scrolls out of view.
69
+ const [focused, setFocused] = React.useState(null);
70
+ const list = useVirtualList({
71
+ enabled: virtualize,
72
+ count: items.length,
73
+ keyOf,
74
+ scroller,
75
+ pin: focused,
76
+ gap: () => (content.current ? Number.parseFloat(getComputedStyle(content.current).rowGap) || 0 : 0),
77
+ });
78
+ return (_jsxs("div", { "data-slot": "thread", className: cn("flex min-h-0 flex-col", className), children: [_jsxs("div", { className: "relative min-h-0 flex-1", children: [_jsx("div", { ref: scroller, onScroll: onScroll, onFocus: virtualize
79
+ ? (e) => {
80
+ setFocused(e.target.closest("[data-virtual-key]")?.getAttribute("data-virtual-key") ?? null);
81
+ }
82
+ : undefined, onBlur: virtualize ? (e) => !e.currentTarget.contains(e.relatedTarget) && setFocused(null) : undefined, className: "h-full overflow-y-auto overscroll-contain", children: _jsx("div", { ref: content, role: "log", "aria-label": t("messages"), className: cn("mx-auto flex w-full max-w-(--cd-thread-width) flex-col gap-7 px-4 py-6", contentClassName), children: !hasMessages ? (empty) : virtualize ? (_jsx(_Fragment, { children: list.rows.map((row) => "spacer" in row ? (_jsx("div", { "aria-hidden": true, style: { height: row.spacer } }, row.key)) : (_jsx("div", { ref: list.measure, "data-virtual-key": String(keyOf(row.index)), className: "flex flex-col", children: items[row.index] }, keyOf(row.index)))) })) : (children) }) }), !pinned && hasMessages && (_jsxs("button", { type: "button", onClick: () => {
62
83
  pinnedRef.current = true;
63
84
  setPinned(true);
64
85
  toBottom("smooth");
package/dist/index.d.ts CHANGED
@@ -2,6 +2,7 @@ export * from "./lib/chat.js";
2
2
  export * from "./lib/status.js";
3
3
  export * from "./lib/use-copy.js";
4
4
  export * from "./lib/use-shortcut.js";
5
+ export * from "./lib/use-virtual-list.js";
5
6
  export * from "./blocks/terms.js";
6
7
  export * from "./blocks/locale.js";
7
8
  export * from "./blocks/locked.js";
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@ export * from "./lib/chat.js";
2
2
  export * from "./lib/status.js";
3
3
  export * from "./lib/use-copy.js";
4
4
  export * from "./lib/use-shortcut.js";
5
+ export * from "./lib/use-virtual-list.js";
5
6
  export * from "./blocks/terms.js";
6
7
  export * from "./blocks/locale.js";
7
8
  export * from "./blocks/locked.js";
@@ -0,0 +1,39 @@
1
+ import * as React from "react";
2
+ /** What to render, in order: an item by its index, or a spacer standing in for the items between. */
3
+ export type VirtualRow = {
4
+ index: number;
5
+ } | {
6
+ spacer: number;
7
+ key: string;
8
+ };
9
+ export type VirtualList = {
10
+ /** The items to render and the spacers between them, in order. */
11
+ rows: VirtualRow[];
12
+ /** The ref for every rendered item, which also carries `data-virtual-key={key}`, so its real height is measured and kept. */
13
+ measure: (el: HTMLElement | null) => void | (() => void);
14
+ };
15
+ /**
16
+ * Render only the items of a long list that are in or near view, for Thread's and SessionList's `virtualize`.
17
+ * Items keep their measured heights by key, so a list that grows or reorders keeps its place; unmeasured items count
18
+ * as `estimate`. Spacers stand in for the rest. `pin` (an item's key) keeps that item rendered (the one holding
19
+ * focus), on its own if it's far from view, so focus is never lost to an item scrolling away. Until the scroller is
20
+ * measured (on the server, on the first render in the browser, and while it's hidden) the first or last `initial`
21
+ * items render, by `anchor`, so server and browser agree.
22
+ *
23
+ * Give each rendered item `ref={list.measure}` and `data-virtual-key={key}`.
24
+ */
25
+ export declare function useVirtualList({ enabled, count, keyOf, scroller, estimate, overscan, initial, anchor, gap, pin, }: {
26
+ enabled: boolean;
27
+ count: number;
28
+ keyOf: (index: number) => React.Key;
29
+ scroller: React.RefObject<HTMLElement | null>;
30
+ estimate?: number;
31
+ overscan?: number;
32
+ initial?: number;
33
+ /** Which end to show before the scroller is measured: "end" for a thread (newest last), "start" for a list. Defaults to "end". */
34
+ anchor?: "start" | "end";
35
+ /** The gap between items, in pixels, read once the scroller is measured (e.g. from the list's computed `row-gap`). */
36
+ gap?: () => number;
37
+ /** The key of an item to keep rendered, such as the one holding focus. */
38
+ pin?: string | null;
39
+ }): VirtualList;
@@ -0,0 +1,124 @@
1
+ import * as React from "react";
2
+ const keyAttr = "data-virtual-key";
3
+ /**
4
+ * Render only the items of a long list that are in or near view, for Thread's and SessionList's `virtualize`.
5
+ * Items keep their measured heights by key, so a list that grows or reorders keeps its place; unmeasured items count
6
+ * as `estimate`. Spacers stand in for the rest. `pin` (an item's key) keeps that item rendered (the one holding
7
+ * focus), on its own if it's far from view, so focus is never lost to an item scrolling away. Until the scroller is
8
+ * measured (on the server, on the first render in the browser, and while it's hidden) the first or last `initial`
9
+ * items render, by `anchor`, so server and browser agree.
10
+ *
11
+ * Give each rendered item `ref={list.measure}` and `data-virtual-key={key}`.
12
+ */
13
+ export function useVirtualList({ enabled, count, keyOf, scroller, estimate = 96, overscan = 800, initial = 20, anchor = "end", gap = () => 0, pin, }) {
14
+ const [heights, setHeights] = React.useState(() => new Map());
15
+ const [viewport, setViewport] = React.useState(null);
16
+ const gapOf = React.useRef(gap);
17
+ React.useLayoutEffect(() => {
18
+ gapOf.current = gap;
19
+ });
20
+ // One observer for the rendered items, made when the first one mounts; a changed height re-renders.
21
+ const observer = React.useRef(null);
22
+ React.useEffect(() => () => {
23
+ observer.current?.disconnect();
24
+ observer.current = null;
25
+ }, []);
26
+ const measure = React.useCallback((el) => {
27
+ if (!el)
28
+ return;
29
+ if (typeof ResizeObserver !== "undefined")
30
+ observer.current ??= new ResizeObserver((entries) => {
31
+ const found = entries.flatMap((e) => {
32
+ const key = e.target.getAttribute(keyAttr);
33
+ const h = e.target.offsetHeight;
34
+ return key !== null && h > 0 ? [[key, h]] : [];
35
+ });
36
+ if (found.length)
37
+ setHeights((prev) => (found.every(([k, h]) => prev.get(k) === h) ? prev : new Map([...prev, ...found])));
38
+ });
39
+ const ro = observer.current;
40
+ ro?.observe(el);
41
+ const key = el.getAttribute(keyAttr);
42
+ const h = el.offsetHeight;
43
+ if (key !== null && h > 0)
44
+ setHeights((prev) => (prev.get(key) === h ? prev : new Map(prev).set(key, h)));
45
+ // An item that unmounts stops being observed, so it can be collected.
46
+ return () => ro?.unobserve(el);
47
+ }, []);
48
+ React.useLayoutEffect(() => {
49
+ const el = scroller.current;
50
+ if (!enabled || !el)
51
+ return;
52
+ const pending = { frame: 0 };
53
+ const read = () => {
54
+ pending.frame = 0;
55
+ const g = gapOf.current();
56
+ setViewport((v) => (v && v.top === el.scrollTop && v.height === el.clientHeight && v.gap === g ? v : { top: el.scrollTop, height: el.clientHeight, gap: g }));
57
+ };
58
+ const later = () => {
59
+ if (!pending.frame)
60
+ pending.frame = requestAnimationFrame(read);
61
+ };
62
+ read();
63
+ el.addEventListener("scroll", later, { passive: true });
64
+ const ro = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(later);
65
+ ro?.observe(el);
66
+ return () => {
67
+ el.removeEventListener("scroll", later);
68
+ ro?.disconnect();
69
+ cancelAnimationFrame(pending.frame);
70
+ };
71
+ }, [enabled, scroller]);
72
+ if (!enabled)
73
+ return { rows: Array.from({ length: count }, (_, index) => ({ index })), measure };
74
+ const g = viewport?.gap ?? 0;
75
+ const size = (i) => heights.get(String(keyOf(i))) ?? estimate;
76
+ const tops = [];
77
+ let y = 0;
78
+ for (let i = 0; i < count; i++) {
79
+ tops.push(y);
80
+ y += size(i) + g;
81
+ }
82
+ const total = Math.max(0, y - g);
83
+ let start;
84
+ let end;
85
+ if (!viewport || viewport.height === 0) {
86
+ start = anchor === "end" ? Math.max(0, count - initial) : 0;
87
+ end = anchor === "end" ? count : Math.min(count, initial);
88
+ }
89
+ else {
90
+ const lo = viewport.top - overscan;
91
+ const hi = viewport.top + viewport.height + overscan;
92
+ start = 0;
93
+ while (start < count - 1 && tops[start] + size(start) < lo)
94
+ start++;
95
+ end = start;
96
+ while (end < count && tops[end] <= hi)
97
+ end++;
98
+ end = Math.max(end, Math.min(count, start + 1));
99
+ }
100
+ // The pinned item and its neighbours (so Tab and Shift+Tab reach the next and previous item) render with the window,
101
+ // or on their own between spacers when they're far from it.
102
+ let ranges = [[start, end]];
103
+ const p = pin == null ? -1 : Array.from({ length: count }, (_, i) => i).find((i) => String(keyOf(i)) === pin) ?? -1;
104
+ if (p >= 0) {
105
+ const island = [Math.max(0, p - 1), Math.min(count, p + 2)];
106
+ ranges = island[1] < start ? [island, [start, end]] : island[0] > end ? [[start, end], island] : [[Math.min(start, island[0]), Math.max(end, island[1])]];
107
+ }
108
+ // A spacer stands where its items were; the gaps the layout puts beside it account for theirs.
109
+ const rows = [];
110
+ let prevEnd = 0;
111
+ for (const [a, b] of ranges) {
112
+ if (a > 0) {
113
+ const height = prevEnd === 0 ? tops[a] - g : tops[a] - (tops[prevEnd - 1] + size(prevEnd - 1)) - 2 * g;
114
+ if (a > prevEnd)
115
+ rows.push({ spacer: Math.max(0, height), key: `spacer-${prevEnd}` });
116
+ }
117
+ for (let i = a; i < b; i++)
118
+ rows.push({ index: i });
119
+ prevEnd = b;
120
+ }
121
+ if (prevEnd < count)
122
+ rows.push({ spacer: Math.max(0, total - tops[prevEnd]), key: `spacer-${prevEnd}` });
123
+ return { rows, measure };
124
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@candor-ui/react",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "Candor UI's free tier as an npm package: React components for agents that do real work, on shadcn/ui and Tailwind 4.",
5
5
  "license": "MIT",
6
6
  "author": "Methodic LLC",