@horon-ai/react 0.0.0-stage → 0.1.1

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 (102) hide show
  1. package/README.md +168 -2
  2. package/package.json +50 -5
  3. package/src/assets/sidebar-photo.jpg +0 -0
  4. package/src/components/agent-activity.tsx +204 -0
  5. package/src/components/agent-lane.tsx +80 -0
  6. package/src/components/agent-lanes-timeline.tsx +312 -0
  7. package/src/components/agent-panel.tsx +125 -0
  8. package/src/components/agent-steering-panel.tsx +276 -0
  9. package/src/components/approval-card.tsx +89 -0
  10. package/src/components/avatar.tsx +104 -0
  11. package/src/components/badge.tsx +63 -0
  12. package/src/components/banner.tsx +103 -0
  13. package/src/components/bot-instructions-panel.tsx +369 -0
  14. package/src/components/bot-list-row.tsx +42 -0
  15. package/src/components/bot-memory-panel.tsx +229 -0
  16. package/src/components/bottom-sheet.tsx +140 -0
  17. package/src/components/breadcrumb-header.tsx +87 -0
  18. package/src/components/breadcrumb.tsx +86 -0
  19. package/src/components/button.tsx +146 -0
  20. package/src/components/calendar.tsx +410 -0
  21. package/src/components/card.tsx +79 -0
  22. package/src/components/chat-bubble.tsx +36 -0
  23. package/src/components/chat-controls.tsx +118 -0
  24. package/src/components/checkbox.tsx +73 -0
  25. package/src/components/chip.tsx +81 -0
  26. package/src/components/choice-card.tsx +55 -0
  27. package/src/components/code-panel.tsx +27 -0
  28. package/src/components/combobox.tsx +312 -0
  29. package/src/components/command-palette.tsx +206 -0
  30. package/src/components/composer.tsx +124 -0
  31. package/src/components/data-table.tsx +429 -0
  32. package/src/components/date-field.tsx +100 -0
  33. package/src/components/dialog.tsx +93 -0
  34. package/src/components/directory-card.tsx +59 -0
  35. package/src/components/document-extract-panel.tsx +398 -0
  36. package/src/components/empty-state.tsx +29 -0
  37. package/src/components/field.tsx +78 -0
  38. package/src/components/file-drop.tsx +89 -0
  39. package/src/components/filter-bar.tsx +83 -0
  40. package/src/components/flue-background.tsx +388 -0
  41. package/src/components/horon-icon.tsx +280 -0
  42. package/src/components/horon-wordmark.tsx +26 -0
  43. package/src/components/icon-button.tsx +61 -0
  44. package/src/components/input-group.tsx +112 -0
  45. package/src/components/input.tsx +72 -0
  46. package/src/components/integration-row.tsx +103 -0
  47. package/src/components/kbd.tsx +42 -0
  48. package/src/components/knowledge-base-panel.tsx +185 -0
  49. package/src/components/liquid-glass.tsx +24 -0
  50. package/src/components/list-detail.tsx +275 -0
  51. package/src/components/marketplace-card.tsx +97 -0
  52. package/src/components/mcp-server-row.tsx +122 -0
  53. package/src/components/menu.tsx +165 -0
  54. package/src/components/message-extras.tsx +146 -0
  55. package/src/components/modal.tsx +108 -0
  56. package/src/components/orchestrator-panel.tsx +175 -0
  57. package/src/components/page-header.tsx +48 -0
  58. package/src/components/plan-card.tsx +70 -0
  59. package/src/components/plan-step.tsx +111 -0
  60. package/src/components/plugin-row.tsx +128 -0
  61. package/src/components/progress.tsx +21 -0
  62. package/src/components/radio-group.tsx +84 -0
  63. package/src/components/research-run-panel.tsx +232 -0
  64. package/src/components/schedule.tsx +332 -0
  65. package/src/components/segmented-control.tsx +136 -0
  66. package/src/components/select.tsx +154 -0
  67. package/src/components/settings-list.tsx +99 -0
  68. package/src/components/sidebar.tsx +542 -0
  69. package/src/components/skeleton.tsx +75 -0
  70. package/src/components/slider.tsx +29 -0
  71. package/src/components/source-row.tsx +112 -0
  72. package/src/components/spinner.tsx +29 -0
  73. package/src/components/split-button.tsx +72 -0
  74. package/src/components/switch.tsx +79 -0
  75. package/src/components/tab-bar.tsx +90 -0
  76. package/src/components/tabs.tsx +162 -0
  77. package/src/components/task-row.tsx +159 -0
  78. package/src/components/textarea-field.tsx +127 -0
  79. package/src/components/textarea.tsx +47 -0
  80. package/src/components/toast.tsx +128 -0
  81. package/src/components/tool-call.tsx +215 -0
  82. package/src/components/tool-calls-header.tsx +65 -0
  83. package/src/components/tool-permissions.tsx +105 -0
  84. package/src/components/tooltip.tsx +33 -0
  85. package/src/components/top-bar.tsx +65 -0
  86. package/src/components/usage-meter.tsx +47 -0
  87. package/src/components/workspace-menu.tsx +77 -0
  88. package/src/hooks/index.ts +8 -0
  89. package/src/hooks/use-active-indicator.ts +60 -0
  90. package/src/hooks/use-color-scheme.ts +27 -0
  91. package/src/hooks/use-copy-to-clipboard.ts +32 -0
  92. package/src/hooks/use-device.ts +41 -0
  93. package/src/hooks/use-hotkey.ts +35 -0
  94. package/src/hooks/use-media-query.ts +25 -0
  95. package/src/hooks/use-platform.ts +39 -0
  96. package/src/hooks/use-reduced-motion.ts +8 -0
  97. package/src/index.ts +99 -0
  98. package/src/lib/cn.ts +25 -0
  99. package/src/lib/cursor-ghost.ts +244 -0
  100. package/src/lib/platform.ts +169 -0
  101. package/src/lib/size.ts +10 -0
  102. package/src/styles/globals.css +564 -0
