@twoux/ui 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 (325) hide show
  1. package/LICENSE.md +61 -0
  2. package/LICENSES/Apache-2.0.txt +202 -0
  3. package/NOTICE.md +64 -0
  4. package/README.md +211 -2
  5. package/SHADCN-DIFF.md +228 -0
  6. package/agent/AGENTS.md +358 -0
  7. package/agent/docs-md/accessibility.md +49 -0
  8. package/agent/docs-md/action-catalogue.md +81 -0
  9. package/agent/docs-md/actions.md +74 -0
  10. package/agent/docs-md/agent-activity-log.md +70 -0
  11. package/agent/docs-md/agent-permissions.md +82 -0
  12. package/agent/docs-md/agent-run.md +90 -0
  13. package/agent/docs-md/agents-on-behalf.md +74 -0
  14. package/agent/docs-md/ai-column.md +85 -0
  15. package/agent/docs-md/ai-origin-label.md +70 -0
  16. package/agent/docs-md/announcing-features.md +73 -0
  17. package/agent/docs-md/answer-actions.md +81 -0
  18. package/agent/docs-md/app-shell.md +114 -0
  19. package/agent/docs-md/artifact-card.md +89 -0
  20. package/agent/docs-md/ask-on-screen.md +72 -0
  21. package/agent/docs-md/assistant-page.md +100 -0
  22. package/agent/docs-md/assistant-panel.md +142 -0
  23. package/agent/docs-md/avatar.md +82 -0
  24. package/agent/docs-md/banner.md +106 -0
  25. package/agent/docs-md/board.md +163 -0
  26. package/agent/docs-md/breadcrumb.md +89 -0
  27. package/agent/docs-md/breakdown-table.md +97 -0
  28. package/agent/docs-md/bulk-action-bar.md +112 -0
  29. package/agent/docs-md/button.md +293 -0
  30. package/agent/docs-md/card.md +115 -0
  31. package/agent/docs-md/change-list.md +99 -0
  32. package/agent/docs-md/change-request.md +166 -0
  33. package/agent/docs-md/chart-area.md +87 -0
  34. package/agent/docs-md/chart-bar.md +100 -0
  35. package/agent/docs-md/chart-box-plot.md +88 -0
  36. package/agent/docs-md/chart-bullet.md +89 -0
  37. package/agent/docs-md/chart-candlestick.md +112 -0
  38. package/agent/docs-md/chart-card-line.md +120 -0
  39. package/agent/docs-md/chart-donut.md +90 -0
  40. package/agent/docs-md/chart-drill-down.md +72 -0
  41. package/agent/docs-md/chart-funnel.md +71 -0
  42. package/agent/docs-md/chart-heatmap.md +76 -0
  43. package/agent/docs-md/chart-histogram.md +81 -0
  44. package/agent/docs-md/chart-map.md +165 -0
  45. package/agent/docs-md/chart-mixed.md +88 -0
  46. package/agent/docs-md/chart-price.md +92 -0
  47. package/agent/docs-md/chart-radial-bars.md +109 -0
  48. package/agent/docs-md/chart-radial-lines.md +111 -0
  49. package/agent/docs-md/chart-sankey.md +104 -0
  50. package/agent/docs-md/chart-scatter.md +87 -0
  51. package/agent/docs-md/chart-share-bar.md +89 -0
  52. package/agent/docs-md/chart-treemap.md +98 -0
  53. package/agent/docs-md/chart-waterfall.md +93 -0
  54. package/agent/docs-md/checkbox.md +131 -0
  55. package/agent/docs-md/choosing-selection-control.md +71 -0
  56. package/agent/docs-md/citations-sources.md +79 -0
  57. package/agent/docs-md/citing-sources.md +63 -0
  58. package/agent/docs-md/clarify-before-acting.md +69 -0
  59. package/agent/docs-md/clarifying-question.md +92 -0
  60. package/agent/docs-md/code-block.md +174 -0
  61. package/agent/docs-md/colours.md +87 -0
  62. package/agent/docs-md/columns-menu.md +140 -0
  63. package/agent/docs-md/combobox.md +161 -0
  64. package/agent/docs-md/command-palette.md +126 -0
  65. package/agent/docs-md/confidence-note.md +87 -0
  66. package/agent/docs-md/configurable-dashboard.md +76 -0
  67. package/agent/docs-md/confirmation-dialog.md +93 -0
  68. package/agent/docs-md/connected-tools.md +80 -0
  69. package/agent/docs-md/connector-picker.md +127 -0
  70. package/agent/docs-md/consistency.md +53 -0
  71. package/agent/docs-md/content-density.md +61 -0
  72. package/agent/docs-md/conversation-history.md +78 -0
  73. package/agent/docs-md/create-record.md +80 -0
  74. package/agent/docs-md/csv-import.md +88 -0
  75. package/agent/docs-md/dashboard-items.md +78 -0
  76. package/agent/docs-md/data-export.md +61 -0
  77. package/agent/docs-md/data-grid.md +137 -0
  78. package/agent/docs-md/data-vis-colours.md +76 -0
  79. package/agent/docs-md/data-visualisation.md +76 -0
  80. package/agent/docs-md/date-picker.md +178 -0
  81. package/agent/docs-md/delete-record.md +75 -0
  82. package/agent/docs-md/dense-table.md +495 -0
  83. package/agent/docs-md/design-tokens.md +53 -0
  84. package/agent/docs-md/disabled-read-only.md +75 -0
  85. package/agent/docs-md/drag-and-drop.md +74 -0
  86. package/agent/docs-md/dropdown-menu.md +137 -0
  87. package/agent/docs-md/edit-record.md +73 -0
  88. package/agent/docs-md/empty-loading-error.md +78 -0
  89. package/agent/docs-md/empty-state.md +128 -0
  90. package/agent/docs-md/error-messages.md +75 -0
  91. package/agent/docs-md/error-state.md +123 -0
  92. package/agent/docs-md/export-menu.md +86 -0
  93. package/agent/docs-md/feedback-prompt.md +88 -0
  94. package/agent/docs-md/field-mapping.md +78 -0
  95. package/agent/docs-md/file-drop.md +110 -0
  96. package/agent/docs-md/filter-bar.md +138 -0
  97. package/agent/docs-md/filter-panel.md +103 -0
  98. package/agent/docs-md/filtering-search.md +79 -0
  99. package/agent/docs-md/follow-ups-starters.md +79 -0
  100. package/agent/docs-md/form-validation.md +95 -0
  101. package/agent/docs-md/generated-columns.md +65 -0
  102. package/agent/docs-md/generated-drafts-files.md +71 -0
  103. package/agent/docs-md/grouped-list.md +89 -0
  104. package/agent/docs-md/guide-cue.md +170 -0
  105. package/agent/docs-md/help-panel.md +103 -0
  106. package/agent/docs-md/help-system.md +74 -0
  107. package/agent/docs-md/iconography.md +49 -0
  108. package/agent/docs-md/inbox.md +108 -0
  109. package/agent/docs-md/inline-editing-validation.md +80 -0
  110. package/agent/docs-md/inline-input-in-answer.md +70 -0
  111. package/agent/docs-md/items-palette.md +116 -0
  112. package/agent/docs-md/keyboard-model.md +47 -0
  113. package/agent/docs-md/keyboard-navigation.md +68 -0
  114. package/agent/docs-md/keyboard-shortcut-hint.md +87 -0
  115. package/agent/docs-md/keyboard-shortcut-sheet.md +90 -0
  116. package/agent/docs-md/labelling-ai-output.md +66 -0
  117. package/agent/docs-md/layout.md +47 -0
  118. package/agent/docs-md/list-table.md +254 -0
  119. package/agent/docs-md/loading-skeleton.md +91 -0
  120. package/agent/docs-md/managing-agents.md +71 -0
  121. package/agent/docs-md/message-composer.md +105 -0
  122. package/agent/docs-md/migrating.md +49 -0
  123. package/agent/docs-md/motion.md +93 -0
  124. package/agent/docs-md/navigation.md +76 -0
  125. package/agent/docs-md/notification-channels.md +65 -0
  126. package/agent/docs-md/notifications-decision.md +100 -0
  127. package/agent/docs-md/onboarding-step.md +114 -0
  128. package/agent/docs-md/order-book.md +108 -0
  129. package/agent/docs-md/page-header.md +96 -0
  130. package/agent/docs-md/pagination.md +88 -0
  131. package/agent/docs-md/pane-layout.md +105 -0
  132. package/agent/docs-md/period-comparison.md +66 -0
  133. package/agent/docs-md/plan-card.md +120 -0
  134. package/agent/docs-md/popover-rules.md +88 -0
  135. package/agent/docs-md/prompt-editor.md +90 -0
  136. package/agent/docs-md/propose-apply.md +65 -0
  137. package/agent/docs-md/provider-logo.md +97 -0
  138. package/agent/docs-md/radio.md +83 -0
  139. package/agent/docs-md/ranked-list.md +102 -0
  140. package/agent/docs-md/reasoning-progress.md +72 -0
  141. package/agent/docs-md/record-detail.md +71 -0
  142. package/agent/docs-md/regenerating-answers.md +70 -0
  143. package/agent/docs-md/run-status-bar.md +94 -0
  144. package/agent/docs-md/saved-tasks-schedules.md +63 -0
  145. package/agent/docs-md/saved-views.md +149 -0
  146. package/agent/docs-md/saving-views.md +68 -0
  147. package/agent/docs-md/scheduler.md +258 -0
  148. package/agent/docs-md/secondary-panels.md +72 -0
  149. package/agent/docs-md/segmented-control.md +88 -0
  150. package/agent/docs-md/selection-bulk-actions.md +70 -0
  151. package/agent/docs-md/separator.md +75 -0
  152. package/agent/docs-md/setup-checklist.md +92 -0
  153. package/agent/docs-md/shape-elevation.md +55 -0
  154. package/agent/docs-md/sheet-grid.md +211 -0
  155. package/agent/docs-md/side-panel.md +134 -0
  156. package/agent/docs-md/sign-in.md +114 -0
  157. package/agent/docs-md/spacing.md +55 -0
  158. package/agent/docs-md/split-button.md +100 -0
  159. package/agent/docs-md/stat-compare.md +82 -0
  160. package/agent/docs-md/static-dashboard.md +75 -0
  161. package/agent/docs-md/status-badge.md +140 -0
  162. package/agent/docs-md/step-card.md +75 -0
  163. package/agent/docs-md/stepper.md +92 -0
  164. package/agent/docs-md/suggested-changes.md +91 -0
  165. package/agent/docs-md/switch.md +78 -0
  166. package/agent/docs-md/table-settings.md +81 -0
  167. package/agent/docs-md/tabs.md +85 -0
  168. package/agent/docs-md/tag-input.md +108 -0
  169. package/agent/docs-md/tag.md +79 -0
  170. package/agent/docs-md/task-builder.md +87 -0
  171. package/agent/docs-md/team-instructions.md +72 -0
  172. package/agent/docs-md/templates-gallery.md +85 -0
  173. package/agent/docs-md/text-input.md +160 -0
  174. package/agent/docs-md/thinking-steps.md +89 -0
  175. package/agent/docs-md/timeline.md +189 -0
  176. package/agent/docs-md/timestamp.md +84 -0
  177. package/agent/docs-md/timestamps.md +70 -0
  178. package/agent/docs-md/toast.md +117 -0
  179. package/agent/docs-md/tooltip.md +90 -0
  180. package/agent/docs-md/typography.md +62 -0
  181. package/agent/docs-md/undo-destructive.md +66 -0
  182. package/agent/docs-md/unsaved-changes.md +71 -0
  183. package/agent/docs-md/usage-meter.md +80 -0
  184. package/agent/docs-md/user-feedback.md +71 -0
  185. package/agent/docs-md/voice-and-tone.md +60 -0
  186. package/agent/docs-md/why-choice.md +93 -0
  187. package/agent/docs-md/workspace-onboarding.md +74 -0
  188. package/agent/docs-md/writing.md +48 -0
  189. package/agent/llms-full.txt +18369 -0
  190. package/agent/llms.txt +198 -0
  191. package/agent/skills/twoux-audit/SKILL.md +49 -0
  192. package/agent/skills/twoux-build/SKILL.md +39 -0
  193. package/agent/skills/twoux-design/SKILL.md +54 -0
  194. package/agent/skills/twoux-write/SKILL.md +32 -0
  195. package/bin/check.mjs +123 -0
  196. package/bin/docs.mjs +24 -0
  197. package/bin/guard.mjs +27 -0
  198. package/bin/init.mjs +113 -0
  199. package/bin/knowledge.mjs +87 -0
  200. package/bin/mcp.mjs +116 -0
  201. package/bin/theme.mjs +168 -0
  202. package/bin/twoux.mjs +25 -0
  203. package/package.json +80 -3
  204. package/registry/jobs.json +1028 -0
  205. package/registry/palette.json +770 -0
  206. package/registry/rules.json +913 -0
  207. package/src/components/agent-review.tsx +364 -0
  208. package/src/components/ai-basics.tsx +212 -0
  209. package/src/components/ai-work.tsx +304 -0
  210. package/src/components/ai.tsx +227 -0
  211. package/src/components/alert-dialog.tsx +203 -0
  212. package/src/components/alert.tsx +86 -0
  213. package/src/components/avatar.tsx +114 -0
  214. package/src/components/badge.tsx +59 -0
  215. package/src/components/board.tsx +397 -0
  216. package/src/components/breadcrumb.tsx +114 -0
  217. package/src/components/button-group.tsx +90 -0
  218. package/src/components/button.tsx +109 -0
  219. package/src/components/calendar.tsx +247 -0
  220. package/src/components/card.tsx +102 -0
  221. package/src/components/chart-map.tsx +336 -0
  222. package/src/components/charts-more.tsx +1331 -0
  223. package/src/components/charts-radial.tsx +328 -0
  224. package/src/components/charts.tsx +390 -0
  225. package/src/components/checkbox.tsx +40 -0
  226. package/src/components/code.tsx +139 -0
  227. package/src/components/collapsible.tsx +37 -0
  228. package/src/components/combobox.tsx +339 -0
  229. package/src/components/command-extras.tsx +11 -0
  230. package/src/components/command.tsx +196 -0
  231. package/src/components/data-grid.tsx +213 -0
  232. package/src/components/data-table.tsx +334 -0
  233. package/src/components/data-views.tsx +740 -0
  234. package/src/components/date-picker.tsx +686 -0
  235. package/src/components/dense-table.tsx +207 -0
  236. package/src/components/dialog.tsx +162 -0
  237. package/src/components/dropdown-menu.tsx +264 -0
  238. package/src/components/empty.tsx +114 -0
  239. package/src/components/feedback-prompt.tsx +80 -0
  240. package/src/components/field.tsx +254 -0
  241. package/src/components/fields.tsx +112 -0
  242. package/src/components/flow.tsx +840 -0
  243. package/src/components/guide-cue.tsx +273 -0
  244. package/src/components/inbox.tsx +113 -0
  245. package/src/components/input-group.tsx +176 -0
  246. package/src/components/input.tsx +26 -0
  247. package/src/components/kbd.tsx +34 -0
  248. package/src/components/label.tsx +27 -0
  249. package/src/components/labels.tsx +176 -0
  250. package/src/components/motion.tsx +136 -0
  251. package/src/components/onboarding.tsx +454 -0
  252. package/src/components/order-book.tsx +185 -0
  253. package/src/components/pane-layout.tsx +417 -0
  254. package/src/components/popover.tsx +96 -0
  255. package/src/components/provider-logos.data.ts +49 -0
  256. package/src/components/radio-group.tsx +52 -0
  257. package/src/components/scheduler.tsx +997 -0
  258. package/src/components/segmented-control.tsx +28 -0
  259. package/src/components/select.tsx +195 -0
  260. package/src/components/separator.tsx +31 -0
  261. package/src/components/sheet.tsx +148 -0
  262. package/src/components/shell.tsx +426 -0
  263. package/src/components/shortcut-sheet.tsx +83 -0
  264. package/src/components/skeleton.tsx +18 -0
  265. package/src/components/sonner.tsx +107 -0
  266. package/src/components/spinner.tsx +13 -0
  267. package/src/components/split-button.tsx +58 -0
  268. package/src/components/stepper.tsx +78 -0
  269. package/src/components/switch.tsx +43 -0
  270. package/src/components/table.tsx +126 -0
  271. package/src/components/tabs.tsx +118 -0
  272. package/src/components/tag-input.tsx +253 -0
  273. package/src/components/textarea.tsx +21 -0
  274. package/src/components/theme.tsx +80 -0
  275. package/src/components/timeline.tsx +408 -0
  276. package/src/components/toggle-group.tsx +87 -0
  277. package/src/components/toggle.tsx +54 -0
  278. package/src/components/tooltip.tsx +65 -0
  279. package/src/components/workspace-actions.tsx +294 -0
  280. package/src/components/workspace-import.tsx +465 -0
  281. package/src/components/workspace.tsx +1884 -0
  282. package/src/index.ts +80 -0
  283. package/src/lib/active-indicator.tsx +53 -0
  284. package/src/lib/chart-kit.tsx +72 -0
  285. package/src/lib/cn.ts +6 -0
  286. package/src/lib/shortcuts.tsx +61 -0
  287. package/src/lib/workspace.tsx +10 -0
  288. package/src/shadcn/alert-dialog.tsx +196 -0
  289. package/src/shadcn/alert.tsx +65 -0
  290. package/src/shadcn/avatar.tsx +108 -0
  291. package/src/shadcn/badge.tsx +47 -0
  292. package/src/shadcn/breadcrumb.tsx +108 -0
  293. package/src/shadcn/button-group.tsx +83 -0
  294. package/src/shadcn/button.tsx +63 -0
  295. package/src/shadcn/calendar.tsx +220 -0
  296. package/src/shadcn/card.tsx +91 -0
  297. package/src/shadcn/checkbox.tsx +31 -0
  298. package/src/shadcn/collapsible.tsx +33 -0
  299. package/src/shadcn/command.tsx +184 -0
  300. package/src/shadcn/dialog.tsx +158 -0
  301. package/src/shadcn/dropdown-menu.tsx +256 -0
  302. package/src/shadcn/empty.tsx +103 -0
  303. package/src/shadcn/field.tsx +248 -0
  304. package/src/shadcn/input-group.tsx +170 -0
  305. package/src/shadcn/input.tsx +20 -0
  306. package/src/shadcn/kbd.tsx +28 -0
  307. package/src/shadcn/label.tsx +23 -0
  308. package/src/shadcn/popover.tsx +88 -0
  309. package/src/shadcn/radio-group.tsx +44 -0
  310. package/src/shadcn/select.tsx +189 -0
  311. package/src/shadcn/separator.tsx +27 -0
  312. package/src/shadcn/sheet.tsx +142 -0
  313. package/src/shadcn/skeleton.tsx +13 -0
  314. package/src/shadcn/sonner.tsx +40 -0
  315. package/src/shadcn/switch.tsx +34 -0
  316. package/src/shadcn/table.tsx +115 -0
  317. package/src/shadcn/tabs.tsx +90 -0
  318. package/src/shadcn/textarea.tsx +17 -0
  319. package/src/shadcn/toggle-group.tsx +83 -0
  320. package/src/shadcn/toggle.tsx +46 -0
  321. package/src/shadcn/tooltip.tsx +56 -0
  322. package/src/styles/palette.css +127 -0
  323. package/src/styles/theme.css +35 -0
  324. package/src/styles/twoux.css +236 -0
  325. package/theme.json +14 -0
