@candor-ui/react 1.0.1 → 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.
Files changed (61) hide show
  1. package/LICENSE +3 -2
  2. package/dist/blocks/copy-field.d.ts +6 -0
  3. package/dist/blocks/copy-field.js +9 -1
  4. package/dist/blocks/degraded-panel.d.ts +6 -0
  5. package/dist/blocks/degraded-panel.js +9 -1
  6. package/dist/blocks/locale.d.ts +86 -0
  7. package/dist/blocks/locale.js +146 -0
  8. package/dist/blocks/relative-time.d.ts +21 -3
  9. package/dist/blocks/relative-time.js +30 -11
  10. package/dist/blocks/status-dot.js +4 -2
  11. package/dist/blocks/step-list.js +4 -2
  12. package/dist/blocks/streaming-text.js +1 -1
  13. package/dist/blocks/surface.d.ts +2 -2
  14. package/dist/chat/agent-avatar.d.ts +14 -0
  15. package/dist/chat/agent-avatar.js +26 -2
  16. package/dist/chat/attachment-preview.d.ts +53 -0
  17. package/dist/chat/attachment-preview.js +82 -0
  18. package/dist/chat/card-registry.d.ts +7 -0
  19. package/dist/chat/card-registry.js +14 -4
  20. package/dist/chat/citation.d.ts +15 -0
  21. package/dist/chat/citation.js +34 -7
  22. package/dist/chat/composer.d.ts +30 -4
  23. package/dist/chat/composer.js +69 -20
  24. package/dist/chat/context-meter.d.ts +9 -0
  25. package/dist/chat/context-meter.js +21 -4
  26. package/dist/chat/entity-chip.d.ts +19 -0
  27. package/dist/chat/entity-chip.js +37 -3
  28. package/dist/chat/folded-turns.d.ts +33 -0
  29. package/dist/chat/folded-turns.js +29 -0
  30. package/dist/chat/message-actions.d.ts +9 -0
  31. package/dist/chat/message-actions.js +12 -1
  32. package/dist/chat/message-content.d.ts +12 -3
  33. package/dist/chat/message-content.js +24 -6
  34. package/dist/chat/message.d.ts +81 -4
  35. package/dist/chat/message.js +50 -9
  36. package/dist/chat/payload.d.ts +21 -4
  37. package/dist/chat/payload.js +38 -5
  38. package/dist/chat/prompt-suggestions.d.ts +6 -2
  39. package/dist/chat/prompt-suggestions.js +9 -2
  40. package/dist/chat/reasoning.d.ts +15 -0
  41. package/dist/chat/reasoning.js +12 -3
  42. package/dist/chat/thread-divider.d.ts +18 -0
  43. package/dist/chat/thread-divider.js +39 -0
  44. package/dist/chat/thread-event.d.ts +19 -0
  45. package/dist/chat/thread-event.js +15 -0
  46. package/dist/chat/thread.d.ts +12 -1
  47. package/dist/chat/thread.js +32 -4
  48. package/dist/chat/tool-call.d.ts +12 -0
  49. package/dist/chat/tool-call.js +16 -3
  50. package/dist/index.d.ts +7 -0
  51. package/dist/index.js +7 -0
  52. package/dist/lib/chat.d.ts +11 -1
  53. package/dist/lib/highlight.d.ts +15 -0
  54. package/dist/lib/highlight.js +224 -0
  55. package/dist/lib/status.d.ts +15 -0
  56. package/dist/lib/status.js +10 -1
  57. package/dist/lib/use-virtual-list.d.ts +39 -0
  58. package/dist/lib/use-virtual-list.js +124 -0
  59. package/dist/styles.css +40 -1
  60. package/dist/ui/button.d.ts +1 -1
  61. package/package.json +1 -1
package/LICENSE CHANGED
@@ -20,5 +20,6 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
20
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
21
  SOFTWARE.
22
22
 