@@ -0,0 +1,159 @@
1
+ import { Archive, ArrowClockwise, CheckCircle, Folder, Stop, WarningCircle } from "@phosphor-icons/react";
2
+ import type { ReactNode } from "react";
3
+
4
+ import { cn } from "../lib/cn";
5
+ import { Spinner } from "./spinner";
6
+
7
+ export type TaskStatus = "running" | "review" | "failed" | "done";
8
+
9
+ export type TaskRowProps = {
10
+ status: TaskStatus;
11
+ title: ReactNode;
12
+ /** Elapsed time, such as "1m 56s". */
13
+ elapsed?: ReactNode;
14
+ /** Bot initial and name. */
15
+ bot: { initial: string; name: ReactNode; tileClassName?: string };
16
+ project?: ReactNode;
17
+ /** Status line: the current step, the result or the error. */
18
+ message: ReactNode;
19
+ /** Caption after the message, such as "2 drafts · 1 calendar change". */
20
+ detail?: ReactNode;
21
+ onCancel?: () => void;
22
+ onRetry?: () => void;
23
+ onReview?: () => void;
24
+ onArchive?: () => void;
25
+ /** `card` is a standalone subtle row; `list` is transparent with a 1px divider, for stacking inside a list. */
26
+ variant?: "card" | "list";
27
+ className?: string;
28
+ };
29
+
30
+ const botTile: Record<TaskStatus, string> = {
31
+ running: "bg-interactive-primary text-interactive-on-primary",
32
+ done: "bg-interactive-primary text-interactive-on-primary",
33
+ review: "bg-sky-500 text-sky-50",
34
+ failed: "bg-sky-700 text-sky-50",
35
+ };
36
+
37
+ const pill =
38
+ "motion-press flex h-7.5 shrink-0 cursor-pointer items-center justify-center gap-1.5 rounded-full px-3 text-label-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-text-brand";
39
+
40
+ /** Figma `Task row` (Agent tasks 112:939): a flat subtle row, 16px radius, with Running / Review / Failed / Done. */
41
+ export function TaskRow({
42
+ status,
43
+ title,
44
+ elapsed,
45
+ bot,
46
+ project,
47
+ message,
48
+ detail,
49
+ onCancel,
50
+ onRetry,
51
+ onReview,
52
+ onArchive,
53
+ variant = "card",
54
+ className,
55
+ }: TaskRowProps) {
56
+ return (
57
+ <div
58
+ className={cn(
59
+ "flex flex-col gap-2 px-4 py-3.5",
60
+ variant === "list"
61
+ ? "relative after:absolute after:inset-x-4 after:bottom-0 after:h-px after:bg-overlay-ink-8 last:after:hidden"
62
+ : "rounded-2xl border border-border-default bg-bg-surface",
63
+ className,
64
+ )}
65
+ >
66
+ <div className="flex items-center gap-2">
67
+ <p className="min-w-0 flex-1 truncate text-label-lg text-text-primary">{title}</p>
68
+ {elapsed && <span className="shrink-0 text-caption text-text-secondary">{elapsed}</span>}
69
+ </div>
70
+ <div className="flex items-center gap-1.5 text-caption text-text-secondary">
71
+ <span
72
+ className={cn(
73
+ "flex size-4.5 shrink-0 items-center justify-center rounded-[5px] text-label-sm",
74
+ bot.tileClassName ? cn("text-text-inverse", bot.tileClassName) : botTile[status],
75
+ )}
76
+ >
77
+ <span className="text-trim">{bot.initial}</span>
78
+ </span>
79
+ <span className="whitespace-nowrap">{bot.name}</span>
80
+ {project && (
81
+ <>
82
+ <span aria-hidden>·</span>
83
+ <Folder aria-hidden className="size-3.5 shrink-0" />
84
+ <span className="truncate">{project}</span>
85
+ </>
86
+ )}
87
+ </div>
88
+ <div className="flex flex-wrap items-center gap-x-2 gap-y-1.5 pt-0.5">
89
+ <span className="flex size-4 shrink-0 items-center justify-center">
90
+ {status === "running" && <Spinner key="running" size={15} className="animate-pop-in" />}
91
+ {status === "review" && (
92
+ <CheckCircle key="review" aria-hidden className="size-4 animate-pop-in text-text-primary" />
93
+ )}
94
+ {status === "done" && (
95
+ <CheckCircle key="done" aria-hidden className="size-4 animate-pop-in text-text-secondary" />
96
+ )}
97
+ {status === "failed" && (
98
+ <WarningCircle key="failed" aria-hidden className="size-4 animate-pop-in text-text-danger" />
99
+ )}
100
+ </span>
101
+ <span
102
+ key={status}
103
+ className={cn(
104
+ "min-w-0 animate-fade-in truncate text-label-md motion-colors",
105
+ status === "failed" ? "text-text-danger" : status === "done" ? "text-text-secondary" : "text-text-primary",
106
+ )}
107
+ >
108
+ {message}
109
+ </span>
110
+ {detail && (
111
+ <>
112
+ <span aria-hidden>·</span>
113
+ <span className="min-w-0 truncate text-caption text-text-secondary">{detail}</span>
114
+ </>
115
+ )}
116
+ <span className="min-w-0 flex-1" />
117
+ {status === "running" && (
118
+ <button
119
+ type="button"
120
+ onClick={onCancel}
121
+ className={cn(pill, "animate-pop-in bg-overlay-ink-6 text-text-primary")}
122
+ >
123
+ <Stop aria-hidden className="size-3.5" />
124
+ Cancel
125
+ </button>
126
+ )}
127
+ {status === "failed" && (
128
+ <button
129
+ type="button"
130
+ onClick={onRetry}
131
+ className={cn(pill, "animate-pop-in bg-overlay-ink-6 text-text-primary")}
132
+ >
133
+ <ArrowClockwise aria-hidden className="size-3.5" />
134
+ Retry
135
+ </button>
136
+ )}
137
+ {status === "review" && (
138
+ <button
139
+ type="button"
140
+ onClick={onReview}
141
+ className={cn(pill, "animate-pop-in bg-interactive-primary text-text-inverse")}
142
+ >
143
+ Review
144
+ </button>
145
+ )}
146
+ {(status === "review" || status === "done") && (
147
+ <button
148
+ type="button"
149
+ aria-label="Archive"
150
+ onClick={onArchive}
151
+ className="flex size-7.5 shrink-0 motion-press animate-pop-in cursor-pointer items-center justify-center rounded-full text-text-secondary hover:bg-overlay-ink-6 focus-visible:outline-2 focus-visible:outline-text-brand"
152
+ >
153
+ <Archive aria-hidden className="size-4" />
154
+ </button>
155
+ )}
156
+ </div>
157
+ </div>
158
+ );
159
+ }
@@ -0,0 +1,127 @@
1
+ import { Field as BaseField } from "@base-ui/react/field";
2
+ import { useState, type ComponentProps, type ReactNode } from "react";
3
+
4
+ import { cn } from "../lib/cn";
5
+ import type { ControlSize } from "../lib/size";
6
+ import { fieldBoxClass, fieldTextClass, type InputSurface } from "./input";
7
+
8
+ export type TextareaFieldSize = ControlSize;
9
+
10
+ export type TextareaFieldProps = Omit<ComponentProps<"textarea">, "className"> & {
11
+ label: ReactNode;
12
+ /** Right side of the label row, such as "Optional". */
13
+ hint?: ReactNode;
14
+ description?: ReactNode;
15
+ /** Error message; replaces the description and turns the border and footer red. */
16
+ error?: ReactNode;
17
+ /** Shows a "count / max" counter when set. */
18
+ maxLength?: number;
19
+ size?: TextareaFieldSize;
20
+ /** Solid (default) or glass, as on `Input`. */
21
+ surface?: InputSurface;
22
+ className?: string;
23
+ };
24
+
25
+ const rootClass: Record<TextareaFieldSize, string> = {
26
+ sm: "gap-1.5",
27
+ md: "gap-2",
28
+ lg: "gap-2.5",
29
+ };
30
+
31
+ const labelClass: Record<TextareaFieldSize, string> = {
32
+ sm: "text-label-sm",
33
+ md: "text-label-md",
34
+ lg: "text-label-lg",
35
+ };
36
+
37
+ const metaClass: Record<TextareaFieldSize, string> = {
38
+ sm: "text-caption",
39
+ md: "text-caption",
40
+ lg: "text-body-sm",
41
+ };
42
+
43
+ /** The same box heights and padding as `Textarea` (88/112/136px, 12/14/20px), so one size reads the same in both. */
44
+ const boxClass: Record<TextareaFieldSize, string> = {
45
+ sm: "h-22 px-3 py-2",
46
+ md: "h-28 px-3.5 py-3",
47
+ lg: "h-34 px-5 py-3.5",
48
+ };
49
+
50
+ /** Figma `Textarea field` (60:466): label row, glass or solid box, footer with help text and counter. */
51
+ export function TextareaField({
52
+ label,
53
+ hint,
54
+ description,
55
+ error,
56
+ maxLength,
57
+ size = "md",
58
+ surface = "solid",
59
+ disabled,
60
+ className,
61
+ defaultValue,
62
+ value,
63
+ onChange,
64
+ ...props
65
+ }: TextareaFieldProps) {
66
+ const [count, setCount] = useState(() => String(value ?? defaultValue ?? "").length);
67
+ // A controlled value can change without an input event (form reset, a parent rewriting it),
68
+ // so the counter follows the value whenever the caller owns it.
69
+ const length = value !== undefined ? String(value).length : count;
70
+ const invalid = !!error;
71
+ return (
72
+ <BaseField.Root
73
+ disabled={disabled}
74
+ invalid={invalid}
75
+ className={cn("flex w-full max-w-90 flex-col items-start data-disabled:opacity-60", rootClass[size], className)}
76
+ >
77
+ <div className="flex w-full items-center">
78
+ <BaseField.Label className={cn("min-w-0 flex-1 text-text-primary", labelClass[size])}>{label}</BaseField.Label>
79
+ {hint && <span className={cn("shrink-0 text-text-secondary", metaClass[size])}>{hint}</span>}
80
+ </div>
81
+ <div
82
+ className={cn(
83
+ "relative flex w-full overflow-clip rounded-md border motion-colors",
84
+ boxClass[size],
85
+ fieldBoxClass[surface],
86
+ )}
87
+ >
88
+ <BaseField.Control
89
+ render={
90
+ <textarea
91
+ defaultValue={defaultValue}
92
+ value={value}
93
+ onChange={(event) => {
94
+ setCount(event.target.value.length);
95
+ onChange?.(event);
96
+ }}
97
+ {...props}
98
+ />
99
+ }
100
+ className={cn("relative h-full resize-none", fieldTextClass[size], fieldBoxClass.text)}
101
+ />
102
+ </div>
103
+ {(description || error || maxLength) && (
104
+ <div
105
+ className={cn(
106
+ "flex w-full items-center gap-2 motion-colors",
107
+ metaClass[size],
108
+ invalid ? "text-text-danger" : "text-text-secondary",
109
+ )}
110
+ >
111
+ {invalid ? (
112
+ <BaseField.Error match className="min-w-0 flex-1 animate-[tab-panel-in_160ms_var(--ease-out-quint)]">
113
+ {error}
114
+ </BaseField.Error>
115
+ ) : (
116
+ <BaseField.Description className="min-w-0 flex-1">{description}</BaseField.Description>
117
+ )}
118
+ {maxLength !== undefined && (
119
+ <span className="shrink-0 whitespace-nowrap">
120
+ {length} / {maxLength}
121
+ </span>
122
+ )}
123
+ </div>
124
+ )}
125
+ </BaseField.Root>
126
+ );
127
+ }
@@ -0,0 +1,47 @@
1
+ import { Field } from "@base-ui/react/field";
2
+ import type { ComponentProps } from "react";
3
+
4
+ import { cn } from "../lib/cn";
5
+ import type { ControlSize } from "../lib/size";
6
+ import { fieldBoxClass, fieldTextClass, type InputSurface } from "./input";
7
+
8
+ export type TextareaSize = ControlSize;
9
+
10
+ export type TextareaProps = Omit<ComponentProps<"textarea">, "className"> & {
11
+ size?: TextareaSize;
12
+ invalid?: boolean;
13
+ /** Solid (default) or glass, as on `Input`. */
14
+ surface?: InputSurface;
15
+ className?: string;
16
+ textareaClassName?: string;
17
+ };
18
+
19
+ const sizeClass: Record<TextareaSize, string> = {
20
+ sm: "min-h-22 px-3 py-2",
21
+ md: "min-h-28 px-3.5 py-3",
22
+ lg: "min-h-34 px-5 py-3.5",
23
+ };
24
+
25
+ /** Figma `Textarea` (58:137): an 88/112/136px glass or solid box with 12/14/20px padding, the same field rungs as `Input`. */
26
+ export function Textarea({
27
+ size = "md",
28
+ invalid,
29
+ surface = "solid",
30
+ className,
31
+ textareaClassName,
32
+ rows = 3,
33
+ ...props
34
+ }: TextareaProps) {
35
+ return (
36
+ <div
37
+ className={cn(fieldBoxClass.base, "items-stretch", sizeClass[size], fieldBoxClass[surface], className)}
38
+ data-invalid={invalid ? "" : undefined}
39
+ >
40
+ <Field.Control
41
+ render={<textarea rows={rows} {...props} />}
42
+ aria-invalid={invalid || undefined}
43
+ className={cn("relative resize-none", fieldTextClass[size], fieldBoxClass.text, textareaClassName)}
44
+ />
45
+ </div>
46
+ );
47
+ }
@@ -0,0 +1,128 @@
1
+ import { Toast as BaseToast } from "@base-ui/react/toast";
2
+ import { CheckCircle, Info, WarningCircle, X } from "@phosphor-icons/react";
3
+ import type { ReactNode } from "react";
4
+
5
+ import { cn } from "../lib/cn";
6
+ import type { ControlSize } from "../lib/size";
7
+
8
+ export type ToastTone = "neutral" | "success" | "danger";
9
+ export type ToastSize = ControlSize;
10
+
11
+ const toneIcon: Record<ToastTone, ReactNode> = {
12
+ neutral: <Info className="text-text-secondary" />,
13
+ success: <CheckCircle className="text-text-brand" />,
14
+ danger: <WarningCircle className="text-text-danger" />,
15
+ };
16
+
17
+ const sizeClass: Record<ToastSize, string> = {
18
+ sm: "w-full max-w-80 gap-2 px-3 py-2.5",
19
+ md: "w-full max-w-90 gap-3 px-4 py-3.5",
20
+ lg: "w-full max-w-100 gap-3.5 px-5 py-4",
21
+ };
22
+
23
+ const iconSizeClass: Record<ToastSize, string> = {
24
+ sm: "[&_svg]:size-4",
25
+ md: "[&_svg]:size-5",
26
+ lg: "[&_svg]:size-6",
27
+ };
28
+
29
+ const titleSizeClass: Record<ToastSize, string> = {
30
+ sm: "text-label-md",
31
+ md: "text-label-lg",
32
+ lg: "text-label-lg",
33
+ };
34
+
35
+ const descriptionSizeClass: Record<ToastSize, string> = {
36
+ sm: "text-body-sm",
37
+ md: "text-body-sm",
38
+ lg: "text-body-md",
39
+ };
40
+
41
+ const contentGapClass: Record<ToastSize, string> = {
42
+ sm: "gap-0.5",
43
+ md: "gap-0.5",
44
+ lg: "gap-1",
45
+ };
46
+
47
+ const closeSizeClass: Record<ToastSize, string> = {
48
+ sm: "[&_svg]:size-3.5",
49
+ md: "[&_svg]:size-4",
50
+ lg: "[&_svg]:size-5",
51
+ };
52
+
53
+ function isTone(value: string | undefined): value is ToastTone {
54
+ return value === "neutral" || value === "success" || value === "danger";
55
+ }
56
+
57
+ function ToastList({ size, surface }: { size: ToastSize; surface: "solid" | "glass" }) {
58
+ const { toasts } = BaseToast.useToastManager();
59
+ return toasts.map((toast) => {
60
+ const tone = isTone(toast.type) ? toast.type : "neutral";
61
+ return (
62
+ <BaseToast.Root
63
+ key={toast.id}
64
+ toast={toast}
65
+ className={cn(
66
+ "relative mt-3 flex h-[var(--toast-height)] items-start overflow-clip rounded-lg data-ending-style:mt-0 data-ending-style:h-0",
67
+ surface === "glass" ? "veil-overlay" : "border border-border-default bg-bg-surface shadow-popover",
68
+ sizeClass[size],
69
+ "transition-[height,margin-top,opacity,translate,scale] duration-240 ease-out-quint data-ending-style:translate-x-6 data-ending-style:opacity-0 data-ending-style:duration-180 data-ending-style:ease-in data-starting-style:translate-y-4 data-starting-style:scale-95 data-starting-style:opacity-0",
70
+ )}
71
+ >
72
+ <span className={cn("relative flex shrink-0", iconSizeClass[size])}>{toneIcon[tone]}</span>
73
+ <BaseToast.Content className={cn("relative flex min-w-0 flex-1 flex-col", contentGapClass[size])}>
74
+ <BaseToast.Title className={cn("text-text-primary", titleSizeClass[size])} />
75
+ <BaseToast.Description className={cn("text-text-secondary", descriptionSizeClass[size])} />
76
+ </BaseToast.Content>
77
+ <BaseToast.Close
78
+ aria-label="Close"
79
+ className={cn(
80
+ "relative flex shrink-0 motion-press cursor-pointer text-text-secondary motion-colors hover:text-text-primary",
81
+ "after:absolute after:-inset-2 after:content-['']",
82
+ closeSizeClass[size],
83
+ )}
84
+ >
85
+ <X />
86
+ </BaseToast.Close>
87
+ </BaseToast.Root>
88
+ );
89
+ });
90
+ }
91
+
92
+ export type ToasterProps = {
93
+ children?: ReactNode;
94
+ /** Toast size: `sm` 320px, `md` 360px (default), `lg` 400px; applies to every toast this provider shows. */
95
+ size?: ToastSize;
96
+ /**
97
+ * `solid` (default) is an opaque surface card with a default border; `glass` puts it on the veil (`veil-overlay`).
98
+ * Applies to every toast this provider shows.
99
+ */
100
+ surface?: "solid" | "glass";
101
+ className?: string;
102
+ };
103
+
104
+ /**
105
+ * Figma `Toast` (62:307): a 360px card, solid or the veil. Wrap the app once; call
106
+ * `useToast().add({ title, description, type: "success" })` to show one.
107
+ */
108
+ export function Toaster({ children, size = "md", surface = "solid", className }: ToasterProps) {
109
+ return (
110
+ <BaseToast.Provider>
111
+ {children}
112
+ <BaseToast.Portal>
113
+ <BaseToast.Viewport
114
+ className={cn(
115
+ // `inset-x-6` gives the stack the same 24px gutter on the left as on the
116
+ // right, so `w-full` toasts stop at the screen edge instead of running off it.
117
+ "fixed inset-x-6 bottom-6 z-50 flex flex-col items-end gap-0",
118
+ className,
119
+ )}
120
+ >
121
+ <ToastList size={size} surface={surface} />
122
+ </BaseToast.Viewport>
123
+ </BaseToast.Portal>
124
+ </BaseToast.Provider>
125
+ );
126
+ }
127
+
128
+ export const useToast = BaseToast.useToastManager;
@@ -0,0 +1,215 @@
1
+ import { Collapsible } from "@base-ui/react/collapsible";
2
+ import { ArrowClockwise, CaretDown, Check, CheckCircle, Copy, WarningCircle } from "@phosphor-icons/react";
3
+ import type { ReactNode } from "react";
4
+
5
+ import { useCopyToClipboard } from "../hooks/use-copy-to-clipboard";
6
+ import { cn } from "../lib/cn";
7
+ import { Button } from "./button";
8
+ import { Spinner } from "./spinner";
9
+
10
+ type Json = string | number | boolean | null | Json[] | { [key: string]: Json };
11
+
12
+ const punctuation = (text: string) => <span className="text-syntax-punctuation">{text}</span>;
13
+
14
+ function renderJson(value: Json, indent: number): ReactNode[] {
15
+ const pad = " ".repeat(indent);
16
+ if (value === null || typeof value === "number" || typeof value === "boolean") {
17
+ return [
18
+ <span key="v" className="text-syntax-number">
19
+ {String(value)}
20
+ </span>,
21
+ ];
22
+ }
23
+ if (typeof value === "string") return [<span key="v">{JSON.stringify(value)}</span>];
24
+ const entries = Array.isArray(value)
25
+ ? value.map((item, index) => [String(index), item] as const)
26
+ : Object.entries(value);
27
+ const [open, close] = Array.isArray(value) ? ["[", "]"] : ["{", "}"];
28
+ return [
29
+ <span key="open">{punctuation(open)}</span>,
30
+ ...entries.map(([key, item], index) => (
31
+ <span key={key}>
32
+ {"\n"}
33
+ {pad}
34
+ {" "}
35
+ {!Array.isArray(value) && (
36
+ <>
37
+ <span className="text-syntax-key">{JSON.stringify(key)}</span>
38
+ {punctuation(":")}{" "}
39
+ </>
40
+ )}
41
+ {renderJson(item, indent + 1)}
42
+ {index < entries.length - 1 && punctuation(",")}
43
+ </span>
44
+ )),
45
+ <span key="close">
46
+ {"\n"}
47
+ {pad}
48
+ {punctuation(close)}
49
+ </span>,
50
+ ];
51
+ }
52
+
53
+ /** JSON in Geist Mono code style with the Figma syntax colors (key, string, number, punctuation). */
54
+ export function JsonCode({ value, className }: { value: Json; className?: string }) {
55
+ return (
56
+ <pre
57
+ className={cn("max-w-full overflow-x-auto font-mono text-code whitespace-pre-wrap text-syntax-string", className)}
58
+ >
59
+ {renderJson(value, 0)}
60
+ </pre>
61
+ );
62
+ }
63
+
64
+ export type ToolCallStatus = "running" | "done" | "failed";
65
+
66
+ export type ToolCallProps = {
67
+ status: ToolCallStatus;
68
+ /** Plain tool name, such as "Web search", with its 16px icon. */
69
+ tool: { icon: ReactNode; name: ReactNode };
70
+ summary: ReactNode;
71
+ elapsed?: ReactNode;
72
+ args?: Json;
73
+ result?: Json;
74
+ error?: ReactNode;
75
+ onRetry?: () => void;
76
+ /** The result is cut off; shows a "Show all" link under it. */
77
+ truncated?: boolean;
78
+ onShowAll?: () => void;
79
+ defaultOpen?: boolean;
80
+ className?: string;
81
+ };
82
+
83
+ function Section({
84
+ label,
85
+ children,
86
+ copyText,
87
+ footer,
88
+ }: {
89
+ label: string;
90
+ children: ReactNode;
91
+ /** Plain text of the section body; adds a real copy button when present. */
92
+ copyText?: string;
93
+ footer?: ReactNode;
94
+ }) {
95
+ const { copied, copy } = useCopyToClipboard(1500);
96
+ return (
97
+ <div className="flex min-w-0 flex-col gap-1.5">
98
+ <div className="flex items-center gap-2 text-label-sm text-text-secondary">
99
+ <span className="flex-1 uppercase">{label}</span>
100
+ {copyText !== undefined && (
101
+ <button
102
+ type="button"
103
+ aria-label={copied ? "Copied" : `Copy ${label.toLowerCase()}`}
104
+ onClick={() => copy(copyText)}
105
+ className="flex motion-press cursor-pointer items-center justify-center rounded-sm hover:text-text-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-text-brand"
106
+ >
107
+ {copied ? (
108
+ <Check key="copied" aria-hidden className="size-3.5 animate-pop-in text-status-live" />
109
+ ) : (
110
+ <Copy key="copy" aria-hidden className="size-3.5 animate-pop-in" />
111
+ )}
112
+ </button>
113
+ )}
114
+ </div>
115
+ <div className="min-w-0 rounded-[10px] bg-bg-subtle px-3.5 py-3">{children}</div>
116
+ {footer}
117
+ </div>
118
+ );
119
+ }
120
+
121
+ /** Figma `Tool call` (Tool call card 186:7811): a solid 16px-radius card that expands to arguments and result. */
122
+ export function ToolCall({
123
+ status,
124
+ tool,
125
+ summary,
126
+ elapsed,
127
+ args,
128
+ result,
129
+ error,
130
+ onRetry,
131
+ truncated,
132
+ onShowAll,
133
+ defaultOpen,
134
+ className,
135
+ }: ToolCallProps) {
136
+ return (
137
+ <Collapsible.Root
138
+ defaultOpen={defaultOpen}
139
+ className={cn(
140
+ "flex w-full min-w-0 flex-col overflow-clip rounded-2xl border bg-bg-surface",
141
+ status === "failed" ? "border-border-danger" : "border-border-default",
142
+ className,
143
+ )}
144
+ >
145
+ <Collapsible.Trigger className="group flex w-full cursor-pointer items-center gap-2.5 px-4 py-3.5 text-left motion-colors hover:bg-overlay-ink-3 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-text-brand">
146
+ <span className="flex max-w-full min-w-0 shrink items-center gap-1.5 rounded-full bg-overlay-ink-6 py-[3px] pr-2.5 pl-2 text-label-md text-text-primary [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-text-secondary">
147
+ {tool.icon}
148
+ <span className="min-w-0 truncate">{tool.name}</span>
149
+ </span>
150
+ <span className="min-w-0 flex-1 truncate text-body-sm text-text-secondary">{summary}</span>
151
+ <span className="flex size-4 shrink-0 items-center justify-center">
152
+ {status === "running" && <Spinner key="running" size="sm" className="animate-pop-in" />}
153
+ {status === "done" && (
154
+ <CheckCircle key="done" aria-hidden className="size-4 animate-pop-in text-status-live" />
155
+ )}
156
+ {status === "failed" && (
157
+ <WarningCircle key="failed" aria-hidden className="size-4 animate-pop-in text-text-danger" />
158
+ )}
159
+ </span>
160
+ {status === "failed" && <span className="shrink-0 text-label-sm text-text-danger">Failed</span>}
161
+ {elapsed && <span className="shrink-0 text-caption text-text-secondary">{elapsed}</span>}
162
+ <CaretDown
163
+ aria-hidden
164
+ className="size-4 shrink-0 text-text-secondary transition-transform duration-200 ease-out-quint group-data-panel-open:rotate-180"
165
+ />
166
+ </Collapsible.Trigger>
167
+ <Collapsible.Panel className="motion-collapse">
168
+ <div className="flex min-w-0 flex-col gap-3.5 px-4 pb-4">
169
+ {status === "failed" && error && (
170
+ <div className="flex items-center gap-2.5 rounded-[10px] bg-bg-danger px-3.5 py-3">
171
+ <WarningCircle aria-hidden className="size-4 shrink-0 text-text-danger" />
172
+ <p className="min-w-0 flex-1 text-body-sm text-text-danger">{error}</p>
173
+ {onRetry && (
174
+ <button
175
+ type="button"
176
+ onClick={onRetry}
177
+ className="flex h-7.5 shrink-0 motion-press cursor-pointer items-center gap-1.5 rounded-full border border-border-default bg-bg-surface px-2.5 text-label-sm text-text-primary"
178
+ >
179
+ <ArrowClockwise aria-hidden className="size-3.5" />
180
+ Retry
181
+ </button>
182
+ )}
183
+ </div>
184
+ )}
185
+ {args !== undefined && (
186
+ <Section label="Arguments" copyText={JSON.stringify(args, null, 2)}>
187
+ <JsonCode value={args} />
188
+ </Section>
189
+ )}
190
+ {status !== "failed" && (
191
+ <Section
192
+ label="Result"
193
+ copyText={typeof result === "string" ? result : JSON.stringify(result, null, 2)}
194
+ footer={
195
+ truncated && result !== undefined && status !== "running" ? (
196
+ <Button variant="link" className="self-start px-0" onClick={onShowAll}>
197
+ Show all
198
+ </Button>
199
+ ) : undefined
200
+ }
201
+ >
202
+ {status === "running" || result === undefined ? (
203
+ <p className="text-body-sm text-text-secondary">Waiting for result…</p>
204
+ ) : (
205
+ <JsonCode value={result} />
206
+ )}
207
+ </Section>
208
+ )}
209
+ </div>
210
+ </Collapsible.Panel>
211
+ </Collapsible.Root>
212
+ );
213
+ }
214
+
215
+ export type { Json };