@schlessera/brain-ui-react 0.4.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 (311) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +70 -0
  3. package/dist/components/chat/ask-user-card.d.ts +12 -0
  4. package/dist/components/chat/ask-user-card.d.ts.map +1 -0
  5. package/dist/components/chat/ask-user-card.js +185 -0
  6. package/dist/components/chat/ask-user-card.js.map +1 -0
  7. package/dist/components/chat/brain-markdown.d.ts +35 -0
  8. package/dist/components/chat/brain-markdown.d.ts.map +1 -0
  9. package/dist/components/chat/brain-markdown.js +363 -0
  10. package/dist/components/chat/brain-markdown.js.map +1 -0
  11. package/dist/components/chat/chat-page.d.ts +2 -0
  12. package/dist/components/chat/chat-page.d.ts.map +1 -0
  13. package/dist/components/chat/chat-page.js +442 -0
  14. package/dist/components/chat/chat-page.js.map +1 -0
  15. package/dist/components/chat/command-palette.d.ts +5 -0
  16. package/dist/components/chat/command-palette.d.ts.map +1 -0
  17. package/dist/components/chat/command-palette.js +18 -0
  18. package/dist/components/chat/command-palette.js.map +1 -0
  19. package/dist/components/chat/markdown-content.d.ts +4 -0
  20. package/dist/components/chat/markdown-content.d.ts.map +1 -0
  21. package/dist/components/chat/markdown-content.js +6 -0
  22. package/dist/components/chat/markdown-content.js.map +1 -0
  23. package/dist/components/chat/message-bubble.d.ts +9 -0
  24. package/dist/components/chat/message-bubble.d.ts.map +1 -0
  25. package/dist/components/chat/message-bubble.js +129 -0
  26. package/dist/components/chat/message-bubble.js.map +1 -0
  27. package/dist/components/chat/message-share.d.ts +10 -0
  28. package/dist/components/chat/message-share.d.ts.map +1 -0
  29. package/dist/components/chat/message-share.js +65 -0
  30. package/dist/components/chat/message-share.js.map +1 -0
  31. package/dist/components/chat/renderers/claude-tools.d.ts +3 -0
  32. package/dist/components/chat/renderers/claude-tools.d.ts.map +1 -0
  33. package/dist/components/chat/renderers/claude-tools.js +38 -0
  34. package/dist/components/chat/renderers/claude-tools.js.map +1 -0
  35. package/dist/components/chat/renderers/generic.d.ts +9 -0
  36. package/dist/components/chat/renderers/generic.d.ts.map +1 -0
  37. package/dist/components/chat/renderers/generic.js +68 -0
  38. package/dist/components/chat/renderers/generic.js.map +1 -0
  39. package/dist/components/chat/renderers/index.d.ts +5 -0
  40. package/dist/components/chat/renderers/index.d.ts.map +1 -0
  41. package/dist/components/chat/renderers/index.js +17 -0
  42. package/dist/components/chat/renderers/index.js.map +1 -0
  43. package/dist/components/chat/risk-hints.d.ts +20 -0
  44. package/dist/components/chat/risk-hints.d.ts.map +1 -0
  45. package/dist/components/chat/risk-hints.js +81 -0
  46. package/dist/components/chat/risk-hints.js.map +1 -0
  47. package/dist/components/chat/session-drawer.d.ts +6 -0
  48. package/dist/components/chat/session-drawer.d.ts.map +1 -0
  49. package/dist/components/chat/session-drawer.js +96 -0
  50. package/dist/components/chat/session-drawer.js.map +1 -0
  51. package/dist/components/chat/share-block.d.ts +9 -0
  52. package/dist/components/chat/share-block.d.ts.map +1 -0
  53. package/dist/components/chat/share-block.js +70 -0
  54. package/dist/components/chat/share-block.js.map +1 -0
  55. package/dist/components/chat/tool-call-timeline.d.ts +8 -0
  56. package/dist/components/chat/tool-call-timeline.d.ts.map +1 -0
  57. package/dist/components/chat/tool-call-timeline.js +100 -0
  58. package/dist/components/chat/tool-call-timeline.js.map +1 -0
  59. package/dist/components/chat/tool-views.d.ts +116 -0
  60. package/dist/components/chat/tool-views.d.ts.map +1 -0
  61. package/dist/components/chat/tool-views.js +628 -0
  62. package/dist/components/chat/tool-views.js.map +1 -0
  63. package/dist/components/chat/welcome-state.d.ts +4 -0
  64. package/dist/components/chat/welcome-state.d.ts.map +1 -0
  65. package/dist/components/chat/welcome-state.js +10 -0
  66. package/dist/components/chat/welcome-state.js.map +1 -0
  67. package/dist/components/connectivity/connection-gate.d.ts +5 -0
  68. package/dist/components/connectivity/connection-gate.d.ts.map +1 -0
  69. package/dist/components/connectivity/connection-gate.js +42 -0
  70. package/dist/components/connectivity/connection-gate.js.map +1 -0
  71. package/dist/components/connectivity/login-screen.d.ts +9 -0
  72. package/dist/components/connectivity/login-screen.d.ts.map +1 -0
  73. package/dist/components/connectivity/login-screen.js +99 -0
  74. package/dist/components/connectivity/login-screen.js.map +1 -0
  75. package/dist/components/files/file-panel.d.ts +5 -0
  76. package/dist/components/files/file-panel.d.ts.map +1 -0
  77. package/dist/components/files/file-panel.js +46 -0
  78. package/dist/components/files/file-panel.js.map +1 -0
  79. package/dist/components/files/file-tree.d.ts +2 -0
  80. package/dist/components/files/file-tree.d.ts.map +1 -0
  81. package/dist/components/files/file-tree.js +63 -0
  82. package/dist/components/files/file-tree.js.map +1 -0
  83. package/dist/components/files/file-viewer-binary.d.ts +5 -0
  84. package/dist/components/files/file-viewer-binary.d.ts.map +1 -0
  85. package/dist/components/files/file-viewer-binary.js +20 -0
  86. package/dist/components/files/file-viewer-binary.js.map +1 -0
  87. package/dist/components/files/file-viewer-html.d.ts +4 -0
  88. package/dist/components/files/file-viewer-html.d.ts.map +1 -0
  89. package/dist/components/files/file-viewer-html.js +5 -0
  90. package/dist/components/files/file-viewer-html.js.map +1 -0
  91. package/dist/components/files/file-viewer-markdown.d.ts +4 -0
  92. package/dist/components/files/file-viewer-markdown.d.ts.map +1 -0
  93. package/dist/components/files/file-viewer-markdown.js +10 -0
  94. package/dist/components/files/file-viewer-markdown.js.map +1 -0
  95. package/dist/components/files/file-viewer-raw.d.ts +5 -0
  96. package/dist/components/files/file-viewer-raw.d.ts.map +1 -0
  97. package/dist/components/files/file-viewer-raw.js +15 -0
  98. package/dist/components/files/file-viewer-raw.js.map +1 -0
  99. package/dist/components/files/file-viewer.d.ts +2 -0
  100. package/dist/components/files/file-viewer.d.ts.map +1 -0
  101. package/dist/components/files/file-viewer.js +175 -0
  102. package/dist/components/files/file-viewer.js.map +1 -0
  103. package/dist/components/files/frontmatter-panel.d.ts +5 -0
  104. package/dist/components/files/frontmatter-panel.d.ts.map +1 -0
  105. package/dist/components/files/frontmatter-panel.js +12 -0
  106. package/dist/components/files/frontmatter-panel.js.map +1 -0
  107. package/dist/components/layout/app-shell.d.ts +5 -0
  108. package/dist/components/layout/app-shell.d.ts.map +1 -0
  109. package/dist/components/layout/app-shell.js +10 -0
  110. package/dist/components/layout/app-shell.js.map +1 -0
  111. package/dist/components/layout/mobile-tab-bar.d.ts +2 -0
  112. package/dist/components/layout/mobile-tab-bar.d.ts.map +1 -0
  113. package/dist/components/layout/mobile-tab-bar.js +16 -0
  114. package/dist/components/layout/mobile-tab-bar.js.map +1 -0
  115. package/dist/components/layout/side-rail.d.ts +2 -0
  116. package/dist/components/layout/side-rail.d.ts.map +1 -0
  117. package/dist/components/layout/side-rail.js +26 -0
  118. package/dist/components/layout/side-rail.js.map +1 -0
  119. package/dist/components/layout/slide-panel.d.ts +9 -0
  120. package/dist/components/layout/slide-panel.d.ts.map +1 -0
  121. package/dist/components/layout/slide-panel.js +18 -0
  122. package/dist/components/layout/slide-panel.js.map +1 -0
  123. package/dist/components/quick-actions/streaming-modal.d.ts +8 -0
  124. package/dist/components/quick-actions/streaming-modal.d.ts.map +1 -0
  125. package/dist/components/quick-actions/streaming-modal.js +92 -0
  126. package/dist/components/quick-actions/streaming-modal.js.map +1 -0
  127. package/dist/components/quick-actions/whatsup-modal.d.ts +5 -0
  128. package/dist/components/quick-actions/whatsup-modal.d.ts.map +1 -0
  129. package/dist/components/quick-actions/whatsup-modal.js +82 -0
  130. package/dist/components/quick-actions/whatsup-modal.js.map +1 -0
  131. package/dist/components/settings/passkey-panel.d.ts +10 -0
  132. package/dist/components/settings/passkey-panel.d.ts.map +1 -0
  133. package/dist/components/settings/passkey-panel.js +118 -0
  134. package/dist/components/settings/passkey-panel.js.map +1 -0
  135. package/dist/components/share/share-menu.d.ts +23 -0
  136. package/dist/components/share/share-menu.d.ts.map +1 -0
  137. package/dist/components/share/share-menu.js +58 -0
  138. package/dist/components/share/share-menu.js.map +1 -0
  139. package/dist/components/voice/dictation-sheet.d.ts +6 -0
  140. package/dist/components/voice/dictation-sheet.d.ts.map +1 -0
  141. package/dist/components/voice/dictation-sheet.js +91 -0
  142. package/dist/components/voice/dictation-sheet.js.map +1 -0
  143. package/dist/components/voice/mic-button.d.ts +6 -0
  144. package/dist/components/voice/mic-button.d.ts.map +1 -0
  145. package/dist/components/voice/mic-button.js +9 -0
  146. package/dist/components/voice/mic-button.js.map +1 -0
  147. package/dist/components/voice/review-card.d.ts +8 -0
  148. package/dist/components/voice/review-card.d.ts.map +1 -0
  149. package/dist/components/voice/review-card.js +8 -0
  150. package/dist/components/voice/review-card.js.map +1 -0
  151. package/dist/config.d.ts +19 -0
  152. package/dist/config.d.ts.map +1 -0
  153. package/dist/config.js +10 -0
  154. package/dist/config.js.map +1 -0
  155. package/dist/hooks/use-vpn-status.d.ts +2 -0
  156. package/dist/hooks/use-vpn-status.d.ts.map +1 -0
  157. package/dist/hooks/use-vpn-status.js +71 -0
  158. package/dist/hooks/use-vpn-status.js.map +1 -0
  159. package/dist/hooks/use-websocket.d.ts +8 -0
  160. package/dist/hooks/use-websocket.d.ts.map +1 -0
  161. package/dist/hooks/use-websocket.js +366 -0
  162. package/dist/hooks/use-websocket.js.map +1 -0
  163. package/dist/index.d.ts +25 -0
  164. package/dist/index.d.ts.map +1 -0
  165. package/dist/index.js +31 -0
  166. package/dist/index.js.map +1 -0
  167. package/dist/lib/api-client.d.ts +115 -0
  168. package/dist/lib/api-client.d.ts.map +1 -0
  169. package/dist/lib/api-client.js +70 -0
  170. package/dist/lib/api-client.js.map +1 -0
  171. package/dist/lib/backend.d.ts +7 -0
  172. package/dist/lib/backend.d.ts.map +1 -0
  173. package/dist/lib/backend.js +33 -0
  174. package/dist/lib/backend.js.map +1 -0
  175. package/dist/lib/frontmatter.d.ts +21 -0
  176. package/dist/lib/frontmatter.d.ts.map +1 -0
  177. package/dist/lib/frontmatter.js +88 -0
  178. package/dist/lib/frontmatter.js.map +1 -0
  179. package/dist/lib/image-attachments.d.ts +36 -0
  180. package/dist/lib/image-attachments.d.ts.map +1 -0
  181. package/dist/lib/image-attachments.js +175 -0
  182. package/dist/lib/image-attachments.js.map +1 -0
  183. package/dist/lib/passkeys.d.ts +18 -0
  184. package/dist/lib/passkeys.d.ts.map +1 -0
  185. package/dist/lib/passkeys.js +36 -0
  186. package/dist/lib/passkeys.js.map +1 -0
  187. package/dist/lib/share.d.ts +53 -0
  188. package/dist/lib/share.d.ts.map +1 -0
  189. package/dist/lib/share.js +144 -0
  190. package/dist/lib/share.js.map +1 -0
  191. package/dist/lib/strip-markdown.d.ts +10 -0
  192. package/dist/lib/strip-markdown.d.ts.map +1 -0
  193. package/dist/lib/strip-markdown.js +37 -0
  194. package/dist/lib/strip-markdown.js.map +1 -0
  195. package/dist/lib/tool-names.d.ts +17 -0
  196. package/dist/lib/tool-names.d.ts.map +1 -0
  197. package/dist/lib/tool-names.js +23 -0
  198. package/dist/lib/tool-names.js.map +1 -0
  199. package/dist/lib/utils.d.ts +3 -0
  200. package/dist/lib/utils.d.ts.map +1 -0
  201. package/dist/lib/utils.js +6 -0
  202. package/dist/lib/utils.js.map +1 -0
  203. package/dist/lib/ws-client.d.ts +26 -0
  204. package/dist/lib/ws-client.d.ts.map +1 -0
  205. package/dist/lib/ws-client.js +93 -0
  206. package/dist/lib/ws-client.js.map +1 -0
  207. package/dist/stores/chat-store.d.ts +131 -0
  208. package/dist/stores/chat-store.d.ts.map +1 -0
  209. package/dist/stores/chat-store.js +376 -0
  210. package/dist/stores/chat-store.js.map +1 -0
  211. package/dist/stores/connection-store.d.ts +11 -0
  212. package/dist/stores/connection-store.d.ts.map +1 -0
  213. package/dist/stores/connection-store.js +8 -0
  214. package/dist/stores/connection-store.js.map +1 -0
  215. package/dist/stores/file-store.d.ts +53 -0
  216. package/dist/stores/file-store.d.ts.map +1 -0
  217. package/dist/stores/file-store.js +284 -0
  218. package/dist/stores/file-store.js.map +1 -0
  219. package/dist/stores/provider-store.d.ts +24 -0
  220. package/dist/stores/provider-store.d.ts.map +1 -0
  221. package/dist/stores/provider-store.js +56 -0
  222. package/dist/stores/provider-store.js.map +1 -0
  223. package/dist/stores/ui-store.d.ts +21 -0
  224. package/dist/stores/ui-store.d.ts.map +1 -0
  225. package/dist/stores/ui-store.js +23 -0
  226. package/dist/stores/ui-store.js.map +1 -0
  227. package/dist/styles.css +2 -0
  228. package/dist/voice/asr-clients.d.ts +2 -0
  229. package/dist/voice/asr-clients.d.ts.map +1 -0
  230. package/dist/voice/asr-clients.js +33 -0
  231. package/dist/voice/asr-clients.js.map +1 -0
  232. package/dist/voice/asr-deepgram.d.ts +38 -0
  233. package/dist/voice/asr-deepgram.d.ts.map +1 -0
  234. package/dist/voice/asr-deepgram.js +307 -0
  235. package/dist/voice/asr-deepgram.js.map +1 -0
  236. package/dist/voice/asr-webspeech.d.ts +23 -0
  237. package/dist/voice/asr-webspeech.d.ts.map +1 -0
  238. package/dist/voice/asr-webspeech.js +93 -0
  239. package/dist/voice/asr-webspeech.js.map +1 -0
  240. package/dist/voice/use-dictation.d.ts +6 -0
  241. package/dist/voice/use-dictation.d.ts.map +1 -0
  242. package/dist/voice/use-dictation.js +174 -0
  243. package/dist/voice/use-dictation.js.map +1 -0
  244. package/dist/voice/voice-store.d.ts +26 -0
  245. package/dist/voice/voice-store.d.ts.map +1 -0
  246. package/dist/voice/voice-store.js +27 -0
  247. package/dist/voice/voice-store.js.map +1 -0
  248. package/package.json +71 -0
  249. package/src/components/chat/ask-user-card.tsx +451 -0
  250. package/src/components/chat/brain-markdown.tsx +547 -0
  251. package/src/components/chat/chat-page.tsx +820 -0
  252. package/src/components/chat/command-palette.tsx +68 -0
  253. package/src/components/chat/markdown-content.tsx +5 -0
  254. package/src/components/chat/message-bubble.tsx +362 -0
  255. package/src/components/chat/message-share.ts +78 -0
  256. package/src/components/chat/renderers/claude-tools.tsx +53 -0
  257. package/src/components/chat/renderers/generic.tsx +85 -0
  258. package/src/components/chat/renderers/index.ts +19 -0
  259. package/src/components/chat/risk-hints.ts +95 -0
  260. package/src/components/chat/session-drawer.tsx +213 -0
  261. package/src/components/chat/share-block.tsx +128 -0
  262. package/src/components/chat/tool-call-timeline.tsx +322 -0
  263. package/src/components/chat/tool-views.tsx +898 -0
  264. package/src/components/chat/welcome-state.tsx +105 -0
  265. package/src/components/connectivity/connection-gate.tsx +137 -0
  266. package/src/components/connectivity/login-screen.tsx +169 -0
  267. package/src/components/files/file-panel.tsx +116 -0
  268. package/src/components/files/file-tree.tsx +143 -0
  269. package/src/components/files/file-viewer-binary.tsx +63 -0
  270. package/src/components/files/file-viewer-html.tsx +10 -0
  271. package/src/components/files/file-viewer-markdown.tsx +14 -0
  272. package/src/components/files/file-viewer-raw.tsx +44 -0
  273. package/src/components/files/file-viewer.tsx +290 -0
  274. package/src/components/files/frontmatter-panel.tsx +78 -0
  275. package/src/components/layout/app-shell.tsx +25 -0
  276. package/src/components/layout/mobile-tab-bar.tsx +54 -0
  277. package/src/components/layout/side-rail.tsx +128 -0
  278. package/src/components/layout/slide-panel.tsx +64 -0
  279. package/src/components/quick-actions/streaming-modal.tsx +168 -0
  280. package/src/components/quick-actions/whatsup-modal.tsx +149 -0
  281. package/src/components/settings/passkey-panel.tsx +281 -0
  282. package/src/components/share/share-menu.tsx +121 -0
  283. package/src/components/voice/dictation-sheet.tsx +217 -0
  284. package/src/components/voice/mic-button.tsx +31 -0
  285. package/src/components/voice/review-card.tsx +66 -0
  286. package/src/config.ts +27 -0
  287. package/src/hooks/use-vpn-status.ts +75 -0
  288. package/src/hooks/use-websocket.ts +403 -0
  289. package/src/index.ts +46 -0
  290. package/src/lib/api-client.ts +173 -0
  291. package/src/lib/backend.ts +35 -0
  292. package/src/lib/frontmatter.ts +99 -0
  293. package/src/lib/image-attachments.ts +221 -0
  294. package/src/lib/passkeys.ts +50 -0
  295. package/src/lib/share.ts +167 -0
  296. package/src/lib/strip-markdown.ts +38 -0
  297. package/src/lib/tool-names.ts +27 -0
  298. package/src/lib/utils.ts +6 -0
  299. package/src/lib/ws-client.ts +109 -0
  300. package/src/stores/chat-store.ts +619 -0
  301. package/src/stores/connection-store.ts +23 -0
  302. package/src/stores/file-store.ts +344 -0
  303. package/src/stores/provider-store.ts +85 -0
  304. package/src/stores/ui-store.ts +48 -0
  305. package/src/styles.css +4 -0
  306. package/src/theme.css +425 -0
  307. package/src/voice/asr-clients.ts +45 -0
  308. package/src/voice/asr-deepgram.ts +325 -0
  309. package/src/voice/asr-webspeech.ts +135 -0
  310. package/src/voice/use-dictation.ts +187 -0
  311. package/src/voice/voice-store.ts +55 -0