23
- This licence covers only the free-tier items listed in scripts/tiers.json. See
24
- LICENSE.md.
23
+ This licence covers only Candor UI's free-tier items, the ones published at
24
+ https://github.com/MethodicDev/candor. Paid items are under the Candor UI
25
+ Commercial Licence (https://candor.themethodic.com/licence).
@@ -1,3 +1,9 @@
1
+ /** CopyField's fixed words; override them under `copyField` in LocaleProvider's `strings`. */
2
+ export declare const copyFieldStrings: {
3
+ copy: string;
4
+ copied: string;
5
+ failed: string;
6
+ };
1
7
  /**
2
8
  * A value a person copies somewhere else (a callback URL, a scope list). The
3
9
  * whole field is the copy target; it says "Copied" only once the clipboard
@@ -3,6 +3,13 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
3
3
  import { Check, Copy, X } from "lucide-react";
4
4
  import { cn } from "../lib/utils.js";
5
5
  import { useCopy } from "../lib/use-copy.js";
6
+ import { useStrings } from "./locale.js";
7
+ /** CopyField's fixed words; override them under `copyField` in LocaleProvider's `strings`. */
8
+ export const copyFieldStrings = {
9
+ copy: "Copy",
10
+ copied: "Copied",
11
+ failed: "Couldn’t copy",
12
+ };
6
13
  /**
7
14
  * A value a person copies somewhere else (a callback URL, a scope list). The
8
15
  * whole field is the copy target; it says "Copied" only once the clipboard
@@ -10,6 +17,7 @@ import { useCopy } from "../lib/use-copy.js";
10
17
  */
11
18
  function CopyField({ label, value, className, }) {
12
19
  const { state, copy } = useCopy();
13
- return (_jsxs("div", { "data-slot": "copy-field", className: cn("flex flex-col gap-1", className), children: [_jsx("span", { className: "text-xs font-medium text-muted-foreground", children: label }), _jsxs("button", { type: "button", onClick: () => copy(value), className: "group/copy flex w-full items-center gap-2 rounded-lg border bg-sunken px-3 py-2 text-left outline-none transition-colors duration-150 hover:border-border-strong focus-visible:ring-[3px] focus-visible:ring-ring/40", children: [_jsx("code", { className: "min-w-0 flex-1 truncate font-mono text-xs", children: value }), _jsx("span", { "aria-live": "polite", className: "inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground group-hover/copy:text-foreground", children: state === "copied" ? (_jsxs(_Fragment, { children: [_jsx(Check, { className: "size-3 text-success-text" }), " Copied"] })) : state === "failed" ? (_jsxs(_Fragment, { children: [_jsx(X, { className: "size-3 text-destructive-text" }), " Couldn\u2019t copy"] })) : (_jsxs(_Fragment, { children: [_jsx(Copy, { className: "size-3" }), " Copy"] })) })] })] }));
20
+ const t = useStrings("copyField", copyFieldStrings);
21
+ return (_jsxs("div", { "data-slot": "copy-field", className: cn("flex flex-col gap-1", className), children: [_jsx("span", { className: "text-xs font-medium text-muted-foreground", children: label }), _jsxs("button", { type: "button", onClick: () => copy(value), className: "group/copy flex w-full items-center gap-2 rounded-lg border bg-sunken px-3 py-2 text-start outline-none transition-colors duration-150 hover:border-border-strong focus-visible:ring-[3px] focus-visible:ring-ring/40", children: [_jsx("code", { className: "min-w-0 flex-1 truncate font-mono text-xs", children: value }), _jsx("span", { "aria-live": "polite", className: "inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground group-hover/copy:text-foreground", children: state === "copied" ? (_jsxs(_Fragment, { children: [_jsx(Check, { className: "size-3 text-success-text" }), " ", t("copied")] })) : state === "failed" ? (_jsxs(_Fragment, { children: [_jsx(X, { className: "size-3 text-destructive-text" }), " ", t("failed")] })) : (_jsxs(_Fragment, { children: [_jsx(Copy, { className: "size-3" }), " ", t("copy")] })) })] })] }));
14
22
  }
15
23
  export { CopyField };
@@ -1,3 +1,9 @@
1
+ /** DegradedPanel's fixed words; override them under `degradedPanel` in LocaleProvider's `strings`. */
2
+ export declare const degradedPanelStrings: {
3
+ missing: string;
4
+ retry: string;
5
+ retrying: string;
6
+ };
1
7
  /**
2
8
  * Something couldn't be done. Says what went wrong, what is missing because of
3
9
  * it, and offers a retry only when the caller can actually retry (Candor's
@@ -3,12 +3,20 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { CircleAlert, RotateCw } from "lucide-react";
4
4
  import { cn } from "../lib/utils.js";
5
5
  import { Button } from "../ui/button.js";
6
+ import { useStrings } from "./locale.js";
7
+ /** DegradedPanel's fixed words; override them under `degradedPanel` in LocaleProvider's `strings`. */
8
+ export const degradedPanelStrings = {
9
+ missing: "Missing:",
10
+ retry: "Retry",
11
+ retrying: "Retrying",
12
+ };
6
13
  /**
7
14
  * Something couldn't be done. Says what went wrong, what is missing because of
8
15
  * it, and offers a retry only when the caller can actually retry (Candor's
9
16
  * DegradedPanel: a degraded state names what's missing, never just looks sad).
10
17
  */
11
18
  function DegradedPanel({ reason, missing, onRetry, retrying = false, variant = "block", className, }) {
12
- return (_jsxs("div", { "data-slot": "degraded-panel", role: "status", className: cn("flex gap-2.5 text-sm", variant === "block" ? "items-start rounded-lg border border-warning/40 bg-warning-weak/60 p-3" : "items-center", className), children: [_jsx(CircleAlert, { "aria-hidden": true, className: "mt-0.5 size-4 shrink-0 text-warning-text" }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("p", { className: "text-foreground", children: reason }), missing && _jsxs("p", { className: "mt-0.5 text-xs text-muted-foreground", children: ["Missing: ", missing] })] }), onRetry && (_jsxs(Button, { size: "xs", variant: "outline", onClick: onRetry, disabled: retrying, "aria-busy": retrying, className: "shrink-0", children: [_jsx(RotateCw, { className: cn(retrying && "animate-spin") }), " ", retrying ? "Retrying" : "Retry"] }))] }));
19
+ const t = useStrings("degradedPanel", degradedPanelStrings);
20
+ return (_jsxs("div", { "data-slot": "degraded-panel", role: "status", className: cn("flex gap-2.5 text-sm", variant === "block" ? "items-start rounded-lg border border-warning/40 bg-warning-weak/60 p-3" : "items-center", className), children: [_jsx(CircleAlert, { "aria-hidden": true, className: "mt-0.5 size-4 shrink-0 text-warning-text" }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("p", { className: "text-foreground", children: reason }), missing && _jsxs("p", { className: "mt-0.5 text-xs text-muted-foreground", children: [t("missing"), " ", missing] })] }), onRetry && (_jsxs(Button, { size: "xs", variant: "outline", onClick: onRetry, disabled: retrying, "aria-busy": retrying, className: "shrink-0", children: [_jsx(RotateCw, { className: cn(retrying && "animate-spin") }), " ", retrying ? t("retrying") : t("retry")] }))] }));
13
21
  }
