handoff-app 2.0.0-9 → 2.0.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 (303) hide show
  1. package/{Changelog.md → CHANGELOG.md} +85 -1
  2. package/{License.md → LICENSE.md} +1 -1
  3. package/README.md +813 -406
  4. package/RECIPES.md +975 -0
  5. package/UPGRADE.md +57 -9
  6. package/components.json +20 -20
  7. package/config/docs/foundations/colors/guidelines.md +0 -1
  8. package/config/docs/foundations/colors.md +0 -1
  9. package/config/docs/foundations/effects/guidelines.md +0 -1
  10. package/config/docs/foundations/effects/resources.md +0 -1
  11. package/config/docs/foundations/effects.md +0 -1
  12. package/config/docs/foundations/fonts.md +0 -1
  13. package/config/docs/foundations/grid.md +0 -1
  14. package/config/docs/foundations/icon.md +0 -1
  15. package/config/docs/foundations/icons/guidelines.md +0 -1
  16. package/config/docs/foundations/icons.md +0 -1
  17. package/config/docs/foundations/images/guidelines.md +0 -1
  18. package/config/docs/foundations/images/resources.md +0 -1
  19. package/config/docs/foundations/images.md +0 -1
  20. package/config/docs/foundations/logo/resources.md +0 -1
  21. package/config/docs/foundations/logo.md +0 -1
  22. package/config/docs/foundations/typography/guidelines.md +0 -1
  23. package/config/docs/foundations/typography/resources.md +0 -1
  24. package/config/docs/foundations/typography.md +0 -1
  25. package/config/docs/foundations.md +0 -1
  26. package/config/docs/guidelines.md +0 -1
  27. package/config/docs/index.md +0 -1
  28. package/config/docs/system/tokens/foundations/colors.md +0 -4
  29. package/config/docs/system/tokens/foundations/effects.md +0 -4
  30. package/config/docs/system/tokens/foundations/typography.md +0 -2
  31. package/config/docs/system.md +0 -2
  32. package/config/templates/page.md +2 -1
  33. package/config/templates/page.mdx +2 -1
  34. package/dist/ai/connections.d.ts +66 -0
  35. package/dist/ai/connections.js +149 -0
  36. package/dist/ai/define.d.ts +10 -0
  37. package/dist/ai/define.js +13 -0
  38. package/dist/ai/types.d.ts +26 -0
  39. package/dist/ai/types.js +10 -0
  40. package/dist/app-builder/build.d.ts +14 -7
  41. package/dist/app-builder/build.js +195 -66
  42. package/dist/app-builder/client-config.js +3 -2
  43. package/dist/app-builder/paths.d.ts +0 -12
  44. package/dist/app-builder/paths.js +3 -20
  45. package/dist/app-builder/static-export.js +1 -1
  46. package/dist/app-builder/vercel-output.d.ts +1 -1
  47. package/dist/app-builder/vercel-output.js +5 -4
  48. package/dist/cache/build-cache.d.ts +27 -4
  49. package/dist/cache/build-cache.js +114 -47
  50. package/dist/cache/build-inputs.d.ts +13 -0
  51. package/dist/cache/build-inputs.js +19 -0
  52. package/dist/cache/index.d.ts +2 -1
  53. package/dist/cache/index.js +5 -1
  54. package/dist/cli/create.js +1 -2
  55. package/dist/commands/build/index.d.ts +1 -1
  56. package/dist/commands/build/index.js +1 -1
  57. package/dist/commands/utils.d.ts +3 -3
  58. package/dist/config/defaults.js +7 -4
  59. package/dist/config/from-env.d.ts +4 -0
  60. package/dist/config/from-env.js +38 -1
  61. package/dist/config/helpers.js +2 -2
  62. package/dist/config/index.d.ts +0 -1
  63. package/dist/config/index.js +1 -4
  64. package/dist/config/loader.js +26 -2
  65. package/dist/config/resolve-env.d.ts +2 -1
  66. package/dist/config/resolve-env.js +28 -12
  67. package/dist/config/runtime.js +1 -1
  68. package/dist/index.d.ts +12 -7
  69. package/dist/index.js +18 -22
  70. package/dist/nav/index.d.ts +2 -2
  71. package/dist/nav/registry.d.ts +3 -3
  72. package/dist/nav/registry.js +1 -1
  73. package/dist/nav/workspace.d.ts +3 -3
  74. package/dist/nav/workspace.js +1 -1
  75. package/dist/pipeline/figma.d.ts +13 -2
  76. package/dist/pipeline/figma.js +86 -46
  77. package/dist/pipeline/styles.js +1 -1
  78. package/dist/pipeline/validation.d.ts +1 -1
  79. package/dist/pipeline/validation.js +3 -3
  80. package/dist/providers.d.ts +10 -0
  81. package/dist/providers.js +13 -0
  82. package/dist/registry/asset-storage/define.d.ts +5 -5
  83. package/dist/registry/asset-storage/define.js +4 -4
  84. package/dist/registry/asset-storage/resolve.d.ts +9 -19
  85. package/dist/registry/asset-storage/resolve.js +33 -21
  86. package/dist/registry/asset-storage/types.d.ts +37 -17
  87. package/dist/registry/asset-storage/types.js +5 -5
  88. package/dist/registry/auth/ai-keys.d.ts +32 -0
  89. package/dist/registry/auth/ai-keys.js +69 -0
  90. package/dist/registry/auth/crypto.d.ts +17 -0
  91. package/dist/registry/auth/crypto.js +61 -1
  92. package/dist/registry/auth/index.d.ts +2 -1
  93. package/dist/registry/auth/index.js +11 -2
  94. package/dist/registry/checkout/index.d.ts +2 -1
  95. package/dist/registry/checkout/index.js +57 -127
  96. package/dist/registry/client.js +53 -17
  97. package/dist/registry/db/driver.js +3 -3
  98. package/dist/registry/db/schema.d.ts +261 -3
  99. package/dist/registry/db/schema.js +29 -4
  100. package/dist/registry/email/define.d.ts +10 -0
  101. package/dist/registry/email/define.js +13 -0
  102. package/dist/registry/email/providers/smtp.d.ts +10 -0
  103. package/dist/registry/email/providers/smtp.js +73 -0
  104. package/dist/registry/email/resolve.d.ts +28 -0
  105. package/dist/registry/email/resolve.js +74 -0
  106. package/dist/registry/email/types.d.ts +24 -0
  107. package/dist/registry/email/types.js +6 -0
  108. package/dist/registry/path.d.ts +2 -0
  109. package/dist/registry/path.js +11 -1
  110. package/dist/registry/publish/package.js +8 -0
  111. package/dist/registry/transfer.d.ts +4 -0
  112. package/dist/store/registry.d.ts +3 -3
  113. package/dist/store/registry.js +4 -4
  114. package/dist/transformers/preview/component/builder.d.ts +1 -9
  115. package/dist/transformers/preview/component/builder.js +17 -17
  116. package/dist/transformers/preview/component/css.d.ts +2 -1
  117. package/dist/transformers/preview/component/css.js +8 -5
  118. package/dist/transformers/preview/component/html.d.ts +3 -1
  119. package/dist/transformers/preview/component/html.js +6 -2
  120. package/dist/transformers/preview/component/javascript.d.ts +2 -1
  121. package/dist/transformers/preview/component/javascript.js +10 -4
  122. package/dist/transformers/utils/build.d.ts +5 -0
  123. package/dist/transformers/utils/build.js +21 -4
  124. package/dist/types/config.d.ts +129 -18
  125. package/dist/utils/path.d.ts +1 -5
  126. package/dist/utils/path.js +6 -23
  127. package/docs/api.md +6 -4
  128. package/docs/cli.md +275 -54
  129. package/drizzle/0007_add_ai_keys.sql +11 -0
  130. package/drizzle/0008_add_source_dir.sql +2 -0
  131. package/drizzle/meta/0007_snapshot.json +2013 -0
  132. package/drizzle/meta/0008_snapshot.json +2025 -0
  133. package/drizzle/meta/_journal.json +14 -0
  134. package/package.json +19 -8
  135. package/src/ai/README.md +42 -0
  136. package/src/ai/connections.ts +189 -0
  137. package/src/ai/define.ts +15 -0
  138. package/src/ai/types.ts +30 -0
  139. package/src/app/components/AiAssistant/AiAssistantPanel.tsx +144 -0
  140. package/src/app/components/AiAssistant/AiAssistantProvider.tsx +215 -0
  141. package/src/app/components/AiAssistant/AiConversation.tsx +737 -0
  142. package/src/app/components/AiAssistant/AiToolResult.tsx +99 -0
  143. package/src/app/components/AiAssistant/dockState.ts +86 -0
  144. package/src/app/components/AiAssistant/toolLabel.ts +24 -0
  145. package/src/app/components/AiAssistant/useAiChat.ts +147 -0
  146. package/src/app/components/AiAssistant/useAiConnections.ts +86 -0
  147. package/src/app/components/AiAssistant/usePageContext.ts +63 -0
  148. package/src/app/components/Auth/AccountLayout.tsx +58 -19
  149. package/src/app/components/Auth/AuthShell.tsx +27 -33
  150. package/src/app/components/Auth/RegistryInstallGate.tsx +4 -8
  151. package/src/app/components/Auth/installStatus.ts +29 -0
  152. package/src/app/components/Component/Preview.tsx +5 -2
  153. package/src/app/components/Foundations/ColorGrid.tsx +13 -14
  154. package/src/app/components/Layout/Header.tsx +21 -15
  155. package/src/app/components/Layout/Main.tsx +2 -2
  156. package/src/app/components/Markdown/MarkdownComponents.tsx +30 -32
  157. package/src/app/components/McpIntegration/McpConfigDialog.tsx +12 -51
  158. package/src/app/components/Navigation/MobileNav.tsx +2 -2
  159. package/src/app/components/Navigation/SideNav.tsx +1 -15
  160. package/src/app/components/context/NavProvider.tsx +7 -3
  161. package/src/app/components/ui/attachment.tsx +50 -0
  162. package/src/app/components/ui/bubble.tsx +57 -0
  163. package/src/app/components/ui/button.tsx +3 -1
  164. package/src/app/components/ui/confirm-dialog.tsx +87 -0
  165. package/src/app/components/ui/dialog.tsx +7 -2
  166. package/src/app/components/ui/marker.tsx +56 -0
  167. package/src/app/components/ui/sidebar.tsx +3 -1
  168. package/src/app/components/util/index.ts +3 -12
  169. package/src/app/css/index.css +64 -0
  170. package/src/app/lib/ai/auth.ts +33 -0
  171. package/src/app/lib/ai/context.ts +99 -0
  172. package/src/app/lib/ai/model.ts +79 -0
  173. package/src/app/lib/ai/prompt.ts +36 -0
  174. package/src/app/lib/ai/resolve.ts +97 -0
  175. package/src/app/lib/ai/tools.ts +120 -0
  176. package/src/app/lib/api/methods.ts +13 -0
  177. package/src/app/lib/asset-storage.ts +35 -36
  178. package/src/app/lib/auth/api.ts +4 -12
  179. package/src/app/lib/auth/email.ts +96 -23
  180. package/src/app/lib/docs-api/page-rendering.ts +1 -1
  181. package/src/app/lib/docs-api/registry.ts +2 -3
  182. package/src/app/lib/docs-api/runtime-config.ts +47 -21
  183. package/src/app/lib/mcp/components.ts +523 -0
  184. package/src/app/lib/mcp/page-url.ts +24 -0
  185. package/src/app/lib/mcp/pages.ts +84 -0
  186. package/src/app/lib/mcp/result.ts +33 -0
  187. package/src/app/lib/mcp/server.ts +39 -318
  188. package/src/app/lib/mcp/tokens.ts +334 -0
  189. package/src/app/lib/mcp/tool.ts +19 -0
  190. package/src/app/lib/registry-api/asset-transfer.ts +119 -11
  191. package/src/app/lib/registry-api/errors.ts +2 -0
  192. package/src/app/lib/registry-api/handler.ts +7 -1
  193. package/src/app/lib/registry-api/transfer.ts +28 -6
  194. package/src/app/lib/server-module.ts +34 -0
  195. package/src/app/lib/utils.ts +20 -0
  196. package/src/app/next.config.mjs +26 -24
  197. package/src/app/pages/[...slug]/index.tsx +2 -1
  198. package/src/app/pages/_app.tsx +6 -2
  199. package/src/app/pages/_document.tsx +6 -0
  200. package/src/app/pages/account/ai.tsx +183 -0
  201. package/src/app/pages/account/index.tsx +68 -68
  202. package/src/app/pages/account/tokens.tsx +37 -7
  203. package/src/app/pages/account/users.tsx +75 -15
  204. package/src/app/pages/api/account/ai/keys/[connectionId].ts +60 -0
  205. package/src/app/pages/api/account/ai/keys/index.ts +20 -0
  206. package/src/app/pages/api/admin/users/[id]/[action].ts +80 -11
  207. package/src/app/pages/api/admin/users/index.ts +2 -2
  208. package/src/app/pages/api/ai/chat.ts +115 -0
  209. package/src/app/pages/api/ai/connections.ts +23 -0
  210. package/src/app/pages/api/auth/request-reset.ts +14 -16
  211. package/src/app/pages/api/docs/nav.json.ts +1 -1
  212. package/src/app/pages/api/install.ts +3 -3
  213. package/src/app/pages/api/mcp.ts +6 -0
  214. package/src/app/pages/api/registry/transfer/assets/blobs/[hash]/complete.ts +11 -0
  215. package/src/app/pages/api/registry/transfer/assets/blobs/[hash]/upload.ts +12 -0
  216. package/src/app/pages/api/registry/transfer/assets/blobs/[hash].ts +1 -1
  217. package/src/app/pages/cli/device.tsx +10 -5
  218. package/src/app/pages/foundations/colors.tsx +9 -8
  219. package/src/app/pages/foundations/index.tsx +3 -3
  220. package/src/app/pages/index.tsx +2 -2
  221. package/src/app/pages/install.tsx +7 -2
  222. package/src/app/pages/login.tsx +32 -27
  223. package/src/app/pages/reset-password.tsx +21 -20
  224. package/src/app/pages/system/component/[component]/index.tsx +2 -5
  225. package/src/app/pages/system/index.tsx +11 -11
  226. package/src/app/pages/system/pattern/[pattern]/index.tsx +2 -2
  227. package/src/app/pages/system/tokens/components/[component]/index.tsx +0 -4
  228. package/src/app-builder/README.md +2 -1
  229. package/src/app-builder/build.ts +218 -73
  230. package/src/app-builder/client-config.ts +3 -2
  231. package/src/app-builder/paths.ts +3 -22
  232. package/src/app-builder/static-export.ts +1 -1
  233. package/src/app-builder/vercel-output.ts +5 -4
  234. package/src/cache/README.md +4 -1
  235. package/src/cache/build-cache.ts +128 -43
  236. package/src/cache/build-inputs.ts +28 -0
  237. package/src/cache/index.ts +4 -0
  238. package/src/cli/create.ts +1 -2
  239. package/src/commands/build/index.ts +1 -1
  240. package/src/config/README.md +10 -9
  241. package/src/config/defaults.ts +7 -4
  242. package/src/config/from-env.ts +33 -0
  243. package/src/config/helpers.ts +1 -3
  244. package/src/config/index.ts +0 -3
  245. package/src/config/loader.ts +25 -3
  246. package/src/config/resolve-env.ts +28 -15
  247. package/src/config/runtime.ts +1 -1
  248. package/src/index.ts +33 -29
  249. package/src/nav/index.ts +2 -2
  250. package/src/nav/registry.ts +3 -3
  251. package/src/nav/workspace.ts +3 -3
  252. package/src/pipeline/README.md +1 -1
  253. package/src/pipeline/figma.ts +98 -49
  254. package/src/pipeline/styles.ts +1 -1
  255. package/src/pipeline/validation.ts +3 -3
  256. package/src/providers.ts +18 -0
  257. package/src/registry/asset-storage/define.ts +6 -6
  258. package/src/registry/asset-storage/resolve.ts +38 -37
  259. package/src/registry/asset-storage/types.ts +39 -17
  260. package/src/registry/auth/ai-keys.ts +62 -0
  261. package/src/registry/auth/crypto.ts +63 -1
  262. package/src/registry/auth/index.ts +11 -1
  263. package/src/registry/checkout/index.ts +60 -138
  264. package/src/registry/client.ts +60 -20
  265. package/src/registry/db/driver.ts +2 -2
  266. package/src/registry/db/schema.ts +49 -22
  267. package/src/registry/email/define.ts +15 -0
  268. package/src/registry/email/providers/smtp.ts +39 -0
  269. package/src/registry/email/resolve.ts +94 -0
  270. package/src/registry/email/types.ts +28 -0
  271. package/src/registry/path.ts +11 -0
  272. package/src/registry/publish/package.ts +9 -0
  273. package/src/registry/transfer.ts +4 -0
  274. package/src/store/registry.ts +7 -7
  275. package/src/transformers/preview/component/builder.ts +19 -25
  276. package/src/transformers/preview/component/css.ts +9 -4
  277. package/src/transformers/preview/component/html.ts +6 -2
  278. package/src/transformers/preview/component/javascript.ts +14 -4
  279. package/src/transformers/utils/build.ts +16 -3
  280. package/src/types/config.ts +142 -20
  281. package/src/utils/README.md +1 -1
  282. package/src/utils/path.ts +6 -29
  283. package/templates/default/handoff.config.js.tpl +10 -2
  284. package/templates/default/handoff.config.ts.tpl +10 -2
  285. package/templates/default/handoff.config.with-examples.js.tpl +10 -2
  286. package/templates/default/handoff.config.with-examples.ts.tpl +10 -2
  287. package/templates/default/package.json.tpl +2 -2
  288. package/.editorconfig +0 -19
  289. package/.eslintrc.json +0 -4
  290. package/.prettierignore +0 -0
  291. package/.prettierrc.json +0 -8
  292. package/.vscode/settings.json +0 -17
  293. package/dist/config/validator.d.ts +0 -9
  294. package/dist/config/validator.js +0 -24
  295. package/dist/registry/asset-storage/adapters/vercel-blob.d.ts +0 -17
  296. package/dist/registry/asset-storage/adapters/vercel-blob.js +0 -87
  297. package/src/app/components/old/Header.tsx +0 -89
  298. package/src/app/lib/mcp/shape.ts +0 -596
  299. package/src/app/pages/assets/fonts.tsx +0 -129
  300. package/src/app/pages/assets/index.tsx +0 -91
  301. package/src/app/pages/assets/logos.tsx +0 -75
  302. package/src/config/validator.ts +0 -22
  303. package/src/registry/asset-storage/adapters/vercel-blob.ts +0 -51
