banhaten 0.4.0 → 0.6.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 (214) hide show
  1. package/CHANGELOG.md +78 -1
  2. package/CONTRIBUTING.md +6 -4
  3. package/README.md +4 -2
  4. package/docs/design-spacing.md +13 -0
  5. package/docs/design-system/README.md +2 -0
  6. package/docs/design-system/appearances/default.md +1 -1
  7. package/docs/design-system/appearances/rounded.md +1 -1
  8. package/docs/design-system/appearances/sharp.md +1 -1
  9. package/docs/design-system/component-showcase-consistency-report.md +5 -5
  10. package/docs/design-system/components/README.md +15 -0
  11. package/docs/design-system/components/accordion.md +1 -1
  12. package/docs/design-system/components/activity-feed.md +1 -1
  13. package/docs/design-system/components/activity-heatmap.md +1 -1
  14. package/docs/design-system/components/agent-input.md +85 -0
  15. package/docs/design-system/components/alert-dialog.md +1 -1
  16. package/docs/design-system/components/alert.md +10 -4
  17. package/docs/design-system/components/app-header.md +97 -0
  18. package/docs/design-system/components/approval-card.md +134 -0
  19. package/docs/design-system/components/attribute.md +14 -2
  20. package/docs/design-system/components/avatar.md +1 -1
  21. package/docs/design-system/components/badge.md +1 -1
  22. package/docs/design-system/components/banner.md +1 -1
  23. package/docs/design-system/components/breadcrumbs.md +8 -3
  24. package/docs/design-system/components/button-group.md +1 -1
  25. package/docs/design-system/components/button.md +8 -5
  26. package/docs/design-system/components/card.md +15 -1
  27. package/docs/design-system/components/catalog-components.md +1 -1
  28. package/docs/design-system/components/chart.md +95 -24
  29. package/docs/design-system/components/checkbox.md +1 -1
  30. package/docs/design-system/components/collapsible.md +20 -6
  31. package/docs/design-system/components/command.md +1 -1
  32. package/docs/design-system/components/context-menu.md +1 -1
  33. package/docs/design-system/components/date-picker.md +1 -1
  34. package/docs/design-system/components/file-upload.md +1 -1
  35. package/docs/design-system/components/hover-card.md +1 -1
  36. package/docs/design-system/components/inline-citations.md +93 -0
  37. package/docs/design-system/components/input-group.md +1 -1
  38. package/docs/design-system/components/input.md +59 -3
  39. package/docs/design-system/components/item.md +1 -1
  40. package/docs/design-system/components/kbd.md +6 -4
  41. package/docs/design-system/components/menu.md +35 -4
  42. package/docs/design-system/components/menubar.md +1 -1
  43. package/docs/design-system/components/modal.md +2 -2
  44. package/docs/design-system/components/native-select.md +1 -1
  45. package/docs/design-system/components/navigation-menu.md +1 -1
  46. package/docs/design-system/components/onboarding-step-list-item.md +1 -1
  47. package/docs/design-system/components/page-header.md +58 -17
  48. package/docs/design-system/components/pagination.md +2 -2
  49. package/docs/design-system/components/popover.md +1 -1
  50. package/docs/design-system/components/progress-slider.md +1 -1
  51. package/docs/design-system/components/radio-card.md +1 -1
  52. package/docs/design-system/components/radio-group.md +1 -1
  53. package/docs/design-system/components/resizable.md +1 -1
  54. package/docs/design-system/components/rolling-count.md +61 -0
  55. package/docs/design-system/components/scroll-area.md +1 -1
  56. package/docs/design-system/components/select.md +12 -2
  57. package/docs/design-system/components/sheet.md +1 -1
  58. package/docs/design-system/components/shimmer-text.md +67 -0
  59. package/docs/design-system/components/sidebar.md +75 -8
  60. package/docs/design-system/components/skeleton.md +2 -2
  61. package/docs/design-system/components/slideout.md +1 -1
  62. package/docs/design-system/components/slider.md +2 -2
  63. package/docs/design-system/components/social-button.md +2 -2
  64. package/docs/design-system/components/streaming-text.md +82 -0
  65. package/docs/design-system/components/table.md +103 -21
  66. package/docs/design-system/components/tabs.md +25 -6
  67. package/docs/design-system/components/tag.md +1 -1
  68. package/docs/design-system/components/task-list.md +95 -0
  69. package/docs/design-system/components/textarea.md +1 -1
  70. package/docs/design-system/components/thinking-reasoning.md +72 -0
  71. package/docs/design-system/components/thinking-state.md +67 -0
  72. package/docs/design-system/components/timeline.md +1 -1
  73. package/docs/design-system/components/toast.md +1 -1
  74. package/docs/design-system/components/toggle.md +2 -2
  75. package/docs/design-system/components/toolbar.md +11 -6
  76. package/docs/design-system/components/tooltip.md +6 -4
  77. package/docs/design-system/density-and-elevation.md +16 -16
  78. package/docs/design-system/design-system-test-missing-items.md +8 -8
  79. package/docs/design-system/installation.md +2 -0
  80. package/docs/design-system/tabs.md +2 -1
  81. package/docs/design-system/template-gap-audit.md +32 -0
  82. package/docs/design-system/templates.md +59 -0
  83. package/docs/design-system/token-governance.md +15 -2
  84. package/package.json +2 -2
  85. package/registry/components/accordion.tsx +2 -2
  86. package/registry/components/agent-input-parts.tsx +552 -0
  87. package/registry/components/agent-input-state.ts +156 -0
  88. package/registry/components/agent-input.css +307 -0
  89. package/registry/components/agent-input.tsx +382 -0
  90. package/registry/components/alert-dialog.tsx +2 -2
  91. package/registry/components/alert.tsx +23 -7
  92. package/registry/components/app-header.tsx +151 -0
  93. package/registry/components/approval-card.css +409 -0
  94. package/registry/components/approval-card.tsx +401 -0
  95. package/registry/components/approval-frame.tsx +108 -0
  96. package/registry/components/approval-questions.tsx +559 -0
  97. package/registry/components/attribute.tsx +48 -13
  98. package/registry/components/autocomplete.tsx +15 -68
  99. package/registry/components/avatar.tsx +2 -2
  100. package/registry/components/badge.tsx +8 -7
  101. package/registry/components/button-group.tsx +2 -2
  102. package/registry/components/button.tsx +19 -14
  103. package/registry/components/card.tsx +18 -5
  104. package/registry/components/chart-block-data.ts +177 -0
  105. package/registry/components/chart-block.css +171 -0
  106. package/registry/components/chart-block.tsx +260 -0
  107. package/registry/components/chart-interactions.ts +123 -0
  108. package/registry/components/chart-options.cartesian.ts +162 -0
  109. package/registry/components/chart-options.paint.ts +99 -0
  110. package/registry/components/chart-options.parts.ts +72 -0
  111. package/registry/components/chart-options.radial.ts +109 -0
  112. package/registry/components/chart-options.ts +44 -0
  113. package/registry/components/chart-options.types.ts +67 -0
  114. package/registry/components/chart.tsx +1 -1
  115. package/registry/components/checkbox.tsx +6 -6
  116. package/registry/components/collapsible.tsx +43 -3
  117. package/registry/components/command.tsx +1 -1
  118. package/registry/components/context-menu.tsx +1 -1
  119. package/registry/components/data-chart.tsx +40 -0
  120. package/registry/components/date-picker.tsx +9 -9
  121. package/registry/components/echart-theme.ts +246 -0
  122. package/registry/components/echart.css +49 -0
  123. package/registry/components/echart.tsx +178 -0
  124. package/registry/components/expanded/Breadcrumbs.tsx +5 -1
  125. package/registry/components/expanded/PageHeader.tsx +67 -24
  126. package/registry/components/expanded/Table.tsx +60 -22
  127. package/registry/components/expanded/activityFeed.css +2 -2
  128. package/registry/components/expanded/activityHeatmap.css +1 -1
  129. package/registry/components/expanded/banner.css +2 -2
  130. package/registry/components/expanded/breadcrumbs.css +19 -2
  131. package/registry/components/expanded/catalogComponentsShowcase.css +1 -1
  132. package/registry/components/expanded/fileUpload.css +7 -7
  133. package/registry/components/expanded/onboardingStepListItem.css +2 -2
  134. package/registry/components/expanded/pageHeader.css +38 -44
  135. package/registry/components/expanded/slideout.css +4 -4
  136. package/registry/components/expanded/table.css +58 -9
  137. package/registry/components/expanded/timeline.css +1 -1
  138. package/registry/components/hover-card.tsx +1 -1
  139. package/registry/components/inline-citations.css +126 -0
  140. package/registry/components/inline-citations.tsx +292 -0
  141. package/registry/components/input-group.tsx +4 -4
  142. package/registry/components/input.tsx +150 -137
  143. package/registry/components/item.tsx +1 -1
  144. package/registry/components/kbd.tsx +3 -2
  145. package/registry/components/menu.tsx +117 -12
  146. package/registry/components/menubar.tsx +2 -2
  147. package/registry/components/modal.tsx +3 -3
  148. package/registry/components/native-select.tsx +4 -4
  149. package/registry/components/navigation-menu.tsx +2 -2
  150. package/registry/components/pagination.tsx +4 -4
  151. package/registry/components/popover.tsx +3 -3
  152. package/registry/components/progress-slider.tsx +1 -1
  153. package/registry/components/radio-card.tsx +7 -7
  154. package/registry/components/radio-group.tsx +2 -2
  155. package/registry/components/resizable.tsx +2 -2
  156. package/registry/components/rolling-count.css +37 -0
  157. package/registry/components/rolling-count.tsx +110 -0
  158. package/registry/components/scroll-area.tsx +1 -1
  159. package/registry/components/select.tsx +15 -10
  160. package/registry/components/sheet.tsx +2 -2
  161. package/registry/components/shimmer-text.css +69 -0
  162. package/registry/components/shimmer-text.tsx +43 -0
  163. package/registry/components/sidebar.tsx +528 -223
  164. package/registry/components/skeleton.tsx +1 -1
  165. package/registry/components/slider.tsx +2 -2
  166. package/registry/components/social-button.tsx +1 -1
  167. package/registry/components/streaming-text.css +45 -0
  168. package/registry/components/streaming-text.tsx +165 -0
  169. package/registry/components/table-elements.tsx +164 -5
  170. package/registry/components/tabs.css +32 -4
  171. package/registry/components/tabs.tsx +5 -1
  172. package/registry/components/tag.tsx +1 -1
  173. package/registry/components/task-list.css +328 -0
  174. package/registry/components/task-list.tsx +429 -0
  175. package/registry/components/textarea.tsx +4 -4
  176. package/registry/components/thinking-reasoning.css +178 -0
  177. package/registry/components/thinking-reasoning.tsx +253 -0
  178. package/registry/components/thinking-state.css +8 -0
  179. package/registry/components/thinking-state.tsx +46 -0
  180. package/registry/components/toast.tsx +1 -1
  181. package/registry/components/toggle.tsx +2 -2
  182. package/registry/components/toolbar.tsx +6 -6
  183. package/registry/components/tooltip.tsx +6 -4
  184. package/registry/examples/agent-input-demo.tsx +90 -0
  185. package/registry/examples/app-header-demo.tsx +40 -0
  186. package/registry/examples/approval-card-demo.tsx +83 -0
  187. package/registry/examples/button-demo.tsx +3 -0
  188. package/registry/examples/chart-block-demo.tsx +5 -0
  189. package/registry/examples/chart-demo.tsx +6 -21
  190. package/registry/examples/collection-filter-bar-demo.tsx +1 -1
  191. package/registry/examples/control-density-contract-demo.tsx +5 -1
  192. package/registry/examples/inline-citations-demo.tsx +31 -0
  193. package/registry/examples/input-actions-demo.tsx +36 -0
  194. package/registry/examples/menu-demo.tsx +1 -1
  195. package/registry/examples/popover-demo.tsx +1 -0
  196. package/registry/examples/rolling-count-demo.tsx +39 -0
  197. package/registry/examples/shimmer-text-demo.tsx +20 -0
  198. package/registry/examples/streaming-text-demo.tsx +28 -0
  199. package/registry/examples/task-list-demo.tsx +74 -0
  200. package/registry/examples/thinking-reasoning-demo.tsx +74 -0
  201. package/registry/examples/thinking-state-demo.tsx +13 -0
  202. package/registry/guidance/charts.LICENSE.txt +24 -0
  203. package/registry/hooks/use-controllable-state.ts +33 -0
  204. package/registry/hooks/use-listbox-navigation.ts +73 -0
  205. package/registry/hooks/use-motion-end.ts +55 -0
  206. package/registry/hooks/use-reduced-motion.ts +28 -0
  207. package/registry/index.json +1808 -190
  208. package/registry/styles/globals.css +992 -1088
  209. package/registry.json +385 -10
  210. package/schema/registry.schema.json +5 -1
  211. package/schema/tokens.schema.json +8 -4
  212. package/src/cli/file-transaction.js +15 -8
  213. package/src/cli/index.js +22 -29
  214. package/tokens/banhaten.tokens.json +490 -192