14
22
  export { DegradedPanel };
@@ -0,0 +1,86 @@
1
+ import * as React from "react";
2
+ /**
3
+ * The interface's words, language and direction. Every fixed string in Candor (labels, buttons, screen-reader text,
4
+ * "just now") reads from here, with English as the default, and dates and numbers follow the locale. Terms stays for
5
+ * the app's own nouns ("environment", "account").
6
+ *
7
+ * Each component keeps its English defaults in an exported table named after it (`threadStrings`, `composerStrings`),
8
+ * and you override any of them under the component's registry name in camelCase (`thread`, `relativeTime`,
9
+ * `commandPalette`):
10
+ *
11
+ * <LocaleProvider locale="fr" strings={{ thread: { jumpToLatest: "Aller au plus récent" } }}>
12
+ *
13
+ * A string can hold `{name}` placeholders, and a count can choose between plural forms
14
+ * (`{ one: "{count} file", other: "{count} files" }`), so every override stays plain, serialisable data that a
15
+ * server component can pass.
16
+ */
17
+ /** One string, or its plural forms chosen by `count`; `{name}` placeholders are filled in. */
18
+ export type Phrase = string | ({
19
+ other: string;
20
+ } & Partial<Record<Intl.LDMLPluralRule, string>>);
21
+ /** A component's strings: its English defaults, or your overrides. */
22
+ export type StringTable = Record<string, Phrase>;
23
+ /** Overrides by component name, e.g. `{ thread: { jumpToLatest: "…" }, composer: { send: "…" } }`. */
24
+ export type StringOverrides = Record<string, Partial<StringTable> | undefined>;
25
+ export type TextDirection = "ltr" | "rtl";
26
+ /** "rtl" for Arabic, Hebrew, Persian, Urdu and the like; "ltr" otherwise. */
27
+ export declare function directionOf(locale: string | undefined): TextDirection;
28
+ declare function LocaleProvider({ locale, dir, timeZone, strings, children, }: {
29
+ /** A BCP 47 tag such as "en-GB", "fr" or "ar-EG", for dates, numbers and plural forms. Left out, the browser's own is used once the page has hydrated. */
30
+ locale?: string;
31
+ /** Text direction. Left out, it follows the locale: "rtl" for Arabic, Hebrew, Persian, Urdu and the like. Set `dir` on `<html>` too, so the page's own layout flips with Candor's. */
32
+ dir?: TextDirection;
33
+ /** An IANA time zone such as "Europe/Paris". With `locale`, server and browser format dates the same way, so absolute times render on the server too. */
34
+ timeZone?: string;
35
+ /** Overrides by component name, e.g. `{ thread: { jumpToLatest: "…" } }`; any left out stay English. Nested providers merge. */
36
+ strings?: StringOverrides;
37
+ children: React.ReactNode;
38
+ }): React.JSX.Element;
39
+ /** The locale, time zone and text direction in effect. */
40
+ declare function useLocale(): {
41
+ locale?: string;
42
+ timeZone?: string;
43
+ dir: TextDirection;
44
+ };
45
+ /**
46
+ * False while rendering on the server and during React's hydration pass, true after. Anything that depends on the
47
+ * browser (its locale, its time zone) waits for true, so server and browser render the same markup.
48
+ */
49
+ declare function useHydrated(): boolean;
50
+ /** How an absolute time reads: "5 Nov 2026", "5 Nov 2026, 14:03" or "14:03". */
51
+ export type DateStyle = "date" | "datetime" | "time";
52
+ export type Formatter = {
53
+ /** The locale actually used right now; "en-US" before hydration when no locale is set. */
54
+ tag: string | undefined;
55
+ /** A number in the locale: "1,234.5", "1 234,5". */
56
+ number: (n: number, options?: Intl.NumberFormatOptions) => string;
57
+ /** An absolute time. Until the page hydrates it reads as an ISO date in UTC, unless both `locale` and `timeZone` are set. */
58
+ date: (date: Date | string | number, style?: DateStyle) => string;
59
+ /** Items joined as the locale does: "A, B and C". */
60
+ list: (items: string[], type?: "conjunction" | "disjunction") => string;
61
+ };
62
+ /**
63
+ * Locale-aware formatting that's safe to render on the server. With no `locale` set, numbers use "en-US" until the
64
+ * page hydrates and then the browser's locale; dates show their ISO form until then. Set `locale` (and `timeZone`
65
+ * for dates) on the provider to render the final text on the server.
66
+ */
67
+ declare function useFormat(): Formatter;
68
+ /** Fill `{name}` placeholders. Numbers go through the locale's number format. */
69
+ export declare function fill(template: string, vars: Record<string, string | number> | undefined, number?: (n: number) => string): string;
70
+ /** The plural form for `count`, falling back to `other`. */
71
+ export declare function pick(message: Phrase, count: number | undefined, rules: Intl.PluralRules): string;
72
+ /**
73
+ * A translated sentence with elements in it: `place(t("signedInAs"), { email: <strong>{email}</strong> })`. Each
74
+ * `{name}` becomes that node, so translators can move it within the sentence and it keeps its styling.
75
+ */
76
+ export declare function place(text: string, nodes: Record<string, React.ReactNode>): React.ReactNode[];
77
+ /** Looks up one string by key and fills its placeholders; `count` also picks the plural form. */
78
+ export type Translate<T extends StringTable> = (key: keyof T & string, vars?: Record<string, string | number>) => string;
79
+ /**
80
+ * A component's strings: `t("jumpToLatest")`, `t("remove", { name })`, `t("files", { count: 3 })`. `name` is the
81
+ * component's registry name in camelCase, the key apps override it under; `defaults` is its English table.
82
+ */
83
+ declare function useStrings<T extends StringTable>(name: string, defaults: T): Translate<T>;
84
+ /** Physical side for the reading direction: "left" in LTR, "right" in RTL. For the few places CSS can't say start. */
85
+ declare function useStartSide(): "left" | "right";
86
+ export { LocaleProvider, useLocale, useFormat, useHydrated, useStrings, useStartSide };
@@ -0,0 +1,146 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import * as React from "react";
4
+ import { Direction } from "radix-ui";
5
+ const LocaleContext = React.createContext({ dir: "ltr", strings: {} });
6
+ // Scripts written right to left, by language subtag. Intl.Locale's text info isn't in every browser yet.
7
+ const rtlLanguages = new Set(["ar", "arc", "ckb", "dv", "fa", "he", "iw", "ks", "ku", "ps", "sd", "syr", "ug", "ur", "yi"]);
8
+ /** "rtl" for Arabic, Hebrew, Persian, Urdu and the like; "ltr" otherwise. */
9
+ export function directionOf(locale) {
10
+ if (!locale)
11
+ return "ltr";
12
+ try {
13
+ const l = new Intl.Locale(locale);
14
+ if (l.script)
15
+ return ["Arab", "Hebr", "Thaa", "Syrc", "Nkoo", "Adlm", "Rohg"].includes(l.script) ? "rtl" : "ltr";
16
+ return rtlLanguages.has(l.language) ? "rtl" : "ltr";
17
+ }
18
+ catch {
19
+ return "ltr";
20
+ }
21
+ }
22
+ function LocaleProvider({ locale, dir, timeZone, strings, children, }) {
23
+ const parent = React.useContext(LocaleContext);
24
+ const nextLocale = locale ?? parent.locale;
25
+ // `strings` is usually an inline object; keyed by its content so consumers don't re-render on every render here.
26
+ const stringsKey = JSON.stringify(strings ?? {});
27
+ const value = React.useMemo(() => {
28
+ const merged = { ...parent.strings };
29
+ for (const [name, table] of Object.entries(strings ?? {}))
30
+ merged[name] = { ...parent.strings[name], ...table };
31
+ return {
32
+ locale: nextLocale,
33
+ timeZone: timeZone ?? parent.timeZone,
34
+ dir: dir ?? (locale ? directionOf(locale) : parent.dir),
35
+ strings: merged,
36
+ };
37
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- `strings` is tracked by its content (stringsKey)
38
+ }, [parent, nextLocale, locale, timeZone, dir, stringsKey]);
39
+ return (_jsx(LocaleContext.Provider, { value: value, children: _jsx(Direction.Provider, { dir: value.dir, children: children }) }));
40
+ }
41
+ /** The locale, time zone and text direction in effect. */
42
+ function useLocale() {
43
+ const { locale, timeZone, dir } = React.useContext(LocaleContext);
44
+ return { locale, timeZone, dir };
45
+ }
46
+ const noop = () => () => { };
47
+ /**
48
+ * False while rendering on the server and during React's hydration pass, true after. Anything that depends on the
49
+ * browser (its locale, its time zone) waits for true, so server and browser render the same markup.
50
+ */
51
+ function useHydrated() {
52
+ return React.useSyncExternalStore(noop, () => true, () => false);
53
+ }
54
+ const dateOptions = {
55
+ date: { dateStyle: "medium" },
56
+ datetime: { dateStyle: "medium", timeStyle: "short" },
57
+ time: { timeStyle: "short" },
58
+ };
59
+ /** The ISO form shown before hydration: the same on server and browser, in UTC, and says so. */
60
+ function isoText(d, style) {
61
+ const iso = d.toISOString();
62
+ if (style === "date")
63
+ return iso.slice(0, 10);
64
+ if (style === "time")
65
+ return `${iso.slice(11, 16)} UTC`;
66
+ return `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC`;
67
+ }
68
+ /**
69
+ * Locale-aware formatting that's safe to render on the server. With no `locale` set, numbers use "en-US" until the
70
+ * page hydrates and then the browser's locale; dates show their ISO form until then. Set `locale` (and `timeZone`
71
+ * for dates) on the provider to render the final text on the server.
72
+ */
73
+ function useFormat() {
74
+ const { locale, timeZone } = React.useContext(LocaleContext);
75
+ const hydrated = useHydrated();
76
+ return React.useMemo(() => {
77
+ const tag = locale ?? (hydrated ? undefined : "en-US");
78
+ const fixedDates = hydrated || Boolean(locale && timeZone);
79
+ return {
80
+ tag,
81
+ number: (n, options) => new Intl.NumberFormat(tag, options).format(n),
82
+ date: (value, style = "datetime") => {
83
+ const d = value instanceof Date ? value : new Date(value);
84
+ if (Number.isNaN(d.getTime()))
85
+ return String(value);
86
+ if (!fixedDates)
87
+ return isoText(d, style);
88
+ return new Intl.DateTimeFormat(tag, { ...dateOptions[style], ...(timeZone && { timeZone }) }).format(d);
89
+ },
90
+ list: (items, type = "conjunction") => new Intl.ListFormat(tag, { style: "long", type }).format(items),
91
+ };
92
+ }, [locale, timeZone, hydrated]);
93
+ }
94
+ /** Fill `{name}` placeholders. Numbers go through the locale's number format. */
95
+ export function fill(template, vars, number = String) {
96
+ if (!vars)
97
+ return template;
98
+ return template.replace(/\{(\w+)\}/g, (whole, key) => {
99
+ const v = vars[key];
100
+ return v === undefined ? whole : typeof v === "number" ? number(v) : v;
101
+ });
102
+ }
103
+ /** The plural form for `count`, falling back to `other`. */
104
+ export function pick(message, count, rules) {
105
+ if (typeof message === "string")
106
+ return message;
107
+ if (count === undefined)
108
+ return message.other;
109
+ if (count === 0 && message.zero !== undefined)
110
+ return message.zero;
111
+ return message[rules.select(count)] ?? message.other;
112
+ }
113
+ /**
114
+ * A translated sentence with elements in it: `place(t("signedInAs"), { email: <strong>{email}</strong> })`. Each
115
+ * `{name}` becomes that node, so translators can move it within the sentence and it keeps its styling.
116
+ */
117
+ export function place(text, nodes) {
118
+ return text.split(/\{(\w+)\}/).map((part, i) => (i % 2 ? _jsx(React.Fragment, { children: part in nodes ? nodes[part] : `{${part}}` }, i) : part));
119
+ }
120
+ /**
121
+ * A component's strings: `t("jumpToLatest")`, `t("remove", { name })`, `t("files", { count: 3 })`. `name` is the
122
+ * component's registry name in camelCase, the key apps override it under; `defaults` is its English table.
123
+ */
124
+ function useStrings(name, defaults) {
125
+ const { strings, locale } = React.useContext(LocaleContext);
126
+ const format = useFormat();
127
+ const overrides = strings[name];
128
+ return React.useMemo(() => {
129
+ // Plural forms follow the language of the strings: the provider's locale, else English (the defaults). Never the
130
+ // browser's, which would pick Japanese "other" for English "{count} file".
131
+ const rules = new Intl.PluralRules(locale ?? "en-US");
132
+ const number = (n) => format.number(n);
133
+ return (key, vars) => {
134
+ const message = overrides?.[key] ?? defaults[key];
135
+ if (message === undefined)
136
+ return key;
137
+ const count = typeof vars?.count === "number" ? vars.count : undefined;
138
+ return fill(pick(message, count, rules), vars, number);
139
+ };
140
+ }, [overrides, defaults, format, locale]);
141
+ }
142
+ /** Physical side for the reading direction: "left" in LTR, "right" in RTL. For the few places CSS can't say start. */
143
+ function useStartSide() {
144
+ return useLocale().dir === "rtl" ? "right" : "left";
145
+ }
146
+ export { LocaleProvider, useLocale, useFormat, useHydrated, useStrings, useStartSide };
@@ -1,13 +1,31 @@
1
1
  import * as React from "react";
