@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.
- package/README.md +168 -2
- package/package.json +50 -5
- package/src/assets/sidebar-photo.jpg +0 -0
- package/src/components/agent-activity.tsx +204 -0
- package/src/components/agent-lane.tsx +80 -0
- package/src/components/agent-lanes-timeline.tsx +312 -0
- package/src/components/agent-panel.tsx +125 -0
- package/src/components/agent-steering-panel.tsx +276 -0
- package/src/components/approval-card.tsx +89 -0
- package/src/components/avatar.tsx +104 -0
- package/src/components/badge.tsx +63 -0
- package/src/components/banner.tsx +103 -0
- package/src/components/bot-instructions-panel.tsx +369 -0
- package/src/components/bot-list-row.tsx +42 -0
- package/src/components/bot-memory-panel.tsx +229 -0
- package/src/components/bottom-sheet.tsx +140 -0
- package/src/components/breadcrumb-header.tsx +87 -0
- package/src/components/breadcrumb.tsx +86 -0
- package/src/components/button.tsx +146 -0
- package/src/components/calendar.tsx +410 -0
- package/src/components/card.tsx +79 -0
- package/src/components/chat-bubble.tsx +36 -0
- package/src/components/chat-controls.tsx +118 -0
- package/src/components/checkbox.tsx +73 -0
- package/src/components/chip.tsx +81 -0
- package/src/components/choice-card.tsx +55 -0
- package/src/components/code-panel.tsx +27 -0
- package/src/components/combobox.tsx +312 -0
- package/src/components/command-palette.tsx +206 -0
- package/src/components/composer.tsx +124 -0
- package/src/components/data-table.tsx +429 -0
- package/src/components/date-field.tsx +100 -0
- package/src/components/dialog.tsx +93 -0
- package/src/components/directory-card.tsx +59 -0
- package/src/components/document-extract-panel.tsx +398 -0
- package/src/components/empty-state.tsx +29 -0
- package/src/components/field.tsx +78 -0
- package/src/components/file-drop.tsx +89 -0
- package/src/components/filter-bar.tsx +83 -0
- package/src/components/flue-background.tsx +388 -0
- package/src/components/horon-icon.tsx +280 -0
- package/src/components/horon-wordmark.tsx +26 -0
- package/src/components/icon-button.tsx +61 -0
- package/src/components/input-group.tsx +112 -0
- package/src/components/input.tsx +72 -0
- package/src/components/integration-row.tsx +103 -0
- package/src/components/kbd.tsx +42 -0
- package/src/components/knowledge-base-panel.tsx +185 -0
- package/src/components/liquid-glass.tsx +24 -0
- package/src/components/list-detail.tsx +275 -0
- package/src/components/marketplace-card.tsx +97 -0
- package/src/components/mcp-server-row.tsx +122 -0
- package/src/components/menu.tsx +165 -0
- package/src/components/message-extras.tsx +146 -0
- package/src/components/modal.tsx +108 -0
- package/src/components/orchestrator-panel.tsx +175 -0
- package/src/components/page-header.tsx +48 -0
- package/src/components/plan-card.tsx +70 -0
- package/src/components/plan-step.tsx +111 -0
- package/src/components/plugin-row.tsx +128 -0
- package/src/components/progress.tsx +21 -0
- package/src/components/radio-group.tsx +84 -0
- package/src/components/research-run-panel.tsx +232 -0
- package/src/components/schedule.tsx +332 -0
- package/src/components/segmented-control.tsx +136 -0
- package/src/components/select.tsx +154 -0
- package/src/components/settings-list.tsx +99 -0
- package/src/components/sidebar.tsx +542 -0
- package/src/components/skeleton.tsx +75 -0
- package/src/components/slider.tsx +29 -0
- package/src/components/source-row.tsx +112 -0
- package/src/components/spinner.tsx +29 -0
- package/src/components/split-button.tsx +72 -0
- package/src/components/switch.tsx +79 -0
- package/src/components/tab-bar.tsx +90 -0
- package/src/components/tabs.tsx +162 -0
- package/src/components/task-row.tsx +159 -0
- package/src/components/textarea-field.tsx +127 -0
- package/src/components/textarea.tsx +47 -0
- package/src/components/toast.tsx +128 -0
- package/src/components/tool-call.tsx +215 -0
- package/src/components/tool-calls-header.tsx +65 -0
- package/src/components/tool-permissions.tsx +105 -0
- package/src/components/tooltip.tsx +33 -0
- package/src/components/top-bar.tsx +65 -0
- package/src/components/usage-meter.tsx +47 -0
- package/src/components/workspace-menu.tsx +77 -0
- package/src/hooks/index.ts +8 -0
- package/src/hooks/use-active-indicator.ts +60 -0
- package/src/hooks/use-color-scheme.ts +27 -0
- package/src/hooks/use-copy-to-clipboard.ts +32 -0
- package/src/hooks/use-device.ts +41 -0
- package/src/hooks/use-hotkey.ts +35 -0
- package/src/hooks/use-media-query.ts +25 -0
- package/src/hooks/use-platform.ts +39 -0
- package/src/hooks/use-reduced-motion.ts +8 -0
- package/src/index.ts +99 -0
- package/src/lib/cn.ts +25 -0
- package/src/lib/cursor-ghost.ts +244 -0
- package/src/lib/platform.ts +169 -0
- package/src/lib/size.ts +10 -0
- 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 };
|