@@ -0,0 +1,364 @@
1
+ /**
2
+ * TwoUX compositions for supervising agents: agree the plan before work starts, review what an agent wants to change
3
+ * (including changes an agent proposes to another agent), and say plainly when the agent is unsure or why it chose.
4
+ *
5
+ * PlanCard: the plan before a multi-step run. Steps can be removed; steps that reach people or can't be undone are marked.
6
+ * PlanChange: one line in the run when the plan changes, saying what changed and why.
7
+ * DiffView: files with +/− counts and a unified diff; markers and words carry the change, never colour alone.
8
+ * SimulationResults: test conversations run against a change, failures first, new failures called out.
9
+ * ChangeRequest: one proposed change with its reason, diff and simulation results; Approve, or Reject with a reason.
10
+ * ConfidenceNote: uncertainty in words (never a percentage), what caused it, and a safer option or a way to check.
11
+ * WhyChoice: a "Why?" control that names the deciding factors and the options that lost, then Change this or Keep it.
12
+ */
13
+ import { AlertTriangle, Check, ChevronRight, CircleHelp, FileText, Lock, Users, X } from "lucide-react"
14
+ import * as React from "react"
15
+ import { cn } from "../lib/cn"
16
+ import { AiOrigin } from "./ai-basics"
17
+ import { Badge } from "./badge"
18
+ import { Button } from "./button"
19
+ import { Field, FieldError, FieldLabel } from "./field"
20
+ import { Popover, PopoverContent, PopoverTrigger } from "./popover"
21
+ import { Textarea } from "./textarea"
22
+
23
+ /* ---------------------------------------------------------------- plan */
24
+
25
+ export type PlanStep = {
26
+ id: string
27
+ title: string
28
+ detail?: string
29
+ /** "external" reaches people or systems outside the workspace; "irreversible" can't be undone */
30
+ risk?: "external" | "irreversible"
31
+ }
32
+
33
+ const RISK = {
34
+ external: { icon: Users, word: "Reaches people outside the workspace" },
35
+ irreversible: { icon: Lock, word: "Can't be undone" },
36
+ } as const
37
+
38
+ /**
39
+ * The plan before the agent starts. People can remove steps (and put them back) before approving. When any step reaches
40
+ * people outside the workspace or can't be undone, Approve and Cancel carry equal weight and nothing runs on a timeout.
41
+ */
42
+ export function PlanCard({
43
+ title,
44
+ steps,
45
+ from,
46
+ onApprove,
47
+ onCancel,
48
+ className,
49
+ }: {
50
+ title: string
51
+ steps: PlanStep[]
52
+ /** What the plan is based on, for the origin label */
53
+ from: string
54
+ onApprove: (steps: PlanStep[]) => void
55
+ onCancel: () => void
56
+ className?: string
57
+ }) {
58
+ const [removed, setRemoved] = React.useState<string[]>([])
59
+ const kept = steps.filter((s) => !removed.includes(s.id))
60
+ const highStakes = kept.some((s) => s.risk)
61
+ return (
62
+ <section aria-label={`Plan: ${title}`} data-slot="plan-card" className={cn("grid rounded-sm border border-line bg-surface", className)}>
63
+ <header className="grid gap-0.5 border-b border-line px-3 py-2">
64
+ <h3 className="text-base font-semibold text-ink">{title}</h3>
65
+ <AiOrigin from={from} />
66
+ </header>
67
+ <ol className="grid">
68
+ {steps.map((s, i) => {
69
+ const off = removed.includes(s.id)
70
+ const R = s.risk ? RISK[s.risk] : null
71
+ return (
72
+ <li key={s.id} className={cn("flex items-start gap-2 border-b border-line px-3 py-2 last:border-b-0", off && "bg-surface-2")}>
73
+ <span className={cn("tabular mt-0.5 w-4 shrink-0 text-sm text-ink-3", off && "line-through")}>{i + 1}</span>
74
+ <span className="grid min-w-0 flex-1 gap-0.5">
75
+ <span className={cn("text-base text-ink", off && "text-ink-3 line-through")}>{s.title}{off && <span className="sr-only"> (removed)</span>}</span>
76
+ {s.detail && !off && <span className="text-sm text-ink-2">{s.detail}</span>}
77
+ {R && !off && <span className="inline-flex items-center gap-1 text-sm font-medium text-warn"><R.icon className="size-3.5" aria-hidden="true" />{R.word}</span>}
78
+ </span>
79
+ <Button variant="ghost" size="xs" onClick={() => setRemoved((r) => (off ? r.filter((x) => x !== s.id) : [...r, s.id]))}>
80
+ {off ? "Put back" : "Remove"}<span className="sr-only"> step {i + 1}</span>
81
+ </Button>
82
+ </li>
83
+ )
84
+ })}
85
+ </ol>
86
+ <footer className="flex flex-wrap items-center gap-2 border-t border-line px-3 py-2">
87
+ <span className="min-w-0 flex-1 text-sm text-ink-2">
88
+ {kept.length} of {steps.length} steps · nothing runs until you approve
89
+ </span>
90
+ <Button variant="outline" size="sm" onClick={onCancel}>Cancel</Button>
91
+ <Button variant={highStakes ? "outline" : "default"} size="sm" disabled={kept.length === 0} onClick={() => onApprove(kept)}>
92
+ Approve plan
93
+ </Button>
94
+ </footer>
95
+ </section>
96
+ )
97
+ }
98
+
99
+ /** One line in the thread or run when the plan changes mid-way. Never reroute silently. */
100
+ export function PlanChange({ what, why, className }: { what: string; why: string; className?: string }) {
101
+ return (
102
+ <p role="status" data-slot="plan-change" className={cn("flex items-start gap-2 rounded-xs border border-line bg-surface-2 px-2 py-2 text-sm text-ink-2", className)}>
103
+ <AlertTriangle className="mt-0.5 size-3.5 shrink-0 text-warn" aria-hidden="true" />
104
+ <span><span className="font-medium text-ink">Plan changed: </span>{what}. {why}</span>
105
+ </p>
106
+ )
107
+ }
108
+
109
+ /* ---------------------------------------------------------------- diff */
110
+
111
+ export type DiffLine = { type: "add" | "del" | "ctx"; text: string }
112
+ export type DiffFile = { path: string; lines: DiffLine[]; note?: string }
113
+
114
+ const count = (f: DiffFile, t: DiffLine["type"]) => f.lines.filter((l) => l.type === t).length
115
+
116
+ /** Changed files with +/− counts; each file opens to a unified diff. Added and removed lines carry + and − and a hidden word. */
117
+ export function DiffView({ files, defaultOpen, className }: { files: DiffFile[]; /** Path of the file to show open */ defaultOpen?: string; className?: string }) {
118
+ const [open, setOpen] = React.useState<string[]>(defaultOpen ? [defaultOpen] : files.slice(0, 1).map((f) => f.path))
119
+ const totals = files.reduce((a, f) => [a[0] + count(f, "add"), a[1] + count(f, "del")], [0, 0])
120
+ return (
121
+ <div data-slot="diff-view" className={cn("grid min-w-0 rounded-sm border border-line bg-surface", className)}>
122
+ <div className="flex items-center gap-3 border-b border-line px-3 py-2 text-sm text-ink-2">
123
+ <span className="flex-1">{files.length} {files.length === 1 ? "file" : "files"} changed</span>
124
+ <span className="tabular font-medium text-ok">+{totals[0]}</span>
125
+ <span className="tabular font-medium text-bad">−{totals[1]}</span>
126
+ </div>
127
+ {files.map((f) => {
128
+ const isOpen = open.includes(f.path)
129
+ return (
130
+ <div key={f.path} className="min-w-0 border-b border-line last:border-b-0">
131
+ <button
132
+ type="button"
133
+ aria-expanded={isOpen}
134
+ onClick={() => setOpen((o) => (isOpen ? o.filter((p) => p !== f.path) : [...o, f.path]))}
135
+ className="flex h-9 w-full items-center gap-2 px-3 text-left hover:bg-hover"
136
+ >
137
+ <ChevronRight className={cn("size-3.5 shrink-0 text-ink-3 transition-transform duration-(--duration-fast)", isOpen && "rotate-90")} aria-hidden="true" />
138
+ <FileText className="size-3.5 shrink-0 text-ink-3" aria-hidden="true" />
139
+ <span className="min-w-0 flex-1 truncate font-mono text-sm text-ink">{f.path}</span>
140
+ <span className="tabular text-sm text-ok">+{count(f, "add")}<span className="sr-only"> added</span></span>
141
+ <span className="tabular text-sm text-bad">−{count(f, "del")}<span className="sr-only"> removed</span></span>
142
+ </button>
143
+ {isOpen && (
144
+ <div className="overflow-x-auto border-t border-line bg-surface-2 py-1 font-mono text-sm">
145
+ {f.note && <p className="px-3 py-1 font-sans text-ink-3">{f.note}</p>}
146
+ {f.lines.map((l, i) => (
147
+ <div key={i} className={cn("flex min-w-max px-3 leading-6", l.type === "add" && "bg-ok-bg", l.type === "del" && "bg-bad-bg")}>
148
+ <span aria-hidden="true" className={cn("w-4 shrink-0 select-none", l.type === "add" ? "text-ok" : l.type === "del" ? "text-bad" : "text-ink-3")}>{l.type === "add" ? "+" : l.type === "del" ? "−" : " "}</span>
149
+ <span className="sr-only">{l.type === "add" ? "Added: " : l.type === "del" ? "Removed: " : ""}</span>
150
+ <span className={cn("whitespace-pre", l.type === "del" ? "text-ink-2" : "text-ink")}>{l.text || " "}</span>
151
+ </div>
152
+ ))}
153
+ </div>
154
+ )}
155
+ </div>
156
+ )
157
+ })}
158
+ </div>
159
+ )
160
+ }
161
+
162
+ /* ---------------------------------------------------------------- simulations */
163
+
164
+ export type SimulationCase = {
165
+ name: string
166
+ /** "fail" failed before and still fails; "new-fail" passed before this change and fails now; "fixed" failed before and passes now */
167
+ status: "pass" | "fail" | "new-fail" | "fixed"
168
+ detail?: string
169
+ }
170
+
171
+ const SIM_ORDER = { "new-fail": 0, fail: 1, fixed: 2, pass: 3 } as const
172
+ const SIM_WORD = { "new-fail": "Newly failing", fail: "Failing", fixed: "Fixed", pass: "Passed" } as const
173
+
174
+ /** Test conversations run against a change. Failures come first; a change that breaks a passing case says so in words. */
175
+ export function SimulationResults({ cases, suite, className }: { cases: SimulationCase[]; /** e.g. "Golden set, 240 conversations" */ suite: string; className?: string }) {
176
+ const n = (s: SimulationCase["status"]) => cases.filter((c) => c.status === s).length
177
+ const passing = n("pass") + n("fixed")
178
+ const sorted = [...cases].sort((a, b) => SIM_ORDER[a.status] - SIM_ORDER[b.status])
179
+ return (
180
+ <section aria-label="Simulation results" data-slot="simulation-results" className={cn("grid rounded-sm border border-line bg-surface", className)}>
181
+ <header className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-line px-3 py-2">
182
+ <span className="text-base font-semibold text-ink">{passing} of {cases.length} pass</span>
183
+ <span className="text-sm text-ink-2">{suite}</span>
184
+ <span className="flex-1" />
185
+ {n("fixed") > 0 && <Badge variant="success" dot>{n("fixed")} fixed</Badge>}
186
+ {n("new-fail") > 0 && <Badge variant="destructive" dot>{n("new-fail")} newly failing</Badge>}
187
+ </header>
188
+ <ul className="grid max-h-64 overflow-y-auto">
189
+ {sorted.map((c, i) => (
190
+ <li key={i} className="flex items-start gap-2 border-b border-line px-3 py-2 last:border-b-0">
191
+ {c.status === "pass" || c.status === "fixed" ? <Check className="mt-0.5 size-3.5 shrink-0 text-ok" aria-hidden="true" /> : <X className="mt-0.5 size-3.5 shrink-0 text-bad" aria-hidden="true" />}
192
+ <span className="grid min-w-0 flex-1"><span className="text-base text-ink">{c.name}</span>{c.detail && <span className="text-sm text-ink-2">{c.detail}</span>}</span>
193
+ <span className={cn("shrink-0 text-sm", c.status === "new-fail" ? "font-medium text-bad" : "text-ink-3")}>{SIM_WORD[c.status]}</span>
194
+ </li>
195
+ ))}
196
+ </ul>
197
+ </section>
198
+ )
199
+ }
200
+
201
+ /* ---------------------------------------------------------------- change request */
202
+
203
+ export type ChangeRequestState = "open" | "approved" | "rejected"
204
+
205
+ /**
206
+ * One change an agent proposes (to records, procedures or another agent): why, what changes, how it tested.
207
+ * Nothing goes live until a person approves; rejecting asks for a reason the agent can learn from.
208
+ * After a decision the card keeps a resolved line with who decided and when.
209
+ */
210
+ export function ChangeRequest({
211
+ title,
212
+ reason,
213
+ from,
214
+ files,
215
+ simulation,
216
+ state = "open",
217
+ decidedBy,
218
+ onApprove,
219
+ onReject,
220
+ className,
221
+ }: {
222
+ title: string
223
+ /** Why the agent proposes this, in one or two sentences */
224
+ reason: string
225
+ /** What the proposal is based on, e.g. "14 conversations this week" */
226
+ from: string
227
+ files?: DiffFile[]
228
+ simulation?: { suite: string; cases: SimulationCase[] }
229
+ state?: ChangeRequestState
230
+ /** e.g. "Approved by Imogen Hart · 10:42" */
231
+ decidedBy?: string
232
+ onApprove: () => void
233
+ onReject: (reason: string) => void
234
+ className?: string
235
+ }) {
236
+ const [rejecting, setRejecting] = React.useState(false)
237
+ const [why, setWhy] = React.useState("")
238
+ const [error, setError] = React.useState("")
239
+ const newFails = simulation?.cases.filter((c) => c.status === "new-fail").length ?? 0
240
+ const submitReject = () => {
241
+ if (!why.trim()) { setError("Say why, so the agent can do better next time."); return }
242
+ onReject(why.trim())
243
+ setRejecting(false)
244
+ }
245
+ return (
246
+ <article aria-label={`Change: ${title}`} data-slot="change-request" className={cn("grid min-w-0 gap-3 rounded-sm border border-line bg-surface p-3", className)}>
247
+ <header className="grid gap-1">
248
+ <div className="flex flex-wrap items-center gap-2">
249
+ <h3 className="min-w-0 flex-1 text-md font-semibold text-ink">{title}</h3>
250
+ <Badge variant={state === "approved" ? "success" : state === "rejected" ? "secondary" : "warning"} dot>
251
+ {state === "approved" ? "Approved" : state === "rejected" ? "Rejected" : "Waiting for review"}
252
+ </Badge>
253
+ </div>
254
+ <AiOrigin from={from} />
255
+ <p className="max-w-prose text-base text-ink-2">{reason}</p>
256
+ </header>
257
+ {files && files.length > 0 && <DiffView files={files} />}
258
+ {simulation && <SimulationResults suite={simulation.suite} cases={simulation.cases} />}
259
+ {state === "open" ? (
260
+ rejecting ? (
261
+ <div className="grid gap-2">
262
+ <Field data-invalid={!!error || undefined}>
263
+ <FieldLabel htmlFor={`${title}-why`}>Why are you rejecting this?</FieldLabel>
264
+ <Textarea
265
+ id={`${title}-why`}
266
+ value={why}
267
+ rows={2}
268
+ aria-invalid={!!error || undefined}
269
+ onChange={(e) => { setWhy(e.target.value); setError("") }}
270
+ />
271
+ {error && <FieldError>{error}</FieldError>}
272
+ </Field>
273
+ <div className="flex justify-end gap-2">
274
+ <Button variant="ghost" size="sm" onClick={() => { setRejecting(false); setError("") }}>Back</Button>
275
+ <Button variant="outline" size="sm" onClick={submitReject}>Reject change</Button>
276
+ </div>
277
+ </div>
278
+ ) : (
279
+ <footer className="flex flex-wrap items-center gap-2">
280
+ <span className="min-w-0 flex-1 text-sm text-ink-2">
281
+ {newFails > 0 ? `${newFails} conversation${newFails === 1 ? "" : "s"} that passed before now fail. Check them before approving.` : "Nothing goes live until you approve."}
282
+ </span>
283
+ <Button variant="outline" size="sm" onClick={() => setRejecting(true)}>Reject</Button>
284
+ <Button size="sm" onClick={onApprove}>Approve change</Button>
285
+ </footer>
286
+ )
287
+ ) : (
288
+ <p role="status" className="text-sm text-ink-2">{decidedBy ?? (state === "approved" ? "Approved" : "Rejected")}</p>
289
+ )}
290
+ </article>
291
+ )
292
+ }
293
+
294
+ /* ---------------------------------------------------------------- confidence */
295
+
296
+ /**
297
+ * The agent is unsure. Say so in words, never as a percentage; say what caused it; offer a safer option or a way to check.
298
+ * Scale it to the stakes: use it when the output affects other people, shared records or money.
299
+ */
300
+ export function ConfidenceNote({ cause, children, action, className }: { /** What made the agent unsure, e.g. "the connection is 35 minutes" */ cause: string; /** The safer option or what to check */ children?: React.ReactNode; action?: React.ReactNode; className?: string }) {
301
+ return (
302
+ <div data-slot="confidence-note" className={cn("grid gap-2 rounded-sm border border-warn-line bg-warn-bg px-3 py-2", className)}>
303
+ <p className="flex items-start gap-2 text-base text-ink">
304
+ <CircleHelp className="mt-0.5 size-3.5 shrink-0 text-warn" aria-hidden="true" />
305
+ <span><span className="font-medium">Not sure: </span>{cause}.</span>
306
+ </p>
307
+ {children && <div className="pl-5.5 text-base text-ink-2">{children}</div>}
308
+ {action && <div className="flex flex-wrap gap-2 pl-5.5">{action}</div>}
309
+ </div>
310
+ )
311
+ }
312
+
313
+ /* ---------------------------------------------------------------- why */
314
+
315
+ /**
316
+ * "Why?" on demand, next to a choice the agent made. Names the deciding factors and the options that lost, says if a
317
+ * requirement couldn't be met, and ends with an action. Store the reasoning with the choice so the answer never drifts.
318
+ */
319
+ export function WhyChoice({
320
+ choice,
321
+ factors,
322
+ alternatives = [],
323
+ dropped,
324
+ onChange,
325
+ onKeep,
326
+ }: {
327
+ choice: string
328
+ factors: string[]
329
+ alternatives?: { name: string; why: string }[]
330
+ /** A requirement that couldn't be met */
331
+ dropped?: string
332
+ onChange?: () => void
333
+ onKeep?: () => void
334
+ }) {
335
+ const [open, setOpen] = React.useState(false)
336
+ return (
337
+ <Popover open={open} onOpenChange={setOpen}>
338
+ <PopoverTrigger asChild>
339
+ <button type="button" data-slot="why-choice" className="relative inline-flex items-center rounded-xs text-sm font-medium text-primary-ink after:absolute after:-inset-1 hover:underline">
340
+ Why?<span className="sr-only"> Why {choice}</span>
341
+ </button>
342
+ </PopoverTrigger>
343
+ <PopoverContent align="start" className="grid w-80 gap-3 text-base">
344
+ <p className="font-medium text-ink">Why {choice}</p>
345
+ <ul className="grid gap-1 text-ink-2">
346
+ {factors.map((f) => <li key={f} className="flex gap-2"><Check className="mt-0.5 size-3.5 shrink-0 text-ok" aria-hidden="true" />{f}</li>)}
347
+ </ul>
348
+ {alternatives.length > 0 && (
349
+ <div className="grid gap-1">
350
+ <span className="text-sm font-medium text-ink">Not chosen</span>
351
+ {alternatives.map((a) => <p key={a.name} className="text-sm text-ink-2"><span className="text-ink">{a.name}</span>: {a.why}</p>)}
352
+ </div>
353
+ )}
354
+ {dropped && <p className="text-sm text-ink-2"><span className="font-medium text-warn">Couldn't meet: </span>{dropped}</p>}
355
+ {(onChange || onKeep) && (
356
+ <div className="flex justify-end gap-2 border-t border-line pt-2">
357
+ {onChange && <Button variant="outline" size="sm" onClick={() => { setOpen(false); onChange() }}>Change this</Button>}
358
+ {onKeep && <Button variant="ghost" size="sm" onClick={() => { setOpen(false); onKeep() }}>Keep it</Button>}
359
+ </div>
360
+ )}
361
+ </PopoverContent>
362
+ </Popover>
363
+ )
364
+ }
@@ -0,0 +1,212 @@
1
+ /**
2
+ * TwoUX assistant basics: the parts every assistant UI needs.
3
+ * Origin label, citations and sources, thinking steps, starters and follow-ups, and the message composer.
4
+ * AI is marked by one small mark (a speech bubble: the assistant wrote this) plus words ("Assistant · from …"), never by a sparkle, a separate colour or a gradient.
5
+ * Proposals, the assistant panel and page, and agent controls live in ai.tsx.
6
+ */
7
+ import { ArrowUp, Check, ChevronDown, ChevronRight, CornerDownRight, Lightbulb, Loader2, MessageSquareText, Plus, Square, X } from "lucide-react"
8
+ import * as React from "react"
9
+ import { cn } from "../lib/cn"
10
+ import { ShimmerText } from "./motion"
11
+ import { Button } from "./button"
12
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from "./dropdown-menu"
13
+ import { Textarea } from "./textarea"
14
+
15
+ /* ---------------------------------------------------------------- origin */
16
+
17
+ export function AiMark({ className }: { className?: string }) {
18
+ return <MessageSquareText aria-hidden="true" className={cn("size-3 shrink-0 text-ink-3", className)} strokeWidth={2} />
19
+ }
20
+
21
+ /** “Assistant · from your workspace data · 2 min ago”: what produced it and from what. One per group of generated content. */
22
+ export function AiOrigin({ from, time, className }: { from: string; time?: string; className?: string }) {
23
+ return (
24
+ <span data-slot="ai-origin" className={cn("inline-flex items-center gap-1 text-sm text-ink-3", className)}>
25
+ <AiMark />
26
+ <span>Assistant · from {from}{time ? ` · ${time}` : ""}</span>
27
+ </span>
28
+ )
29
+ }
30
+
31
+ /* ---------------------------------------------------------------- citations */
32
+
33
+ /** A record ID that opens the record, placed at the end of the claim it supports. */
34
+ export function Citation({ id, onOpen, active }: { id: string; onOpen?: (id: string) => void; active?: boolean }) {
35
+ return (
36
+ <button
37
+ type="button"
38
+ onClick={() => onOpen?.(id)}
39
+ className={cn("relative mx-0.5 inline-flex h-4.5 items-center rounded-xs border px-1 align-baseline font-mono text-xs transition-colors after:absolute after:-inset-1", active ? "border-primary bg-primary-soft text-primary-ink" : "border-line-strong bg-surface-2 text-ink-2 hover:border-input-hover hover:text-ink")}
40
+ >
41
+ {id}
42
+ </button>
43
+ )
44
+ }
45
+
46
+ export function SourcesList({ sources, active, onSelect }: { sources: { id: string; title: string; detail?: string }[]; active?: string; onSelect?: (id: string) => void }) {
47
+ return (
48
+ <ul aria-label="Sources" className="grid gap-1">
49
+ {sources.map((s) => (
50
+ <li key={s.id}>
51
+ <button type="button" onClick={() => onSelect?.(s.id)} aria-current={active === s.id || undefined} className={cn("grid w-full gap-0.5 rounded-xs border px-2 py-2 text-left transition-colors", active === s.id ? "border-primary bg-primary-soft" : "border-line hover:bg-hover")}>
52
+ <span className="flex items-center gap-2"><span className="font-mono text-xs text-ink-3">{s.id}</span><span className="truncate text-base font-medium text-ink">{s.title}</span></span>
53
+ {s.detail && <span className="text-sm text-ink-2">{s.detail}</span>}
54
+ </button>
55
+ </li>
56
+ ))}
57
+ </ul>
58
+ )
59
+ }
60
+
61
+ /* ---------------------------------------------------------------- thinking */
62
+
63
+ export type ThinkingStep = { title: string; detail?: string }
64
+
65
+ /** Says the current step in words with a running timer, then collapses to “Thought for 2.1s · 4 steps”. */
66
+ export function ThinkingSteps({ steps, done, seconds, verb = "Thought" }: { steps: ThinkingStep[]; done: boolean; seconds: number; /** "Thought" for reasoning, "Worked" for an agent that also ran tools */ verb?: string }) {
67
+ const [open, setOpen] = React.useState(false)
68
+ if (!done) {
69
+ const cur = steps[steps.length - 1]
70
+ return (
71
+ <div role="status" className="grid gap-2 text-base">
72
+ <span className="flex items-center gap-2 text-ink-2"><Loader2 className="size-3.5 animate-spin text-ink-3" aria-hidden="true" /><ShimmerText active={!done}>{cur?.title ?? "Thinking"}</ShimmerText><span className="tabular text-sm text-ink-3">{seconds.toFixed(1)}s</span></span>
73
+ {cur?.detail && <span className="pl-5.5 text-sm text-ink-3">{cur.detail}</span>}
74
+ </div>
75
+ )
76
+ }
77
+ return (
78
+ <div className="grid gap-2">
79
+ <button type="button" aria-expanded={open} onClick={() => setOpen((o) => !o)} className="inline-flex w-fit items-center gap-control rounded-xs text-sm text-ink-2 hover:text-ink">
80
+ <Check className="size-3.5 text-ok" aria-hidden="true" />{verb} for {seconds.toFixed(1)}s · {steps.length} steps
81
+ <ChevronRight className={cn("size-3.5 text-ink-3 transition-transform duration-(--duration-fast)", open && "rotate-90")} aria-hidden="true" />
82
+ </button>
83
+ {open && (
84
+ <ol className="ml-2 grid gap-2 border-l border-line pl-3.5">
85
+ {steps.map((s, i) => <li key={i} className="grid text-sm"><span className="font-medium text-ink">{s.title}</span>{s.detail && <span className="text-ink-2">{s.detail}</span>}</li>)}
86
+ </ol>
87
+ )}
88
+ </div>
89
+ )
90
+ }
91
+
92
+ /* ---------------------------------------------------------------- suggestions */
93
+
94
+ export function Starters({ items, onPick, onHide }: { items: string[]; onPick: (q: string) => void; onHide?: () => void }) {
95
+ return (
96
+ <div className="grid gap-2">
97
+ <span className="flex items-center gap-control text-sm text-ink-3"><Lightbulb className="size-3.5" aria-hidden="true" />Try asking{onHide && <Button variant="ghost" size="xs" className="ml-auto" onClick={onHide}>Hide</Button>}</span>
98
+ <div className="flex flex-wrap gap-control">{items.slice(0, 3).map((q) => <Button key={q} variant="outline" size="sm" onClick={() => onPick(q)}>{q}</Button>)}</div>
99
+ </div>
100
+ )
101
+ }
102
+
103
+ export function FollowUps({ items, onPick }: { items: string[]; onPick: (q: string) => void }) {
104
+ return (
105
+ <div className="grid gap-0.5">
106
+ <span className="text-sm text-ink-3">Follow-ups</span>
107
+ {items.slice(0, 3).map((q) => (
108
+ <button key={q} type="button" onClick={() => onPick(q)} className="flex items-start gap-2 rounded-xs px-1 py-1 text-left text-base text-ink-2 hover:bg-hover hover:text-ink">
109
+ <CornerDownRight className="mt-0.5 size-3.5 shrink-0 text-ink-3" aria-hidden="true" />{q}
110
+ </button>
111
+ ))}
112
+ </div>
113
+ )
114
+ }
115
+
116
+ /* ---------------------------------------------------------------- composer */
117
+
118
+ export type ContextChip = { id: string; label: string }
119
+
120
+ export function MessageComposer({
121
+ chips = [],
122
+ onRemoveChip,
123
+ onSend,
124
+ busy = false,
125
+ onStop,
126
+ mentions = [],
127
+ tasks = [],
128
+ plusMenu,
129
+ noun = { one: "record", many: "records" },
130
+ placeholder = `Ask about these ${noun.many}`,
131
+ }: {
132
+ chips?: ContextChip[]
133
+ onRemoveChip?: (id: string) => void
134
+ onSend: (text: string, mode: "quick" | "thorough") => void
135
+ busy?: boolean
136
+ onStop?: () => void
137
+ /** @ suggestions: the records people can mention */
138
+ mentions?: { id: string; label: string }[]
139
+ /** / suggestions: saved tasks */
140
+ tasks?: string[]
141
+ plusMenu?: React.ReactNode
142
+ /** What @ mentions refer to in this product, e.g. { one: "listing", many: "listings" } or { one: "file", many: "files" } */
143
+ noun?: { one: string; many: string }
144
+ placeholder?: string
145
+ }) {
146
+ const [text, setText] = React.useState("")
147
+ const [mode, setMode] = React.useState<"quick" | "thorough">("quick")
148
+ const [hi, setHi] = React.useState(0)
149
+ const trigger = /(^|\s)([@/])(\w*)$/.exec(text)
150
+ const list = trigger ? (trigger[2] === "@" ? mentions.map((m) => m.label) : tasks).filter((x) => x.toLowerCase().includes(trigger[3].toLowerCase())).slice(0, 6) : []
151
+ const accept = (v: string) => { setText((t) => t.replace(/(^|\s)[@/]\w*$/, `$1${trigger?.[2] === "@" ? "@" : "/"}${v} `)); setHi(0) }
152
+ const send = () => { if (!text.trim() || busy) return; onSend(text.trim(), mode); setText("") }
153
+ const listId = React.useId()
154
+ return (
155
+ <div data-slot="composer" className="grid gap-2 rounded-md border border-input bg-surface p-2 shadow-xs focus-within:border-primary focus-within:ring-3 focus-within:ring-primary/20">
156
+ {chips.length > 0 && (
157
+ <div className="flex flex-wrap gap-1">
158
+ {chips.map((c) => (
159
+ <span key={c.id} className="inline-flex h-5.5 items-center gap-1 rounded-xs border border-line bg-surface-2 pr-0.5 pl-2 text-sm text-ink-2">
160
+ {c.label}
161
+ {onRemoveChip && <button type="button" onClick={() => onRemoveChip(c.id)} aria-label={`Remove ${c.label} from context`} className="relative grid size-4 place-items-center rounded-xs text-ink-3 after:absolute after:-inset-1 hover:bg-hover hover:text-ink"><X className="size-3" aria-hidden="true" /></button>}
162
+ </span>
163
+ ))}
164
+ </div>
165
+ )}
166
+ <div className="relative">
167
+ <Textarea
168
+ value={text}
169
+ onChange={(e) => { setText(e.target.value); setHi(0) }}
170
+ placeholder={placeholder}
171
+ aria-label="Message the assistant"
172
+ role="combobox"
173
+ aria-expanded={list.length > 0}
174
+ aria-controls={listId}
175
+ aria-activedescendant={list.length ? `${listId}-${hi}` : undefined}
176
+ onKeyDown={(e) => {
177
+ if (list.length && (e.key === "ArrowDown" || e.key === "ArrowUp")) { e.preventDefault(); setHi((h) => (h + (e.key === "ArrowDown" ? 1 : -1) + list.length) % list.length); return }
178
+ if (list.length && (e.key === "Enter" || e.key === "Tab")) { e.preventDefault(); accept(list[hi]); return }
179
+ if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send() }
180
+ }}
181
+ className="min-h-10 resize-none border-0 bg-transparent p-1 shadow-none focus-visible:ring-0"
182
+ />
183
+ {list.length > 0 && (
184
+ <ul id={listId} role="listbox" aria-label={trigger?.[2] === "@" ? noun.many.charAt(0).toUpperCase() + noun.many.slice(1) : "Saved tasks"} className="absolute bottom-full left-0 z-20 mb-1 grid w-64 rounded-md border border-line bg-surface p-1 shadow-pop">
185
+ {list.map((v, i) => <li key={v} id={`${listId}-${i}`} role="option" aria-selected={i === hi} onMouseDown={(e) => { e.preventDefault(); accept(v) }} className={cn("rounded-xs px-2 py-2 text-base", i === hi ? "bg-accent text-ink" : "text-ink-2")}>{trigger?.[2]}{v}</li>)}
186
+ </ul>
187
+ )}
188
+ </div>
189
+ <div className="flex items-center gap-1">
190
+ <DropdownMenu>
191
+ <DropdownMenuTrigger asChild><Button variant="ghost" size="icon-sm" aria-label="Add context or options"><Plus /></Button></DropdownMenuTrigger>
192
+ <DropdownMenuContent align="start" className="w-56">
193
+ {plusMenu ?? (<><DropdownMenuLabel>Insert</DropdownMenuLabel><DropdownMenuItem onSelect={() => setText((t) => `${t}@`)}>Mention a {noun.one}</DropdownMenuItem><DropdownMenuItem onSelect={() => setText((t) => `${t}/`)}>Run a saved task</DropdownMenuItem></>)}
194
+ </DropdownMenuContent>
195
+ </DropdownMenu>
196
+ <DropdownMenu>
197
+ <DropdownMenuTrigger asChild><Button variant="ghost" size="sm">{mode === "quick" ? "Quick" : "Thorough"}<ChevronDown className="text-ink-3" /></Button></DropdownMenuTrigger>
198
+ <DropdownMenuContent align="start">
199
+ <DropdownMenuItem onSelect={() => setMode("quick")}>Quick: answers in seconds</DropdownMenuItem>
200
+ <DropdownMenuItem onSelect={() => setMode("thorough")}>Thorough: reads more {noun.many}, takes longer</DropdownMenuItem>
201
+ </DropdownMenuContent>
202
+ </DropdownMenu>
203
+ <span className="flex-1" />
204
+ {busy ? (
205
+ <Button size="sm" variant="outline" onClick={onStop} aria-label="Stop the answer"><Square className="fill-current" />Stop</Button>
206
+ ) : (
207
+ <Button size="icon-sm" aria-label="Send" disabled={!text.trim()} onClick={send}><ArrowUp /></Button>
208
+ )}
209
+ </div>
210
+ </div>
211
+ )
212
+ }