@horon-ai/react 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) 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 +202 -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 +121 -0
  8. package/src/components/agent-steering-panel.tsx +274 -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 +365 -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 +191 -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 +402 -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 +35 -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 +156 -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 +224 -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/index.ts +86 -0
  89. package/src/lib/cn.ts +25 -0
  90. package/src/lib/cursor-ghost.ts +244 -0
  91. package/src/lib/size.ts +10 -0
  92. package/src/lib/use-active-indicator.ts +60 -0
  93. package/src/styles/globals.css +564 -0
@@ -0,0 +1,365 @@
1
+ import { CaretDown, Play } from "@phosphor-icons/react";
2
+ import { type ComponentProps, type KeyboardEvent, type ReactNode, useRef, useState } from "react";
3
+
4
+ import { cn } from "../lib/cn";
5
+ import { useActiveIndicator } from "../lib/use-active-indicator";
6
+ import { Kbd } from "./kbd";
7
+
8
+ type WithClass<T> = Omit<T, "className"> & { className?: string };
9
+
10
+ export type PanelPillTabsProps = {
11
+ items: { value: string; label: ReactNode }[];
12
+ value?: string;
13
+ defaultValue?: string;
14
+ onValueChange?: (value: string) => void;
15
+ "aria-label"?: string;
16
+ className?: string;
17
+ };
18
+
19
+ /**
20
+ * The 5% ink pill track shared by the Bot panels (Paper `YNQ-1`): 3px padding, 2px gap, 28px options with
21
+ * 12px inset and a 10% ink selection that slides between options in 280ms.
22
+ */
23
+ export function PanelPillTabs({ items, value, defaultValue, onValueChange, className, ...props }: PanelPillTabsProps) {
24
+ const [inner, setInner] = useState(defaultValue ?? items[0]?.value ?? "");
25
+ const selected = value ?? inner;
26
+ const track = useRef<HTMLDivElement>(null);
27
+ const { rect, animate } = useActiveIndicator(track, '[aria-selected="true"]');
28
+
29
+ const select = (next: string) => {
30
+ setInner(next);
31
+ onValueChange?.(next);
32
+ };
33
+ const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
34
+ if (event.key !== "ArrowRight" && event.key !== "ArrowLeft") return;
35
+ const at = items.findIndex((item) => item.value === selected);
36
+ const step = event.key === "ArrowRight" ? 1 : -1;
37
+ const next = items[(at + step + items.length) % items.length];
38
+ if (!next) return;
39
+ event.preventDefault();
40
+ select(next.value);
41
+ track.current?.querySelectorAll<HTMLElement>('[role="tab"]')[items.indexOf(next)]?.focus();
42
+ };
43
+
44
+ return (
45
+ <div
46
+ ref={track}
47
+ role="tablist"
48
+ aria-label={props["aria-label"]}
49
+ onKeyDown={onKeyDown}
50
+ className={cn(
51
+ "relative flex max-w-full min-w-0 items-center gap-0.5 overflow-x-auto rounded-full bg-overlay-ink-5 p-[3px]",
52
+ className,
53
+ )}
54
+ >
55
+ <span
56
+ aria-hidden
57
+ className={cn("pointer-events-none absolute rounded-full bg-overlay-ink-10", animate && "motion-indicator")}
58
+ style={rect ? { ...rect, opacity: 1 } : { opacity: 0 }}
59
+ />
60
+ {items.map((item) => {
61
+ const active = item.value === selected;
62
+ return (
63
+ <button
64
+ key={item.value}
65
+ type="button"
66
+ role="tab"
67
+ aria-selected={active}
68
+ tabIndex={active ? 0 : -1}
69
+ onClick={() => select(item.value)}
70
+ className={cn(
71
+ "relative flex h-7.5 cursor-pointer items-center rounded-full px-3 text-label-md whitespace-nowrap motion-colors",
72
+ "focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-text-brand",
73
+ active ? "text-text-primary" : "text-text-secondary hover:text-text-primary",
74
+ )}
75
+ >
76
+ {item.label}
77
+ </button>
78
+ );
79
+ })}
80
+ </div>
81
+ );
82
+ }
83
+
84
+ export type InstructionVariableTone = "sky" | "brand" | "neutral";
85
+
86
+ const variableToneClass: Record<InstructionVariableTone, string> = {
87
+ sky: "bg-accent-sky-500-18 ring-accent-sky-500-90/40",
88
+ brand: "bg-accent-sky-700-18 ring-accent-sky-700-90/40",
89
+ neutral: "bg-text-placeholder-18 ring-text-placeholder-90/40",
90
+ };
91
+
92
+ const variableDotClass: Record<InstructionVariableTone, string> = {
93
+ sky: "bg-sky-500",
94
+ brand: "bg-sky-700",
95
+ neutral: "bg-neutral-500",
96
+ };
97
+
98
+ /** One piece of an instruction line: plain text, or a `{{variable}}` chip. */
99
+ export type InstructionPart = string | { variable: string; tone?: InstructionVariableTone };
100
+
101
+ export type InstructionVariable = { name: string; tone?: InstructionVariableTone; value: string };
102
+
103
+ export type BotInstructionsPanelProps = WithClass<Omit<ComponentProps<"section">, "title">> & {
104
+ title?: ReactNode;
105
+ /** Version label in the header pill, for example `v4`. */
106
+ version?: string;
107
+ /** Status next to the version, rendered with a live dot. */
108
+ versionStatus?: string;
109
+ bot: { initial: string; name: string };
110
+ /** The editable prompt, one array of parts per line. */
111
+ instructions: InstructionPart[][];
112
+ /** Shown under the Example tab instead of the prompt. */
113
+ example?: ReactNode;
114
+ tokenCount?: number;
115
+ /** Test values for each variable in the prompt. */
116
+ variables?: InstructionVariable[];
117
+ scenarios?: string[];
118
+ defaultScenario?: string;
119
+ onRun?: (scenario: string | undefined) => void;
120
+ /** The last run's output, or a hint until there is one. */
121
+ output?: ReactNode;
122
+ /** Makes the version pill actionable; without it the pill is a plain label. */
123
+ onVersionClick?: () => void;
124
+ /** Makes the bot pill actionable; without it the pill is a plain label. */
125
+ onBotClick?: () => void;
126
+ };
127
+
128
+ function partKey(part: InstructionPart) {
129
+ return typeof part === "string" ? part : part.variable;
130
+ }
131
+
132
+ function VariableChip({
133
+ tone = "sky",
134
+ className,
135
+ children,
136
+ }: {
137
+ tone?: InstructionVariableTone;
138
+ className?: string;
139
+ children: ReactNode;
140
+ }) {
141
+ return (
142
+ <span className={cn("rounded-xs px-1.5 py-px ring-1 ring-inset", variableToneClass[tone], className)}>
143
+ {children}
144
+ </span>
145
+ );
146
+ }
147
+
148
+ function Divider() {
149
+ return <div role="separator" className="h-px self-stretch bg-overlay-ink-8" />;
150
+ }
151
+
152
+ /**
153
+ * Bot instructions panel (Paper `YN3-1` / `GOZ-0`): a 24px-radius surface with the bot header, an
154
+ * Instructions / Example tab pair over the prompt text with `{{variable}}` chips, and a test-run block.
155
+ */
156
+ export function BotInstructionsPanel({
157
+ title = "Instructions",
158
+ version,
159
+ versionStatus,
160
+ bot,
161
+ instructions,
162
+ example,
163
+ tokenCount,
164
+ variables,
165
+ scenarios,
166
+ defaultScenario,
167
+ onRun,
168
+ output,
169
+ onVersionClick,
170
+ onBotClick,
171
+ className,
172
+ ...props
173
+ }: BotInstructionsPanelProps) {
174
+ const [tab, setTab] = useState("instructions");
175
+ const [scenario, setScenario] = useState(defaultScenario ?? scenarios?.[0]);
176
+ const [runCount, setRunCount] = useState(0);
177
+
178
+ return (
179
+ <section
180
+ className={cn(
181
+ "flex w-full max-w-155 min-w-0 flex-col overflow-clip rounded-[24px] border border-border-default bg-bg-surface",
182
+ className,
183
+ )}
184
+ {...props}
185
+ >
186
+ <header className="flex flex-wrap items-center gap-2 px-5 pt-4 pb-3.5">
187
+ <h3 className="min-w-0 text-label-lg text-text-primary">{title}</h3>
188
+ {version ? (
189
+ onVersionClick ? (
190
+ <button
191
+ type="button"
192
+ onClick={onVersionClick}
193
+ className="flex h-7.5 shrink-0 motion-press cursor-pointer items-center gap-1.5 rounded-full bg-overlay-ink-6 px-2.5 text-label-sm text-text-primary hover:bg-overlay-ink-10 focus-visible:outline-2 focus-visible:outline-text-brand"
194
+ >
195
+ {version}
196
+ {versionStatus ? (
197
+ <>
198
+ <span
199
+ aria-hidden
200
+ className="size-1.5 animate-live-pulse rounded-full bg-status-live text-status-live"
201
+ />
202
+ <span className="text-text-secondary">{versionStatus}</span>
203
+ </>
204
+ ) : null}
205
+ <CaretDown aria-hidden className="size-3 text-text-secondary" />
206
+ </button>
207
+ ) : (
208
+ <span className="flex h-7.5 shrink-0 items-center gap-1.5 rounded-full bg-overlay-ink-6 px-2.5 text-label-sm text-text-primary">
209
+ {version}
210
+ {versionStatus ? (
211
+ <>
212
+ <span
213
+ aria-hidden
214
+ className="size-1.5 animate-live-pulse rounded-full bg-status-live text-status-live"
215
+ />
216
+ <span className="text-text-secondary">{versionStatus}</span>
217
+ </>
218
+ ) : null}
219
+ </span>
220
+ )
221
+ ) : null}
222
+ <span className="min-w-0 flex-1" />
223
+ {onBotClick ? (
224
+ <button
225
+ type="button"
226
+ onClick={onBotClick}
227
+ className="flex h-7.5 shrink-0 motion-press cursor-pointer items-center gap-1.5 rounded-full bg-overlay-ink-6 pr-2.5 pl-1.5 text-label-sm text-text-primary hover:bg-overlay-ink-10 focus-visible:outline-2 focus-visible:outline-text-brand"
228
+ >
229
+ <span className="flex size-4.5 items-center justify-center rounded-[5px] bg-interactive-primary text-label-sm text-text-inverse">
230
+ {bot.initial}
231
+ </span>
232
+ {bot.name}
233
+ <CaretDown aria-hidden className="size-3 text-text-secondary" />
234
+ </button>
235
+ ) : (
236
+ <span className="flex h-7.5 shrink-0 items-center gap-1.5 rounded-full bg-overlay-ink-6 pr-2.5 pl-1.5 text-label-sm text-text-primary">
237
+ <span className="flex size-4.5 items-center justify-center rounded-[5px] bg-interactive-primary text-label-sm text-text-inverse">
238
+ {bot.initial}
239
+ </span>
240
+ {bot.name}
241
+ </span>
242
+ )}
243
+ </header>
244
+ <Divider />
245
+ <div className="flex flex-col gap-1 px-5 pt-3 pb-3.5">
246
+ <div className="flex items-center gap-2 pb-2">
247
+ <PanelPillTabs
248
+ aria-label="Instructions view"
249
+ value={tab}
250
+ onValueChange={setTab}
251
+ items={[
252
+ { value: "instructions", label: "Instructions" },
253
+ { value: "example", label: "Example" },
254
+ ]}
255
+ />
256
+ <span className="min-w-0 flex-1" />
257
+ {tokenCount === undefined ? null : (
258
+ <span className="text-caption text-text-secondary">{tokenCount} tokens</span>
259
+ )}
260
+ </div>
261
+ <div
262
+ key={tab}
263
+ className="group flex motion-rise flex-col gap-1 rounded-lg outline-none focus-visible:outline-2 focus-visible:outline-text-brand"
264
+ tabIndex={0}
265
+ >
266
+ {tab === "example" && example !== undefined ? (
267
+ <div className="text-body-sm text-text-primary">{example}</div>
268
+ ) : (
269
+ instructions.map((line, index) => (
270
+ <p
271
+ key={line.map(partKey).join("|")}
272
+ className="flex flex-wrap items-center gap-1 text-body-sm text-text-primary"
273
+ >
274
+ {/* A lone period after a `{{variable}}` is its own flex item; pull it back
275
+ so the 4px gap between items does not open before the punctuation. */}
276
+ {line.map((part) =>
277
+ typeof part === "string" ? (
278
+ <span key={part} className={cn(/^[,.;:!?%)\]]/.test(part) && "-ml-1")}>
279
+ {part}
280
+ </span>
281
+ ) : (
282
+ <VariableChip key={part.variable} tone={part.tone}>
283
+ {part.variable}
284
+ </VariableChip>
285
+ ),
286
+ )}
287
+ {index === instructions.length - 1 ? (
288
+ <span
289
+ aria-hidden
290
+ className="hidden h-4 w-px animate-caret bg-text-primary group-focus-within:inline-block"
291
+ />
292
+ ) : null}
293
+ </p>
294
+ ))
295
+ )}
296
+ </div>
297
+ <div className="flex items-center gap-1.5 pt-2 text-caption text-text-secondary">
298
+ Type
299
+ <span className="rounded-md bg-overlay-ink-6 px-1.5 py-px">{"{{"}</span>
300
+ to add a variable
301
+ </div>
302
+ </div>
303
+ {variables || scenarios ? (
304
+ <>
305
+ <Divider />
306
+ <div className="flex flex-col gap-2.5 px-5 pt-3 pb-3.5">
307
+ {variables ? (
308
+ <div className="flex flex-wrap gap-2">
309
+ {variables.map((variable) => (
310
+ <div key={variable.name} className="flex min-w-0 flex-1 basis-35 flex-col gap-1.5">
311
+ <div className="flex items-center gap-1.5 text-caption text-text-secondary">
312
+ <span
313
+ aria-hidden
314
+ className={cn("size-1.5 rounded-full", variableDotClass[variable.tone ?? "sky"])}
315
+ />
316
+ {variable.name}
317
+ </div>
318
+ <div className="flex h-10 min-w-0 items-center rounded-md bg-overlay-ink-5 px-3 text-body-sm text-text-primary">
319
+ <span className="min-w-0 truncate">{variable.value}</span>
320
+ </div>
321
+ </div>
322
+ ))}
323
+ </div>
324
+ ) : null}
325
+ <div className="flex flex-wrap items-center gap-2">
326
+ {scenarios ? (
327
+ <PanelPillTabs
328
+ aria-label="Scenario"
329
+ value={scenario}
330
+ onValueChange={setScenario}
331
+ items={scenarios.map((name) => ({ value: name, label: name }))}
332
+ />
333
+ ) : null}
334
+ <span className="min-w-0 flex-1" />
335
+ <button
336
+ type="button"
337
+ onClick={() => {
338
+ setRunCount((count) => count + 1);
339
+ onRun?.(scenario);
340
+ }}
341
+ className="flex h-7.5 shrink-0 motion-press cursor-pointer items-center justify-center gap-1.5 rounded-full bg-interactive-primary pr-3 pl-3.5 text-label-sm text-text-inverse hover:bg-interactive-primary-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-text-brand"
342
+ >
343
+ <Play aria-hidden className="size-3.5" />
344
+ Run
345
+ <Kbd className="bg-bg-surface-6 text-text-inverse/80">⌘↵</Kbd>
346
+ </button>
347
+ </div>
348
+ </div>
349
+ </>
350
+ ) : null}
351
+ <div className="motion-reveal" data-collapsed={output === undefined || undefined}>
352
+ <div>
353
+ {output === undefined ? null : (
354
+ <div
355
+ key={runCount}
356
+ className="motion-rise bg-overlay-ink-3 px-5 pt-3.5 pb-4 text-body-sm text-text-secondary"
357
+ >
358
+ {output}
359
+ </div>
360
+ )}
361
+ </div>
362
+ </div>
363
+ </section>
364
+ );
365
+ }
@@ -0,0 +1,42 @@
1
+ import type { ComponentProps, ReactNode } from "react";
2
+
3
+ import { cn } from "../lib/cn";
4
+ import { Avatar } from "./avatar";
5
+
6
+ export type BotListRowProps = Omit<ComponentProps<"button">, "className"> & {
7
+ name: ReactNode;
8
+ /** Last activity time, such as "8:41 AM". */
9
+ time?: ReactNode;
10
+ /** One-line preview of the latest activity. */
11
+ preview?: ReactNode;
12
+ /** Running bots show the live dot on the avatar. */
13
+ live?: boolean;
14
+ selected?: boolean;
15
+ className?: string;
16
+ };
17
+
18
+ /** Figma `Bot list row` (64:456): a flat 12px-padded row; hover 5% ink, selected 10% ink. */
19
+ export function BotListRow({ name, time, preview, live, selected, className, ...props }: BotListRowProps) {
20
+ return (
21
+ <button
22
+ type="button"
23
+ aria-pressed={selected}
24
+ className={cn(
25
+ "flex w-full min-w-0 motion-press cursor-pointer items-center gap-3 rounded-lg p-3 text-left",
26
+ selected ? "bg-overlay-ink-10" : "hover:bg-overlay-ink-5",
27
+ "focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-text-brand",
28
+ className,
29
+ )}
30
+ {...props}
31
+ >
32
+ <Avatar type="bot" size="lg" live={live} />
33
+ <span className="flex min-w-0 flex-1 flex-col gap-0.5">
34
+ <span className="flex min-w-0 items-center gap-2">
35
+ <span className="min-w-0 flex-1 truncate text-label-lg text-text-primary">{name}</span>
36
+ {time && <span className="shrink-0 text-caption text-text-secondary">{time}</span>}
37
+ </span>
38
+ {preview && <span className="min-w-0 truncate text-body-sm text-text-secondary">{preview}</span>}
39
+ </span>
40
+ </button>
41
+ );
42
+ }
@@ -0,0 +1,229 @@
1
+ import { BookmarkSimple, MagnifyingGlass, Plus, X } from "@phosphor-icons/react";
2
+ import { type ComponentProps, type ReactNode, useState } from "react";
3
+
4
+ import { cn } from "../lib/cn";
5
+ import { PanelPillTabs } from "./bot-instructions-panel";
6
+ import { Switch } from "./switch";
7
+
8
+ type WithClass<T> = Omit<T, "className"> & { className?: string };
9
+
10
+ export type BotMemorySetting = {
11
+ id: string;
12
+ label: string;
13
+ description?: string;
14
+ defaultChecked?: boolean;
15
+ };
16
+
17
+ export type BotMemoryItem = {
18
+ id: string;
19
+ text: string;
20
+ /** Where the memory came from, for example `From a chat · Oct 8`. */
21
+ source: string;
22
+ /** Group label; the filter tabs are built from these in order of appearance. */
23
+ group: string;
24
+ /** Highlights a memory that was just added. */
25
+ fresh?: boolean;
26
+ };
27
+
28
+ export type BotMemoryToast = { title: string; message?: string };
29
+
30
+ export type BotMemoryPanelProps = WithClass<Omit<ComponentProps<"section">, "title">> & {
31
+ title?: ReactNode;
32
+ subtitle?: ReactNode;
33
+ /** Fill of the memory meter, 0 to 100. */
34
+ usage: number;
35
+ settings?: BotMemorySetting[];
36
+ memories: BotMemoryItem[];
37
+ /** The "Memory updated" pill; it enters with a pop and leaves through its close button. */
38
+ toast?: BotMemoryToast;
39
+ onToastDismiss?: () => void;
40
+ onAdd?: () => void;
41
+ onSettingChange?: (id: string, checked: boolean) => void;
42
+ /** Makes the search button actionable; without it the icon is a plain decoration. */
43
+ onSearch?: () => void;
44
+ };
45
+
46
+ function Divider({ inset }: { inset?: boolean }) {
47
+ return (
48
+ <div className={cn("flex self-stretch", inset && "px-5")}>
49
+ <div role="separator" className="h-px flex-1 bg-overlay-ink-8" />
50
+ </div>
51
+ );
52
+ }
53
+
54
+ /**
55
+ * Bot memory panel (Paper `ZO2-1` / `HPY-0`): a 24px-radius surface with a memory meter, two switch rows,
56
+ * a filter pill track, a transient "Memory updated" pill and the grouped memories.
57
+ */
58
+ export function BotMemoryPanel({
59
+ title = "Memory",
60
+ subtitle,
61
+ usage,
62
+ settings,
63
+ memories,
64
+ toast,
65
+ onToastDismiss,
66
+ onAdd,
67
+ onSettingChange,
68
+ onSearch,
69
+ className,
70
+ ...props
71
+ }: BotMemoryPanelProps) {
72
+ const [filter, setFilter] = useState("all");
73
+ // Both marks are tied to the toast object, so a new toast event revives the pill without an effect.
74
+ const [leavingFor, setLeavingFor] = useState<BotMemoryToast | undefined>();
75
+ const [dismissedFor, setDismissedFor] = useState<BotMemoryToast | undefined>();
76
+ const leaving = toast !== undefined && leavingFor === toast;
77
+ const dismissed = toast !== undefined && dismissedFor === toast;
78
+
79
+ const groups = [...new Set(memories.map((memory) => memory.group))];
80
+ const visible = filter === "all" ? groups : groups.filter((group) => group === filter);
81
+ const percent = Math.min(100, Math.max(0, usage));
82
+ let rowIndex = 0;
83
+
84
+ return (
85
+ <section
86
+ className={cn(
87
+ "flex w-full max-w-140 min-w-0 flex-col overflow-clip rounded-[24px] border border-border-default bg-bg-surface",
88
+ className,
89
+ )}
90
+ {...props}
91
+ >
92
+ <header className="flex flex-wrap items-center gap-x-2.5 gap-y-2 px-5 pt-4 pb-3.5">
93
+ <div className="flex min-w-0 flex-1 flex-col gap-0.5">
94
+ <h3 className="min-w-0 truncate text-label-lg text-text-primary">{title}</h3>
95
+ {subtitle ? <p className="min-w-0 truncate text-caption text-text-secondary">{subtitle}</p> : null}
96
+ </div>
97
+ <div className="flex flex-col items-end gap-1">
98
+ <span className="text-caption text-text-secondary">{percent}% full</span>
99
+ <div
100
+ role="progressbar"
101
+ aria-label="Memory used"
102
+ aria-valuemin={0}
103
+ aria-valuemax={100}
104
+ aria-valuenow={percent}
105
+ className="h-1.25 w-20 shrink-0 overflow-clip rounded-full bg-overlay-ink-8"
106
+ >
107
+ <div
108
+ className="h-full rounded-full bg-interactive-primary transition-[width] duration-280 ease-emphasized"
109
+ style={{ width: `${percent}%` }}
110
+ />
111
+ </div>
112
+ </div>
113
+ </header>
114
+ {settings?.map((setting, index) => (
115
+ <div key={setting.id} className="flex flex-col">
116
+ {index > 0 ? <Divider inset /> : null}
117
+ <div className="flex items-center gap-3 px-5 py-2.5">
118
+ <div className="flex min-w-0 flex-1 flex-col gap-0.5">
119
+ <span className="text-label-md text-text-primary">{setting.label}</span>
120
+ {setting.description ? (
121
+ <span className="text-caption text-text-secondary">{setting.description}</span>
122
+ ) : null}
123
+ </div>
124
+ <Switch
125
+ size="compact"
126
+ aria-label={setting.label}
127
+ defaultChecked={setting.defaultChecked}
128
+ onCheckedChange={(checked) => onSettingChange?.(setting.id, checked)}
129
+ />
130
+ </div>
131
+ </div>
132
+ ))}
133
+ <Divider />
134
+ <div className="flex items-center gap-2 px-5 pt-3 pb-1.5">
135
+ <PanelPillTabs
136
+ aria-label="Memory filter"
137
+ value={filter}
138
+ onValueChange={setFilter}
139
+ items={[{ value: "all", label: "All" }, ...groups.map((group) => ({ value: group, label: group }))]}
140
+ />
141
+ <span className="min-w-0 flex-1" />
142
+ {onSearch ? (
143
+ <button
144
+ type="button"
145
+ aria-label="Search memories"
146
+ onClick={onSearch}
147
+ className="flex size-7.5 shrink-0 motion-press cursor-pointer items-center justify-center rounded-full bg-overlay-ink-6 text-text-secondary hover:bg-overlay-ink-10 hover:text-text-primary focus-visible:outline-2 focus-visible:outline-text-brand"
148
+ >
149
+ <MagnifyingGlass aria-hidden className="size-4" />
150
+ </button>
151
+ ) : (
152
+ <span
153
+ aria-hidden
154
+ className="flex size-7.5 shrink-0 items-center justify-center rounded-full bg-overlay-ink-6 text-text-secondary"
155
+ >
156
+ <MagnifyingGlass className="size-4" />
157
+ </span>
158
+ )}
159
+ </div>
160
+ <div className="motion-reveal" data-collapsed={!toast || dismissed || undefined}>
161
+ <div className="px-5 py-1.5">
162
+ {toast && !dismissed ? (
163
+ <div
164
+ role="status"
165
+ onAnimationEnd={() => {
166
+ if (leaving && toast) {
167
+ setDismissedFor(toast);
168
+ onToastDismiss?.();
169
+ }
170
+ }}
171
+ className={cn(
172
+ "inline-flex h-8 max-w-full min-w-0 items-center gap-2 rounded-full bg-accent-sky-500-16 pr-3 pl-2.5",
173
+ leaving ? "animate-pop-out" : "animate-pop-in",
174
+ )}
175
+ >
176
+ <BookmarkSimple aria-hidden className="size-3.5 shrink-0 text-text-primary" />
177
+ <span className="min-w-0 truncate text-label-sm text-text-primary">{toast.title}</span>
178
+ {toast.message ? (
179
+ <span className="min-w-0 truncate text-caption text-text-secondary">{toast.message}</span>
180
+ ) : null}
181
+ <button
182
+ type="button"
183
+ aria-label="Dismiss"
184
+ onClick={() => setLeavingFor(toast)}
185
+ className="flex size-3 shrink-0 cursor-pointer items-center justify-center text-text-secondary motion-colors hover:text-text-primary focus-visible:outline-2 focus-visible:outline-text-brand"
186
+ >
187
+ <X aria-hidden className="size-3" />
188
+ </button>
189
+ </div>
190
+ ) : null}
191
+ </div>
192
+ </div>
193
+ <div key={filter} className="flex flex-col">
194
+ {visible.map((group) => {
195
+ const items = memories.filter((memory) => memory.group === group);
196
+ return (
197
+ <div key={group} className="flex flex-col">
198
+ <div className="flex items-center gap-1.5 px-5 pt-3 pb-1">
199
+ <span className="text-label-sm text-text-secondary">{group}</span>
200
+ <span className="text-caption text-text-secondary">{items.length}</span>
201
+ </div>
202
+ {items.map((memory) => (
203
+ <div
204
+ key={memory.id}
205
+ className={cn(
206
+ "flex motion-rise flex-col gap-0.5 px-5 py-2 motion-colors",
207
+ memory.fresh && "bg-accent-sky-500-10",
208
+ )}
209
+ style={{ animationDelay: `${rowIndex++ * 30}ms` }}
210
+ >
211
+ <span className="text-body-sm wrap-break-word text-text-primary">{memory.text}</span>
212
+ <span className="text-caption wrap-break-word text-text-secondary">{memory.source}</span>
213
+ </div>
214
+ ))}
215
+ </div>
216
+ );
217
+ })}
218
+ </div>
219
+ <button
220
+ type="button"
221
+ onClick={onAdd}
222
+ className="flex cursor-pointer items-center gap-2 px-5 pt-3 pb-4 text-label-md text-text-secondary motion-colors hover:text-text-primary focus-visible:outline-2 focus-visible:outline-text-brand"
223
+ >
224
+ <Plus aria-hidden className="size-3.5" />
225
+ Add memory
226
+ </button>
227
+ </section>
228
+ );
229
+ }