@@ -0,0 +1,281 @@
1
+ import { useEffect, useState } from "react";
2
+ import {
3
+ Check,
4
+ Cloud,
5
+ Fingerprint,
6
+ Loader2,
7
+ LogOut,
8
+ Pencil,
9
+ Plus,
10
+ Trash2,
11
+ X,
12
+ } from "lucide-react";
13
+ import type { PasskeySummary } from "@schlessera/brain-ui-sdk/protocol";
14
+ import { api } from "../../lib/api-client.js";
15
+ import { isUserCancel, registerPasskey, supportsPasskeys } from "../../lib/passkeys.js";
16
+ import { SlidePanel } from "../layout/slide-panel.js";
17
+ import { cn } from "../../lib/utils.js";
18
+
19
+ /**
20
+ * Passkey management + sign out. Registration requires the current session
21
+ * (routes are behind the auth guard); credentials are RP-scoped, so entries
22
+ * registered on another hostname are badged and only useful there.
23
+ */
24
+ export function PasskeyPanel({
25
+ open,
26
+ onClose,
27
+ }: {
28
+ open: boolean;
29
+ onClose: () => void;
30
+ }) {
31
+ const [credentials, setCredentials] = useState<PasskeySummary[]>([]);
32
+ const [loading, setLoading] = useState(false);
33
+ const [busy, setBusy] = useState(false);
34
+ const [error, setError] = useState<string | null>(null);
35
+ const [passkeyMode, setPasskeyMode] = useState(true);
36
+
37
+ const webAuthnSupported = supportsPasskeys();
38
+
39
+ async function refresh() {
40
+ setLoading(true);
41
+ try {
42
+ const data = await api.passkeyList();
43
+ setCredentials(data.credentials);
44
+ setPasskeyMode(true);
45
+ } catch {
46
+ // 400 = server not in password mode; anything else degrades the same way.
47
+ setPasskeyMode(false);
48
+ setCredentials([]);
49
+ } finally {
50
+ setLoading(false);
51
+ }
52
+ }
53
+
54
+ useEffect(() => {
55
+ if (open) {
56
+ setError(null);
57
+ void refresh();
58
+ }
59
+ }, [open]);
60
+
61
+ async function onAdd() {
62
+ if (busy) return;
63
+ setBusy(true);
64
+ setError(null);
65
+ try {
66
+ await registerPasskey();
67
+ await refresh();
68
+ } catch (err) {
69
+ if (!isUserCancel(err)) {
70
+ setError(err instanceof Error ? err.message : "Registration failed");
71
+ }
72
+ } finally {
73
+ setBusy(false);
74
+ }
75
+ }
76
+
77
+ async function onDelete(id: string) {
78
+ if (!window.confirm("Remove this passkey? You can't undo this.")) return;
79
+ setError(null);
80
+ try {
81
+ await api.passkeyDelete(id);
82
+ setCredentials((rows) => rows.filter((row) => row.id !== id));
83
+ } catch (err) {
84
+ setError(err instanceof Error ? err.message : "Delete failed");
85
+ }
86
+ }
87
+
88
+ async function onRename(id: string, label: string) {
89
+ setError(null);
90
+ try {
91
+ await api.passkeyRename(id, label);
92
+ setCredentials((rows) =>
93
+ rows.map((row) => (row.id === id ? { ...row, label } : row))
94
+ );
95
+ } catch (err) {
96
+ setError(err instanceof Error ? err.message : "Rename failed");
97
+ }
98
+ }
99
+
100
+ async function onSignOut() {
101
+ try {
102
+ await api.logout();
103
+ } finally {
104
+ window.location.reload();
105
+ }
106
+ }
107
+
108
+ return (
109
+ <SlidePanel open={open} onClose={onClose} title="Security">
110
+ <div className="flex h-full flex-col">
111
+ <div className="flex-1 overflow-y-auto p-4">
112
+ <h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
113
+ Passkeys
114
+ </h3>
115
+ <p className="mt-1 text-xs text-muted-foreground">
116
+ Sign in with Face ID, fingerprint, or a security key instead of the
117
+ password. Passkeys only work on the site they were added on.
118
+ </p>
119
+
120
+ {loading ? (
121
+ <div className="mt-6 flex justify-center">
122
+ <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
123
+ </div>
124
+ ) : (
125
+ <ul className="mt-4 flex flex-col gap-2">
126
+ {credentials.map((credential) => (
127
+ <PasskeyRow
128
+ key={credential.id}
129
+ credential={credential}
130
+ onDelete={() => onDelete(credential.id)}
131
+ onRename={(label) => onRename(credential.id, label)}
132
+ />
133
+ ))}
134
+ {credentials.length === 0 && passkeyMode && (
135
+ <li className="rounded-lg border border-dashed border-border-subtle p-4 text-center text-xs text-muted-foreground">
136
+ No passkeys yet.
137
+ </li>
138
+ )}
139
+ </ul>
140
+ )}
141
+
142
+ {error && (
143
+ <p role="alert" className="mt-3 text-xs text-destructive">
144
+ {error}
145
+ </p>
146
+ )}
147
+
148
+ {passkeyMode && webAuthnSupported ? (
149
+ <button
150
+ onClick={onAdd}
151
+ disabled={busy}
152
+ className="mt-4 flex w-full items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:brightness-110 disabled:opacity-50"
153
+ >
154
+ {busy ? (
155
+ <Loader2 className="h-4 w-4 animate-spin" />
156
+ ) : (
157
+ <Plus className="h-4 w-4" />
158
+ )}
159
+ Add a passkey
160
+ </button>
161
+ ) : (
162
+ <p className="mt-4 text-xs text-muted-foreground">
163
+ {passkeyMode
164
+ ? "This browser does not support passkeys."
165
+ : "Passkeys need password auth mode on the server."}
166
+ </p>
167
+ )}
168
+ </div>
169
+
170
+ {/* Sign out */}
171
+ <div className="border-t border-border p-4">
172
+ <button
173
+ onClick={onSignOut}
174
+ className="flex w-full items-center justify-center gap-2 rounded-lg border border-border-subtle bg-surface px-4 py-2 text-sm font-medium text-foreground transition-colors hover:border-destructive hover:text-destructive"
175
+ >
176
+ <LogOut className="h-4 w-4" />
177
+ Sign out
178
+ </button>
179
+ </div>
180
+ </div>
181
+ </SlidePanel>
182
+ );
183
+ }
184
+
185
+ function PasskeyRow({
186
+ credential,
187
+ onDelete,
188
+ onRename,
189
+ }: {
190
+ credential: PasskeySummary;
191
+ onDelete: () => void;
192
+ onRename: (label: string) => void;
193
+ }) {
194
+ const [editing, setEditing] = useState(false);
195
+ const [draft, setDraft] = useState(credential.label);
196
+ const foreignRp =
197
+ typeof window !== "undefined" && credential.rpId !== window.location.hostname;
198
+
199
+ const displayName =
200
+ credential.label ||
201
+ `Passkey · ${new Date(credential.createdAt).toLocaleDateString()}`;
202
+
203
+ function commit() {
204
+ setEditing(false);
205
+ const label = draft.trim();
206
+ if (label !== credential.label) onRename(label);
207
+ }
208
+
209
+ return (
210
+ <li className="rounded-lg border border-border-subtle bg-surface p-3">
211
+ <div className="flex items-center gap-2">
212
+ <Fingerprint className="h-4 w-4 shrink-0 text-primary" />
213
+ {editing ? (
214
+ <span className="flex flex-1 items-center gap-1">
215
+ <input
216
+ autoFocus
217
+ value={draft}
218
+ onChange={(e) => setDraft(e.target.value)}
219
+ onKeyDown={(e) => {
220
+ if (e.key === "Enter") commit();
221
+ if (e.key === "Escape") setEditing(false);
222
+ }}
223
+ className="w-full rounded border border-border-subtle bg-background px-2 py-0.5 text-sm text-foreground outline-none focus:border-primary"
224
+ />
225
+ <button onClick={commit} title="Save" className="p-1 text-muted-foreground hover:text-foreground">
226
+ <Check className="h-3.5 w-3.5" />
227
+ </button>
228
+ <button onClick={() => setEditing(false)} title="Cancel" className="p-1 text-muted-foreground hover:text-foreground">
229
+ <X className="h-3.5 w-3.5" />
230
+ </button>
231
+ </span>
232
+ ) : (
233
+ <span className="flex-1 truncate text-sm text-foreground">{displayName}</span>
234
+ )}
235
+ {!editing && (
236
+ <>
237
+ <button
238
+ onClick={() => {
239
+ setDraft(credential.label);
240
+ setEditing(true);
241
+ }}
242
+ title="Rename"
243
+ className="p-1 text-muted-foreground transition-colors hover:text-foreground"
244
+ >
245
+ <Pencil className="h-3.5 w-3.5" />
246
+ </button>
247
+ <button
248
+ onClick={onDelete}
249
+ title="Remove"
250
+ className="p-1 text-muted-foreground transition-colors hover:text-destructive"
251
+ >
252
+ <Trash2 className="h-3.5 w-3.5" />
253
+ </button>
254
+ </>
255
+ )}
256
+ </div>
257
+ <div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 pl-6 text-[11px] text-muted-foreground">
258
+ {credential.backedUp && (
259
+ <span className="flex items-center gap-1">
260
+ <Cloud className="h-3 w-3" /> Synced
261
+ </span>
262
+ )}
263
+ {foreignRp && (
264
+ <span
265
+ className={cn(
266
+ "rounded bg-surface-raised px-1.5 py-0.5 font-medium text-foreground/70"
267
+ )}
268
+ title={`Registered on ${credential.rpId}; only usable there.`}
269
+ >
270
+ {credential.rpId}
271
+ </span>
272
+ )}
273
+ <span>
274
+ {credential.lastUsedAt
275
+ ? `Last used ${new Date(credential.lastUsedAt).toLocaleDateString()}`
276
+ : `Added ${new Date(credential.createdAt).toLocaleDateString()}`}
277
+ </span>
278
+ </div>
279
+ </li>
280
+ );
281
+ }
@@ -0,0 +1,121 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { Share2, Loader2, Check, AlertCircle } from "lucide-react";
3
+ import { cn } from "../../lib/utils.js";
4
+
5
+ export interface ShareOption {
6
+ id: string;
7
+ label: string;
8
+ /** Optional short hint shown below the label. */
9
+ hint?: string;
10
+ /** Async action: returns true on success, false on user-cancel. */
11
+ run: () => Promise<boolean>;
12
+ }
13
+
14
+ interface ShareMenuProps {
15
+ options: ShareOption[];
16
+ /** Aria label / tooltip for the trigger button. */
17
+ title?: string;
18
+ /** When provided, override the default icon button with a custom trigger. */
19
+ className?: string;
20
+ /** Optional render-prop for a custom trigger; receives the click handler. */
21
+ renderTrigger?: (props: { onClick: () => void; busy: boolean }) => React.ReactNode;
22
+ }
23
+
24
+ type Status = "idle" | "busy" | "done" | "error";
25
+
26
+ export function ShareMenu({ options, title = "Share", className, renderTrigger }: ShareMenuProps) {
27
+ const [open, setOpen] = useState(false);
28
+ const [status, setStatus] = useState<Status>("idle");
29
+ const [error, setError] = useState<string | null>(null);
30
+ const wrapperRef = useRef<HTMLDivElement>(null);
31
+
32
+ useEffect(() => {
33
+ if (!open) return;
34
+ const onDocClick = (e: MouseEvent) => {
35
+ if (!wrapperRef.current?.contains(e.target as Node)) setOpen(false);
36
+ };
37
+ const onKey = (e: KeyboardEvent) => {
38
+ if (e.key === "Escape") setOpen(false);
39
+ };
40
+ document.addEventListener("mousedown", onDocClick);
41
+ document.addEventListener("keydown", onKey);
42
+ return () => {
43
+ document.removeEventListener("mousedown", onDocClick);
44
+ document.removeEventListener("keydown", onKey);
45
+ };
46
+ }, [open]);
47
+
48
+ // If there's only one option, the trigger should fire it directly.
49
+ const single = options.length === 1 ? options[0] : null;
50
+
51
+ const runOption = async (opt: ShareOption) => {
52
+ setOpen(false);
53
+ setStatus("busy");
54
+ setError(null);
55
+ try {
56
+ await opt.run();
57
+ setStatus("done");
58
+ setTimeout(() => setStatus("idle"), 1200);
59
+ } catch (err) {
60
+ console.error("[share-menu]", err);
61
+ setError(err instanceof Error ? err.message : "Share failed");
62
+ setStatus("error");
63
+ setTimeout(() => setStatus("idle"), 3000);
64
+ }
65
+ };
66
+
67
+ const handleTrigger = () => {
68
+ if (status === "busy") return;
69
+ if (single) {
70
+ void runOption(single);
71
+ return;
72
+ }
73
+ setOpen((v) => !v);
74
+ };
75
+
76
+ const icon =
77
+ status === "busy" ? (
78
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
79
+ ) : status === "done" ? (
80
+ <Check className="h-3.5 w-3.5" />
81
+ ) : status === "error" ? (
82
+ <AlertCircle className="h-3.5 w-3.5 text-destructive" />
83
+ ) : (
84
+ <Share2 className="h-3.5 w-3.5" />
85
+ );
86
+
87
+ return (
88
+ <div ref={wrapperRef} className={cn("relative inline-flex", className)}>
89
+ {renderTrigger ? (
90
+ renderTrigger({ onClick: handleTrigger, busy: status === "busy" })
91
+ ) : (
92
+ <button
93
+ onClick={handleTrigger}
94
+ disabled={status === "busy"}
95
+ title={error ?? title}
96
+ className="rounded p-1.5 text-muted-foreground transition-colors hover:bg-surface-raised hover:text-foreground disabled:opacity-50"
97
+ >
98
+ {icon}
99
+ </button>
100
+ )}
101
+ {open && !single && (
102
+ <div
103
+ role="menu"
104
+ className="absolute right-0 top-full z-50 mt-1 min-w-[12rem] overflow-hidden rounded-lg border border-border bg-background py-1 shadow-lg"
105
+ >
106
+ {options.map((opt) => (
107
+ <button
108
+ key={opt.id}
109
+ role="menuitem"
110
+ onClick={() => void runOption(opt)}
111
+ className="block w-full px-3 py-2 text-left text-xs text-foreground transition-colors hover:bg-surface-raised"
112
+ >
113
+ <div className="font-medium">{opt.label}</div>
114
+ {opt.hint && <div className="text-[10px] text-muted-foreground">{opt.hint}</div>}
115
+ </button>
116
+ ))}
117
+ </div>
118
+ )}
119
+ </div>
120
+ );
121
+ }
@@ -0,0 +1,217 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { Mic, X } from "lucide-react";
3
+ import { useVoiceStore } from "../../voice/voice-store.js";
4
+ import { cn } from "../../lib/utils.js";
5
+
6
+ export function DictationSheet({
7
+ open,
8
+ onStop,
9
+ onCancel,
10
+ }: {
11
+ open: boolean;
12
+ onStop: () => void;
13
+ onCancel: () => void;
14
+ }) {
15
+ const partial = useVoiceStore((s) => s.partial);
16
+ const finalText = useVoiceStore((s) => s.finalText);
17
+ const audioLevel = useVoiceStore((s) => s.audioLevel);
18
+ const error = useVoiceStore((s) => s.error);
19
+ const draining = useVoiceStore((s) => s.draining);
20
+ // Mic still opening — the session is being fetched, so capture isn't live
21
+ // yet. Drives the honest "Connecting…" state instead of claiming "Listening".
22
+ const connecting = useVoiceStore((s) => s.connecting);
23
+ const providerId = useVoiceStore((s) => s.providerId);
24
+ // Text already under review — the current capture appends to it on stop,
25
+ // so show it dimmed as the lead-in.
26
+ const reviewText = useVoiceStore((s) => s.reviewText);
27
+ const transcriptRef = useRef<HTMLDivElement>(null);
28
+
29
+ const statusLabel = connecting
30
+ ? "Connecting…"
31
+ : draining
32
+ ? "Finalizing…"
33
+ : "Listening";
34
+ // Surface the active provider so a silent fallback to browser speech (which
35
+ // streams mic audio to Google on Chrome) isn't invisible.
36
+ const providerNote = providerNoteFor(providerId);
37
+
38
+ // Keep the newest words in view — the live transcript grows past the
39
+ // sheet's height quickly when dictating longer prompts.
40
+ useEffect(() => {
41
+ const el = transcriptRef.current;
42
+ if (el) el.scrollTop = el.scrollHeight;
43
+ }, [finalText, partial, open]);
44
+
45
+ // Lock body scroll while open
46
+ useEffect(() => {
47
+ if (!open) return;
48
+ const prev = document.body.style.overflow;
49
+ document.body.style.overflow = "hidden";
50
+ return () => {
51
+ document.body.style.overflow = prev;
52
+ };
53
+ }, [open]);
54
+
55
+ // Escape cancels
56
+ useEffect(() => {
57
+ if (!open) return;
58
+ const h = (e: KeyboardEvent) => {
59
+ if (e.key === "Escape") onCancel();
60
+ };
61
+ document.addEventListener("keydown", h);
62
+ return () => document.removeEventListener("keydown", h);
63
+ }, [open, onCancel]);
64
+
65
+ if (!open) return null;
66
+
67
+ return (
68
+ <>
69
+ {/* Backdrop — tap-to-stop is intentional: huge target. Disabled while
70
+ draining so a stray tap doesn't double-trigger. */}
71
+ <div
72
+ className="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm"
73
+ onClick={() => { if (!draining) onStop(); }}
74
+ />
75
+
76
+ {/* Sheet */}
77
+ <div
78
+ className={cn(
79
+ "fixed inset-x-0 bottom-0 z-50 flex flex-col rounded-t-3xl border-t border-border bg-surface shadow-[0_-16px_48px_rgba(0,0,0,0.5)]",
80
+ "max-h-[60vh] min-h-[40vh]",
81
+ "animate-in slide-in-from-bottom duration-200"
82
+ )}
83
+ // Don't propagate to backdrop
84
+ onClick={(e) => e.stopPropagation()}
85
+ >
86
+ <div className="flex items-center justify-between border-b border-border/50 px-5 py-3">
87
+ <div className="flex items-center gap-2">
88
+ <span className="relative inline-flex h-2 w-2">
89
+ {/* Pulse only once capture is actually live — a "connecting" dot
90
+ must not imply the mic is already open. */}
91
+ {!connecting && (
92
+ <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-75" />
93
+ )}
94
+ <span
95
+ className={cn(
96
+ "relative inline-flex h-2 w-2 rounded-full",
97
+ connecting ? "bg-muted-foreground/50" : "bg-primary"
98
+ )}
99
+ />
100
+ </span>
101
+ <span className="font-[family-name:var(--font-mono)] text-xs uppercase tracking-widest text-muted-foreground">
102
+ {statusLabel}
103
+ </span>
104
+ </div>
105
+ <button
106
+ onClick={onCancel}
107
+ disabled={draining}
108
+ title="Cancel"
109
+ className="rounded-lg p-2 text-muted-foreground hover:bg-surface-raised hover:text-foreground disabled:opacity-30"
110
+ >
111
+ <X className="h-4 w-4" />
112
+ </button>
113
+ </div>
114
+
115
+ {/* Active-provider note — subtle for Deepgram, amber for the browser
116
+ speech fallback whose audio leaves the device. */}
117
+ {providerNote && !error && (
118
+ <div
119
+ className={cn(
120
+ "px-5 pt-2 text-center text-[11px]",
121
+ providerNote.warn
122
+ ? "text-primary/90"
123
+ : "text-muted-foreground/50"
124
+ )}
125
+ >
126
+ {providerNote.text}
127
+ </div>
128
+ )}
129
+
130
+ {/* Waveform */}
131
+ <div className="flex items-center justify-center px-6 py-4">
132
+ <Waveform level={audioLevel} />
133
+ </div>
134
+
135
+ {/* Transcript */}
136
+ <div ref={transcriptRef} className="flex-1 overflow-y-auto px-6 pb-6">
137
+ {error ? (
138
+ <div className="rounded-lg bg-destructive/10 p-4 text-sm text-destructive">
139
+ {error}
140
+ </div>
141
+ ) : (
142
+ <p className="text-base leading-relaxed">
143
+ {reviewText && (
144
+ <span className="text-muted-foreground/50">{reviewText} </span>
145
+ )}
146
+ <span className="text-foreground">{finalText}</span>
147
+ {finalText && partial && " "}
148
+ <span className="text-muted-foreground/60">{partial}</span>
149
+ {!reviewText && !finalText && !partial && (
150
+ <span className="text-muted-foreground/40">
151
+ Speak to start...
152
+ </span>
153
+ )}
154
+ </p>
155
+ )}
156
+ </div>
157
+
158
+ {/* Big stop button — thumb-friendly */}
159
+ <div className="border-t border-border/50 p-4">
160
+ <button
161
+ onClick={onStop}
162
+ disabled={draining}
163
+ className="flex h-14 w-full items-center justify-center gap-3 rounded-xl bg-primary text-primary-foreground transition-all duration-150 hover:brightness-110 active:scale-[0.99] disabled:opacity-60 disabled:cursor-progress"
164
+ >
165
+ <Mic className="h-5 w-5" />
166
+ <span className="text-sm font-semibold uppercase tracking-wider">
167
+ {draining ? "Finalizing…" : "Done"}
168
+ </span>
169
+ </button>
170
+ <p className="mt-2 text-center text-[11px] text-muted-foreground/50">
171
+ {draining
172
+ ? "Waiting for the last words…"
173
+ : "Tap anywhere outside to stop"}
174
+ </p>
175
+ </div>
176
+ </div>
177
+ </>
178
+ );
179
+ }
180
+
181
+ /** Human-readable note for the active speech provider, or null if unknown. */
182
+ function providerNoteFor(
183
+ providerId: string | null
184
+ ): { text: string; warn: boolean } | null {
185
+ switch (providerId) {
186
+ case "deepgram":
187
+ return { text: "via Deepgram", warn: false };
188
+ case "webspeech":
189
+ // Browser speech (Chrome) streams mic audio to Google — call it out.
190
+ return { text: "via browser speech · audio goes to Google", warn: true };
191
+ case null:
192
+ return null;
193
+ default:
194
+ return { text: `via ${providerId}`, warn: false };
195
+ }
196
+ }
197
+
198
+ function Waveform({ level }: { level: number }) {
199
+ // Render 16 bars whose height is driven by current level + per-bar phase.
200
+ const bars = 16;
201
+ return (
202
+ <div className="flex h-16 items-center gap-1.5">
203
+ {Array.from({ length: bars }).map((_, i) => {
204
+ const phase = (i / bars) * Math.PI;
205
+ const offset = (Math.sin(phase + Date.now() / 200) + 1) / 2;
206
+ const h = 8 + level * 56 * (0.4 + 0.6 * offset);
207
+ return (
208
+ <span
209
+ key={i}
210
+ style={{ height: `${h}px` }}
211
+ className="w-1.5 rounded-full bg-primary transition-[height] duration-75 ease-linear"
212
+ />
213
+ );
214
+ })}
215
+ </div>
216
+ );
217
+ }
@@ -0,0 +1,31 @@
1
+ import { Mic } from "lucide-react";
2
+ import { cn } from "../../lib/utils.js";
3
+
4
+ export function MicButton({
5
+ active,
6
+ disabled,
7
+ onTap,
8
+ }: {
9
+ active: boolean;
10
+ disabled?: boolean;
11
+ onTap: () => void;
12
+ }) {
13
+ return (
14
+ <button
15
+ type="button"
16
+ onClick={onTap}
17
+ disabled={disabled}
18
+ title={active ? "Stop dictation" : "Voice dictation"}
19
+ aria-label={active ? "Stop dictation" : "Voice dictation"}
20
+ className={cn(
21
+ "flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150",
22
+ active
23
+ ? "bg-primary text-primary-foreground animate-pulse"
24
+ : "text-muted-foreground hover:bg-surface-raised hover:text-foreground",
25
+ disabled && "opacity-30 cursor-not-allowed"
26
+ )}
27
+ >
28
+ <Mic className="h-4 w-4" />
29
+ </button>
30
+ );
31
+ }