@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.
- package/README.md +168 -2
- package/package.json +50 -5
- package/src/assets/sidebar-photo.jpg +0 -0
- package/src/components/agent-activity.tsx +202 -0
- package/src/components/agent-lane.tsx +80 -0
- package/src/components/agent-lanes-timeline.tsx +312 -0
- package/src/components/agent-panel.tsx +121 -0
- package/src/components/agent-steering-panel.tsx +274 -0
- package/src/components/approval-card.tsx +89 -0
- package/src/components/avatar.tsx +104 -0
- package/src/components/badge.tsx +63 -0
- package/src/components/banner.tsx +103 -0
- package/src/components/bot-instructions-panel.tsx +365 -0
- package/src/components/bot-list-row.tsx +42 -0
- package/src/components/bot-memory-panel.tsx +229 -0
- package/src/components/bottom-sheet.tsx +140 -0
- package/src/components/breadcrumb-header.tsx +87 -0
- package/src/components/breadcrumb.tsx +86 -0
- package/src/components/button.tsx +146 -0
- package/src/components/calendar.tsx +410 -0
- package/src/components/card.tsx +79 -0
- package/src/components/chat-bubble.tsx +36 -0
- package/src/components/chat-controls.tsx +118 -0
- package/src/components/checkbox.tsx +73 -0
- package/src/components/chip.tsx +81 -0
- package/src/components/choice-card.tsx +55 -0
- package/src/components/code-panel.tsx +27 -0
- package/src/components/combobox.tsx +312 -0
- package/src/components/command-palette.tsx +191 -0
- package/src/components/composer.tsx +124 -0
- package/src/components/data-table.tsx +429 -0
- package/src/components/date-field.tsx +100 -0
- package/src/components/dialog.tsx +93 -0
- package/src/components/directory-card.tsx +59 -0
- package/src/components/document-extract-panel.tsx +398 -0
- package/src/components/empty-state.tsx +29 -0
- package/src/components/field.tsx +78 -0
- package/src/components/file-drop.tsx +89 -0
- package/src/components/filter-bar.tsx +83 -0
- package/src/components/flue-background.tsx +402 -0
- package/src/components/horon-icon.tsx +280 -0
- package/src/components/horon-wordmark.tsx +26 -0
- package/src/components/icon-button.tsx +61 -0
- package/src/components/input-group.tsx +112 -0
- package/src/components/input.tsx +72 -0
- package/src/components/integration-row.tsx +103 -0
- package/src/components/kbd.tsx +35 -0
- package/src/components/knowledge-base-panel.tsx +185 -0
- package/src/components/liquid-glass.tsx +24 -0
- package/src/components/list-detail.tsx +275 -0
- package/src/components/marketplace-card.tsx +97 -0
- package/src/components/mcp-server-row.tsx +122 -0
- package/src/components/menu.tsx +165 -0
- package/src/components/message-extras.tsx +156 -0
- package/src/components/modal.tsx +108 -0
- package/src/components/orchestrator-panel.tsx +175 -0
- package/src/components/page-header.tsx +48 -0
- package/src/components/plan-card.tsx +70 -0
- package/src/components/plan-step.tsx +111 -0
- package/src/components/plugin-row.tsx +128 -0
- package/src/components/progress.tsx +21 -0
- package/src/components/radio-group.tsx +84 -0
- package/src/components/research-run-panel.tsx +232 -0
- package/src/components/schedule.tsx +332 -0
- package/src/components/segmented-control.tsx +136 -0
- package/src/components/select.tsx +154 -0
- package/src/components/settings-list.tsx +99 -0
- package/src/components/sidebar.tsx +542 -0
- package/src/components/skeleton.tsx +75 -0
- package/src/components/slider.tsx +29 -0
- package/src/components/source-row.tsx +112 -0
- package/src/components/spinner.tsx +29 -0
- package/src/components/split-button.tsx +72 -0
- package/src/components/switch.tsx +79 -0
- package/src/components/tab-bar.tsx +90 -0
- package/src/components/tabs.tsx +162 -0
- package/src/components/task-row.tsx +159 -0
- package/src/components/textarea-field.tsx +127 -0
- package/src/components/textarea.tsx +47 -0
- package/src/components/toast.tsx +128 -0
- package/src/components/tool-call.tsx +224 -0
- package/src/components/tool-calls-header.tsx +65 -0
- package/src/components/tool-permissions.tsx +105 -0
- package/src/components/tooltip.tsx +33 -0
- package/src/components/top-bar.tsx +65 -0
- package/src/components/usage-meter.tsx +47 -0
- package/src/components/workspace-menu.tsx +77 -0
- package/src/index.ts +86 -0
- package/src/lib/cn.ts +25 -0
- package/src/lib/cursor-ghost.ts +244 -0
- package/src/lib/size.ts +10 -0
- package/src/lib/use-active-indicator.ts +60 -0
- package/src/styles/globals.css +564 -0
package/README.md
CHANGED
|
@@ -1,3 +1,169 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @horon-ai/react
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Horon 웹 컴포넌트 라이브러리입니다. Figma `Horon` 파일의 Components 보드(`54:81`)를 그대로
|
|
4
|
+
옮겼고, 동작과 접근성은 [Base UI](https://base-ui.com)(`@base-ui/react`) 하나에만 기댑니다. `cn()`은 Horon 토큰 이름을 아는 tailwind-merge라서, 넘긴 클래스가 컴포넌트의 Figma 기본값(너비, 반경 등)을 덮어씁니다.
|
|
5
|
+
shadcn/ui와 그 CLI는 쓰지 않습니다. 색, 크기, 글자 값은 모두 `@horon-ai/tokens`에서 옵니다.
|
|
6
|
+
|
|
7
|
+
빌드 산출물 없이 TypeScript 소스를 그대로 배포하므로, 소비하는 쪽 번들러가 소스를 컴파일합니다.
|
|
8
|
+
|
|
9
|
+
## 설치와 진입 스타일
|
|
10
|
+
|
|
11
|
+
```json
|
|
12
|
+
{ "dependencies": { "@horon-ai/react": "file:../design/packages/react", "@horon-ai/tokens": "file:../design/packages/tokens" } }
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
앱 진입 CSS에서 한 번 import합니다. Tailwind CSS v4, Pretendard, Geist Mono, 토큰 `theme.css`,
|
|
16
|
+
유리(liquid glass) 레이어 스타일이 여기서 함께 들어옵니다.
|
|
17
|
+
|
|
18
|
+
```css
|
|
19
|
+
@import "@horon-ai/react/globals.css";
|
|
20
|
+
@source "../node_modules/@horon-ai/react/src/**/*.{ts,tsx}";
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
- 다크 모드: 상위 요소에 `class="dark"`를 둡니다. 값은 Figma Components / Dark 보드(`166:1107`)에서 읽었습니다.
|
|
24
|
+
- 플랫폼 글자 크기: 데스크톱 앱은 `data-platform="desktop"`, 모바일 웹은 `data-platform="mobile"`을 둡니다.
|
|
25
|
+
Typography 보드(`26:2`)의 Web / Desktop / Mobile 값으로 13개 글자 스타일이 바뀝니다.
|
|
26
|
+
- 아이콘: Figma 아이콘은 Phosphor Regular입니다. `@phosphor-icons/react`를 직접 쓰고,
|
|
27
|
+
Phosphor에 없는 Horon 눈 아이콘만 `HoronIcon`으로 제공합니다.
|
|
28
|
+
|
|
29
|
+
## 사용
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
import { Button, Card, Input } from "@horon-ai/react";
|
|
33
|
+
import { Plus } from "@phosphor-icons/react";
|
|
34
|
+
|
|
35
|
+
<Card title="Inbox Manager" description="6 emails need you." />;
|
|
36
|
+
<Button icon={<Plus />}>New bot</Button>;
|
|
37
|
+
<Input surface="solid" placeholder="Search bots" />;
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
하위 경로도 됩니다: `@horon-ai/react/components/button`.
|
|
41
|
+
|
|
42
|
+
## 커서 잔상
|
|
43
|
+
|
|
44
|
+
마우스나 트랙패드처럼 정밀한 포인터가 있는 데스크톱 환경에서는 화살표 뒤로 흐려지는 잔상 5개가 따라옵니다.
|
|
45
|
+
`@horon-ai/react`를 한 번이라도 import하면 페이지마다 한 번 자동으로 붙습니다. React 컴포넌트를 쓰지 않는
|
|
46
|
+
페이지에서는 `import "@horon-ai/react/cursor-ghost";` 한 줄로 붙입니다.
|
|
47
|
+
|
|
48
|
+
- 시스템 커서는 숨기지 않습니다. 잔상은 클릭·포커스를 받지 않는 맨 위 겹 레이어이고 `aria-hidden`입니다.
|
|
49
|
+
- 터치·펜처럼 거친 포인터, 동작 줄이기(`prefers-reduced-motion: reduce`) 설정에서는 붙지 않고, 설정이 바뀌면 다시 판단합니다.
|
|
50
|
+
- 흰색을 `difference`로 섞어서 어두운 화면에서는 밝게, 밝은 화면에서는 어둡게 보입니다.
|
|
51
|
+
- 끄려면 그 import보다 먼저 `<html data-horon-cursor="off">`를 둡니다. 값을 바꾸려면 끈 뒤
|
|
52
|
+
`mountCursorGhost({ ghosts, size, headFollow, smear, color })`를 직접 부르고, 돌려받은 함수로 떼어 냅니다.
|
|
53
|
+
|
|
54
|
+
## 유리 표면
|
|
55
|
+
|
|
56
|
+
`LiquidGlass`가 `@horon-ai/tokens`의 `glass.ts` 재질을 그립니다. Flutter의 `HoronLiquidGlass`도 같은 값을 읽으므로
|
|
57
|
+
web, desktop(WebView2, WKWebView), iOS, Android가 같은 모습입니다.
|
|
58
|
+
|
|
59
|
+
- 배경: 뒤 내용을 흐리게(20px) 하고 채도를 180%로 올립니다.
|
|
60
|
+
- 채움: 호스트가 `glass-clear` / `glass-regular` / `glass-thick`으로 칠합니다.
|
|
61
|
+
- 테두리: 가장자리 안쪽 1px 링으로, 위가 밝고 옆으로 갈수록 옅어집니다. 호스트에는 자기 테두리를 두지 않고,
|
|
62
|
+
`data-invalid` 상태에서는 링이 위험 색이 됩니다.
|
|
63
|
+
|
|
64
|
+
유리 컴포넌트는 `LiquidGlass`를 첫 자식으로 둡니다. 굴절은 Safari와 WKWebView에서 그릴 수 없어서 쓰지 않습니다.
|
|
65
|
+
투명도 줄이기 설정이 켜져 있거나 `backdrop-filter`를 지원하지 않으면 흐림 대신 `glass-fill-frost` 채움이 됩니다.
|
|
66
|
+
|
|
67
|
+
`LiquidGlass`는 **prop을 받지 않습니다**. 유리 높이(`clear` / `regular` / `thick`)는 호스트의 채움 클래스 하나로 정하고,
|
|
68
|
+
레이어에 채움을 칠하지 않습니다. 레이어에 `bg-glass-*`를 얹으면 위의 `glass-fill-frost` 폴백을 Tailwind 유틸리티가
|
|
69
|
+
덮어 투명도 줄이기·미지원 엔진에서 표면이 실제와 다르게 그려집니다. 예전 호출부의 `tier="thick"` 같은 인자는 지웁니다.
|
|
70
|
+
|
|
71
|
+
레이어는 `absolute; inset: 0`이라 그대로 두면 정적 본문 위로 페인트됩니다. 호스트를 `relative isolate`로 두고
|
|
72
|
+
레이어에 `-z-10`을 주면 호스트 채움 위, 제목·본문 아래로 확정됩니다(`Card`는 이 두 클래스를 스스로 답니다).
|
|
73
|
+
`isolate`를 걸 수 없는 호스트는 본문 요소를 `relative`로 올려 같은 순서를 만듭니다.
|
|
74
|
+
|
|
75
|
+
## 컴포넌트 (84개)
|
|
76
|
+
|
|
77
|
+
| 파일 | Figma 노드 | 내용 |
|
|
78
|
+
| --- | --- | --- |
|
|
79
|
+
| `agent-activity` | 108:660 | Thinking, streaming, tool steps and work log. |
|
|
80
|
+
| `agent-lane` | 113:953 | Sub-agent lane card. |
|
|
81
|
+
| `agent-lanes-timeline` | YCO-1 | Timeline of each bot's run spans. |
|
|
82
|
+
| `agent-panel` | 112:797 | Solid agent panel, task composer and picker chips. |
|
|
83
|
+
| `agent-steering-panel` | Y51-1 | Steering panel that redirects a running bot. |
|
|
84
|
+
| `approval-card` | 64:398 | Glass approval card. |
|
|
85
|
+
| `avatar` | 61:347 | Initials, Bot and Image avatar with a live dot. |
|
|
86
|
+
| `badge` | 61:283 | Neutral, Brand, Danger and Solid status pill. |
|
|
87
|
+
| `banner` | 62:318 | Info and Danger glass banner. |
|
|
88
|
+
| `bot-instructions-panel` | YN3-1 | Bot instructions and examples in tabs. |
|
|
89
|
+
| `bot-list-row` | 64:456 | Bot list row with live avatar. |
|
|
90
|
+
| `bot-memory-panel` | ZO2-1 | Bot memory grouped by category. |
|
|
91
|
+
| `bottom-sheet` | 88:808 | Thick-glass bottom sheet; the light board adds frost. |
|
|
92
|
+
| `breadcrumb` | 110:948 | Breadcrumb with workspace tile. |
|
|
93
|
+
| `breadcrumb-header` | U8H-1 | Glass header row with a breadcrumb and optional search and more buttons. |
|
|
94
|
+
| `button` | 55:339 | Primary, Glass, Solid, Danger and Link in three sizes; `href` renders a real link, `type` reaches the button, and a child icon sits on the label's row. |
|
|
95
|
+
| `calendar` | 60:245 | Month grid with single and range selection. |
|
|
96
|
+
| `card` | 63:310 | Solid, thick-glass or frost card: Default, Interactive, Selected. Isolates its own stacking context so the glass layer stays under the body. |
|
|
97
|
+
| `chat-bubble` | 64:359 | Flat Me and Bot chat bubbles. |
|
|
98
|
+
| `chat-controls` | 88:659 | Jump to latest, new messages, voice input. |
|
|
99
|
+
| `checkbox` | 58:217 | Off, On and Mixed checkbox. |
|
|
100
|
+
| `chip` | 61:302 | Clear-glass toggle chip. |
|
|
101
|
+
| `choice-card` | 1VXM-0 | Radio card that picks one of two options. |
|
|
102
|
+
| `code-panel` | 1VV9-0 | Light code panel for settings screens. |
|
|
103
|
+
| `combobox` | — | Searchable picker: type to narrow the list, then pick one item or, with `multiple`, several as chips. `variant="segment"` goes inside an `InputGroup`. |
|
|
104
|
+
| `command-palette` | 110:676 | Frosted command palette; `empty` is the no-match slot. |
|
|
105
|
+
| `composer` | 63:462 | Glass message composer. |
|
|
106
|
+
| `data-table` | 192:9046 | Solid data table with header, tags and footer. |
|
|
107
|
+
| `date-field` | 60:244 | Glass field that opens the calendar. |
|
|
108
|
+
| `dialog` | 63:329 | Thick-glass dialog on a scrim; the light board adds frost. |
|
|
109
|
+
| `directory-card` | 1VJJ-0 | Directory card with a description; the add button shows when `onAdd` is passed. |
|
|
110
|
+
| `document-extract-panel` | YUT-1 | Document viewer beside the extracted fields. |
|
|
111
|
+
| `empty-state` | 64:485 | Mascot empty state. |
|
|
112
|
+
| `field` | 58:163 | Label, control, help and error text. |
|
|
113
|
+
| `file-drop` | 1VWI-0 | Dashed drop zone for files. |
|
|
114
|
+
| `filter-bar` | WLN-1 | Pill filter bar with counts. |
|
|
115
|
+
| `flue-background` | — | Night-and-sky smoke gradient behind glass; `variant` picks the sidebar column or the full auth screen and bends toward the cursor. |
|
|
116
|
+
| `horon-icon` | 256:9116 | The Horon falcon-eye icon. |
|
|
117
|
+
| `horon-wordmark` | 88:808 | UI-size Horon wordmark. |
|
|
118
|
+
| `icon-button` | 57:167 | Primary, Ghost, Glass and Solid icon-only buttons in three sizes, plus a fill-free `quiet` variant that draws only the ink hover/press wash. |
|
|
119
|
+
| `input` | 58:126 | Glass or Solid text input, MD and LG. |
|
|
120
|
+
| `input-group` | 60:429 | Input with a prefix or suffix segment. |
|
|
121
|
+
| `integration-row` | 110:883 | Integration row: connected, reconnect, not connected. |
|
|
122
|
+
| `kbd` | 110:676 | Keyboard hint chip. |
|
|
123
|
+
| `knowledge-base-panel` | XVM-1 | Indexing state of each knowledge source. |
|
|
124
|
+
| `liquid-glass` | 55:339 | The liquid glass layer stack. Takes no props: the host owns the tier class and the `relative isolate` stacking. |
|
|
125
|
+
| `list-detail` | 1W6P-0 | Two-pane layout with a list on the left and detail on the right. |
|
|
126
|
+
| `marketplace-card` | 1VMY-0 | Marketplace card with a description; the install button shows when `onInstall` is passed. |
|
|
127
|
+
| `mcp-server-row` | 1VHS-0 | Connected MCP server row with name, tags and state. |
|
|
128
|
+
| `menu` | 88:495 | Glass menu and solid account/workspace menu. |
|
|
129
|
+
| `message-extras` | 109:667 | Message actions, date separator, attachment, code block. |
|
|
130
|
+
| `modal` | — | Form or long-read window: a fixed header, a body that scrolls on its own and a footer of actions at the right. |
|
|
131
|
+
| `orchestrator-panel` | X01-1 | Lead bot, parallel lanes and a summary. |
|
|
132
|
+
| `page-header` | 88:723 | Glass header with back, title, actions and filter. |
|
|
133
|
+
| `plan-card` | XMH-1 | Card shell that wraps plan steps. |
|
|
134
|
+
| `plan-step` | 191:8296 | Plan step on a rail. |
|
|
135
|
+
| `plugin-row` | 1VKS-0 | Plugin row with name, version, update and an enable switch. |
|
|
136
|
+
| `progress` | 109:733 | 6px progress bar. |
|
|
137
|
+
| `radio-group` | 58:239 | Radio group with 20px radios. |
|
|
138
|
+
| `research-run-panel` | ZUU-1 | Research question, progress and search terms. |
|
|
139
|
+
| `schedule` | 110:765 | Time field, weekday picker and routine schedule card. |
|
|
140
|
+
| `segmented-control` | 62:379 | Glass segmented control. |
|
|
141
|
+
| `select` | 58:162 | Glass or Solid select with a glass list. |
|
|
142
|
+
| `settings-list` | 1VD8-0 | Settings card of label and switch rows split by dividers. |
|
|
143
|
+
| `sidebar` | 88:808 | Thick-glass sidebar with groups, tree and account. |
|
|
144
|
+
| `skeleton` | 109:733 | Placeholder blocks and cards. |
|
|
145
|
+
| `slider` | — | Settings slider: a thin ink track with a white hairline thumb; width comes from the caller. |
|
|
146
|
+
| `source-row` | 114:1287 | Knowledge source with pipeline stages. |
|
|
147
|
+
| `spinner` | 109:733 | Ink spinner in three sizes. |
|
|
148
|
+
| `split-button` | 1VGK-0 | One pill with a primary action and a secondary menu caret. |
|
|
149
|
+
| `switch` | 58:264 | SM, MD, LG and COMPACT switch, with an optional label; the thumb holds its size inside the track. |
|
|
150
|
+
| `tab-bar` | 63:441 | Mobile glass tab bar. |
|
|
151
|
+
| `tabs` | 62:335 | Pill tabs on a glass tray. |
|
|
152
|
+
| `task-row` | 112:939 | Task row: running, review, failed, done. |
|
|
153
|
+
| `textarea` | 58:137 | Glass multi-line input. |
|
|
154
|
+
| `textarea-field` | 60:466 | Labeled textarea with footer and counter. |
|
|
155
|
+
| `toast` | 62:307 | Glass toast with Neutral, Success and Danger tones. |
|
|
156
|
+
| `tool-call` | 186:7811 | Expandable tool call with JSON arguments and result. |
|
|
157
|
+
| `tool-calls-header` | XDG-1 | Title row and tool chips for a group of tool calls. |
|
|
158
|
+
| `tool-permissions` | 1VT5-0 | Per-tool permission list: allow, confirm or off. |
|
|
159
|
+
| `tooltip` | 62:282 | Ink tooltip with an arrow. |
|
|
160
|
+
| `top-bar` | UR5-1 | Mobile app bar with bot avatar, title and subtitle, plus back and overflow when their handlers are passed. |
|
|
161
|
+
| `usage-meter` | 109:733 | Glass usage meter card. |
|
|
162
|
+
| `workspace-menu` | V56-1 | Solid menu to pick a workspace or create one. |
|
|
163
|
+
|
|
164
|
+
## 검사
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
bun run lint && bun run typecheck # 이 패키지에서
|
|
168
|
+
bun test # 저장소 루트에서, 진입점 재수출 커버리지
|
|
169
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,51 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@horon-ai/react",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "git+https://github.com/horon-ai/design.git",
|
|
7
|
+
"directory": "packages/react"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"exports": {
|
|
18
|
+
".": "./src/index.ts",
|
|
19
|
+
"./globals.css": "./src/styles/globals.css",
|
|
20
|
+
"./components/*": "./src/components/*.tsx",
|
|
21
|
+
"./cursor-ghost": "./src/lib/cursor-ghost.ts",
|
|
22
|
+
"./package.json": "./package.json"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"lint": "oxlint --deny-warnings .",
|
|
26
|
+
"lint:fix": "oxlint --fix .",
|
|
27
|
+
"format": "prettier --write \"**/*.{ts,tsx}\"",
|
|
28
|
+
"typecheck": "tsc --noEmit"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@base-ui/react": "^1.9.0",
|
|
32
|
+
"@fontsource-variable/geist-mono": "^5.3.0",
|
|
33
|
+
"@horon-ai/tokens": "0.1.0",
|
|
34
|
+
"@phosphor-icons/react": "^2.1.10",
|
|
35
|
+
"@tanstack/react-table": "^9.3.1",
|
|
36
|
+
"pretendard": "^1.3.9",
|
|
37
|
+
"tailwind-merge": "^3.7.0"
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"react": ">=19",
|
|
41
|
+
"react-dom": ">=19"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@tailwindcss/vite": "^4",
|
|
45
|
+
"@types/node": "^24",
|
|
46
|
+
"@types/react": "^19",
|
|
47
|
+
"@types/react-dom": "^19",
|
|
48
|
+
"tailwindcss": "^4",
|
|
49
|
+
"typescript": "~6.0.3"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { CaretDown, CaretRight, CheckCircle, WarningCircle, Wrench } from "@phosphor-icons/react";
|
|
2
|
+
import { type ComponentProps, type ReactNode, useEffect, useRef, useState } from "react";
|
|
3
|
+
|
|
4
|
+
import { cn } from "../lib/cn";
|
|
5
|
+
import { Spinner } from "./spinner";
|
|
6
|
+
|
|
7
|
+
/** Figma `Thinking` (Agent activity 108:660): a 69x31 subtle pill with three 7px dots at 35/55/80% ink. */
|
|
8
|
+
export function ThinkingIndicator({ label = "Thinking", className }: { label?: string; className?: string }) {
|
|
9
|
+
return (
|
|
10
|
+
<span
|
|
11
|
+
role="status"
|
|
12
|
+
aria-label={label}
|
|
13
|
+
className={cn(
|
|
14
|
+
"inline-flex h-7.75 w-17.25 items-center justify-center gap-2 rounded-full border border-border-default bg-bg-surface",
|
|
15
|
+
className,
|
|
16
|
+
)}
|
|
17
|
+
>
|
|
18
|
+
<span className="size-[7px] animate-thinking rounded-full bg-overlay-ink-35" />
|
|
19
|
+
<span className="size-[7px] animate-thinking rounded-full bg-overlay-ink-55 [animation-delay:150ms]" />
|
|
20
|
+
<span className="size-[7px] animate-thinking rounded-full bg-overlay-ink-80 [animation-delay:300ms]" />
|
|
21
|
+
</span>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Characters per second the reveal moves at least; a larger backlog drains faster (about a third of a second). */
|
|
26
|
+
const REVEAL_RATE = 90;
|
|
27
|
+
const BACKLOG_DRAIN = 3;
|
|
28
|
+
|
|
29
|
+
/** Moves `end` past a high surrogate so an emoji is never cut in half. */
|
|
30
|
+
function codePointEnd(text: string, end: number): number {
|
|
31
|
+
const code = text.charCodeAt(end - 1);
|
|
32
|
+
return code >= 0xd800 && code <= 0xdbff ? end + 1 : end;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* How much of `text` to show. While `smooth` streaming runs, new characters are revealed a few per frame so
|
|
37
|
+
* bursty chunks read as steady typing; otherwise, and under reduced motion, everything shows at once.
|
|
38
|
+
* Text that no longer extends what was shown (a rewrite) restarts from the shared prefix.
|
|
39
|
+
*/
|
|
40
|
+
function useRevealedLength(text: string, smooth: boolean): number {
|
|
41
|
+
const [shown, setShown] = useState(text.length);
|
|
42
|
+
const shownRef = useRef(text.length);
|
|
43
|
+
const previous = useRef(text);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
const set = (length: number) => {
|
|
47
|
+
shownRef.current = length;
|
|
48
|
+
setShown(length);
|
|
49
|
+
};
|
|
50
|
+
let start = Math.min(shownRef.current, text.length);
|
|
51
|
+
const before = previous.current;
|
|
52
|
+
previous.current = text;
|
|
53
|
+
let shared = 0;
|
|
54
|
+
while (shared < start && text[shared] === before[shared]) shared++;
|
|
55
|
+
start = shared;
|
|
56
|
+
|
|
57
|
+
if (!smooth || window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
|
58
|
+
set(text.length);
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
if (start !== shownRef.current) set(start);
|
|
62
|
+
if (start >= text.length) return undefined;
|
|
63
|
+
|
|
64
|
+
let position = start;
|
|
65
|
+
let last = performance.now();
|
|
66
|
+
let frame = requestAnimationFrame(function tick(now) {
|
|
67
|
+
const seconds = Math.min(0.1, (now - last) / 1000);
|
|
68
|
+
last = now;
|
|
69
|
+
const backlog = text.length - position;
|
|
70
|
+
position = Math.min(text.length, position + Math.max(REVEAL_RATE * seconds, backlog * BACKLOG_DRAIN * seconds));
|
|
71
|
+
const length = Math.min(text.length, codePointEnd(text, Math.floor(position)));
|
|
72
|
+
if (length !== shownRef.current) set(length);
|
|
73
|
+
if (length < text.length) frame = requestAnimationFrame(tick);
|
|
74
|
+
});
|
|
75
|
+
return () => cancelAnimationFrame(frame);
|
|
76
|
+
}, [text, smooth]);
|
|
77
|
+
|
|
78
|
+
return Math.min(shown, text.length);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type StreamingTextProps = Omit<ComponentProps<"div">, "className" | "children"> & {
|
|
82
|
+
/** Everything received so far; pass the growing string as chunks arrive. */
|
|
83
|
+
text: string;
|
|
84
|
+
/** True while more text is coming: the caret shows, and the rest is revealed when it turns false. */
|
|
85
|
+
streaming?: boolean;
|
|
86
|
+
/** Reveal arriving chunks a few characters per frame instead of in jumps. Off under reduced motion. */
|
|
87
|
+
smooth?: boolean;
|
|
88
|
+
className?: string;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Figma `Streaming` (108:660): the bot bubble an answer types into, body-sm text and a 2px ink caret that sits
|
|
93
|
+
* right after the last character and wraps with it. The caret holds still while characters land and blinks
|
|
94
|
+
* while waiting for the next chunk; it goes away when the stream ends. Screen readers get the answer once,
|
|
95
|
+
* when it is complete, instead of every chunk.
|
|
96
|
+
*/
|
|
97
|
+
export function StreamingText({ text, streaming = true, smooth = true, className, ...props }: StreamingTextProps) {
|
|
98
|
+
const shown = useRevealedLength(text, streaming && smooth);
|
|
99
|
+
const typing = shown < text.length;
|
|
100
|
+
return (
|
|
101
|
+
<div
|
|
102
|
+
className={cn(
|
|
103
|
+
"w-fit max-w-full rounded-xl border border-border-default bg-bg-surface px-4 py-3 text-body-sm wrap-break-word whitespace-pre-wrap text-text-primary",
|
|
104
|
+
className,
|
|
105
|
+
)}
|
|
106
|
+
{...props}
|
|
107
|
+
>
|
|
108
|
+
<span aria-hidden>{text.slice(0, shown)}</span>
|
|
109
|
+
{streaming && (
|
|
110
|
+
<span
|
|
111
|
+
aria-hidden
|
|
112
|
+
className={cn(
|
|
113
|
+
"ml-px inline-block h-[1.15em] w-0.5 rounded-[1px] bg-interactive-primary align-[-0.2em]",
|
|
114
|
+
!typing && "animate-caret",
|
|
115
|
+
)}
|
|
116
|
+
/>
|
|
117
|
+
)}
|
|
118
|
+
<span className="sr-only" aria-live="polite" aria-busy={streaming}>
|
|
119
|
+
{streaming ? "" : text}
|
|
120
|
+
</span>
|
|
121
|
+
</div>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export type ToolStepState = "running" | "done" | "failed";
|
|
126
|
+
|
|
127
|
+
export type ToolStepProps = Omit<ComponentProps<"button">, "className" | "title"> & {
|
|
128
|
+
state?: ToolStepState;
|
|
129
|
+
title: ReactNode;
|
|
130
|
+
detail?: ReactNode;
|
|
131
|
+
className?: string;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/** Figma `Tool step` (108:660): a flat 12px-radius row with spinner, check or warning, and a caret. */
|
|
135
|
+
export function ToolStep({ state = "running", title, detail, className, ...props }: ToolStepProps) {
|
|
136
|
+
return (
|
|
137
|
+
<button
|
|
138
|
+
type="button"
|
|
139
|
+
className={cn(
|
|
140
|
+
"flex w-full min-w-0 motion-press cursor-pointer items-center gap-2.5 rounded-md border border-border-default bg-bg-surface px-2.5 py-2 text-left focus-visible:outline-2 focus-visible:outline-text-brand",
|
|
141
|
+
className,
|
|
142
|
+
)}
|
|
143
|
+
{...props}
|
|
144
|
+
>
|
|
145
|
+
{state === "running" && <Spinner size="sm" />}
|
|
146
|
+
{state === "done" && <CheckCircle aria-hidden className="size-4 shrink-0 text-text-secondary" />}
|
|
147
|
+
{state === "failed" && <WarningCircle aria-hidden className="size-4 shrink-0 text-text-danger" />}
|
|
148
|
+
<span className="flex min-w-0 flex-1 flex-col gap-px">
|
|
149
|
+
<span className={cn("truncate text-label-md", state === "failed" ? "text-text-danger" : "text-text-primary")}>
|
|
150
|
+
{title}
|
|
151
|
+
</span>
|
|
152
|
+
{detail && <span className="truncate text-caption text-text-secondary">{detail}</span>}
|
|
153
|
+
</span>
|
|
154
|
+
<CaretRight aria-hidden className="size-3.5 shrink-0 text-text-secondary" />
|
|
155
|
+
</button>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export type WorkLogStep = { id: string; title: ReactNode; detail?: ReactNode; running?: boolean };
|
|
160
|
+
|
|
161
|
+
/** Figma `Work log` (108:660): a flat subtle card with a wrench summary row and compact steps. */
|
|
162
|
+
export function WorkLog({
|
|
163
|
+
summary,
|
|
164
|
+
steps,
|
|
165
|
+
className,
|
|
166
|
+
}: {
|
|
167
|
+
summary: ReactNode;
|
|
168
|
+
steps: WorkLogStep[];
|
|
169
|
+
className?: string;
|
|
170
|
+
}) {
|
|
171
|
+
return (
|
|
172
|
+
<details
|
|
173
|
+
open
|
|
174
|
+
className={cn(
|
|
175
|
+
"group flex w-full max-w-85 min-w-0 flex-col motion-details rounded-[16px] border border-border-default bg-bg-surface p-1.5",
|
|
176
|
+
className,
|
|
177
|
+
)}
|
|
178
|
+
>
|
|
179
|
+
<summary className="flex min-w-0 cursor-pointer list-none items-center gap-2 px-2.5 py-1.5 text-caption text-text-secondary [&::-webkit-details-marker]:hidden">
|
|
180
|
+
<Wrench aria-hidden className="size-3.5 shrink-0" />
|
|
181
|
+
<span className="min-w-0 flex-1">{summary}</span>
|
|
182
|
+
<CaretDown
|
|
183
|
+
aria-hidden
|
|
184
|
+
className="size-3.5 shrink-0 transition-transform duration-200 ease-out-quint group-not-open:-rotate-90"
|
|
185
|
+
/>
|
|
186
|
+
</summary>
|
|
187
|
+
{steps.map((step) => (
|
|
188
|
+
<div key={step.id} className="flex items-center gap-2.5 px-2.5 py-1.5">
|
|
189
|
+
{step.running ? (
|
|
190
|
+
<Spinner size="xs" />
|
|
191
|
+
) : (
|
|
192
|
+
<CheckCircle aria-hidden className="size-3.5 shrink-0 text-text-secondary" />
|
|
193
|
+
)}
|
|
194
|
+
<span className="flex min-w-0 flex-1 flex-col">
|
|
195
|
+
<span className="truncate text-label-sm text-text-primary">{step.title}</span>
|
|
196
|
+
{step.detail && <span className="truncate text-caption text-text-secondary">{step.detail}</span>}
|
|
197
|
+
</span>
|
|
198
|
+
</div>
|
|
199
|
+
))}
|
|
200
|
+
</details>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { CheckCircle, WarningCircle } from "@phosphor-icons/react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
import { cn } from "../lib/cn";
|
|
5
|
+
import { Spinner } from "./spinner";
|
|
6
|
+
|
|
7
|
+
export type AgentLaneStatus = "running" | "done" | "failed";
|
|
8
|
+
|
|
9
|
+
export type AgentLaneProps = {
|
|
10
|
+
status: AgentLaneStatus;
|
|
11
|
+
bot: { initial: string; name: ReactNode };
|
|
12
|
+
/** Duration, such as "1.9s". */
|
|
13
|
+
elapsed?: ReactNode;
|
|
14
|
+
task: ReactNode;
|
|
15
|
+
/** 14px tool icon and source, such as "Notion · Q3 metrics dashboard". */
|
|
16
|
+
tool?: { icon: ReactNode; label: ReactNode };
|
|
17
|
+
/** Latest output, or the error when failed. */
|
|
18
|
+
output?: ReactNode;
|
|
19
|
+
/** Usage caption, such as "2.6k tokens". */
|
|
20
|
+
usage?: ReactNode;
|
|
21
|
+
className?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const botTile: Record<AgentLaneStatus, string> = {
|
|
25
|
+
running: "bg-sky-400",
|
|
26
|
+
done: "bg-sky-600",
|
|
27
|
+
failed: "bg-sky-800",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Figma `Agent lane` (Agent orchestrator 113:953): one sub-agent's card in a fan-out, flat subtle with a red rim on failure. */
|
|
31
|
+
export function AgentLane({ status, bot, elapsed, task, tool, output, usage, className }: AgentLaneProps) {
|
|
32
|
+
return (
|
|
33
|
+
<div
|
|
34
|
+
className={cn(
|
|
35
|
+
"flex min-w-0 flex-col gap-1.5 rounded-2xl border bg-bg-surface px-4 py-3 motion-colors",
|
|
36
|
+
status === "failed" ? "border-border-danger" : "border-border-default",
|
|
37
|
+
className,
|
|
38
|
+
)}
|
|
39
|
+
>
|
|
40
|
+
<div className="flex items-center gap-2 text-caption text-text-secondary">
|
|
41
|
+
{/* live-pulse draws its ring in currentColor, so the running wrapper carries the tile's sky tone. */}
|
|
42
|
+
<span className={cn("shrink-0 rounded-[5px]", status === "running" && "animate-live-pulse text-sky-400")}>
|
|
43
|
+
<span
|
|
44
|
+
className={cn(
|
|
45
|
+
"flex size-4.5 items-center justify-center rounded-[5px] text-label-sm text-text-inverse motion-colors",
|
|
46
|
+
botTile[status],
|
|
47
|
+
)}
|
|
48
|
+
>
|
|
49
|
+
{bot.initial}
|
|
50
|
+
</span>
|
|
51
|
+
</span>
|
|
52
|
+
<span className="min-w-0 flex-1 truncate">{bot.name}</span>
|
|
53
|
+
<span className="flex size-3.5 shrink-0 items-center justify-center">
|
|
54
|
+
{status === "running" && <Spinner size={12} />}
|
|
55
|
+
{status === "done" && <CheckCircle aria-hidden className="size-3.5" />}
|
|
56
|
+
{status === "failed" && <WarningCircle aria-hidden className="size-3.5 text-text-danger" />}
|
|
57
|
+
</span>
|
|
58
|
+
{elapsed && <span className="shrink-0 tabular-nums">{elapsed}</span>}
|
|
59
|
+
</div>
|
|
60
|
+
<p className="min-w-0 text-label-lg wrap-break-word text-text-primary">{task}</p>
|
|
61
|
+
{tool && (
|
|
62
|
+
<div className="flex items-center gap-1.5 text-caption text-text-secondary [&>svg]:size-3.5">
|
|
63
|
+
{tool.icon}
|
|
64
|
+
<span className="truncate">{tool.label}</span>
|
|
65
|
+
</div>
|
|
66
|
+
)}
|
|
67
|
+
{output && (
|
|
68
|
+
<p
|
|
69
|
+
className={cn(
|
|
70
|
+
"min-w-0 text-body-sm wrap-break-word",
|
|
71
|
+
status === "failed" ? "text-text-danger" : "text-text-primary",
|
|
72
|
+
)}
|
|
73
|
+
>
|
|
74
|
+
{output}
|
|
75
|
+
</p>
|
|
76
|
+
)}
|
|
77
|
+
{usage && <p className="text-caption text-text-secondary">{usage}</p>}
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|