@@ -0,0 +1,99 @@
1
+ 'use client';
2
+
3
+ import { ArrowRight, Layers } from 'lucide-react';
4
+ import Link from 'next/link';
5
+ import * as React from 'react';
6
+
7
+ /**
8
+ * Tool results the reader can act on, drawn as cards under the step that produced them. Every other
9
+ * tool, and any result in a shape this does not recognize, stays a status row.
10
+ */
11
+
12
+ /** Search results shown before the rest collapse into a count. */
13
+ const MAX_COMPONENT_CARDS = 5;
14
+
15
+ type ComponentCard = { id: string; title: string; group?: string; description?: string };
16
+
17
+ type PreviewCard = { id: string; title: string; url: string; html?: string; component: { id: string; title: string } };
18
+
19
+ const componentRoute = (id: string): string => `/system/component/${encodeURIComponent(id)}`;
20
+
21
+ /** The JSON payload of the single text block an MCP tool returns, or `null` for anything else. */
22
+ const payloadOf = (output: unknown): Record<string, unknown> | null => {
23
+ const block = Array.isArray(output) ? output.find((entry) => entry?.type === 'text') : null;
24
+ if (typeof block?.text !== 'string') return null;
25
+ try {
26
+ const value = JSON.parse(block.text);
27
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
28
+ } catch {
29
+ return null;
30
+ }
31
+ };
32
+
33
+ const ComponentCards: React.FC<{ components: ComponentCard[]; total: number }> = ({ components, total }) => {
34
+ const shown = components.slice(0, MAX_COMPONENT_CARDS);
35
+ const hidden = total - shown.length;
36
+
37
+ return (
38
+ <div className="flex flex-col gap-1.5">
39
+ {shown.map((component) => (
40
+ <Link
41
+ key={component.id}
42
+ href={componentRoute(component.id)}
43
+ className="flex w-full items-center gap-3 rounded-xl border bg-card px-3 py-2.5 text-left no-underline transition-colors hover:bg-muted/60 active:bg-muted"
44
+ >
45
+ <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
46
+ <Layers className="h-4 w-4" />
47
+ </span>
48
+ <span className="min-w-0 flex-1">
49
+ <span className="block truncate text-sm font-medium leading-tight text-foreground">{component.title}</span>
50
+ {(component.description || component.group) && (
51
+ <span className="line-clamp-2 text-xs text-muted-foreground">{component.description || component.group}</span>
52
+ )}
53
+ </span>
54
+ <ArrowRight className="h-4 w-4 shrink-0 text-muted-foreground" />
55
+ </Link>
56
+ ))}
57
+ {hidden > 0 && <p className="px-1 text-xs text-muted-foreground">and {hidden} more</p>}
58
+ </div>
59
+ );
60
+ };
61
+
62
+ const PreviewFrame: React.FC<{ preview: PreviewCard }> = ({ preview }) => (
63
+ <div className="overflow-hidden rounded-xl border bg-card">
64
+ {/* The rendered artifact, as the component page frames it. A preview that was never built has no markup to show. */}
65
+ {preview.html && (
66
+ <iframe
67
+ src={preview.url}
68
+ title={`${preview.component.title}: ${preview.title}`}
69
+ loading="lazy"
70
+ className="block h-48 w-full border-b bg-background"
71
+ />
72
+ )}
73
+ <Link
74
+ href={componentRoute(preview.component.id)}
75
+ className="flex items-center gap-3 px-3 py-2.5 no-underline transition-colors hover:bg-muted/60"
76
+ >
77
+ <span className="min-w-0 flex-1">
78
+ <span className="block truncate text-sm font-medium leading-tight text-foreground">{preview.component.title}</span>
79
+ <span className="block truncate text-xs text-muted-foreground">{preview.title}</span>
80
+ </span>
81
+ <ArrowRight className="h-4 w-4 shrink-0 text-muted-foreground" />
82
+ </Link>
83
+ </div>
84
+ );
85
+
86
+ /** The card for one finished tool call, or `null` when the result has none. */
87
+ export const toolResultCard = (toolName: string, output: unknown): React.ReactNode => {
88
+ const payload = payloadOf(output);
89
+ if (!payload) return null;
90
+
91
+ if (toolName === 'handoff_search_components' && Array.isArray(payload.components) && payload.components.length > 0) {
92
+ const total = typeof payload.total === 'number' ? payload.total : payload.components.length;
93
+ return <ComponentCards components={payload.components as ComponentCard[]} total={total} />;
94
+ }
95
+ if (toolName === 'handoff_get_component_preview' && typeof payload.url === 'string' && payload.component) {
96
+ return <PreviewFrame preview={payload as unknown as PreviewCard} />;
97
+ }
98
+ return null;
99
+ };
@@ -0,0 +1,86 @@
1
+ /** The shape of the docked assistant, kept per reader in `localStorage` the way the side nav is. */
2
+
3
+ export const MIN_WIDTH = 320;
4
+ export const MAX_WIDTH = 720;
5
+ export const DEFAULT_WIDTH = 320;
6
+
7
+ /** Below this the assistant overlays the page instead of docking. Matches `useIsMobile`. */
8
+ export const DOCK_BREAKPOINT = 768;
9
+
10
+ /** Drives both the panel's width and the page's inset, so the two can never disagree. */
11
+ export const DOCK_WIDTH_VAR = '--ai-dock-width';
12
+
13
+ const STORAGE_KEY = 'handoff:ai:dock';
14
+ const NON_DOCS_ROUTES = ['/cli/device', '/install', '/login', '/reset-password'] as const;
15
+ const NON_DOCS_ROUTE_PREFIXES = ['/account'] as const;
16
+
17
+ /** The assistant answers from the docs catalog, so application and authentication routes omit it. */
18
+ export const isAiAssistantRoute = (pathname: string): boolean => {
19
+ const normalized = pathname.length > 1 ? pathname.replace(/\/+$/, '') : pathname;
20
+ return (
21
+ !NON_DOCS_ROUTES.some((route) => normalized === route) &&
22
+ !NON_DOCS_ROUTE_PREFIXES.some((route) => normalized === route || normalized.startsWith(`${route}/`))
23
+ );
24
+ };
25
+
26
+ export interface DockState {
27
+ open: boolean;
28
+ width: number;
29
+ }
30
+
31
+ export const DEFAULT_DOCK: DockState = { open: false, width: DEFAULT_WIDTH };
32
+
33
+ const MAX_VIEWPORT_SHARE = 0.5;
34
+
35
+ export const clampWidth = (width: number): number => Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, Math.round(width)));
36
+
37
+ /**
38
+ * Caps the width against this viewport. A width kept from a large screen is otherwise most of a
39
+ * small one, and the expanded component preview is left with a strip to render into.
40
+ */
41
+ export const fitWidth = (width: number): number =>
42
+ Math.min(clampWidth(width), Math.max(MIN_WIDTH, Math.round(window.innerWidth * MAX_VIEWPORT_SHARE)));
43
+
44
+ export const dockWidth = (state: DockState): number => (state.open ? clampWidth(state.width) : 0);
45
+
46
+ export const readDock = (): DockState => {
47
+ try {
48
+ const stored = JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? 'null');
49
+ if (!stored || typeof stored !== 'object') return DEFAULT_DOCK;
50
+ return {
51
+ open: Boolean(stored.open),
52
+ width: fitWidth(Number(stored.width) || DEFAULT_WIDTH),
53
+ };
54
+ } catch {
55
+ return DEFAULT_DOCK;
56
+ }
57
+ };
58
+
59
+ export const writeDock = (state: DockState): void => {
60
+ try {
61
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
62
+ } catch {
63
+ /* empty */
64
+ }
65
+ };
66
+
67
+ /**
68
+ * Reserves the dock's column before the first paint.
69
+ *
70
+ * React reads the stored state only after hydration, so without this the page paints full width and
71
+ * then reflows. `next-themes` avoids its theme flash the same way.
72
+ */
73
+ export const dockWidthScript = `(function(){try{
74
+ var p=location.pathname;
75
+ var b=${JSON.stringify((process.env.HANDOFF_APP_BASE_PATH ?? '').replace(/\/+$/, ''))};
76
+ if(b&&p.indexOf(b)===0&&(p.length===b.length||p.charAt(b.length)==='/'))p=p.slice(b.length)||'/';
77
+ while(p.length>1&&p.charAt(p.length-1)==='/')p=p.slice(0,-1);
78
+ var hidden=${JSON.stringify(NON_DOCS_ROUTES)}.indexOf(p)!==-1||${JSON.stringify(NON_DOCS_ROUTE_PREFIXES)}.some(function(r){return p===r||p.indexOf(r+'/')===0});
79
+ if(hidden)return;
80
+ var s=JSON.parse(localStorage.getItem(${JSON.stringify(STORAGE_KEY)})||'null');
81
+ if(!s||!s.open)return;
82
+ document.documentElement.dataset.aiDockOpen='true';
83
+ if(window.innerWidth<${DOCK_BREAKPOINT})return;
84
+ var w=Math.min(${MAX_WIDTH},Math.max(${MIN_WIDTH},Math.round(s.width)||${DEFAULT_WIDTH}),Math.max(${MIN_WIDTH},Math.round(window.innerWidth*${MAX_VIEWPORT_SHARE})));
85
+ document.documentElement.style.setProperty(${JSON.stringify(DOCK_WIDTH_VAR)},w+'px');
86
+ }catch(e){}})();`;
@@ -0,0 +1,24 @@
1
+ /** How one tool call reads in the collapsed row under the answer. */
2
+ export const toolCallLabel = (toolName: string, input: unknown): string => {
3
+ const args = (input && typeof input === 'object' ? input : {}) as Record<string, unknown>;
4
+ const value = (key: string): string | undefined => (typeof args[key] === 'string' && args[key] ? (args[key] as string) : undefined);
5
+
6
+ switch (toolName) {
7
+ case 'handoff_search_components':
8
+ return value('query') ? `Searched components for “${value('query')}”` : 'Listed components';
9
+ case 'handoff_get_component':
10
+ return `Read component ${value('id') ?? ''}`.trim();
11
+ case 'handoff_get_component_source':
12
+ return `Read ${value('id') ?? 'component'} source`;
13
+ case 'handoff_get_component_preview':
14
+ return `Read ${value('id') ?? 'component'} preview ${value('preview') ?? ''}`.trim();
15
+ case 'handoff_get_tokens':
16
+ return value('set') ? `Read tokens for ${value('set')}` : 'Read design tokens';
17
+ case 'handoff_search_pages':
18
+ return value('query') ? `Searched pages for “${value('query')}”` : 'Listed pages';
19
+ case 'handoff_get_page':
20
+ return `Read page ${value('url') ?? ''}`.trim();
21
+ default:
22
+ return toolName;
23
+ }
24
+ };
@@ -0,0 +1,147 @@
1
+ 'use client';
2
+
3
+ import { Chat, useChat, type UseChatHelpers } from '@ai-sdk/react';
4
+ import { DefaultChatTransport, isDynamicToolUIPart, isToolUIPart, type UIMessage, type UIMessagePart } from 'ai';
5
+ import * as React from 'react';
6
+
7
+ const basePath = process.env.HANDOFF_APP_BASE_PATH ?? '';
8
+
9
+ const TRANSCRIPT_KEY = 'handoff:ai:transcript';
10
+ const MODEL_KEY = 'handoff:ai:model';
11
+
12
+ /** A transcript past this size is dropped, so one long answer cannot fill the storage quota. */
13
+ const MAX_TRANSCRIPT_BYTES = 512 * 1024;
14
+
15
+ /** Each token postpones the write, so only a settled answer reaches storage. */
16
+ const SAVE_DELAY_MS = 400;
17
+
18
+ /**
19
+ * Whether a stored part is safe to send back to the model.
20
+ *
21
+ * The transcript is written while an answer is in flight, so it can hold a call with an input and
22
+ * no output. A provider rejects a call with no matching result, and one of those in a restored
23
+ * conversation breaks every question after it.
24
+ */
25
+ const isSettledPart = (part: UIMessagePart<never, never>): boolean => {
26
+ // MCP tools reach the agent as dynamic tools, so their parts carry `dynamic-tool` and not
27
+ // `tool-<name>`. Check both or this matches none of the app's own calls.
28
+ if (!isToolUIPart(part) && !isDynamicToolUIPart(part)) return true;
29
+ return (
30
+ part.state === 'output-error' ||
31
+ part.state === 'output-denied' ||
32
+ part.state === 'approval-responded' ||
33
+ (part.state === 'output-available' && part.preliminary !== true)
34
+ );
35
+ };
36
+
37
+ /** A message still worth restoring once the unsettled parts are gone. */
38
+ const hasContent = (message: UIMessage): boolean =>
39
+ message.role === 'user' ||
40
+ message.parts.some(
41
+ (part) =>
42
+ (part.type === 'text' && part.text.trim().length > 0) || part.type === 'source-url' || isToolUIPart(part) || isDynamicToolUIPart(part)
43
+ );
44
+
45
+ const readTranscript = (): UIMessage[] => {
46
+ try {
47
+ const stored = JSON.parse(window.localStorage.getItem(TRANSCRIPT_KEY) ?? 'null');
48
+ if (!Array.isArray(stored)) return [];
49
+ return (stored as UIMessage[]).map((message) => ({ ...message, parts: message.parts.filter(isSettledPart) })).filter(hasContent);
50
+ } catch {
51
+ return [];
52
+ }
53
+ };
54
+
55
+ const writeTranscript = (messages: UIMessage[]): void => {
56
+ try {
57
+ const payload = JSON.stringify(messages);
58
+ // Drop the older transcript too: the reader must not return to a conversation that stops halfway.
59
+ if (messages.length === 0 || payload.length > MAX_TRANSCRIPT_BYTES) window.localStorage.removeItem(TRANSCRIPT_KEY);
60
+ else window.localStorage.setItem(TRANSCRIPT_KEY, payload);
61
+ } catch {
62
+ /* empty */
63
+ }
64
+ };
65
+
66
+ const readModel = (): string | null => {
67
+ try {
68
+ return window.localStorage.getItem(MODEL_KEY);
69
+ } catch {
70
+ return null;
71
+ }
72
+ };
73
+
74
+ const writeModel = (model: string): void => {
75
+ try {
76
+ window.localStorage.setItem(MODEL_KEY, model);
77
+ } catch {
78
+ /* empty */
79
+ }
80
+ };
81
+
82
+ /**
83
+ * The live conversation, owned here and not by a component.
84
+ *
85
+ * `useChat` discards everything it owns when its component goes, and the panel does unmount. An
86
+ * instance held here outlives that, and an answer that still streams keeps arriving into it.
87
+ *
88
+ * Created on first use, never at import: a module-scope chat on the server would be shared by every
89
+ * request.
90
+ */
91
+ let chat: Chat<UIMessage> | null = null;
92
+ const listeners = new Set<() => void>();
93
+
94
+ const createChat = (messages: UIMessage[]): Chat<UIMessage> =>
95
+ new Chat<UIMessage>({
96
+ messages,
97
+ transport: new DefaultChatTransport({ api: `${basePath}/api/ai/chat`, credentials: 'include' }),
98
+ });
99
+
100
+ const getChat = (): Chat<UIMessage> => (chat ??= createChat(readTranscript()));
101
+
102
+ const subscribe = (onChange: () => void): (() => void) => {
103
+ listeners.add(onChange);
104
+ return () => listeners.delete(onChange);
105
+ };
106
+
107
+ export interface AiChat extends UseChatHelpers<UIMessage> {
108
+ /** The model this reader picked, kept across reloads. `null` until the connections load. */
109
+ model: string | null;
110
+ chooseModel: (model: string) => void;
111
+ /** Replace the conversation with an empty one. */
112
+ startFresh: () => void;
113
+ }
114
+
115
+ export const useAiChat = (): AiChat => {
116
+ // Every caller must land on the same instance, including after a reset. Component state would
117
+ // give each a copy, and the one that did not call `startFresh` would keep reporting on the
118
+ // conversation the reader just cleared.
119
+ const instance = React.useSyncExternalStore(subscribe, getChat, getChat);
120
+ const [model, setModel] = React.useState<string | null>(readModel);
121
+ const helpers = useChat<UIMessage>({ chat: instance });
122
+ const { messages } = helpers;
123
+
124
+ React.useEffect(() => {
125
+ const timer = window.setTimeout(() => writeTranscript(messages), SAVE_DELAY_MS);
126
+ return () => window.clearTimeout(timer);
127
+ }, [messages]);
128
+
129
+ /**
130
+ * A replacement instance also settles an answer that still streams: the discarded chat is aborted
131
+ * and keeps whatever the transport wrote midway. Clearing the list in place races that write.
132
+ *
133
+ * The chosen model survives, because the reader already did the work to pick one.
134
+ */
135
+ const startFresh = React.useCallback(() => {
136
+ chat = createChat([]);
137
+ writeTranscript([]);
138
+ listeners.forEach((onChange) => onChange());
139
+ }, []);
140
+
141
+ const chooseModel = React.useCallback((next: string) => {
142
+ writeModel(next);
143
+ setModel(next);
144
+ }, []);
145
+
146
+ return { ...helpers, model, chooseModel, startFresh };
147
+ };
@@ -0,0 +1,86 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useState } from 'react';
4
+
5
+ /** One connection as `/api/ai/connections` reports it. Never carries a key, only whether one is set. */
6
+ export interface AiConnection {
7
+ id: string;
8
+ label: string;
9
+ baseUrl?: string;
10
+ models?: string[];
11
+ credential: 'service' | 'user' | 'none';
12
+ configured: boolean;
13
+ }
14
+
15
+ /** One model this reader can run, as `<connectionId>/<model>`. */
16
+ export interface AiModelOption {
17
+ id: string;
18
+ connectionId: string;
19
+ label: string;
20
+ model: string;
21
+ }
22
+
23
+ export interface AiConnectionsState {
24
+ /**
25
+ * Every model this reader can run right now, grouped by connection. Connections and their models
26
+ * keep the order the config declares, which is also the order the server falls back through.
27
+ */
28
+ models: AiModelOption[];
29
+ defaultModel: string | null;
30
+ /** Whether this deployment has an account page where a reader can add a key. */
31
+ canAddKeys: boolean;
32
+ loading: boolean;
33
+ /** The request failed, rather than succeeding with nothing usable. */
34
+ failed: boolean;
35
+ }
36
+
37
+ const basePath = process.env.HANDOFF_APP_BASE_PATH ?? '';
38
+
39
+ /**
40
+ * The models the assistant may offer. Loaded once the assistant is first opened rather than on every
41
+ * page, so a reader who never asks a question pays nothing for the feature.
42
+ */
43
+ export const useAiConnections = (active: boolean): AiConnectionsState => {
44
+ const [state, setState] = useState<AiConnectionsState>({
45
+ models: [],
46
+ defaultModel: null,
47
+ canAddKeys: false,
48
+ loading: true,
49
+ failed: false,
50
+ });
51
+
52
+ const load = useCallback(async () => {
53
+ const response = await fetch(`${basePath}/api/ai/connections`, { credentials: 'include', cache: 'no-store' });
54
+ if (!response.ok) throw new Error('unavailable');
55
+ const body = (await response.json()) as { connections: AiConnection[]; defaultModel: string | null; canAddKeys: boolean };
56
+ setState({
57
+ models: body.connections
58
+ .filter((connection) => connection.configured)
59
+ .flatMap((connection) =>
60
+ (connection.models ?? []).map((model) => ({
61
+ id: `${connection.id}/${model}`,
62
+ connectionId: connection.id,
63
+ label: connection.label,
64
+ model,
65
+ }))
66
+ ),
67
+ defaultModel: body.defaultModel,
68
+ canAddKeys: body.canAddKeys,
69
+ loading: false,
70
+ failed: false,
71
+ });
72
+ }, []);
73
+
74
+ useEffect(() => {
75
+ if (!active) return;
76
+ let live = true;
77
+ void load().catch(() => {
78
+ if (live) setState((current) => ({ ...current, loading: false, failed: true }));
79
+ });
80
+ return () => {
81
+ live = false;
82
+ };
83
+ }, [active, load]);
84
+
85
+ return state;
86
+ };
@@ -0,0 +1,63 @@
1
+ 'use client';
2
+
3
+ import type { NavMenuItem, SectionLink } from '@handoff/nav';
4
+ import { useRouter } from 'next/router';
5
+ import * as React from 'react';
6
+
7
+ import { pageRefFromPath, type AiPageKind } from '../../lib/ai/context';
8
+ import { normalizePathForMatch, stripBasePath } from '../../lib/utils';
9
+ import { useNav } from '../context/NavProvider';
10
+
11
+ /** The page a question can be asked about: its route as the server reads it, and its nav title. */
12
+ export interface AiPage {
13
+ path: string;
14
+ kind: AiPageKind;
15
+ title: string;
16
+ }
17
+
18
+ /** A route with no base path, query, fragment or trailing slash, so nav paths and the router compare. */
19
+ const routeOf = (href: string): string => `/${normalizePathForMatch(href)}`;
20
+
21
+ /** Every titled route in the nav. The first title found for a route wins, as the side nav shows it first. */
22
+ const collectTitles = (sections: SectionLink[]): Map<string, string> => {
23
+ const titles = new Map<string, string>();
24
+ const add = (path: string | undefined, title: string) => {
25
+ if (!path || !title) return;
26
+ const route = routeOf(stripBasePath(path));
27
+ if (!titles.has(route)) titles.set(route, title);
28
+ };
29
+ const walk = (items: NavMenuItem[] = []) => {
30
+ for (const item of items) {
31
+ add(item.path, item.title);
32
+ walk(item.menu);
33
+ }
34
+ };
35
+ for (const section of sections) {
36
+ add(section.path, section.title);
37
+ for (const sub of section.subSections) {
38
+ add(sub.path, sub.title);
39
+ walk(sub.menu);
40
+ }
41
+ }
42
+ return titles;
43
+ };
44
+
45
+ /** Nav titles by route, from the data the side nav already holds, so the panel makes no request. */
46
+ export const useNavTitles = (): Map<string, string> => {
47
+ const { nav } = useNav();
48
+ return React.useMemo(() => collectTitles(nav.shell), [nav]);
49
+ };
50
+
51
+ /** The page at a route, or `null` when the route gives no context or is not in the nav. */
52
+ export const pageAt = (path: string, titles: Map<string, string>): AiPage | null => {
53
+ const ref = pageRefFromPath(path);
54
+ const title = titles.get(path);
55
+ return ref && title ? { path, kind: ref.kind, title } : null;
56
+ };
57
+
58
+ /** The current page, or `null` when the route gives no context or is not in the nav. */
59
+ export const usePageContext = (): AiPage | null => {
60
+ const { asPath } = useRouter();
61
+ const titles = useNavTitles();
62
+ return pageAt(routeOf(asPath), titles);
63
+ };
@@ -1,33 +1,57 @@
1
+ import type { ClientConfig } from '@handoff/types/config';
2
+ import { Bot, CircleUserRound, KeyRound, Users, type LucideIcon } from 'lucide-react';
1
3
  import { useSession } from 'next-auth/react';