@@ -15,7 +15,7 @@ const accordionVariants = cva("w-full", {
15
15
  bordered:
16
16
  "rounded-[var(--bh-radius-md-6)] border border-[var(--bh-border-subtle)] bg-[var(--bh-bg-raised)] [&>[data-slot=accordion-item]]:border-b-[var(--bh-space-xxxs-1)] [&>[data-slot=accordion-item]]:border-[var(--bh-border-subtle)] [&>[data-slot=accordion-item]:last-child]:border-b-0 [&_[data-slot=accordion-content]>div]:px-[var(--bh-space-3xl-16)] [&_[data-slot=accordion-trigger]]:px-[var(--bh-space-3xl-16)]",
17
17
  cards:
18
- "flex flex-col gap-[var(--bh-space-md-8)] [&>[data-slot=accordion-item]]:rounded-[var(--bh-radius-md-6)] [&>[data-slot=accordion-item]]:border [&>[data-slot=accordion-item]]:border-[var(--bh-border-subtle)] [&>[data-slot=accordion-item]]:bg-[var(--bh-bg-raised)] [&>[data-slot=accordion-item]]:shadow-[var(--shadow-accordion-item)] [&_[data-slot=accordion-content]>div]:px-[var(--bh-space-3xl-16)] [&_[data-slot=accordion-trigger]]:px-[var(--bh-space-3xl-16)]",
18
+ "flex flex-col gap-[var(--bh-space-md-8)] [&>[data-slot=accordion-item]]:rounded-[var(--bh-radius-md-6)] [&>[data-slot=accordion-item]]:border [&>[data-slot=accordion-item]]:border-[var(--bh-border-subtle)] [&>[data-slot=accordion-item]]:bg-[var(--bh-bg-raised)] [&>[data-slot=accordion-item]]:shadow-[var(--elevation-accordion-item)] [&_[data-slot=accordion-content]>div]:px-[var(--bh-space-3xl-16)] [&_[data-slot=accordion-trigger]]:px-[var(--bh-space-3xl-16)]",
19
19
  soft: "rounded-[var(--bh-radius-md-6)] bg-[var(--bh-bg-neutral-subtle)] p-[var(--bh-space-xs-4)] [&>[data-slot=accordion-item]]:rounded-[var(--bh-radius-xs-2)] [&_[data-slot=accordion-content]>div]:px-[var(--bh-space-xl-12)] [&_[data-slot=accordion-trigger]]:px-[var(--bh-space-xl-12)]",
20
20
  },
21
21
  size: {
@@ -78,7 +78,7 @@ function AccordionTrigger({
78
78
  dir={triggerDirection}
79
79
  data-slot="accordion-trigger"
80
80
  className={cn(
81
- "flex flex-1 items-start justify-between gap-[var(--bh-space-3xl-16)] rounded-[var(--bh-radius-md-6)] py-[var(--bh-space-3xl-16)] text-start text-[length:var(--bh-text-body-sm-medium-font-size)] font-[var(--bh-text-body-sm-medium-font-weight)] leading-[var(--bh-text-body-sm-medium-line-height)] tracking-[var(--bh-text-body-sm-medium-letter-spacing)] text-[var(--bh-content-default)] outline-none transition-[background-color,color,box-shadow] hover:text-[var(--bh-content-default)] focus-visible:shadow-[var(--shadow-button-focus)] disabled:pointer-events-none disabled:opacity-[var(--bh-opacity-50)] data-[disabled]:pointer-events-none data-[disabled]:opacity-[var(--bh-opacity-50)] [&[data-state=open]>svg]:rotate-180 [&_svg]:size-[var(--bh-space-3xl-16)]",
81
+ "flex flex-1 items-start justify-between gap-[var(--bh-space-3xl-16)] rounded-[var(--bh-radius-md-6)] py-[var(--bh-space-3xl-16)] text-start text-[length:var(--bh-text-body-sm-medium-font-size)] font-[var(--bh-text-body-sm-medium-font-weight)] leading-[var(--bh-text-body-sm-medium-line-height)] tracking-[var(--bh-text-body-sm-medium-letter-spacing)] text-[var(--bh-content-default)] outline-none transition-[background-color,color,box-shadow] hover:text-[var(--bh-content-default)] focus-visible:shadow-[var(--elevation-button-focus)] disabled:pointer-events-none disabled:opacity-[var(--bh-opacity-50)] data-[disabled]:pointer-events-none data-[disabled]:opacity-[var(--bh-opacity-50)] [&[data-state=open]>svg]:rotate-180 [&_svg]:size-[var(--bh-space-3xl-16)]",
82
82
  className
83
83
  )}
84
84
  {...props}
@@ -0,0 +1,552 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import {
5
+ BookOpenIcon,
6
+ CheckIcon,
7
+ ImageIcon,
8
+ PaperclipIcon,
9
+ PlusIcon,
10
+ SlashIcon,
11
+ } from "lucide-react"
12
+
13
+ import { Button } from "@/components/ui/button"
14
+ import {
15
+ Menu,
16
+ MenuContent,
17
+ MenuItem,
18
+ MenuItemDescription,
19
+ MenuItemIcon,
20
+ MenuItemText,
21
+ MenuItemTitle,
22
+ MenuLabel,
23
+ MenuSeparator,
24
+ MenuSub,
25
+ MenuSubContent,
26
+ MenuSubTrigger,
27
+ MenuTrigger,
28
+ } from "@/components/ui/menu"
29
+ import { Tag } from "@/components/ui/tag"
30
+ import { useListboxNavigation } from "@/hooks/use-listbox-navigation"
31
+ import { useMotionEnd, waitForMotion } from "@/hooks/use-motion-end"
32
+ import { useReducedMotion } from "@/hooks/use-reduced-motion"
33
+
34
+ import {
35
+ findSlashQuery,
36
+ matchSkills,
37
+ settleLeavingChip,
38
+ withLeavingChips,
39
+ type AgentInputLabels,
40
+ type AgentInputModel,
41
+ type AgentInputPhase,
42
+ type AgentInputSkill,
43
+ type AttachmentKind,
44
+ type Chip,
45
+ type ChipEntry,
46
+ type LeavingChip,
47
+ type SlashToken,
48
+ } from "./agent-input-state"
49
+
50
+ /* ------------------------------------------------------------------ Chips */
51
+
52
+ /**
53
+ * Wraps one chip. A removed chip keeps its slot until its exit animation
54
+ * ends, then reports `onExited`; without a running animation it reports at
55
+ * once, so a removed chip never lingers.
56
+ */
57
+ function ChipSlot({
58
+ children,
59
+ exiting,
60
+ onExited,
61
+ }: {
62
+ children: React.ReactNode
63
+ exiting: boolean
64
+ onExited: () => void
65
+ }) {
66
+ const ref = React.useRef<HTMLSpanElement>(null)
67
+ useMotionEnd(ref, exiting, onExited)
68
+
69
+ return (
70
+ <span data-exit={exiting ? "true" : "false"} data-slot="agent-input-chip" ref={ref}>
71
+ {children}
72
+ </span>
73
+ )
74
+ }
75
+
76
+ /**
77
+ * The chips of the current value, plus copies of removed ones kept where they
78
+ * were until their exit animation ends. The value drops a chip at once and
79
+ * only its exit lingers, so quick removals and host updates in the meantime
80
+ * are never undone.
81
+ */
82
+ function useChipPresence(live: readonly Chip[]) {
83
+ const [leaving, setLeaving] = React.useState<readonly LeavingChip[]>([])
84
+ const liveKeys = new Set(live.map((chip) => chip.key))
85
+
86
+ // A chip added back while its old copy was still leaving replaces it.
87
+ if (leaving.some(({ chip }) => liveKeys.has(chip.key))) {
88
+ setLeaving(leaving.filter(({ chip }) => !liveKeys.has(chip.key)))
89
+ }
90
+
91
+ const chips = withLeavingChips(live, leaving)
92
+
93
+ /** Keeps a live chip on screen while it leaves; returns it, or null. */
94
+ const leave = (key: string) => {
95
+ const index = chips.findIndex(({ chip }) => chip.key === key)
96
+ const entry = chips[index]
97
+ if (!entry || entry.exiting) return null
98
+ setLeaving((current) => [...current, { chip: entry.chip, index }])
99
+ return entry.chip
100
+ }
101
+
102
+ const settle = (key: string) => setLeaving((current) => settleLeavingChip(current, key))
103
+
104
+ return { chips, leave, settle }
105
+ }
106
+
107
+ type ChipRowProps = {
108
+ chips: readonly ChipEntry[]
109
+ disabled: boolean
110
+ labels: AgentInputLabels
111
+ onExited: (key: string) => void
112
+ onRemove: (key: string) => void
113
+ skillName: (id: string) => string
114
+ }
115
+
116
+ function ChipRow({ chips, disabled, labels, onExited, onRemove, skillName }: ChipRowProps) {
117
+ if (chips.length === 0) return null
118
+
119
+ return (
120
+ <div data-slot="agent-input-chips">
121
+ {chips.map(({ chip, exiting }) => (
122
+ <ChipSlot exiting={exiting} key={chip.key} onExited={() => onExited(chip.key)}>
123
+ {chip.kind === "skill" ? (
124
+ <Tag
125
+ closeLabel={labels.removeSkill(skillName(chip.skillId))}
126
+ disabled={disabled}
127
+ icon={<SlashIcon />}
128
+ onClose={() => onRemove(chip.key)}
129
+ showCloseButton
130
+ size="xs"
131
+ state="active"
132
+ type="icon"
133
+ >
134
+ {skillName(chip.skillId)}
135
+ </Tag>
136
+ ) : (
137
+ <Tag
138
+ closeLabel={labels.removeAttachment(chip.attachment.name)}
139
+ disabled={disabled}
140
+ icon={chip.attachment.kind === "image" ? <ImageIcon /> : <PaperclipIcon />}
141
+ onClose={() => onRemove(chip.key)}
142
+ showCloseButton
143
+ size="xs"
144
+ type="icon"
145
+ >
146
+ {chip.attachment.name}
147
+ </Tag>
148
+ )}
149
+ </ChipSlot>
150
+ ))}
151
+ </div>
152
+ )
153
+ }
154
+
155
+ /* ---------------------------------------------------------- Slash palette */
156
+
157
+ /**
158
+ * Opens when a "/" sits before the caret, filters skills by what follows it,
159
+ * and remembers that token so picking a skill can take it out of the text.
160
+ */
161
+ function useSlashPalette(skills: readonly AgentInputSkill[]) {
162
+ const [open, setOpen] = React.useState(false)
163
+ const [query, setQuery] = React.useState("")
164
+ // True while the arrows drive the list, so a resting pointer does not steal
165
+ // the active option.
166
+ const [keyboard, setKeyboard] = React.useState(false)
167
+ const token = React.useRef<SlashToken | null>(null)
168
+ const id = React.useId()
169
+ const results = React.useMemo(() => matchSkills(skills, query), [skills, query])
170
+ const nav = useListboxNavigation({ idPrefix: id, open, options: results })
171
+
172
+ const close = () => {
173
+ setOpen(false)
174
+ setQuery("")
175
+ setKeyboard(false)
176
+ token.current = null
177
+ }
178
+
179
+ const detect = (text: string, caret: number | null) => {
180
+ if (caret === null || skills.length === 0) return close()
181
+ const found = findSlashQuery(text, caret)
182
+ if (!found) return close()
183
+ token.current = { end: found.end, start: found.start }
184
+ // A new query starts again from its first match.
185
+ if (found.query !== query) nav.setActiveIndex(0)
186
+ setQuery(found.query)
187
+ setOpen(true)
188
+ }
189
+
190
+ /** Moves, picks, or closes from the keyboard; returns whether it used the key. */
191
+ const handleKey = (
192
+ event: React.KeyboardEvent,
193
+ pick: (skill: AgentInputSkill) => void
194
+ ) => {
195
+ if (!open) return false
196
+ if (results.length > 0 && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
197
+ event.preventDefault()
198
+ setKeyboard(true)
199
+ nav.move(event.key === "ArrowDown" ? 1 : -1)
200
+ return true
201
+ }
202
+ if (results.length > 0 && (event.key === "Enter" || event.key === "Tab")) {
203
+ event.preventDefault()
204
+ pick(results[Math.max(nav.activeIndex, 0)])
205
+ return true
206
+ }
207
+ if (event.key === "Escape") {
208
+ event.preventDefault()
209
+ close()
210
+ return true
211
+ }
212
+ return false
213
+ }
214
+
215
+ return { close, detect, handleKey, id, keyboard, nav, open, results, setKeyboard, token }
216
+ }
217
+
218
+ type SlashPaletteState = ReturnType<typeof useSlashPalette>
219
+
220
+ function SlashPalette({
221
+ labels,
222
+ onPick,
223
+ slash,
224
+ }: {
225
+ labels: AgentInputLabels
226
+ onPick: (skill: AgentInputSkill) => void
227
+ slash: SlashPaletteState
228
+ }) {
229
+ return (
230
+ <div
231
+ aria-label={labels.skills}
232
+ data-keyboard={slash.keyboard ? "true" : "false"}
233
+ data-slot="agent-input-palette"
234
+ id={slash.id}
235
+ onMouseMove={() => {
236
+ if (slash.keyboard) slash.setKeyboard(false)
237
+ }}
238
+ role="listbox"
239
+ >
240
+ <div aria-hidden="true" data-slot="agent-input-palette-label">
241
+ {labels.skills}
242
+ </div>
243
+ {slash.results.length > 0 ? (
244
+ slash.results.map((skill, index) => (
245
+ <button
246
+ aria-selected={index === slash.nav.activeIndex}
247
+ data-active={index === slash.nav.activeIndex ? "true" : "false"}
248
+ data-slot="agent-input-palette-item"
249
+ id={slash.nav.optionId(index)}
250
+ key={skill.id}
251
+ onClick={() => onPick(skill)}
252
+ onMouseDown={(event) => event.preventDefault()}
253
+ onMouseEnter={() => {
254
+ if (!slash.keyboard) slash.nav.setActiveIndex(index)
255
+ }}
256
+ role="option"
257
+ tabIndex={-1}
258
+ type="button"
259
+ >
260
+ {skill.icon ? <span data-slot="agent-input-palette-icon">{skill.icon}</span> : null}
261
+ <span data-slot="agent-input-palette-name" dir="auto">
262
+ {skill.name}
263
+ </span>
264
+ </button>
265
+ ))
266
+ ) : (
267
+ <div data-slot="agent-input-palette-empty">{labels.noSkills}</div>
268
+ )}
269
+ </div>
270
+ )
271
+ }
272
+
273
+ /* ------------------------------------------------------------ Enhancement */
274
+
275
+ /**
276
+ * Rewrites the prompt through `onEnhance`: a failure restores the original,
277
+ * revert brings it back, and unmounting aborts the request. The frame eases
278
+ * between its old and new height when the text changes size.
279
+ */
280
+ function usePromptEnhancement({
281
+ frameRef,
282
+ onEnhance,
283
+ onFinish,
284
+ onStart,
285
+ setValue,
286
+ value,
287
+ }: {
288
+ frameRef: React.RefObject<HTMLDivElement | null>
289
+ onEnhance?: (prompt: string, signal: AbortSignal) => Promise<string>
290
+ /** Runs once a request settles, unless the field unmounted meanwhile. */
291
+ onFinish: () => void
292
+ onStart: () => void
293
+ setValue: (value: string) => void
294
+ value: string
295
+ }) {
296
+ const [phase, setPhase] = React.useState<AgentInputPhase>("idle")
297
+ const original = React.useRef("")
298
+ const request = React.useRef<AbortController | null>(null)
299
+ const flipFrom = React.useRef<number | null>(null)
300
+ const reducedMotion = useReducedMotion()
301
+ const enhancing = phase === "enhancing"
302
+
303
+ React.useLayoutEffect(() => {
304
+ const frame = frameRef.current
305
+ const from = flipFrom.current
306
+ flipFrom.current = null
307
+ if (!frame || from === null || enhancing || reducedMotion) return
308
+ const to = frame.offsetHeight
309
+ if (from === to) return
310
+ frame.style.height = `${from}px`
311
+ frame.style.overflow = "hidden"
312
+ void frame.offsetHeight
313
+ frame.style.transition = "height var(--bh-duration-default) var(--bh-easing-emphasized)"
314
+ frame.style.height = `${to}px`
315
+ // Never cancelled: however the next render goes, the frame must drop
316
+ // its fixed height once the transition is over.
317
+ waitForMotion(frame, () => {
318
+ frame.style.transition = ""
319
+ frame.style.height = ""
320
+ frame.style.overflow = ""
321
+ })
322
+ }, [enhancing, frameRef, phase, reducedMotion])
323
+
324
+ React.useEffect(() => () => request.current?.abort(), [])
325
+
326
+ const run = async () => {
327
+ if (!onEnhance || !value.trim() || enhancing) return
328
+ original.current = value
329
+ setPhase("enhancing")
330
+ onStart()
331
+ const controller = new AbortController()
332
+ request.current = controller
333
+ try {
334
+ const result = await onEnhance(value, controller.signal)
335
+ if (controller.signal.aborted) return
336
+ flipFrom.current = frameRef.current?.offsetHeight ?? null
337
+ setValue(result)
338
+ setPhase("enhanced")
339
+ } catch {
340
+ if (controller.signal.aborted) return
341
+ setValue(original.current)
342
+ setPhase("idle")
343
+ }
344
+ onFinish()
345
+ }
346
+
347
+ const revert = () => {
348
+ request.current?.abort()
349
+ flipFrom.current = frameRef.current?.offsetHeight ?? null
350
+ setValue(original.current)
351
+ setPhase("idle")
352
+ }
353
+
354
+ /** Editing or sending leaves the enhanced prompt behind. */
355
+ const reset = () => setPhase("idle")
356
+
357
+ return { enhancing, phase, reset, revert, run }
358
+ }
359
+
360
+ /** Keeps the enhance pill mounted through its exit animation. */
361
+ function useEnhancePill(visible: boolean, enhancing: boolean) {
362
+ const ref = React.useRef<HTMLButtonElement>(null)
363
+ const [mounted, setMounted] = React.useState(false)
364
+ const [exiting, setExiting] = React.useState(false)
365
+
366
+ React.useEffect(() => {
367
+ if (visible) {
368
+ setMounted(true)
369
+ setExiting(false)
370
+ return
371
+ }
372
+ if (!mounted) return
373
+ // The spinner takes its place at once while a request runs.
374
+ if (enhancing) {
375
+ setMounted(false)
376
+ setExiting(false)
377
+ return
378
+ }
379
+ setExiting(true)
380
+ }, [enhancing, mounted, visible])
381
+
382
+ useMotionEnd(ref, exiting, () => {
383
+ setMounted(false)
384
+ setExiting(false)
385
+ })
386
+
387
+ return { exiting, mounted, ref }
388
+ }
389
+
390
+ /* --------------------------------------------------------------- Add menu */
391
+
392
+ /** A hidden multi-file input that the add menu opens as a photo or file picker. */
393
+ function useFilePicker(onPick: (files: File[], picker: AttachmentKind) => void) {
394
+ const ref = React.useRef<HTMLInputElement>(null)
395
+
396
+ const open = (kind: AttachmentKind) => {
397
+ const input = ref.current
398
+ if (!input) return
399
+ input.accept = kind === "image" ? "image/*" : ""
400
+ input.dataset.kind = kind
401
+ input.value = ""
402
+ input.click()
403
+ }
404
+
405
+ const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
406
+ const files = Array.from(event.target.files ?? [])
407
+ if (files.length === 0) return
408
+ onPick(files, (event.target.dataset.kind as AttachmentKind | undefined) ?? "file")
409
+ event.target.value = ""
410
+ }
411
+
412
+ return {
413
+ inputProps: { hidden: true, multiple: true, onChange, ref, tabIndex: -1, type: "file" },
414
+ open,
415
+ }
416
+ }
417
+
418
+ type AddMenuProps = {
419
+ disabled: boolean
420
+ labels: AgentInputLabels
421
+ model?: string
422
+ models: readonly AgentInputModel[]
423
+ onModelChange: (model: string) => void
424
+ onPickFiles: (kind: AttachmentKind) => void
425
+ onToggleSkill: (skillId: string) => void
426
+ selectedSkills: readonly string[]
427
+ skills: readonly AgentInputSkill[]
428
+ }
429
+
430
+ /** Photos, files, skills, and the model, behind the plus button. */
431
+ function AddMenu({
432
+ disabled,
433
+ labels,
434
+ model,
435
+ models,
436
+ onModelChange,
437
+ onPickFiles,
438
+ onToggleSkill,
439
+ selectedSkills,
440
+ skills,
441
+ }: AddMenuProps) {
442
+ const [open, setOpen] = React.useState(false)
443
+
444
+ return (
445
+ <Menu onOpenChange={setOpen} open={open}>
446
+ <MenuTrigger asChild>
447
+ <Button
448
+ aria-label={labels.addMenu}
449
+ data-open={open ? "true" : "false"}
450
+ data-slot="agent-input-add"
451
+ disabled={disabled}
452
+ size="icon-xs"
453
+ variant="soft"
454
+ >
455
+ <PlusIcon data-slot="agent-input-add-icon" />
456
+ </Button>
457
+ </MenuTrigger>
458
+ <MenuContent align="start" side="top">
459
+ <MenuItem onSelect={() => onPickFiles("image")}>
460
+ <MenuItemIcon>
461
+ <ImageIcon />
462
+ </MenuItemIcon>
463
+ {labels.addPhotos}
464
+ </MenuItem>
465
+ <MenuItem onSelect={() => onPickFiles("file")}>
466
+ <MenuItemIcon>
467
+ <PaperclipIcon />
468
+ </MenuItemIcon>
469
+ {labels.addFiles}
470
+ </MenuItem>
471
+ {skills.length > 0 ? (
472
+ <>
473
+ <MenuSeparator />
474
+ <MenuSub>
475
+ <MenuSubTrigger>
476
+ <MenuItemIcon>
477
+ <BookOpenIcon />
478
+ </MenuItemIcon>
479
+ {labels.skills}
480
+ </MenuSubTrigger>
481
+ <MenuSubContent>
482
+ {skills.map((skill) => {
483
+ const selected = selectedSkills.includes(skill.id)
484
+ return (
485
+ <MenuItem
486
+ aria-checked={selected}
487
+ key={skill.id}
488
+ onSelect={() => onToggleSkill(skill.id)}
489
+ role="menuitemcheckbox"
490
+ >
491
+ {skill.icon ? <MenuItemIcon>{skill.icon}</MenuItemIcon> : null}
492
+ {skill.name}
493
+ {selected ? (
494
+ <MenuItemIcon position="trailing">
495
+ <CheckIcon />
496
+ </MenuItemIcon>
497
+ ) : null}
498
+ </MenuItem>
499
+ )
500
+ })}
501
+ </MenuSubContent>
502
+ </MenuSub>
503
+ </>
504
+ ) : null}
505
+ {models.length > 0 ? (
506
+ <>
507
+ <MenuSeparator />
508
+ <MenuLabel label={labels.model} />
509
+ {models.map((item) => {
510
+ const selected = item.id === model
511
+ return (
512
+ <MenuItem
513
+ aria-checked={selected}
514
+ key={item.id}
515
+ kind={item.description ? "multiline" : "default"}
516
+ onSelect={() => onModelChange(item.id)}
517
+ role="menuitemcheckbox"
518
+ >
519
+ {item.icon ? <MenuItemIcon>{item.icon}</MenuItemIcon> : null}
520
+ <MenuItemText>
521
+ <MenuItemTitle>{item.name}</MenuItemTitle>
522
+ {item.description ? (
523
+ <MenuItemDescription>
524
+ {item.meta ? `${item.description} · ${item.meta}` : item.description}
525
+ </MenuItemDescription>
526
+ ) : null}
527
+ </MenuItemText>
528
+ {selected ? (
529
+ <MenuItemIcon position="trailing">
530
+ <CheckIcon />
531
+ </MenuItemIcon>
532
+ ) : null}
533
+ </MenuItem>
534
+ )
535
+ })}
536
+ </>
537
+ ) : null}
538
+ </MenuContent>
539
+ </Menu>
540
+ )
541
+ }
542
+
543
+ export {
544
+ AddMenu,
545
+ ChipRow,
546
+ SlashPalette,
547
+ useChipPresence,
548
+ useEnhancePill,
549
+ useFilePicker,
550
+ usePromptEnhancement,
551
+ useSlashPalette,
552
+ }