@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,146 @@
1
+ import { Copy, FileText, X } from "@phosphor-icons/react";
2
+ import type { ComponentProps, ReactNode } from "react";
3
+
4
+ import { useCopyToClipboard } from "../hooks/use-copy-to-clipboard";
5
+ import { cn } from "../lib/cn";
6
+ import { IconButton } from "./icon-button";
7
+ import { LiquidGlass } from "./liquid-glass";
8
+
9
+ /** Figma `Date separator` (Message extras 109:667): caption between two 10% ink lines. */
10
+ export function DateSeparator({ children, className }: { children: ReactNode; className?: string }) {
11
+ return (
12
+ <div role="separator" className={cn("flex items-center gap-3", className)}>
13
+ <span className="h-px flex-1 bg-overlay-ink-10" />
14
+ <span className="shrink-0 text-caption text-text-secondary">{children}</span>
15
+ <span className="h-px flex-1 bg-overlay-ink-10" />
16
+ </div>
17
+ );
18
+ }
19
+
20
+ export type MessageAction = { label: string; icon: ReactNode; onClick?: () => void };
21
+
22
+ /** Figma `Message actions` (109:667): a pill of 30px icon buttons with 16px icons, solid or regular glass. */
23
+ export function MessageActions({
24
+ actions,
25
+ surface = "solid",
26
+ className,
27
+ }: {
28
+ actions: MessageAction[];
29
+ /** `solid` (default) is an opaque surface pill with a default border; `glass` is the regular glass pill. */
30
+ surface?: "solid" | "glass";
31
+ className?: string;
32
+ }) {
33
+ return (
34
+ <div
35
+ role="toolbar"
36
+ className={cn(
37
+ "relative flex w-fit items-center gap-0.5 overflow-clip rounded-full p-[3px]",
38
+ surface === "glass" ? "bg-glass-regular" : "border border-border-default bg-bg-surface",
39
+ className,
40
+ )}
41
+ >
42
+ {surface === "glass" && <LiquidGlass />}
43
+ {actions.map((action) => (
44
+ <button
45
+ key={action.label}
46
+ type="button"
47
+ aria-label={action.label}
48
+ onClick={action.onClick}
49
+ className="relative flex size-7.5 motion-press cursor-pointer items-center justify-center rounded-full text-text-secondary motion-colors hover:bg-overlay-ink-6 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-text-brand [&>svg]:size-4"
50
+ >
51
+ {action.icon}
52
+ </button>
53
+ ))}
54
+ </div>
55
+ );
56
+ }
57
+
58
+ export type AttachmentProps = {
59
+ name: ReactNode;
60
+ /** Caption, such as "PDF · 2.4 MB". */
61
+ detail?: ReactNode;
62
+ /** Image thumbnail URL; files show a document icon on a sky tile. */
63
+ thumbnail?: string;
64
+ onRemove?: () => void;
65
+ removeLabel?: string;
66
+ className?: string;
67
+ };
68
+
69
+ /** Figma `Attachment` (109:667): a flat subtle card with a 40px thumb, name, detail and remove button. */
70
+ export function Attachment({ name, detail, thumbnail, onRemove, removeLabel = "Remove", className }: AttachmentProps) {
71
+ return (
72
+ <div
73
+ className={cn(
74
+ "flex w-full max-w-70 min-w-0 motion-rise items-center gap-2.5 rounded-lg bg-bg-subtle py-2 pr-2 pl-2",
75
+ className,
76
+ )}
77
+ >
78
+ {thumbnail ? (
79
+ <span
80
+ aria-hidden
81
+ className="size-10 shrink-0 rounded-[10px] bg-cover bg-center"
82
+ style={{ backgroundImage: `url(${JSON.stringify(thumbnail)})` }}
83
+ />
84
+ ) : (
85
+ <span className="flex size-10 shrink-0 items-center justify-center rounded-[10px] bg-accent-sky-500-16 text-text-brand">
86
+ <FileText aria-hidden className="size-5" />
87
+ </span>
88
+ )}
89
+ <span className="flex min-w-0 flex-1 flex-col gap-px">
90
+ <span className="truncate text-label-md text-text-primary">{name}</span>
91
+ {detail && <span className="truncate text-caption text-text-secondary">{detail}</span>}
92
+ </span>
93
+ {onRemove && (
94
+ <IconButton
95
+ size="sm"
96
+ icon={<X aria-hidden />}
97
+ label={removeLabel}
98
+ onClick={onRemove}
99
+ className="text-text-secondary"
100
+ />
101
+ )}
102
+ </div>
103
+ );
104
+ }
105
+
106
+ export type CodeBlockProps = Omit<ComponentProps<"div">, "className" | "children"> & {
107
+ code: string;
108
+ language?: string;
109
+ copyLabel?: string;
110
+ copiedLabel?: string;
111
+ className?: string;
112
+ };
113
+
114
+ /** Figma `Code block` (109:667): ink surface, 14px radius, 6% header with copy, Geist Mono 13/20 code. */
115
+ export function CodeBlock({
116
+ code,
117
+ language,
118
+ copyLabel = "Copy",
119
+ copiedLabel = "Copied",
120
+ className,
121
+ ...props
122
+ }: CodeBlockProps) {
123
+ const { copied, copy } = useCopyToClipboard(1500);
124
+ return (
125
+ <div className={cn("flex flex-col overflow-clip rounded-lg bg-surface-ink", className)} {...props}>
126
+ <div className="flex items-center gap-2 bg-bg-surface-6 py-2 pr-3.5 pl-3.5 text-caption text-text-on-ink">
127
+ <span className="min-w-0 flex-1 opacity-70">{language}</span>
128
+ <button
129
+ type="button"
130
+ onClick={() => copy(code)}
131
+ className="flex shrink-0 motion-press cursor-pointer items-center gap-1 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-text-brand"
132
+ >
133
+ <Copy aria-hidden className="size-3.5 shrink-0" />
134
+ <span className="min-w-10 text-left">
135
+ <span key={copied ? "copied" : "copy"} className="inline-block animate-fade-in">
136
+ {copied ? copiedLabel : copyLabel}
137
+ </span>
138
+ </span>
139
+ </button>
140
+ </div>
141
+ <pre className="overflow-x-auto px-3.5 pt-3 pb-3.5 font-mono text-code text-text-on-ink">
142
+ <code>{code}</code>
143
+ </pre>
144
+ </div>
145
+ );
146
+ }
@@ -0,0 +1,108 @@
1
+ import { Dialog as BaseDialog } from "@base-ui/react/dialog";
2
+ import { X } from "@phosphor-icons/react";
3
+ import type { ComponentProps, ReactNode } from "react";
4
+
5
+ import { cn } from "../lib/cn";
6
+
7
+ export const Modal = BaseDialog.Root;
8
+ export const ModalTrigger = BaseDialog.Trigger;
9
+ export const ModalClose = BaseDialog.Close;
10
+
11
+ export type ModalSize = "sm" | "md" | "lg";
12
+ export type ModalSurface = "solid" | "glass";
13
+
14
+ type DivProps = Omit<ComponentProps<"div">, "className"> & { className?: string };
15
+
16
+ export type ModalContentProps = Omit<ComponentProps<typeof BaseDialog.Popup>, "className" | "title"> & {
17
+ title: ReactNode;
18
+ description?: ReactNode;
19
+ /** Width: `sm` 400px, `md` 560px (default), `lg` 720px. Never wider than the viewport minus 32px. */
20
+ size?: ModalSize;
21
+ /** `solid` (default) is the opaque page fill with a 0 24 64 shadow; `glass` is the veil (`veil-overlay`). */
22
+ surface?: ModalSurface;
23
+ /** Tile or icon before the title. */
24
+ leading?: ReactNode;
25
+ /** Label for the close button; hides it when `null`. */
26
+ closeLabel?: string | null;
27
+ /** `ModalFooter`, kept below the scrolling body. */
28
+ footer?: ReactNode;
29
+ className?: string;
30
+ };
31
+
32
+ const widthClass: Record<ModalSize, string> = {
33
+ sm: "w-full max-w-100",
34
+ md: "w-full max-w-140",
35
+ lg: "w-full max-w-180",
36
+ };
37
+
38
+ /**
39
+ * A modal for work that needs a form or a longer read, where `Dialog` is a short confirmation. It has three
40
+ * fixed parts: a header (title, description and close), a body that scrolls on its own once the modal reaches
41
+ * the viewport height, and a footer whose actions sit at the right. 24px padding, 20px radius, 32% scrim.
42
+ */
43
+ export function ModalContent({
44
+ title,
45
+ description,
46
+ size = "md",
47
+ surface = "solid",
48
+ leading,
49
+ closeLabel = "Close",
50
+ footer,
51
+ className,
52
+ children,
53
+ ...props
54
+ }: ModalContentProps) {
55
+ return (
56
+ <BaseDialog.Portal>
57
+ <BaseDialog.Backdrop className="fixed inset-0 z-50 bg-bg-scrim/32 motion-fade backdrop-blur-[6px]" />
58
+ <BaseDialog.Viewport className="fixed inset-0 z-50 flex items-center justify-center p-4">
59
+ <BaseDialog.Popup
60
+ className={cn(
61
+ // The 16px viewport padding caps `w-full`, so the popup never exceeds
62
+ // the screen even on the narrowest phone.
63
+ "relative flex max-h-[calc(100dvh-2rem)] flex-col overflow-clip rounded-xl outline-none",
64
+ widthClass[size],
65
+ surface === "solid" ? "bg-bg-surface shadow-[0_24px_64px_#0A142433]" : "veil-overlay",
66
+ "motion-popup duration-240 data-starting-style:translate-y-2",
67
+ className,
68
+ )}
69
+ {...props}
70
+ >
71
+ <header className={cn("flex shrink-0 items-start px-6 pt-6 pb-4", leading ? "gap-3.5" : "gap-2")}>
72
+ {leading}
73
+ <div className="flex min-w-0 flex-1 flex-col gap-1">
74
+ <BaseDialog.Title className="text-title-md text-text-primary">{title}</BaseDialog.Title>
75
+ {description && (
76
+ <BaseDialog.Description className="text-body-md text-text-secondary">
77
+ {description}
78
+ </BaseDialog.Description>
79
+ )}
80
+ </div>
81
+ {closeLabel !== null && (
82
+ <BaseDialog.Close
83
+ aria-label={closeLabel}
84
+ className="-mt-1 -mr-2 flex size-8 shrink-0 motion-press cursor-pointer items-center justify-center rounded-full text-text-secondary motion-colors hover:bg-overlay-ink-6 hover:text-text-primary focus-visible:outline-2 focus-visible:outline-text-brand [&_svg]:size-4"
85
+ >
86
+ <X />
87
+ </BaseDialog.Close>
88
+ )}
89
+ </header>
90
+ <div
91
+ className={cn(
92
+ "min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 text-body-md text-text-primary",
93
+ footer ? "pb-2" : "pb-6",
94
+ )}
95
+ >
96
+ {children}
97
+ </div>
98
+ {footer}
99
+ </BaseDialog.Popup>
100
+ </BaseDialog.Viewport>
101
+ </BaseDialog.Portal>
102
+ );
103
+ }
104
+
105
+ /** The modal's action row: actions at the right, secondary first and primary last, 8px apart, 24px padding. */
106
+ export function ModalFooter({ className, ...props }: DivProps) {
107
+ return <div className={cn("flex shrink-0 items-center justify-end gap-2 px-6 pt-4 pb-6", className)} {...props} />;
108
+ }
@@ -0,0 +1,175 @@
1
+ import { Stop } from "@phosphor-icons/react";
2
+ import { Children, isValidElement, type CSSProperties, type ReactNode } from "react";
3
+
4
+ import { cn } from "../lib/cn";
5
+ import { SegmentedControl } from "./segmented-control";
6
+ import { Spinner } from "./spinner";
7
+
8
+ export type OrchestratorLead = {
9
+ /** One letter shown in the 22px ink tile. */
10
+ initial: string;
11
+ name: ReactNode;
12
+ /** Role caption next to the name, such as "Lead". */
13
+ role?: ReactNode;
14
+ /** Status text, such as "1 working". */
15
+ status?: ReactNode;
16
+ /** Shows the 13px spinner while the lead waits on its lanes. */
17
+ running?: boolean;
18
+ elapsed?: ReactNode;
19
+ };
20
+
21
+ export type OrchestratorPlanItem = {
22
+ label: string;
23
+ /** Dot color of the lane that runs this step, such as "bg-sky-600". Defaults to the live blue. */
24
+ dotClassName?: string;
25
+ /** Pulses the dot while the lane works. */
26
+ running?: boolean;
27
+ };
28
+
29
+ export type OrchestratorSummary = {
30
+ title?: ReactNode;
31
+ /** Right-aligned count, such as "1 of 3 returned". */
32
+ count?: ReactNode;
33
+ body: ReactNode;
34
+ };
35
+
36
+ export type OrchestratorPanelProps = {
37
+ title: ReactNode;
38
+ /** Label before the segmented picker, such as "Bots in parallel". */
39
+ parallelLabel?: ReactNode;
40
+ /** Choices of the parallel picker. */
41
+ parallelOptions?: number[];
42
+ parallelValue?: number;
43
+ onParallelChange?: (value: number) => void;
44
+ stopLabel?: ReactNode;
45
+ /** Renders the Stop button when set. */
46
+ onStop?: () => void;
47
+ lead: OrchestratorLead;
48
+ plan?: OrchestratorPlanItem[];
49
+ /** `AgentLane` cards; the branch connector draws one elbow per lane. */
50
+ children?: ReactNode;
51
+ summary?: OrchestratorSummary;
52
+ className?: string;
53
+ };
54
+
55
+ const stagger = (index: number) => ({ "--i": index, animationDelay: `calc(var(--i) * 30ms)` }) as CSSProperties;
56
+
57
+ /** Figma `Orchestrator` (Agents N1): a 24px-radius solid panel, header with a parallel picker and Stop, a lead row, its plan, branching lanes and a summary. */
58
+ export function OrchestratorPanel({
59
+ title,
60
+ parallelLabel = "Bots in parallel",
61
+ parallelOptions,
62
+ parallelValue,
63
+ onParallelChange,
64
+ stopLabel = "Stop",
65
+ onStop,
66
+ lead,
67
+ plan,
68
+ children,
69
+ summary,
70
+ className,
71
+ }: OrchestratorPanelProps) {
72
+ const lanes = Children.toArray(children);
73
+ return (
74
+ <section
75
+ className={cn(
76
+ "flex w-full min-w-0 flex-col overflow-clip rounded-[24px] border border-border-default bg-bg-surface",
77
+ className,
78
+ )}
79
+ >
80
+ <header className="flex flex-col gap-3 px-5 pt-4 pb-3.5">
81
+ <h3 className="text-label-lg text-text-primary">{title}</h3>
82
+ <div className="flex flex-wrap items-center gap-2.5">
83
+ <span className="text-body-sm text-text-secondary">{parallelLabel}</span>
84
+ {parallelOptions && (
85
+ <SegmentedControl
86
+ aria-label={typeof parallelLabel === "string" ? parallelLabel : undefined}
87
+ size="sm"
88
+ surface="inset"
89
+ value={parallelValue === undefined ? undefined : String(parallelValue)}
90
+ onValueChange={(next) => onParallelChange?.(Number(next))}
91
+ items={parallelOptions.map((option) => ({ value: String(option), label: String(option) }))}
92
+ />
93
+ )}
94
+ <span className="min-w-0 flex-1" />
95
+ {onStop && (
96
+ <button
97
+ type="button"
98
+ onClick={onStop}
99
+ className="flex h-7.5 shrink-0 motion-press cursor-pointer items-center justify-center gap-1.5 rounded-full bg-overlay-ink-6 px-3 text-label-sm text-text-primary hover:bg-overlay-ink-10 focus-visible:outline-2 focus-visible:outline-text-brand"
100
+ >
101
+ <Stop aria-hidden className="size-3.5" />
102
+ {stopLabel}
103
+ </button>
104
+ )}
105
+ </div>
106
+ </header>
107
+ <div role="separator" className="h-px bg-overlay-ink-8" />
108
+ <div className="flex flex-col gap-2.5 px-5 pt-4 pb-5">
109
+ <div className="flex flex-wrap items-center gap-2">
110
+ <span className="flex size-5.5 shrink-0 items-center justify-center rounded-[5px] bg-interactive-primary text-label-sm text-text-inverse">
111
+ {lead.initial}
112
+ </span>
113
+ <span className="min-w-0 truncate text-label-lg text-text-primary">{lead.name}</span>
114
+ <span className="min-w-0 flex-1 truncate text-body-sm text-text-secondary">{lead.role ?? "Lead"}</span>
115
+ {lead.running && <Spinner size={13} />}
116
+ {lead.status && <span className="shrink-0 text-caption text-text-secondary">{lead.status}</span>}
117
+ {lead.elapsed && <span className="shrink-0 text-caption text-text-secondary">{lead.elapsed}</span>}
118
+ </div>
119
+ {plan && plan.length > 0 && (
120
+ <ul className="flex flex-col gap-1.5 pt-0.5 pb-1 pl-7.5">
121
+ {plan.map((item, index) => (
122
+ <li key={item.label} className="flex min-w-0 motion-rise items-center gap-2.5" style={stagger(index)}>
123
+ <span
124
+ aria-hidden
125
+ className={cn("shrink-0 rounded-full text-sky-400", item.running && "animate-live-pulse")}
126
+ >
127
+ <span className={cn("block size-1.5 rounded-full", item.dotClassName ?? "bg-status-live")} />
128
+ </span>
129
+ <span className="min-w-0 text-body-sm wrap-break-word text-text-primary">{item.label}</span>
130
+ </li>
131
+ ))}
132
+ </ul>
133
+ )}
134
+ {lanes.length > 0 && (
135
+ <div className="flex items-start">
136
+ <div className="flex min-w-0 flex-1 flex-col gap-2.5 pl-7.5">
137
+ {lanes.map((lane, index) => (
138
+ <div
139
+ key={isValidElement(lane) && lane.key != null ? lane.key : `lane-${index}`}
140
+ className="relative motion-rise"
141
+ style={stagger(index)}
142
+ >
143
+ <span
144
+ aria-hidden
145
+ className="absolute top-0 -left-7.5 h-5.5 w-7.5 rounded-bl-lg border-b-[1.5px] border-l-[1.5px] border-overlay-ink-14"
146
+ />
147
+ {index < lanes.length - 1 && (
148
+ <span
149
+ aria-hidden
150
+ className="absolute top-5.5 -bottom-2.5 -left-7.5 border-l-[1.5px] border-overlay-ink-14"
151
+ />
152
+ )}
153
+ {lane}
154
+ </div>
155
+ ))}
156
+ </div>
157
+ </div>
158
+ )}
159
+ <div className="motion-reveal" data-collapsed={!summary || undefined}>
160
+ <div>
161
+ {summary && (
162
+ <div className="flex flex-col gap-1 rounded-2xl bg-overlay-ink-4 px-4 py-3" style={stagger(lanes.length)}>
163
+ <div className="flex items-center gap-2">
164
+ <span className="min-w-0 flex-1 text-label-md text-text-primary">{summary.title ?? "Summary"}</span>
165
+ {summary.count && <span className="shrink-0 text-caption text-text-secondary">{summary.count}</span>}
166
+ </div>
167
+ <p className="text-body-sm text-text-secondary">{summary.body}</p>
168
+ </div>
169
+ )}
170
+ </div>
171
+ </div>
172
+ </div>
173
+ </section>
174
+ );
175
+ }
@@ -0,0 +1,48 @@
1
+ import { CaretLeft } from "@phosphor-icons/react";
2
+ import type { ReactNode } from "react";
3
+
4
+ import { cn } from "../lib/cn";
5
+ import { IconButton } from "./icon-button";
6
+ import { LiquidGlass } from "./liquid-glass";
7
+
8
+ export type PageHeaderProps = {
9
+ title: ReactNode;
10
+ onBack?: () => void;
11
+ backLabel?: string;
12
+ /** Trailing 40px icon buttons, such as search. */
13
+ actions?: ReactNode;
14
+ /** Content under the top row, such as a `SegmentedControl` filter. */
15
+ children?: ReactNode;
16
+ /** `solid` (default) is an opaque surface header with a default border; `glass` is the regular glass header. */
17
+ surface?: "solid" | "glass";
18
+ className?: string;
19
+ };
20
+
21
+ /** Figma `Header with filter` (Segmented filters 88:723): a 28px-radius header, 8px padding, solid or regular glass. */
22
+ export function PageHeader({
23
+ title,
24
+ onBack,
25
+ backLabel = "Back",
26
+ actions,
27
+ children,
28
+ surface = "solid",
29
+ className,
30
+ }: PageHeaderProps) {
31
+ return (
32
+ <header
33
+ className={cn(
34
+ "relative flex max-w-full min-w-0 flex-col gap-1.5 overflow-clip rounded-[28px] p-2",
35
+ surface === "glass" ? "bg-glass-regular" : "border border-border-default bg-bg-surface",
36
+ className,
37
+ )}
38
+ >
39
+ {surface === "glass" && <LiquidGlass />}
40
+ <div className="relative flex h-10 min-w-0 items-center gap-2">
41
+ {onBack && <IconButton icon={<CaretLeft />} label={backLabel} onClick={onBack} />}
42
+ <h1 className={cn("min-w-0 flex-1 truncate text-label-lg text-text-primary", !onBack && "pl-2")}>{title}</h1>
43
+ {actions}
44
+ </div>
45
+ {children && <div className="relative animate-swap-move">{children}</div>}
46
+ </header>
47
+ );
48
+ }
@@ -0,0 +1,70 @@
1
+ import { Collapsible } from "@base-ui/react/collapsible";
2
+ import { ListChecks, WarningCircle } from "@phosphor-icons/react";
3
+ import type { ReactNode } from "react";
4
+
5
+ import { cn } from "../lib/cn";
6
+ import { Spinner } from "./spinner";
7
+
8
+ export type PlanCardStatus = "running" | "failed";
9
+
10
+ export type PlanCardProps = {
11
+ /** Label line next to the 16px list icon, such as "Plan · 5 steps". */
12
+ label?: ReactNode;
13
+ title: ReactNode;
14
+ /** `PlanStep`s, stacked without dividers. */
15
+ children?: ReactNode;
16
+ /** Footer state. Unset hides the footer; a change expands or collapses it. */
17
+ status?: PlanCardStatus;
18
+ /** Footer text, such as "Stopped · step 4 failed" or "Running · 1 of 5". */
19
+ statusLabel?: ReactNode;
20
+ elapsed?: ReactNode;
21
+ className?: string;
22
+ };
23
+
24
+ /** Figma `Plan card` (Agents N3): a 20px-radius solid card with a label, title, the steps and a status footer. */
25
+ export function PlanCard({ label, title, children, status, statusLabel, elapsed, className }: PlanCardProps) {
26
+ return (
27
+ <section
28
+ className={cn(
29
+ "flex w-full min-w-0 flex-col overflow-clip rounded-xl border border-border-default bg-bg-surface",
30
+ className,
31
+ )}
32
+ >
33
+ <header className="flex flex-col gap-2 px-6 pt-5 pb-3.5">
34
+ {label && (
35
+ <div className="flex min-w-0 items-center gap-2 text-body-sm text-text-secondary">
36
+ <ListChecks aria-hidden className="size-4 shrink-0" />
37
+ <span className="min-w-0 wrap-break-word">{label}</span>
38
+ </div>
39
+ )}
40
+ <h3 className="min-w-0 text-label-lg font-semibold wrap-break-word text-text-primary">{title}</h3>
41
+ </header>
42
+ <div className="flex flex-col px-6 pt-1.5 pb-1">{children}</div>
43
+ <Collapsible.Root open={status !== undefined}>
44
+ <Collapsible.Panel keepMounted className="motion-collapse">
45
+ <div className="h-px bg-overlay-ink-8" />
46
+ <div
47
+ className={cn(
48
+ "flex min-w-0 items-center gap-2 px-6 pt-3.5 pb-4 text-label-md motion-colors",
49
+ status === "failed" ? "text-text-danger" : "text-text-primary",
50
+ )}
51
+ >
52
+ {status === "running" && <Spinner size={14} />}
53
+ {status === "failed" && <WarningCircle aria-hidden className="size-4 shrink-0" />}
54
+ <span key={String(status)} className="min-w-0 flex-1 animate-fade-in wrap-break-word">
55
+ {statusLabel}
56
+ </span>
57
+ {elapsed && (
58
+ <span
59
+ key={elapsed ? "with-elapsed" : "without-elapsed"}
60
+ className="shrink-0 animate-fade-in text-caption text-text-secondary"
61
+ >
62
+ {elapsed}
63
+ </span>
64
+ )}
65
+ </div>
66
+ </Collapsible.Panel>
67
+ </Collapsible.Root>
68
+ </section>
69
+ );
70
+ }
@@ -0,0 +1,111 @@
1
+ import { ArrowClockwise, Check } 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 PlanStepStatus = "done" | "running" | "failed" | "pending";
8
+
9
+ export type PlanStepProps = {
10
+ status: PlanStepStatus;
11
+ /** Step number, shown on pending steps. */
12
+ index: number;
13
+ title: ReactNode;
14
+ description?: ReactNode;
15
+ /** Tool id in mono, such as "stripe.query". */
16
+ tool?: ReactNode;
17
+ elapsed?: ReactNode;
18
+ error?: ReactNode;
19
+ onRetry?: () => void;
20
+ /** Hides the rail line under the last step. */
21
+ last?: boolean;
22
+ className?: string;
23
+ };
24
+
25
+ /** Figma `Plan step` (Agent plan 191:8296): a 24px marker on a 2px rail, sky when done. */
26
+ export function PlanStep({
27
+ status,
28
+ index,
29
+ title,
30
+ description,
31
+ tool,
32
+ elapsed,
33
+ error,
34
+ onRetry,
35
+ last,
36
+ className,
37
+ }: PlanStepProps) {
38
+ return (
39
+ <div className={cn("flex min-w-0 items-stretch gap-3.5", className)}>
40
+ <div className="flex shrink-0 flex-col items-center gap-1">
41
+ <span
42
+ className={cn(
43
+ "flex size-6 shrink-0 items-center justify-center rounded-md text-label-sm motion-colors",
44
+ status === "done" && "bg-status-live text-interactive-on-primary",
45
+ status === "failed" && "bg-bg-danger text-text-danger",
46
+ status === "pending" && "border border-border-default text-text-secondary",
47
+ status === "running" && "bg-bg-subtle",
48
+ )}
49
+ >
50
+ <span key={status} className="flex animate-pop-in items-center justify-center">
51
+ {status === "done" && <Check aria-hidden className="size-3.5" />}
52
+ {status === "running" && <Spinner size={20} />}
53
+ {status === "failed" && "!"}
54
+ {status === "pending" && index}
55
+ </span>
56
+ </span>
57
+ {!last && (
58
+ <span
59
+ className={cn(
60
+ "w-0.5 flex-1 rounded-[1px] motion-colors",
61
+ status === "done" ? "bg-status-live" : "bg-border-default",
62
+ )}
63
+ />
64
+ )}
65
+ </div>
66
+ <div className="flex min-w-0 flex-1 flex-col pt-0.5 pb-4.5">
67
+ <div className="flex items-start gap-3">
68
+ <p
69
+ className={cn(
70
+ "min-w-0 flex-1 text-label-lg wrap-break-word",
71
+ status === "pending" ? "text-text-secondary" : "text-text-primary",
72
+ )}
73
+ >
74
+ {title}
75
+ </p>
76
+ {elapsed && (status === "done" || status === "running") && (
77
+ <span className="shrink-0 text-caption text-text-secondary">{elapsed}</span>
78
+ )}
79
+ </div>
80
+ <div className="mt-1.5 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1.5">
81
+ {description && (
82
+ <span className="min-w-0 text-body-sm wrap-break-word text-text-secondary">{description}</span>
83
+ )}
84
+ {tool && (
85
+ <span className="rounded-xs bg-bg-subtle px-2 py-0.5 font-mono text-label-md text-text-secondary">
86
+ {tool}
87
+ </span>
88
+ )}
89
+ </div>
90
+ <div className="motion-reveal" data-collapsed={status !== "failed" || undefined}>
91
+ <div>
92
+ <div className="mt-1.5 flex flex-col gap-1.5">
93
+ <p className="text-body-sm wrap-break-word text-text-danger">{error}</p>
94
+ {onRetry && (
95
+ <button
96
+ type="button"
97
+ onClick={onRetry}
98
+ inert={status !== "failed"}
99
+ className="flex h-7.5 w-fit motion-press cursor-pointer items-center gap-1.5 rounded-full bg-overlay-ink-6 px-3 text-label-sm text-text-primary hover:bg-overlay-ink-10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-text-brand"
100
+ >
101
+ <ArrowClockwise aria-hidden className="size-3.5" />
102
+ Retry from here
103
+ </button>
104
+ )}
105
+ </div>
106
+ </div>
107
+ </div>
108
+ </div>
109
+ </div>
110
+ );
111
+ }