2
4
  import Link from 'next/link';
3
5
  import { useRouter } from 'next/router';
4
6
  import { useEffect, type ReactNode } from 'react';
5
7
  import { cn } from '../../lib/utils';
8
+ import { Separator } from '../ui/separator';
6
9
  import { AuthShell } from './AuthShell';
7
10
 
8
- const navGroups = [
11
+ interface NavLink {
12
+ href: string;
13
+ label: string;
14
+ icon: LucideIcon;
15
+ adminOnly?: boolean;
16
+ }
17
+
18
+ const navGroups: { label: string; links: NavLink[] }[] = [
9
19
  {
10
20
  label: 'Account',
11
21
  links: [
12
- { href: '/account', label: 'Profile' },
13
- { href: '/account/tokens', label: 'Access tokens' },
22
+ { href: '/account', label: 'Profile', icon: CircleUserRound },
23
+ { href: '/account/tokens', label: 'Access tokens', icon: KeyRound },
24
+ // Only meaningful where a connection declares `credential: 'user'`; the page itself says so
25
+ // when none does, rather than the nav guessing at a build-time value the browser cannot read.
26
+ ...(process.env.HANDOFF_AI_ENABLED === 'true' ? [{ href: '/account/ai', label: 'AI providers', icon: Bot }] : []),
14
27
  ],
15
28
  },
16
29
  {
17
30
  label: 'Administration',
18
- links: [{ href: '/account/users', label: 'Users', adminOnly: true }],
31
+ links: [{ href: '/account/users', label: 'Users', icon: Users, adminOnly: true }],
19
32
  },
20
33
  ];
21
34
 
22
- export function AccountLayout({ children, title }: { children: ReactNode; title: string }) {
35
+ interface AccountLayoutProps {
36
+ children: ReactNode;
37
+ config?: ClientConfig;
38
+ title: string;
39
+ description?: string;
40
+ }
41
+
42
+ export function AccountLayout({ children, config, title, description }: AccountLayoutProps) {
23
43
  if (process.env.HANDOFF_RUNTIME_MODE !== 'registry') {
24
- return <AuthShell title={title}>{null}</AuthShell>;
44
+ return <AuthShell config={config} title={title}>{null}</AuthShell>;
25
45
  }
26
46
 
27
- return <RegistryAccountLayout title={title}>{children}</RegistryAccountLayout>;
47
+ return (
48
+ <RegistryAccountLayout config={config} title={title} description={description}>
49
+ {children}
50
+ </RegistryAccountLayout>
51
+ );
28
52
  }
29
53
 
30
- function RegistryAccountLayout({ children, title }: { children: ReactNode; title: string }) {
54
+ function RegistryAccountLayout({ children, config, title, description }: AccountLayoutProps) {
31
55
  const { data: session, status } = useSession();
32
56
  const router = useRouter();
33
57
  const role = (session?.user as { role?: string } | undefined)?.role;
@@ -42,35 +66,42 @@ function RegistryAccountLayout({ children, title }: { children: ReactNode; title
42
66
  }, [router, session, status]);
43
67
 
44
68
  return (
45
- <AuthShell title={title} wide>
69
+ <AuthShell config={config} title={title} wide>
46
70
  {status === 'loading' || !session?.user ? (
47
71
  <p className="py-20 text-center text-sm text-muted-foreground">Loading account…</p>
48
72
  ) : (
49
- <div className="grid gap-8 md:grid-cols-[200px_minmax(0,1fr)]">
50
- <aside className="space-y-6">
73
+ <div className="grid md:grid-cols-[16rem_minmax(0,1fr)]">
74
+ <aside className="pt-5">
51
75
  {navGroups
52
76
  .map((group) => ({
53
77
  ...group,
54
- links: group.links.filter((link) => !('adminOnly' in link && link.adminOnly) || role === 'admin'),
78
+ links: group.links.filter((link) => !link.adminOnly || role === 'admin'),
55
79
  }))
56
80
  .filter((group) => group.links.length > 0)
57
- .map((group) => (
81
+ .map((group, index) => (
58
82
  <div key={group.label}>
59
- <p className="mb-3 px-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">{group.label}</p>
83
+ {index > 0 ? <Separator className="my-4 hidden md:block" /> : null}
84
+ <p className="flex h-8 items-center px-2 text-xs font-medium text-muted-foreground">{group.label}</p>
60
85
  <nav className="flex gap-1 overflow-x-auto md:flex-col">
61
86
  {group.links.map((link) => {
62
87
  const active = router.pathname === link.href;
88
+ const Icon = link.icon;
63
89
  return (
64
90
  <Link
65
91
  key={link.href}
66
92
  href={link.href}
67
93
  className={cn(
68
- 'whitespace-nowrap rounded-md px-3 py-2 text-sm',
69
- active
70
- ? 'bg-accent font-medium text-accent-foreground'
71
- : 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'
94
+ 'group/nav-item flex h-9 items-center gap-3 whitespace-nowrap rounded-md px-3 text-sm',
95
+ active ? 'bg-accent font-medium text-accent-foreground' : 'hover:bg-accent/50'
72
96
  )}
73
97
  >
98
+ <Icon
99
+ className={cn(
100
+ 'size-4 shrink-0',
101
+ active ? 'text-foreground' : 'text-muted-foreground group-hover/nav-item:text-foreground'
102
+ )}
103
+ strokeWidth={1.5}
104
+ />
74
105
  {link.label}
75
106
  </Link>
76
107
  );
@@ -79,7 +110,15 @@ function RegistryAccountLayout({ children, title }: { children: ReactNode; title
79
110
  </div>
80
111
  ))}
81
112
  </aside>
82
- <section className="min-w-0">{children}</section>
113
+ <section className="min-w-0 py-8 md:pl-8 lg:py-16 lg:pl-16">
114
+ <div className="mx-auto w-full max-w-4xl space-y-8">
115
+ <div>
116
+ <h1 className="text-xl font-semibold">{title}</h1>
117
+ {description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
118
+ </div>
119
+ {children}
120
+ </div>
121
+ </section>
83
122
  </div>
84
123
  )}
85
124
  </AuthShell>