2
- export declare function formatRelative(date: Date, now?: Date): string;
2
+ import { type DateStyle } from "./locale.js";
3
+ /** RelativeTime's fixed words; override them under `relativeTime` in LocaleProvider's `strings`. */
4
+ export declare const relativeTimeStrings: {
5
+ justNow: string;
6
+ };
7
+ /**
8
+ * "5 minutes ago", "in 6 days", in the locale (the runtime's own when left out). `bare` drops the direction for tight
9
+ * spaces: "5 minutes".
10
+ */
11
+ export declare function formatRelative(date: Date, now?: Date, { locale, justNow, bare }?: {
12
+ locale?: string;
13
+ justNow?: string;
14
+ bare?: boolean;
15
+ }): string;
3
16
  /** The current time in milliseconds, updated every 30 seconds; null while rendering on the server. */
4
17
  export declare function useNow(): number | null;
5
- /** "3 minutes ago" / "in 6 days", kept fresh, with the exact time on hover. Shows the date until the clock is known. */
6
- declare function RelativeTime({ date, bare, className, }: {
18
+ /**
19
+ * "3 minutes ago" / "in 6 days", kept fresh, with the exact time on hover. Shows the date until the clock is known.
20
+ * `format` shows an absolute time instead ("5 Nov 2026, 14:03"), in the locale, with the same server-safe gate.
21
+ */
22
+ declare function RelativeTime({ date, bare, format, className, }: {
7
23
  /** When it happened. Shown as "5 minutes ago", with the exact time on hover. */
8
24
  date: Date | string;
9
25
  /** Drop "ago" and "in", for tight spaces: "5 minutes". */
10
26
  bare?: boolean;
27
+ /** "relative" (the default), or an absolute "date", "datetime" or "time" in the locale. */
28
+ format?: "relative" | DateStyle;
11
29
  className?: string;
12
30
  }): React.JSX.Element;
13
31
  export { RelativeTime };
@@ -1,6 +1,11 @@
1
1
  "use client";
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import * as React from "react";
4
+ import { useFormat, useStrings } from "./locale.js";
5
+ /** RelativeTime's fixed words; override them under `relativeTime` in LocaleProvider's `strings`. */
6
+ export const relativeTimeStrings = {
7
+ justNow: "just now",
8
+ };
4
9
  const units = [
5
10
  ["year", 31_536_000],
6
11
  ["month", 2_592_000],
@@ -10,14 +15,21 @@ const units = [
10
15
  ["minute", 60],
11
16
  ["second", 1],
12
17
  ];
13
- const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
14
- export function formatRelative(date, now = new Date()) {
18
+ /**
19
+ * "5 minutes ago", "in 6 days", in the locale (the runtime's own when left out). `bare` drops the direction for tight
20
+ * spaces: "5 minutes".
21
+ */
22
+ export function formatRelative(date, now = new Date(), { locale, justNow = relativeTimeStrings.justNow, bare = false } = {}) {
15
23
  const seconds = Math.round((date.getTime() - now.getTime()) / 1000);
16
24
  if (Math.abs(seconds) < 10)
17
- return "just now";
25
+ return justNow;
18
26
  for (const [unit, size] of units) {
19
- if (Math.abs(seconds) >= size || unit === "second")
20
- return rtf.format(Math.round(seconds / size), unit);
27
+ if (Math.abs(seconds) >= size || unit === "second") {
28
+ const n = Math.round(seconds / size);
29
+ return bare
30
+ ? new Intl.NumberFormat(locale, { style: "unit", unit, unitDisplay: "long" }).format(Math.abs(n))
31
+ : new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(n, unit);
32
+ }
21
33
  }
22
34
  return "";
23
35
  }
@@ -50,13 +62,20 @@ function subscribe(onChange) {
50
62
  export function useNow() {
51
63
  return React.useSyncExternalStore(subscribe, () => now || (now = Date.now()), () => null);
52
64
  }
53
- /** "3 minutes ago" / "in 6 days", kept fresh, with the exact time on hover. Shows the date until the clock is known. */
54
- function RelativeTime({ date, bare = false, className, }) {
65
+ /**
66
+ * "3 minutes ago" / "in 6 days", kept fresh, with the exact time on hover. Shows the date until the clock is known.
67
+ * `format` shows an absolute time instead ("5 Nov 2026, 14:03"), in the locale, with the same server-safe gate.
68
+ */
69
+ function RelativeTime({ date, bare = false, format = "relative", className, }) {
55
70
  const d = typeof date === "string" ? new Date(date) : date;
56
71
  const current = useNow();
57
- let text = current === null ? d.toISOString().slice(0, 10) : formatRelative(d, new Date(current));
58
- if (bare)
59
- text = text.replace(/ ago$/, "").replace(/^in /, "");
60
- return (_jsx("time", { dateTime: d.toISOString(), title: current === null ? undefined : d.toLocaleString(), className: className, children: text }));
72
+ const fmt = useFormat();
73
+ const t = useStrings("relativeTime", relativeTimeStrings);
74
+ const text = format !== "relative"
75
+ ? fmt.date(d, format)
76
+ : current === null
77
+ ? d.toISOString().slice(0, 10)
78
+ : formatRelative(d, new Date(current), { locale: fmt.tag, justNow: t("justNow"), bare });
79
+ return (_jsx("time", { dateTime: d.toISOString(), title: current === null ? undefined : fmt.date(d, "datetime"), className: className, children: text }));
61
80
  }
62
81
  export { RelativeTime };
@@ -1,6 +1,7 @@
1
+ "use client";
1
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
3
  import { cn } from "../lib/utils.js";
3
- import { statusLabel } from "../lib/status.js";
4
+ import { useStatusLabel } from "../lib/status.js";
4
5
  const dotColor = {
5
6
  idle: "bg-faint-foreground/60",
6
7
  queued: "bg-faint-foreground",
@@ -18,7 +19,8 @@ const dotColor = {
18
19
  * to assistive tech; pass `showLabel` to print it too.
19
20
  */
20
21
  function StatusDot({ status, showLabel = false, label, className, }) {
21
- const text = label ?? statusLabel[status];
22
+ const statusLabel = useStatusLabel();
23
+ const text = label ?? statusLabel(status);
22
24
  const live = status === "running" || status === "waiting";
23
25
  return (_jsxs("span", { "data-slot": "status-dot", "data-status": status, className: cn("inline-flex items-center gap-1.5 text-xs text-muted-foreground", className), children: [_jsx("span", { "aria-hidden": true, className: cn("size-2 shrink-0 rounded-full", dotColor[status], live && "animate-pulse-dot") }), showLabel ? _jsx("span", { children: text }) : _jsx("span", { className: "sr-only", children: text })] }));
24
26
  }
@@ -1,8 +1,9 @@
1
+ "use client";
1
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
3
  import * as React from "react";
3
4
  import { Check, CircleSlash, Hand, LoaderCircle, X } from "lucide-react";
4
5
  import { cn } from "../lib/utils.js";
5
- import { statusLabel } from "../lib/status.js";
6
+ import { useStatusLabel } from "../lib/status.js";
6
7
  function StepMark({ status, }) {
7
8
  const base = "relative z-10 flex size-5 shrink-0 items-center justify-center rounded-full";
8
9
  switch (status) {
@@ -28,10 +29,11 @@ function StepMark({ status, }) {
28
29
  * a run's stages. The rail fills as steps finish.
29
30
  */
30
31
  function StepList({ steps, className, }) {
32
+ const statusLabel = useStatusLabel();
31
33
  return (_jsx("ol", { "data-slot": "step-list", className: cn("flex flex-col", className), children: steps.map((step, i) => {
32
34
  const last = i === steps.length - 1;
33
35
  const done = step.status === "succeeded";
34
- return (_jsxs("li", { className: "relative flex gap-3 pb-4 last:pb-0 animate-rise-in", style: { animationDelay: `${i * 40}ms` }, children: [!last && (_jsx("span", { "aria-hidden": true, className: cn("absolute top-5 bottom-0 left-[9.5px] w-px transition-colors duration-500", done ? "bg-success/50" : "bg-input") })), _jsx(StepMark, { status: step.status }), _jsxs("div", { className: "min-w-0 flex-1 pt-px", children: [_jsxs("div", { className: "flex items-baseline justify-between gap-3", children: [_jsxs("p", { className: cn("text-sm font-medium", step.status === "idle" && "text-muted-foreground", (step.status === "skipped" || step.status === "cancelled") && "text-faint-foreground line-through"), children: [step.title, _jsxs("span", { className: "sr-only", children: [" \u2014 ", statusLabel[step.status]] })] }), step.meta && _jsx("span", { className: "shrink-0 text-xs tabular-nums text-faint-foreground", children: step.meta })] }), step.detail && _jsx("div", { className: "mt-1 text-[0.8125rem] text-muted-foreground", children: step.detail })] })] }, step.id));
36
+ return (_jsxs("li", { className: "relative flex gap-3 pb-4 last:pb-0 animate-rise-in", style: { animationDelay: `${i * 40}ms` }, children: [!last && (_jsx("span", { "aria-hidden": true, className: cn("absolute top-5 bottom-0 start-[9.5px] w-px transition-colors duration-500", done ? "bg-success/50" : "bg-input") })), _jsx(StepMark, { status: step.status }), _jsxs("div", { className: "min-w-0 flex-1 pt-px", children: [_jsxs("div", { className: "flex items-baseline justify-between gap-3", children: [_jsxs("p", { className: cn("text-sm font-medium", step.status === "idle" && "text-muted-foreground", (step.status === "skipped" || step.status === "cancelled") && "text-faint-foreground line-through"), children: [step.title, _jsxs("span", { className: "sr-only", children: [" \u2014 ", statusLabel(step.status)] })] }), step.meta && _jsx("span", { className: "shrink-0 text-xs tabular-nums text-faint-foreground", children: step.meta })] }), step.detail && _jsx("div", { className: "mt-1 text-[0.8125rem] text-muted-foreground", children: step.detail })] })] }, step.id));
35
37
  }) }));
36
38
  }
37
39
  export { StepList, StepMark };
@@ -5,7 +5,7 @@ import { cn } from "../lib/utils.js";
5
5
  * politely so screen readers hear the finished text rather than every token.
6
6
  */
7
7
  function StreamingText({ text, streaming = false, className, }) {
8
- return (_jsxs("div", { "data-slot": "streaming-text", "aria-live": "polite", "aria-busy": streaming, className: cn("whitespace-pre-wrap text-[0.9375rem] leading-relaxed text-foreground", className), children: [text, streaming && (_jsx("span", { "aria-hidden": true, className: "ml-0.5 inline-block h-[1.05em] w-[2px] translate-y-[0.18em] rounded-full bg-primary animate-caret" }))] }));
8
+ return (_jsxs("div", { "data-slot": "streaming-text", "aria-live": "polite", "aria-busy": streaming, className: cn("whitespace-pre-wrap text-[0.9375rem] leading-relaxed text-foreground", className), children: [text, streaming && (_jsx("span", { "aria-hidden": true, className: "ms-0.5 inline-block h-[1.05em] w-[2px] translate-y-[0.18em] rounded-full bg-primary animate-caret" }))] }));
9
9
  }
10
10
  /** A label that shimmers while an agent is working ("Thinking", "Reading the docs"). */
11
11
  function ShimmerLabel({ children, className, }) {
@@ -2,8 +2,8 @@ import * as React from "react";
2
2
  import { type VariantProps } from "class-variance-authority";
3
3
  declare const surfaceVariants: (props?: ({
4
4
  tone?: "plain" | "muted" | "subtle" | "raised" | "floating" | "accent" | null | undefined;
5
- radius?: "lg" | "md" | "xl" | null | undefined;
6
- pad?: "none" | "sm" | "lg" | "md" | null | undefined;
5
+ radius?: "md" | "lg" | "xl" | null | undefined;
6
+ pad?: "none" | "md" | "sm" | "lg" | null | undefined;
7
7
  } & import("class-variance-authority/types").ClassProp) | undefined) => string;
8
8
  declare function Surface({ className, tone, radius, pad, ...props }: React.ComponentProps<"div"> & VariantProps<typeof surfaceVariants>): React.JSX.Element;
9
9
  export { Surface, surfaceVariants };
@@ -1,6 +1,20 @@
1
1
  import * as React from "react";
2
2
  import { type LucideIcon } from "lucide-react";
3
3
  import type { AgentIdentity } from "../lib/chat.js";
4
+ /** AgentAvatar's fixed words; override them under `agentAvatar` in LocaleProvider's `strings`. */
5
+ export declare const agentAvatarStrings: {
6
+ assistant: string;
7
+ orchestrator: string;
8
+ planner: string;
9
+ researcher: string;
10
+ analyst: string;
11
+ designer: string;
12
+ coder: string;
13
+ reviewer: string;
14
+ tester: string;
15
+ /** Screen-reader text after the agent's name. */
16
+ roleAgent: string;
17
+ };
4
18
  export type AgentRoleInfo = {
5
19
  Icon: LucideIcon;
6
20
  noun: string;
@@ -3,6 +3,22 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import * as React from "react";
4
4
  import { Bot, ClipboardList, CodeXml, Compass, FlaskConical, PenTool, ScanSearch, SearchCheck, Workflow } from "lucide-react";
5
5
  import { cn } from "../lib/utils.js";
6
+ import { useStrings } from "../blocks/locale.js";
7
+ /** AgentAvatar's fixed words; override them under `agentAvatar` in LocaleProvider's `strings`. */
8
+ export const agentAvatarStrings = {
9
+ assistant: "Assistant",
10
+ orchestrator: "Orchestrator",
11
+ planner: "Planner",
12
+ researcher: "Researcher",
13
+ analyst: "Analyst",
14
+ designer: "Designer",
15
+ coder: "Coder",
16
+ reviewer: "Reviewer",
17
+ tester: "Tester",
18
+ /** Screen-reader text after the agent's name. */
19
+ roleAgent: ", {noun} agent",
20
+ };
21
+ const isBaseRole = (role) => role !== "roleAgent" && Object.hasOwn(agentAvatarStrings, role);
6
22
  // Each role keeps one glyph and one chart hue everywhere it appears, so a
7
23
  // person learns "the green flask is the tester" once.
8
24
  export const baseAgentRoles = {
@@ -27,7 +43,14 @@ function AgentRolesProvider({ roles, children, }) {
27
43
  /** Look up a role's glyph, noun and hue; unknown roles read as "Assistant". */
28
44
  function useAgentRoles() {
29
45
  const roles = React.useContext(RolesContext);
30
- return React.useCallback((role) => roles[role] ?? unknownRole, [roles]);
46
+ const t = useStrings("agentAvatar", agentAvatarStrings);
47
+ return React.useCallback((role) => {
48
+ const info = roles[role];
49
+ if (!info)
50
+ return { ...unknownRole, noun: t("assistant") };
51
+ // Built-in roles take their noun from the strings table; an app's own roles keep theirs.
52
+ return isBaseRole(role) && info === baseAgentRoles[role] ? { ...info, noun: t(role) } : info;
53
+ }, [roles, t]);
31
54
  }
32
55
  /** An agent's mark: its role glyph on a tile tinted with the role's hue. */
33
56
  function AgentAvatar({ agent, size = "md", className, }) {
@@ -37,6 +60,7 @@ function AgentAvatar({ agent, size = "md", className, }) {
37
60
  /** Avatar plus name, with the role spelled out for screen readers. */
38
61
  function AgentBadge({ agent, size = "md", className, }) {
39
62
  const { noun } = useAgentRoles()(agent.role);
40
- return (_jsxs("span", { "data-slot": "agent-badge", className: cn("inline-flex min-w-0 items-center gap-2", className), children: [_jsx(AgentAvatar, { agent: agent, size: size }), _jsx("span", { className: cn("truncate font-medium", size === "sm" ? "text-xs" : "text-sm"), children: agent.name }), _jsxs("span", { className: "sr-only", children: [", ", noun, " agent"] })] }));
63
+ const t = useStrings("agentAvatar", agentAvatarStrings);
64
+ return (_jsxs("span", { "data-slot": "agent-badge", className: cn("inline-flex min-w-0 items-center gap-2", className), children: [_jsx(AgentAvatar, { agent: agent, size: size }), _jsx("span", { className: cn("truncate font-medium", size === "sm" ? "text-xs" : "text-sm"), children: agent.name }), _jsx("span", { className: "sr-only", children: t("roleAgent", { noun }) })] }));
41
65
  }
42
66
  export { AgentAvatar, AgentBadge, AgentRolesProvider, useAgentRoles };
@@ -0,0 +1,53 @@
1
+ import * as React from "react";
2
+ import { type LucideIcon } from "lucide-react";
3
+ import { type Formatter, type Translate } from "../blocks/locale.js";
4
+ import type { Attachment } from "../lib/chat.js";
5
+ /** AttachmentPreview's fixed words; override them under `attachmentPreview` in LocaleProvider's `strings`. */
6
+ export declare const attachmentPreviewStrings: {
7
+ remove: string;
8
+ open: string;
9
+ attachments: {
10
+ one: string;
11
+ other: string;
12
+ };
13
+ /** File sizes; `{size}` is already formatted. */
14
+ bytes: string;
15
+ kilobytes: string;
16
+ megabytes: string;
17
+ };
18
+ /** "820 B", "14 KB", "2.4 MB", in the locale. */
19
+ export declare function formatFileSize(bytes: number | undefined, number: Formatter["number"], t: Translate<typeof attachmentPreviewStrings>): string | null;
20
+ /** An icon for a file, from its media type or, failing that, its extension. */
21
+ export declare function fileIcon(a: Pick<Attachment, "name" | "mediaType">): LucideIcon;
22
+ /**
23
+ * A file waiting to be sent, as a chip in the composer: its name, then upload progress, an error, or its size, and a
24
+ * remove button. Images show their preview when the app gives one.
25
+ */
26
+ declare function AttachmentChip({ attachment: a, onRemove, className, }: {
27
+ /** The file, with progress (0–1) while uploading or an error if it failed. */
28
+ attachment: Attachment;
29
+ /** Shows a remove button. */
30
+ onRemove?: (id: string) => void;
31
+ className?: string;
32
+ }): React.JSX.Element;
33
+ /**
34
+ * A sent file, as a tile in a message: an image preview when the app supplied one, else an icon for its type, with
35
+ * its name and size. The name is never a link: with `onOpen` the tile is a button and your app decides what opening
36
+ * means (a viewer, a signed download).
37
+ */
38
+ declare function AttachmentPreview({ attachment: a, onOpen, className, }: {
39
+ /** The file. `previewUrl` must be one your app serves (a thumbnail, a blob: URL); it's shown only for images. */
40
+ attachment: Attachment;
41
+ /** Makes the tile a button that opens the file. */
42
+ onOpen?: (attachment: Attachment) => void;
43
+ className?: string;
44
+ }): React.JSX.Element;
45
+ /** Several sent files, as a wrapping row of tiles, announced as a list. */
46
+ declare function AttachmentList({ attachments, onOpen, className, }: {
47
+ /** The files. */
48
+ attachments: Attachment[];
49
+ /** Makes each tile a button that opens its file. */
50
+ onOpen?: (attachment: Attachment) => void;
51
+ className?: string;
52
+ }): React.JSX.Element;
53
+ export { AttachmentChip, AttachmentPreview, AttachmentList };