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.
- package/{Changelog.md → CHANGELOG.md} +85 -1
- package/{License.md → LICENSE.md} +1 -1
- package/README.md +813 -406
- package/RECIPES.md +975 -0
- package/UPGRADE.md +57 -9
- package/components.json +20 -20
- package/config/docs/foundations/colors/guidelines.md +0 -1
- package/config/docs/foundations/colors.md +0 -1
- package/config/docs/foundations/effects/guidelines.md +0 -1
- package/config/docs/foundations/effects/resources.md +0 -1
- package/config/docs/foundations/effects.md +0 -1
- package/config/docs/foundations/fonts.md +0 -1
- package/config/docs/foundations/grid.md +0 -1
- package/config/docs/foundations/icon.md +0 -1
- package/config/docs/foundations/icons/guidelines.md +0 -1
- package/config/docs/foundations/icons.md +0 -1
- package/config/docs/foundations/images/guidelines.md +0 -1
- package/config/docs/foundations/images/resources.md +0 -1
- package/config/docs/foundations/images.md +0 -1
- package/config/docs/foundations/logo/resources.md +0 -1
- package/config/docs/foundations/logo.md +0 -1
- package/config/docs/foundations/typography/guidelines.md +0 -1
- package/config/docs/foundations/typography/resources.md +0 -1
- package/config/docs/foundations/typography.md +0 -1
- package/config/docs/foundations.md +0 -1
- package/config/docs/guidelines.md +0 -1
- package/config/docs/index.md +0 -1
- package/config/docs/system/tokens/foundations/colors.md +0 -4
- package/config/docs/system/tokens/foundations/effects.md +0 -4
- package/config/docs/system/tokens/foundations/typography.md +0 -2
- package/config/docs/system.md +0 -2
- package/config/templates/page.md +2 -1
- package/config/templates/page.mdx +2 -1
- package/dist/ai/connections.d.ts +66 -0
- package/dist/ai/connections.js +149 -0
- package/dist/ai/define.d.ts +10 -0
- package/dist/ai/define.js +13 -0
- package/dist/ai/types.d.ts +26 -0
- package/dist/ai/types.js +10 -0
- package/dist/app-builder/build.d.ts +14 -7
- package/dist/app-builder/build.js +195 -66
- package/dist/app-builder/client-config.js +3 -2
- package/dist/app-builder/paths.d.ts +0 -12
- package/dist/app-builder/paths.js +3 -20
- package/dist/app-builder/static-export.js +1 -1
- package/dist/app-builder/vercel-output.d.ts +1 -1
- package/dist/app-builder/vercel-output.js +5 -4
- package/dist/cache/build-cache.d.ts +27 -4
- package/dist/cache/build-cache.js +114 -47
- package/dist/cache/build-inputs.d.ts +13 -0
- package/dist/cache/build-inputs.js +19 -0
- package/dist/cache/index.d.ts +2 -1
- package/dist/cache/index.js +5 -1
- package/dist/cli/create.js +1 -2
- package/dist/commands/build/index.d.ts +1 -1
- package/dist/commands/build/index.js +1 -1
- package/dist/commands/utils.d.ts +3 -3
- package/dist/config/defaults.js +7 -4
- package/dist/config/from-env.d.ts +4 -0
- package/dist/config/from-env.js +38 -1
- package/dist/config/helpers.js +2 -2
- package/dist/config/index.d.ts +0 -1
- package/dist/config/index.js +1 -4
- package/dist/config/loader.js +26 -2
- package/dist/config/resolve-env.d.ts +2 -1
- package/dist/config/resolve-env.js +28 -12
- package/dist/config/runtime.js +1 -1
- package/dist/index.d.ts +12 -7
- package/dist/index.js +18 -22
- package/dist/nav/index.d.ts +2 -2
- package/dist/nav/registry.d.ts +3 -3
- package/dist/nav/registry.js +1 -1
- package/dist/nav/workspace.d.ts +3 -3
- package/dist/nav/workspace.js +1 -1
- package/dist/pipeline/figma.d.ts +13 -2
- package/dist/pipeline/figma.js +86 -46
- package/dist/pipeline/styles.js +1 -1
- package/dist/pipeline/validation.d.ts +1 -1
- package/dist/pipeline/validation.js +3 -3
- package/dist/providers.d.ts +10 -0
- package/dist/providers.js +13 -0
- package/dist/registry/asset-storage/define.d.ts +5 -5
- package/dist/registry/asset-storage/define.js +4 -4
- package/dist/registry/asset-storage/resolve.d.ts +9 -19
- package/dist/registry/asset-storage/resolve.js +33 -21
- package/dist/registry/asset-storage/types.d.ts +37 -17
- package/dist/registry/asset-storage/types.js +5 -5
- package/dist/registry/auth/ai-keys.d.ts +32 -0
- package/dist/registry/auth/ai-keys.js +69 -0
- package/dist/registry/auth/crypto.d.ts +17 -0
- package/dist/registry/auth/crypto.js +61 -1
- package/dist/registry/auth/index.d.ts +2 -1
- package/dist/registry/auth/index.js +11 -2
- package/dist/registry/checkout/index.d.ts +2 -1
- package/dist/registry/checkout/index.js +57 -127
- package/dist/registry/client.js +53 -17
- package/dist/registry/db/driver.js +3 -3
- package/dist/registry/db/schema.d.ts +261 -3
- package/dist/registry/db/schema.js +29 -4
- package/dist/registry/email/define.d.ts +10 -0
- package/dist/registry/email/define.js +13 -0
- package/dist/registry/email/providers/smtp.d.ts +10 -0
- package/dist/registry/email/providers/smtp.js +73 -0
- package/dist/registry/email/resolve.d.ts +28 -0
- package/dist/registry/email/resolve.js +74 -0
- package/dist/registry/email/types.d.ts +24 -0
- package/dist/registry/email/types.js +6 -0
- package/dist/registry/path.d.ts +2 -0
- package/dist/registry/path.js +11 -1
- package/dist/registry/publish/package.js +8 -0
- package/dist/registry/transfer.d.ts +4 -0
- package/dist/store/registry.d.ts +3 -3
- package/dist/store/registry.js +4 -4
- package/dist/transformers/preview/component/builder.d.ts +1 -9
- package/dist/transformers/preview/component/builder.js +17 -17
- package/dist/transformers/preview/component/css.d.ts +2 -1
- package/dist/transformers/preview/component/css.js +8 -5
- package/dist/transformers/preview/component/html.d.ts +3 -1
- package/dist/transformers/preview/component/html.js +6 -2
- package/dist/transformers/preview/component/javascript.d.ts +2 -1
- package/dist/transformers/preview/component/javascript.js +10 -4
- package/dist/transformers/utils/build.d.ts +5 -0
- package/dist/transformers/utils/build.js +21 -4
- package/dist/types/config.d.ts +129 -18
- package/dist/utils/path.d.ts +1 -5
- package/dist/utils/path.js +6 -23
- package/docs/api.md +6 -4
- package/docs/cli.md +275 -54
- package/drizzle/0007_add_ai_keys.sql +11 -0
- package/drizzle/0008_add_source_dir.sql +2 -0
- package/drizzle/meta/0007_snapshot.json +2013 -0
- package/drizzle/meta/0008_snapshot.json +2025 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +19 -8
- package/src/ai/README.md +42 -0
- package/src/ai/connections.ts +189 -0
- package/src/ai/define.ts +15 -0
- package/src/ai/types.ts +30 -0
- package/src/app/components/AiAssistant/AiAssistantPanel.tsx +144 -0
- package/src/app/components/AiAssistant/AiAssistantProvider.tsx +215 -0
- package/src/app/components/AiAssistant/AiConversation.tsx +737 -0
- package/src/app/components/AiAssistant/AiToolResult.tsx +99 -0
- package/src/app/components/AiAssistant/dockState.ts +86 -0
- package/src/app/components/AiAssistant/toolLabel.ts +24 -0
- package/src/app/components/AiAssistant/useAiChat.ts +147 -0
- package/src/app/components/AiAssistant/useAiConnections.ts +86 -0
- package/src/app/components/AiAssistant/usePageContext.ts +63 -0
- package/src/app/components/Auth/AccountLayout.tsx +58 -19
- package/src/app/components/Auth/AuthShell.tsx +27 -33
- package/src/app/components/Auth/RegistryInstallGate.tsx +4 -8
- package/src/app/components/Auth/installStatus.ts +29 -0
- package/src/app/components/Component/Preview.tsx +5 -2
- package/src/app/components/Foundations/ColorGrid.tsx +13 -14
- package/src/app/components/Layout/Header.tsx +21 -15
- package/src/app/components/Layout/Main.tsx +2 -2
- package/src/app/components/Markdown/MarkdownComponents.tsx +30 -32
- package/src/app/components/McpIntegration/McpConfigDialog.tsx +12 -51
- package/src/app/components/Navigation/MobileNav.tsx +2 -2
- package/src/app/components/Navigation/SideNav.tsx +1 -15
- package/src/app/components/context/NavProvider.tsx +7 -3
- package/src/app/components/ui/attachment.tsx +50 -0
- package/src/app/components/ui/bubble.tsx +57 -0
- package/src/app/components/ui/button.tsx +3 -1
- package/src/app/components/ui/confirm-dialog.tsx +87 -0
- package/src/app/components/ui/dialog.tsx +7 -2
- package/src/app/components/ui/marker.tsx +56 -0
- package/src/app/components/ui/sidebar.tsx +3 -1
- package/src/app/components/util/index.ts +3 -12
- package/src/app/css/index.css +64 -0
- package/src/app/lib/ai/auth.ts +33 -0
- package/src/app/lib/ai/context.ts +99 -0
- package/src/app/lib/ai/model.ts +79 -0
- package/src/app/lib/ai/prompt.ts +36 -0
- package/src/app/lib/ai/resolve.ts +97 -0
- package/src/app/lib/ai/tools.ts +120 -0
- package/src/app/lib/api/methods.ts +13 -0
- package/src/app/lib/asset-storage.ts +35 -36
- package/src/app/lib/auth/api.ts +4 -12
- package/src/app/lib/auth/email.ts +96 -23
- package/src/app/lib/docs-api/page-rendering.ts +1 -1
- package/src/app/lib/docs-api/registry.ts +2 -3
- package/src/app/lib/docs-api/runtime-config.ts +47 -21
- package/src/app/lib/mcp/components.ts +523 -0
- package/src/app/lib/mcp/page-url.ts +24 -0
- package/src/app/lib/mcp/pages.ts +84 -0
- package/src/app/lib/mcp/result.ts +33 -0
- package/src/app/lib/mcp/server.ts +39 -318
- package/src/app/lib/mcp/tokens.ts +334 -0
- package/src/app/lib/mcp/tool.ts +19 -0
- package/src/app/lib/registry-api/asset-transfer.ts +119 -11
- package/src/app/lib/registry-api/errors.ts +2 -0
- package/src/app/lib/registry-api/handler.ts +7 -1
- package/src/app/lib/registry-api/transfer.ts +28 -6
- package/src/app/lib/server-module.ts +34 -0
- package/src/app/lib/utils.ts +20 -0
- package/src/app/next.config.mjs +26 -24
- package/src/app/pages/[...slug]/index.tsx +2 -1
- package/src/app/pages/_app.tsx +6 -2
- package/src/app/pages/_document.tsx +6 -0
- package/src/app/pages/account/ai.tsx +183 -0
- package/src/app/pages/account/index.tsx +68 -68
- package/src/app/pages/account/tokens.tsx +37 -7
- package/src/app/pages/account/users.tsx +75 -15
- package/src/app/pages/api/account/ai/keys/[connectionId].ts +60 -0
- package/src/app/pages/api/account/ai/keys/index.ts +20 -0
- package/src/app/pages/api/admin/users/[id]/[action].ts +80 -11
- package/src/app/pages/api/admin/users/index.ts +2 -2
- package/src/app/pages/api/ai/chat.ts +115 -0
- package/src/app/pages/api/ai/connections.ts +23 -0
- package/src/app/pages/api/auth/request-reset.ts +14 -16
- package/src/app/pages/api/docs/nav.json.ts +1 -1
- package/src/app/pages/api/install.ts +3 -3
- package/src/app/pages/api/mcp.ts +6 -0
- package/src/app/pages/api/registry/transfer/assets/blobs/[hash]/complete.ts +11 -0
- package/src/app/pages/api/registry/transfer/assets/blobs/[hash]/upload.ts +12 -0
- package/src/app/pages/api/registry/transfer/assets/blobs/[hash].ts +1 -1
- package/src/app/pages/cli/device.tsx +10 -5
- package/src/app/pages/foundations/colors.tsx +9 -8
- package/src/app/pages/foundations/index.tsx +3 -3
- package/src/app/pages/index.tsx +2 -2
- package/src/app/pages/install.tsx +7 -2
- package/src/app/pages/login.tsx +32 -27
- package/src/app/pages/reset-password.tsx +21 -20
- package/src/app/pages/system/component/[component]/index.tsx +2 -5
- package/src/app/pages/system/index.tsx +11 -11
- package/src/app/pages/system/pattern/[pattern]/index.tsx +2 -2
- package/src/app/pages/system/tokens/components/[component]/index.tsx +0 -4
- package/src/app-builder/README.md +2 -1
- package/src/app-builder/build.ts +218 -73
- package/src/app-builder/client-config.ts +3 -2
- package/src/app-builder/paths.ts +3 -22
- package/src/app-builder/static-export.ts +1 -1
- package/src/app-builder/vercel-output.ts +5 -4
- package/src/cache/README.md +4 -1
- package/src/cache/build-cache.ts +128 -43
- package/src/cache/build-inputs.ts +28 -0
- package/src/cache/index.ts +4 -0
- package/src/cli/create.ts +1 -2
- package/src/commands/build/index.ts +1 -1
- package/src/config/README.md +10 -9
- package/src/config/defaults.ts +7 -4
- package/src/config/from-env.ts +33 -0
- package/src/config/helpers.ts +1 -3
- package/src/config/index.ts +0 -3
- package/src/config/loader.ts +25 -3
- package/src/config/resolve-env.ts +28 -15
- package/src/config/runtime.ts +1 -1
- package/src/index.ts +33 -29
- package/src/nav/index.ts +2 -2
- package/src/nav/registry.ts +3 -3
- package/src/nav/workspace.ts +3 -3
- package/src/pipeline/README.md +1 -1
- package/src/pipeline/figma.ts +98 -49
- package/src/pipeline/styles.ts +1 -1
- package/src/pipeline/validation.ts +3 -3
- package/src/providers.ts +18 -0
- package/src/registry/asset-storage/define.ts +6 -6
- package/src/registry/asset-storage/resolve.ts +38 -37
- package/src/registry/asset-storage/types.ts +39 -17
- package/src/registry/auth/ai-keys.ts +62 -0
- package/src/registry/auth/crypto.ts +63 -1
- package/src/registry/auth/index.ts +11 -1
- package/src/registry/checkout/index.ts +60 -138
- package/src/registry/client.ts +60 -20
- package/src/registry/db/driver.ts +2 -2
- package/src/registry/db/schema.ts +49 -22
- package/src/registry/email/define.ts +15 -0
- package/src/registry/email/providers/smtp.ts +39 -0
- package/src/registry/email/resolve.ts +94 -0
- package/src/registry/email/types.ts +28 -0
- package/src/registry/path.ts +11 -0
- package/src/registry/publish/package.ts +9 -0
- package/src/registry/transfer.ts +4 -0
- package/src/store/registry.ts +7 -7
- package/src/transformers/preview/component/builder.ts +19 -25
- package/src/transformers/preview/component/css.ts +9 -4
- package/src/transformers/preview/component/html.ts +6 -2
- package/src/transformers/preview/component/javascript.ts +14 -4
- package/src/transformers/utils/build.ts +16 -3
- package/src/types/config.ts +142 -20
- package/src/utils/README.md +1 -1
- package/src/utils/path.ts +6 -29
- package/templates/default/handoff.config.js.tpl +10 -2
- package/templates/default/handoff.config.ts.tpl +10 -2
- package/templates/default/handoff.config.with-examples.js.tpl +10 -2
- package/templates/default/handoff.config.with-examples.ts.tpl +10 -2
- package/templates/default/package.json.tpl +2 -2
- package/.editorconfig +0 -19
- package/.eslintrc.json +0 -4
- package/.prettierignore +0 -0
- package/.prettierrc.json +0 -8
- package/.vscode/settings.json +0 -17
- package/dist/config/validator.d.ts +0 -9
- package/dist/config/validator.js +0 -24
- package/dist/registry/asset-storage/adapters/vercel-blob.d.ts +0 -17
- package/dist/registry/asset-storage/adapters/vercel-blob.js +0 -87
- package/src/app/components/old/Header.tsx +0 -89
- package/src/app/lib/mcp/shape.ts +0 -596
- package/src/app/pages/assets/fonts.tsx +0 -129
- package/src/app/pages/assets/index.tsx +0 -91
- package/src/app/pages/assets/logos.tsx +0 -75
- package/src/config/validator.ts +0 -22
- package/src/registry/asset-storage/adapters/vercel-blob.ts +0 -51
|
@@ -0,0 +1,737 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { getToolOrDynamicToolName, isDynamicToolUIPart, isToolUIPart, type UIMessage } from 'ai';
|
|
4
|
+
import {
|
|
5
|
+
ArrowUp,
|
|
6
|
+
ArrowUpRight,
|
|
7
|
+
Check,
|
|
8
|
+
CircleAlert,
|
|
9
|
+
Copy,
|
|
10
|
+
FileText,
|
|
11
|
+
Layers,
|
|
12
|
+
LayoutTemplate,
|
|
13
|
+
Lightbulb,
|
|
14
|
+
Loader2,
|
|
15
|
+
MessageSquareText,
|
|
16
|
+
Plus,
|
|
17
|
+
Square,
|
|
18
|
+
SquarePen,
|
|
19
|
+
X,
|
|
20
|
+
} from 'lucide-react';
|
|
21
|
+
import Link from 'next/link';
|
|
22
|
+
import * as React from 'react';
|
|
23
|
+
import ReactMarkdown from 'react-markdown';
|
|
24
|
+
import remarkGfm from 'remark-gfm';
|
|
25
|
+
|
|
26
|
+
import { cn, stripBasePath } from '../../lib/utils';
|
|
27
|
+
import type { AiPageKind } from '../../lib/ai/context';
|
|
28
|
+
import { MarkdownComponents } from '../Markdown/MarkdownComponents';
|
|
29
|
+
import { Attachment, AttachmentAction, AttachmentContent, AttachmentMedia, AttachmentTitle } from '../ui/attachment';
|
|
30
|
+
import { Bubble, BubbleContent } from '../ui/bubble';
|
|
31
|
+
import { Button } from '../ui/button';
|
|
32
|
+
import { Marker, MarkerContent, MarkerIcon } from '../ui/marker';
|
|
33
|
+
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '../ui/select';
|
|
34
|
+
import { toolResultCard } from './AiToolResult';
|
|
35
|
+
import { toolCallLabel } from './toolLabel';
|
|
36
|
+
import { useAiChat } from './useAiChat';
|
|
37
|
+
import { useAiConnections, type AiModelOption } from './useAiConnections';
|
|
38
|
+
import { pageAt, useNavTitles, usePageContext, type AiPage } from './usePageContext';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The docs assistant: a conversation with the design system, grounded in the same records the MCP
|
|
42
|
+
* tools return.
|
|
43
|
+
*
|
|
44
|
+
* The conversation alone. {@link AiAssistantPanel} decides where it sits and passes that frame's
|
|
45
|
+
* controls in through `controls`. The messages live in {@link useAiChat}, above both, so they
|
|
46
|
+
* outlast a page navigation, a close and a reload.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/** Openers that name what the assistant can reach, because the tools answer from this catalog. */
|
|
50
|
+
const BROWSE_PROMPT = 'What components are in this design system?';
|
|
51
|
+
const PROMPT_SUGGESTIONS = [
|
|
52
|
+
'Which color tokens are defined, and what is each one for?',
|
|
53
|
+
'What typography styles are available, and when should I use each?',
|
|
54
|
+
'Which components accept an icon, and how do I pass one?',
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
const COMPOSER_MAX_HEIGHT = 160;
|
|
58
|
+
|
|
59
|
+
/** Earlier pages shown as chips. The rest fold into one count, so the composer stays short. */
|
|
60
|
+
const EARLIER_PAGE_LIMIT = 2;
|
|
61
|
+
|
|
62
|
+
/** Within this distance of the end, the transcript follows the answer. Past it, the reader reads. */
|
|
63
|
+
const FOLLOW_THRESHOLD_PX = 48;
|
|
64
|
+
|
|
65
|
+
/** Every page and component the tools read during this answer, deduplicated across its parts. */
|
|
66
|
+
const sourcesOf = (message: UIMessage): { url: string; title: string }[] => {
|
|
67
|
+
const seen = new Map<string, string>();
|
|
68
|
+
for (const part of message.parts) {
|
|
69
|
+
if (part.type === 'source-url' && !seen.has(part.url)) seen.set(part.url, part.title ?? part.url);
|
|
70
|
+
}
|
|
71
|
+
return [...seen].map(([url, title]) => ({ url, title }));
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A page of this app, not a file it serves. A rendered preview and an API route share the origin,
|
|
76
|
+
* and the router answers a soft navigation to either with its 404 page.
|
|
77
|
+
*/
|
|
78
|
+
const isPageRoute = (pathname: string): boolean => !/\.[a-z0-9]+$/i.test(pathname) && !/(^|\/)api\//.test(pathname);
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The route on this site an href points at, or `null` where it leaves the site. An answer can carry
|
|
82
|
+
* an absolute URL back to this same site, and that has to travel by soft navigation too.
|
|
83
|
+
*/
|
|
84
|
+
const internalRoute = (href: string): string | null => {
|
|
85
|
+
try {
|
|
86
|
+
const url = new URL(href, window.location.href);
|
|
87
|
+
if (url.origin !== window.location.origin || !isPageRoute(url.pathname)) return null;
|
|
88
|
+
return stripBasePath(`${url.pathname}${url.search}${url.hash}`);
|
|
89
|
+
} catch {
|
|
90
|
+
// A `mailto:` or any other scheme with no origin of its own.
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* `react-markdown` renders a plain anchor, which reloads the app and restarts the panel holding the
|
|
97
|
+
* answer. A route on this site goes through `next/link` instead, so only the page beside the
|
|
98
|
+
* assistant changes. A link off this site keeps its new tab, because leaving would take the
|
|
99
|
+
* conversation with it.
|
|
100
|
+
*/
|
|
101
|
+
const AnswerLink: React.FC<{ href?: string; children?: React.ReactNode }> = ({ href, children }) => {
|
|
102
|
+
const route = href && !href.startsWith('#') ? internalRoute(href) : null;
|
|
103
|
+
if (route) return <Link href={route}>{children}</Link>;
|
|
104
|
+
return (
|
|
105
|
+
<a href={href} target={href?.startsWith('#') ? undefined : '_blank'} rel="noopener noreferrer">
|
|
106
|
+
{children}
|
|
107
|
+
</a>
|
|
108
|
+
);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const AnswerMarkdown = { ...MarkdownComponents, a: AnswerLink };
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* One tool call as a status row. The spinner is the only progress signal the transport gives before
|
|
115
|
+
* the output arrives.
|
|
116
|
+
*/
|
|
117
|
+
const ToolRow: React.FC<{ label: string; state: 'running' | 'done' | 'failed' }> = ({ label, state }) => (
|
|
118
|
+
<Marker className="text-xs">
|
|
119
|
+
<MarkerIcon>
|
|
120
|
+
{state === 'running' ? (
|
|
121
|
+
<Loader2 className="size-3.5 animate-spin" />
|
|
122
|
+
) : state === 'failed' ? (
|
|
123
|
+
<CircleAlert className="size-3.5 text-destructive" />
|
|
124
|
+
) : (
|
|
125
|
+
<Check className="size-3.5" />
|
|
126
|
+
)}
|
|
127
|
+
</MarkerIcon>
|
|
128
|
+
<MarkerContent className="truncate">{label}</MarkerContent>
|
|
129
|
+
</Marker>
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
/** Progress for time that has no tool activity or answer text of its own. */
|
|
133
|
+
const Thinking: React.FC = () => (
|
|
134
|
+
<Marker role="status">
|
|
135
|
+
<MarkerIcon>
|
|
136
|
+
<Loader2 className="animate-spin" />
|
|
137
|
+
</MarkerIcon>
|
|
138
|
+
<MarkerContent>Thinking…</MarkerContent>
|
|
139
|
+
</Marker>
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const hasTextContent = (message: UIMessage): boolean =>
|
|
143
|
+
message.parts.some((part) => part.type === 'text' && part.text.trim().length > 0);
|
|
144
|
+
|
|
145
|
+
const hasRunningTool = (message: UIMessage): boolean =>
|
|
146
|
+
message.parts.some(
|
|
147
|
+
(part) =>
|
|
148
|
+
(isToolUIPart(part) || isDynamicToolUIPart(part)) && part.state !== 'output-available' && part.state !== 'output-error'
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const Answer: React.FC<{ message: UIMessage; waitingForText: boolean }> = ({ message, waitingForText }) => {
|
|
152
|
+
const sources = sourcesOf(message);
|
|
153
|
+
const showThinking = waitingForText && !hasTextContent(message) && !hasRunningTool(message);
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<Bubble variant="ghost">
|
|
157
|
+
<BubbleContent className="flex w-full flex-col gap-2.5 overflow-visible">
|
|
158
|
+
{message.parts.map((part, index) => {
|
|
159
|
+
if (part.type === 'text') {
|
|
160
|
+
return (
|
|
161
|
+
// Tailwind Typography puts literal backticks around inline code with `::before` and
|
|
162
|
+
// `::after`. The assistant hits this in almost every answer, because it answers about
|
|
163
|
+
// props and token names. Wide output, such as a props table, scrolls inside the answer
|
|
164
|
+
// and does not widen the panel.
|
|
165
|
+
<div
|
|
166
|
+
key={index}
|
|
167
|
+
className="prose prose-sm max-w-none overflow-x-auto dark:prose-invert prose-code:before:content-none prose-code:after:content-none"
|
|
168
|
+
>
|
|
169
|
+
<ReactMarkdown remarkPlugins={[remarkGfm]} components={AnswerMarkdown}>
|
|
170
|
+
{part.text}
|
|
171
|
+
</ReactMarkdown>
|
|
172
|
+
</div>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
if (isToolUIPart(part) || isDynamicToolUIPart(part)) {
|
|
176
|
+
const name = getToolOrDynamicToolName(part);
|
|
177
|
+
const card = part.state === 'output-available' ? toolResultCard(name, part.output) : null;
|
|
178
|
+
const state = part.state === 'output-available' ? 'done' : part.state === 'output-error' ? 'failed' : 'running';
|
|
179
|
+
return (
|
|
180
|
+
<div key={index} className="flex flex-col gap-2">
|
|
181
|
+
<ToolRow label={toolCallLabel(name, part.input)} state={state} />
|
|
182
|
+
{card}
|
|
183
|
+
</div>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
return null;
|
|
187
|
+
})}
|
|
188
|
+
{showThinking && <Thinking />}
|
|
189
|
+
{sources.length > 0 && (
|
|
190
|
+
<div className="flex flex-wrap gap-1.5 pt-1">
|
|
191
|
+
{/*
|
|
192
|
+
A source opens in place: the conversation outlives the navigation, so the reader lands
|
|
193
|
+
on the page the answer came from with the answer still beside it.
|
|
194
|
+
*/}
|
|
195
|
+
{sources.map((source) => (
|
|
196
|
+
<Link
|
|
197
|
+
key={source.url}
|
|
198
|
+
href={stripBasePath(source.url)}
|
|
199
|
+
className="inline-flex items-center gap-1.5 rounded-full border bg-muted/40 px-2.5 py-1 text-xs text-muted-foreground no-underline transition-colors hover:bg-muted hover:text-foreground"
|
|
200
|
+
>
|
|
201
|
+
<ArrowUpRight className="h-3 w-3" />
|
|
202
|
+
{source.title}
|
|
203
|
+
</Link>
|
|
204
|
+
))}
|
|
205
|
+
</div>
|
|
206
|
+
)}
|
|
207
|
+
</BubbleContent>
|
|
208
|
+
</Bubble>
|
|
209
|
+
);
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The transport opens the assistant's message as soon as the request is accepted. That message first
|
|
214
|
+
* carries only bookkeeping parts, such as `step-start`, which show nothing. An answer in that state
|
|
215
|
+
* draws an avatar above an empty column, so the transcript leaves it out until it has content.
|
|
216
|
+
*/
|
|
217
|
+
const hasAnswerContent = (message: UIMessage): boolean =>
|
|
218
|
+
hasTextContent(message) ||
|
|
219
|
+
message.parts.some((part) => part.type === 'source-url' || isToolUIPart(part) || isDynamicToolUIPart(part));
|
|
220
|
+
|
|
221
|
+
/** "just now", then minutes, then hours, then a date. Read at render, since precision is not the point. */
|
|
222
|
+
const timeAgo = (at: number): string => {
|
|
223
|
+
const seconds = Math.max(0, Math.floor((Date.now() - at) / 1000));
|
|
224
|
+
if (seconds < 60) return 'just now';
|
|
225
|
+
const minutes = Math.floor(seconds / 60);
|
|
226
|
+
if (minutes < 60) return `${minutes} minute${minutes === 1 ? '' : 's'} ago`;
|
|
227
|
+
const hours = Math.floor(minutes / 60);
|
|
228
|
+
if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago`;
|
|
229
|
+
return new Date(at).toLocaleDateString();
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
/** When the question was sent. Messages saved before this was recorded have none. */
|
|
233
|
+
const sentAtOf = (message: UIMessage): number | undefined => {
|
|
234
|
+
const sentAt = (message.metadata as { sentAt?: unknown } | undefined)?.sentAt;
|
|
235
|
+
return typeof sentAt === 'number' ? sentAt : undefined;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
/** The page the question was asked from. Messages sent without page context have none. */
|
|
239
|
+
const pathOf = (message: UIMessage): string | undefined => {
|
|
240
|
+
const path = (message.metadata as { path?: unknown } | undefined)?.path;
|
|
241
|
+
return typeof path === 'string' ? path : undefined;
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
/** The reader's question, with when and where it was sent and a copy action revealed on hover or focus. */
|
|
245
|
+
const Question: React.FC<{ message: UIMessage; titles: Map<string, string> }> = ({ message, titles }) => {
|
|
246
|
+
const text = message.parts.map((part) => (part.type === 'text' ? part.text : '')).join('');
|
|
247
|
+
const sentAt = sentAtOf(message);
|
|
248
|
+
const path = pathOf(message);
|
|
249
|
+
|
|
250
|
+
return (
|
|
251
|
+
<div className="group flex flex-col items-end gap-2">
|
|
252
|
+
<span className="sr-only">You said:</span>
|
|
253
|
+
<Bubble variant="secondary" align="end">
|
|
254
|
+
<BubbleContent className="whitespace-pre-wrap">{text}</BubbleContent>
|
|
255
|
+
</Bubble>
|
|
256
|
+
<div className="-mt-1.5 flex h-5 items-center justify-end gap-2 text-[11px] text-muted-foreground opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100">
|
|
257
|
+
{(sentAt !== undefined || path) && (
|
|
258
|
+
<span>
|
|
259
|
+
{sentAt !== undefined && timeAgo(sentAt)}
|
|
260
|
+
{sentAt !== undefined && path && ' · '}
|
|
261
|
+
{path && (
|
|
262
|
+
<>
|
|
263
|
+
on{' '}
|
|
264
|
+
<Link href={path} className="text-muted-foreground no-underline transition-colors hover:text-foreground">
|
|
265
|
+
{titles.get(path) ?? path}
|
|
266
|
+
</Link>
|
|
267
|
+
</>
|
|
268
|
+
)}
|
|
269
|
+
</span>
|
|
270
|
+
)}
|
|
271
|
+
<button
|
|
272
|
+
type="button"
|
|
273
|
+
title="Copy message"
|
|
274
|
+
aria-label="Copy message"
|
|
275
|
+
onClick={() => void navigator.clipboard?.writeText(text)}
|
|
276
|
+
className="transition-colors hover:text-foreground"
|
|
277
|
+
>
|
|
278
|
+
<Copy className="h-3.5 w-3.5" />
|
|
279
|
+
</button>
|
|
280
|
+
</div>
|
|
281
|
+
</div>
|
|
282
|
+
);
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const Conversation: React.FC<{ messages: UIMessage[]; thinking: boolean }> = ({ messages, thinking }) => {
|
|
286
|
+
const visible = messages.filter((message) => message.role === 'user' || hasAnswerContent(message));
|
|
287
|
+
const activeMessage = messages[messages.length - 1];
|
|
288
|
+
// One mark at a time. The waiting row stands in for the answer until the answer has a first tool
|
|
289
|
+
// call or a first token of its own.
|
|
290
|
+
const waiting = thinking && visible[visible.length - 1]?.role !== 'assistant';
|
|
291
|
+
const titles = useNavTitles();
|
|
292
|
+
|
|
293
|
+
return (
|
|
294
|
+
<div className="flex flex-col gap-4">
|
|
295
|
+
{visible.map((message) =>
|
|
296
|
+
message.role === 'user' ? (
|
|
297
|
+
<Question key={message.id} message={message} titles={titles} />
|
|
298
|
+
) : (
|
|
299
|
+
<Answer key={message.id} message={message} waitingForText={thinking && message.id === activeMessage?.id} />
|
|
300
|
+
)
|
|
301
|
+
)}
|
|
302
|
+
{waiting && <Thinking />}
|
|
303
|
+
</div>
|
|
304
|
+
);
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
/** The opening panel: a heading and a few ways in, sitting just above the composer. */
|
|
308
|
+
const PanelIntro: React.FC<{ title: string; children?: React.ReactNode }> = ({ title, children }) => (
|
|
309
|
+
<div className="flex min-h-full flex-col justify-end gap-3 p-4">
|
|
310
|
+
<span className="mb-1 flex h-12 w-12 items-center justify-center rounded-xl bg-muted">
|
|
311
|
+
<MessageSquareText className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
|
|
312
|
+
</span>
|
|
313
|
+
<p className="text-sm font-semibold text-foreground">{title}</p>
|
|
314
|
+
{children}
|
|
315
|
+
</div>
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
const IntroAction: React.FC<{ icon: React.ReactNode; onClick: () => void; expanded?: boolean; children: React.ReactNode }> = ({
|
|
319
|
+
icon,
|
|
320
|
+
onClick,
|
|
321
|
+
expanded,
|
|
322
|
+
children,
|
|
323
|
+
}) => (
|
|
324
|
+
<button
|
|
325
|
+
type="button"
|
|
326
|
+
onClick={onClick}
|
|
327
|
+
aria-expanded={expanded}
|
|
328
|
+
className="flex w-full items-center gap-2.5 rounded-lg px-2 py-2 text-left text-sm text-muted-foreground transition hover:bg-muted hover:text-foreground"
|
|
329
|
+
>
|
|
330
|
+
{icon}
|
|
331
|
+
<span className="truncate">{children}</span>
|
|
332
|
+
</button>
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
/** "Try a prompt" drops the example into the composer rather than sending it, so the reader can adjust it first. */
|
|
336
|
+
const EmptyState: React.FC<{ onAsk: (text: string) => void; onDraft: (text: string) => void }> = ({ onAsk, onDraft }) => {
|
|
337
|
+
const [suggestionsOpen, setSuggestionsOpen] = React.useState(false);
|
|
338
|
+
|
|
339
|
+
return (
|
|
340
|
+
<PanelIntro title="Explore and understand your design system">
|
|
341
|
+
<div className="space-y-0.5">
|
|
342
|
+
<IntroAction icon={<Layers className="h-4 w-4 shrink-0" />} onClick={() => onAsk(BROWSE_PROMPT)}>
|
|
343
|
+
Browse the components...
|
|
344
|
+
</IntroAction>
|
|
345
|
+
<IntroAction
|
|
346
|
+
icon={<Lightbulb className="h-4 w-4 shrink-0" />}
|
|
347
|
+
onClick={() => setSuggestionsOpen((open) => !open)}
|
|
348
|
+
expanded={suggestionsOpen}
|
|
349
|
+
>
|
|
350
|
+
Try a prompt...
|
|
351
|
+
</IntroAction>
|
|
352
|
+
{suggestionsOpen && (
|
|
353
|
+
<div className="space-y-2 pt-1">
|
|
354
|
+
{PROMPT_SUGGESTIONS.map((text) => (
|
|
355
|
+
<button
|
|
356
|
+
key={text}
|
|
357
|
+
type="button"
|
|
358
|
+
onClick={() => onDraft(text)}
|
|
359
|
+
className="block w-full rounded-lg bg-muted/60 px-2 py-2 text-left text-xs leading-snug text-muted-foreground transition hover:bg-muted hover:text-foreground"
|
|
360
|
+
>
|
|
361
|
+
{text}
|
|
362
|
+
</button>
|
|
363
|
+
))}
|
|
364
|
+
</div>
|
|
365
|
+
)}
|
|
366
|
+
</div>
|
|
367
|
+
</PanelIntro>
|
|
368
|
+
);
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
/** Takes the place of the conversation when no model is reachable for this reader. */
|
|
372
|
+
const Unavailable: React.FC<{ canAddKeys: boolean; failed: boolean }> = ({ canAddKeys, failed }) => (
|
|
373
|
+
<PanelIntro title={canAddKeys ? 'Add a provider key' : 'The assistant is unavailable'}>
|
|
374
|
+
<p className="text-xs text-muted-foreground">
|
|
375
|
+
{failed
|
|
376
|
+
? 'The list of AI providers could not be loaded.'
|
|
377
|
+
: canAddKeys
|
|
378
|
+
? 'This site needs your API key before the assistant can answer.'
|
|
379
|
+
: 'No AI provider is configured for this site.'}
|
|
380
|
+
</p>
|
|
381
|
+
{canAddKeys && (
|
|
382
|
+
<Link
|
|
383
|
+
href="/account/ai"
|
|
384
|
+
className="-mx-2 flex items-center gap-2.5 rounded-lg px-2 py-2 text-sm text-muted-foreground no-underline transition hover:bg-muted hover:text-foreground"
|
|
385
|
+
>
|
|
386
|
+
<ArrowUpRight className="h-4 w-4 shrink-0" />
|
|
387
|
+
Add a key...
|
|
388
|
+
</Link>
|
|
389
|
+
)}
|
|
390
|
+
</PanelIntro>
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* The models grouped under their connection, in the order the config declares both. That order is
|
|
395
|
+
* the author's preference and the server's fallback order, so it is kept rather than sorted.
|
|
396
|
+
*
|
|
397
|
+
* The trigger names the model alone, and adds its connection only when another connection offers a
|
|
398
|
+
* model of the same name.
|
|
399
|
+
*/
|
|
400
|
+
const ModelPicker: React.FC<{ models: AiModelOption[]; value: string | null; onChange: (id: string) => void }> = ({
|
|
401
|
+
models,
|
|
402
|
+
value,
|
|
403
|
+
onChange,
|
|
404
|
+
}) => {
|
|
405
|
+
// A `Map` keeps insertion order for every key; a plain object would move an integer-like id first.
|
|
406
|
+
const byConnection = new Map<string, AiModelOption[]>();
|
|
407
|
+
for (const entry of models) byConnection.set(entry.connectionId, [...(byConnection.get(entry.connectionId) ?? []), entry]);
|
|
408
|
+
const groups = [...byConnection.values()];
|
|
409
|
+
const selected = models.find((entry) => entry.id === value);
|
|
410
|
+
const ambiguous = selected ? models.some((entry) => entry.model === selected.model && entry.id !== selected.id) : false;
|
|
411
|
+
|
|
412
|
+
return (
|
|
413
|
+
<Select value={value ?? undefined} onValueChange={onChange}>
|
|
414
|
+
<SelectTrigger
|
|
415
|
+
aria-label="Model"
|
|
416
|
+
title={selected ? `${selected.label} · ${selected.model}` : undefined}
|
|
417
|
+
className="-ml-2 h-8 w-auto min-w-0 gap-1 border-0 px-2 text-xs font-medium text-muted-foreground shadow-none hover:bg-muted hover:text-foreground focus:ring-0"
|
|
418
|
+
>
|
|
419
|
+
<SelectValue>
|
|
420
|
+
<span className="truncate">{selected && (ambiguous ? `${selected.label} · ${selected.model}` : selected.model)}</span>
|
|
421
|
+
</SelectValue>
|
|
422
|
+
</SelectTrigger>
|
|
423
|
+
<SelectContent align="start" className="max-w-72">
|
|
424
|
+
{groups.map((group, index) => (
|
|
425
|
+
<React.Fragment key={group[0].connectionId}>
|
|
426
|
+
{index > 0 && <SelectSeparator />}
|
|
427
|
+
<SelectGroup>
|
|
428
|
+
<SelectLabel className="px-2 pb-1 pt-1.5 text-xs font-medium text-muted-foreground">{group[0].label}</SelectLabel>
|
|
429
|
+
{group.map((entry) => (
|
|
430
|
+
<SelectItem key={entry.id} value={entry.id} className="text-xs">
|
|
431
|
+
{entry.model}
|
|
432
|
+
</SelectItem>
|
|
433
|
+
))}
|
|
434
|
+
</SelectGroup>
|
|
435
|
+
</React.Fragment>
|
|
436
|
+
))}
|
|
437
|
+
</SelectContent>
|
|
438
|
+
</Select>
|
|
439
|
+
);
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
const PAGE_ICONS: Record<AiPageKind, React.ElementType> = { component: Layers, pattern: LayoutTemplate, page: FileText };
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* The page the next question is asked about.
|
|
446
|
+
*
|
|
447
|
+
* - `attached`: sent with the next question. The reader can leave it out.
|
|
448
|
+
* - `left-out`: stays in view, so the reader can see it is not sent and can add it back.
|
|
449
|
+
* - `in-conversation`: an earlier question already carries this page, and the server sends that
|
|
450
|
+
* question's page with every turn. Leaving it out would not take it from the model, so there is
|
|
451
|
+
* no action.
|
|
452
|
+
*
|
|
453
|
+
* A tighter `xs` attachment: the chip sits inside the composer, so it stays about one line tall.
|
|
454
|
+
*/
|
|
455
|
+
type PageChipState = 'attached' | 'left-out' | 'in-conversation';
|
|
456
|
+
|
|
457
|
+
const PageChip: React.FC<{ page: AiPage; state: PageChipState; onToggle?: () => void }> = ({ page, state, onToggle }) => {
|
|
458
|
+
const Icon = PAGE_ICONS[page.kind];
|
|
459
|
+
const leftOut = state === 'left-out';
|
|
460
|
+
const hint = state === 'in-conversation' ? `${page.title} is already in this conversation` : page.title;
|
|
461
|
+
return (
|
|
462
|
+
<Attachment
|
|
463
|
+
size="xs"
|
|
464
|
+
className={cn(
|
|
465
|
+
'has-data-[slot=attachment-content]:p-0.5 has-data-[slot=attachment-media]:p-0.5 min-w-0',
|
|
466
|
+
leftOut && 'border-dashed bg-transparent text-muted-foreground',
|
|
467
|
+
state === 'in-conversation' && 'border-transparent bg-muted/60'
|
|
468
|
+
)}
|
|
469
|
+
>
|
|
470
|
+
<AttachmentMedia className={cn('group-data-[size=xs]/attachment:w-5', leftOut && 'bg-transparent text-muted-foreground')}>
|
|
471
|
+
<Icon className="size-3" aria-hidden="true" />
|
|
472
|
+
</AttachmentMedia>
|
|
473
|
+
<AttachmentContent>
|
|
474
|
+
<AttachmentTitle title={hint} className={cn(leftOut && 'line-through')}>
|
|
475
|
+
{page.title}
|
|
476
|
+
</AttachmentTitle>
|
|
477
|
+
</AttachmentContent>
|
|
478
|
+
{state === 'in-conversation' ? (
|
|
479
|
+
<span className="flex h-5 w-5 items-center justify-center text-muted-foreground">
|
|
480
|
+
<Check className="size-3" aria-hidden="true" />
|
|
481
|
+
<span className="sr-only">Already in this conversation</span>
|
|
482
|
+
</span>
|
|
483
|
+
) : (
|
|
484
|
+
<AttachmentAction
|
|
485
|
+
type="button"
|
|
486
|
+
aria-label={leftOut ? `Ask about ${page.title}` : `Leave ${page.title} out of the question`}
|
|
487
|
+
title={leftOut ? `Ask about ${page.title}` : `Leave ${page.title} out of the question`}
|
|
488
|
+
onClick={onToggle}
|
|
489
|
+
className="h-5 w-5 text-muted-foreground hover:text-foreground [&_svg]:size-3"
|
|
490
|
+
>
|
|
491
|
+
{leftOut ? <Plus /> : <X />}
|
|
492
|
+
</AttachmentAction>
|
|
493
|
+
)}
|
|
494
|
+
</Attachment>
|
|
495
|
+
);
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
export const AiConversation: React.FC<{ active: boolean; controls?: React.ReactNode }> = ({ active, controls }) => {
|
|
499
|
+
const [input, setInput] = React.useState('');
|
|
500
|
+
const connections = useAiConnections(active);
|
|
501
|
+
const composerRef = React.useRef<HTMLTextAreaElement>(null);
|
|
502
|
+
const transcriptRef = React.useRef<HTMLDivElement>(null);
|
|
503
|
+
// A reader who scrolled up to an earlier answer must not be pulled back down by the next token.
|
|
504
|
+
const following = React.useRef(true);
|
|
505
|
+
|
|
506
|
+
const { messages, sendMessage, status, stop, error, model, chooseModel, startFresh } = useAiChat();
|
|
507
|
+
|
|
508
|
+
// A page left out stays out only while the reader stays on it. A page an earlier question
|
|
509
|
+
// carries cannot be left out.
|
|
510
|
+
const page = usePageContext();
|
|
511
|
+
const titles = useNavTitles();
|
|
512
|
+
const [leftOutPath, setLeftOutPath] = React.useState<string | null>(null);
|
|
513
|
+
React.useEffect(() => setLeftOutPath(null), [page?.path]);
|
|
514
|
+
// Every page the questions were asked from, most recent first.
|
|
515
|
+
const askedPaths = [...new Set(messages.map(pathOf).reverse())].filter((path): path is string => Boolean(path));
|
|
516
|
+
const earlierPages = askedPaths
|
|
517
|
+
.filter((path) => path !== page?.path)
|
|
518
|
+
.map((path) => pageAt(path, titles))
|
|
519
|
+
.filter((earlier): earlier is AiPage => Boolean(earlier));
|
|
520
|
+
const shownEarlierPages = earlierPages.slice(0, EARLIER_PAGE_LIMIT);
|
|
521
|
+
const hiddenEarlierPages = earlierPages.slice(EARLIER_PAGE_LIMIT);
|
|
522
|
+
const hiddenTitles = hiddenEarlierPages.map((hidden) => hidden.title).join(', ');
|
|
523
|
+
const pageState: PageChipState | null = !page
|
|
524
|
+
? null
|
|
525
|
+
: askedPaths.includes(page.path)
|
|
526
|
+
? 'in-conversation'
|
|
527
|
+
: page.path === leftOutPath
|
|
528
|
+
? 'left-out'
|
|
529
|
+
: 'attached';
|
|
530
|
+
const context = pageState && pageState !== 'left-out' ? page : null;
|
|
531
|
+
|
|
532
|
+
// The picker follows the deployment's default until the reader chooses, and falls back to the
|
|
533
|
+
// first usable model so a deployment whose default is not usable here still answers.
|
|
534
|
+
const usable = (id: string | null) => Boolean(id) && connections.models.some((entry) => entry.id === id);
|
|
535
|
+
const selectedModel =
|
|
536
|
+
(usable(model) ? model : usable(connections.defaultModel) ? connections.defaultModel : connections.models[0]?.id) ?? null;
|
|
537
|
+
|
|
538
|
+
const busy = status === 'submitted' || status === 'streaming';
|
|
539
|
+
const hasModel = connections.models.length > 0;
|
|
540
|
+
|
|
541
|
+
const draft = (text: string) => {
|
|
542
|
+
setInput(text);
|
|
543
|
+
composerRef.current?.focus();
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
const ask = (text: string) => {
|
|
547
|
+
if (!text.trim() || busy || !selectedModel) return;
|
|
548
|
+
setInput('');
|
|
549
|
+
following.current = true;
|
|
550
|
+
void sendMessage(
|
|
551
|
+
{ text: text.trim(), metadata: { sentAt: Date.now(), ...(context && { path: context.path }) } },
|
|
552
|
+
{ body: { model: selectedModel } }
|
|
553
|
+
);
|
|
554
|
+
composerRef.current?.focus();
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
// A reset control on an empty panel is noise, and an error outlives the messages that caused it.
|
|
558
|
+
const canStartFresh = messages.length > 0 || Boolean(error);
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* A turn that ended with no text at all.
|
|
562
|
+
*
|
|
563
|
+
* The transport reports it as an ordinary completion, so without this the panel draws the tool
|
|
564
|
+
* rows and stops, which reads like a request that is still running. A conversation long enough to
|
|
565
|
+
* overrun the model's context window is the usual cause, and only the reader can act on that.
|
|
566
|
+
*/
|
|
567
|
+
const lastMessage = messages[messages.length - 1];
|
|
568
|
+
const answeredNothing =
|
|
569
|
+
!busy &&
|
|
570
|
+
!error &&
|
|
571
|
+
lastMessage?.role === 'assistant' &&
|
|
572
|
+
!hasTextContent(lastMessage);
|
|
573
|
+
|
|
574
|
+
// The composer is a textarea, so a long question wraps and does not scroll sideways. A textarea
|
|
575
|
+
// has no automatic height, so it needs a measurement after every change.
|
|
576
|
+
React.useLayoutEffect(() => {
|
|
577
|
+
const composer = composerRef.current;
|
|
578
|
+
if (!composer) return;
|
|
579
|
+
composer.style.height = 'auto';
|
|
580
|
+
composer.style.height = `${Math.min(composer.scrollHeight, COMPOSER_MAX_HEIGHT)}px`;
|
|
581
|
+
}, [input]);
|
|
582
|
+
|
|
583
|
+
// The scroll waits one frame: the transcript has no scroll height on the render that reveals it.
|
|
584
|
+
React.useEffect(() => {
|
|
585
|
+
if (!active || !following.current) return;
|
|
586
|
+
const frame = requestAnimationFrame(() => {
|
|
587
|
+
const transcript = transcriptRef.current;
|
|
588
|
+
if (transcript) transcript.scrollTop = transcript.scrollHeight;
|
|
589
|
+
});
|
|
590
|
+
return () => cancelAnimationFrame(frame);
|
|
591
|
+
}, [active, messages, status]);
|
|
592
|
+
|
|
593
|
+
const onTranscriptScroll = () => {
|
|
594
|
+
const transcript = transcriptRef.current;
|
|
595
|
+
if (!transcript) return;
|
|
596
|
+
following.current = transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight < FOLLOW_THRESHOLD_PX;
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
return (
|
|
600
|
+
<div className="flex h-full min-h-0 flex-col">
|
|
601
|
+
<div className="flex h-11 shrink-0 items-center justify-end gap-0.5 px-2">
|
|
602
|
+
{canStartFresh && (
|
|
603
|
+
<Button
|
|
604
|
+
type="button"
|
|
605
|
+
variant="ghost"
|
|
606
|
+
size="icon-sm"
|
|
607
|
+
onClick={() => {
|
|
608
|
+
startFresh();
|
|
609
|
+
setInput('');
|
|
610
|
+
composerRef.current?.focus();
|
|
611
|
+
}}
|
|
612
|
+
title="New conversation"
|
|
613
|
+
aria-label="New conversation"
|
|
614
|
+
className="text-muted-foreground"
|
|
615
|
+
>
|
|
616
|
+
<SquarePen className="h-4 w-4" />
|
|
617
|
+
</Button>
|
|
618
|
+
)}
|
|
619
|
+
{controls}
|
|
620
|
+
</div>
|
|
621
|
+
|
|
622
|
+
<div ref={transcriptRef} onScroll={onTranscriptScroll} className="min-h-0 flex-1 overflow-y-auto">
|
|
623
|
+
{messages.length > 0 ? (
|
|
624
|
+
<div className="flex flex-col gap-4 px-3 pb-4 pt-1">
|
|
625
|
+
<Conversation messages={messages} thinking={busy} />
|
|
626
|
+
{answeredNothing && (
|
|
627
|
+
<Marker>
|
|
628
|
+
<MarkerIcon>
|
|
629
|
+
<CircleAlert />
|
|
630
|
+
</MarkerIcon>
|
|
631
|
+
<MarkerContent>
|
|
632
|
+
The model stopped without an answer. A long conversation is the usual cause, so a new one often helps.
|
|
633
|
+
</MarkerContent>
|
|
634
|
+
</Marker>
|
|
635
|
+
)}
|
|
636
|
+
{error && (
|
|
637
|
+
<Marker>
|
|
638
|
+
<MarkerIcon>
|
|
639
|
+
<CircleAlert className="text-destructive" />
|
|
640
|
+
</MarkerIcon>
|
|
641
|
+
<MarkerContent className="text-destructive">{error.message}</MarkerContent>
|
|
642
|
+
</Marker>
|
|
643
|
+
)}
|
|
644
|
+
</div>
|
|
645
|
+
) : connections.loading ? null : hasModel ? (
|
|
646
|
+
<EmptyState onAsk={ask} onDraft={draft} />
|
|
647
|
+
) : (
|
|
648
|
+
<Unavailable canAddKeys={connections.canAddKeys} failed={connections.failed} />
|
|
649
|
+
)}
|
|
650
|
+
</div>
|
|
651
|
+
|
|
652
|
+
<form
|
|
653
|
+
onSubmit={(event) => {
|
|
654
|
+
event.preventDefault();
|
|
655
|
+
ask(input);
|
|
656
|
+
}}
|
|
657
|
+
className="shrink-0 border-t p-3"
|
|
658
|
+
>
|
|
659
|
+
<div className="px-1 pt-1.5">
|
|
660
|
+
{/* `-mt-1.5` cancels the composer's top padding, which is sized for text, so the chips sit as far
|
|
661
|
+
from the border above as from the text below. */}
|
|
662
|
+
{(pageState || earlierPages.length > 0) && (
|
|
663
|
+
<div className="-mt-1.5 mb-2 flex flex-wrap gap-1.5">
|
|
664
|
+
{page && pageState && (
|
|
665
|
+
<PageChip
|
|
666
|
+
page={page}
|
|
667
|
+
state={pageState}
|
|
668
|
+
onToggle={() => {
|
|
669
|
+
setLeftOutPath(pageState === 'left-out' ? null : page.path);
|
|
670
|
+
composerRef.current?.focus();
|
|
671
|
+
}}
|
|
672
|
+
/>
|
|
673
|
+
)}
|
|
674
|
+
{shownEarlierPages.map((earlier) => (
|
|
675
|
+
<PageChip key={earlier.path} page={earlier} state="in-conversation" />
|
|
676
|
+
))}
|
|
677
|
+
{hiddenEarlierPages.length > 0 && (
|
|
678
|
+
<Attachment
|
|
679
|
+
size="xs"
|
|
680
|
+
title={hiddenTitles}
|
|
681
|
+
className="min-w-0 border-transparent bg-muted/60 px-2 py-1 text-muted-foreground"
|
|
682
|
+
>
|
|
683
|
+
+{hiddenEarlierPages.length}
|
|
684
|
+
<span className="sr-only"> more in this conversation: {hiddenTitles}</span>
|
|
685
|
+
</Attachment>
|
|
686
|
+
)}
|
|
687
|
+
</div>
|
|
688
|
+
)}
|
|
689
|
+
<textarea
|
|
690
|
+
ref={composerRef}
|
|
691
|
+
value={input}
|
|
692
|
+
onChange={(event) => setInput(event.target.value)}
|
|
693
|
+
onKeyDown={(event) => {
|
|
694
|
+
if (event.key === 'Enter' && !event.shiftKey) {
|
|
695
|
+
event.preventDefault();
|
|
696
|
+
ask(input);
|
|
697
|
+
}
|
|
698
|
+
}}
|
|
699
|
+
rows={1}
|
|
700
|
+
aria-label="Question"
|
|
701
|
+
placeholder={hasModel ? 'Ask about your design system...' : 'The assistant is unavailable'}
|
|
702
|
+
disabled={!hasModel}
|
|
703
|
+
className="outline-hidden block w-full resize-none border-0 bg-transparent p-0 text-sm leading-6 text-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed"
|
|
704
|
+
/>
|
|
705
|
+
</div>
|
|
706
|
+
<div className="flex items-center justify-between gap-2 px-1 pb-1 pt-2">
|
|
707
|
+
{hasModel ? <ModelPicker models={connections.models} value={selectedModel} onChange={chooseModel} /> : <span />}
|
|
708
|
+
{busy ? (
|
|
709
|
+
// The spinner doubles as the stop control, and shows its square on hover.
|
|
710
|
+
<Button
|
|
711
|
+
type="button"
|
|
712
|
+
size="icon-sm"
|
|
713
|
+
onClick={() => void stop()}
|
|
714
|
+
title="Stop"
|
|
715
|
+
aria-label="Stop"
|
|
716
|
+
className="group shrink-0 rounded-full"
|
|
717
|
+
>
|
|
718
|
+
<Loader2 className="h-4 w-4 animate-spin group-hover:hidden" />
|
|
719
|
+
<Square className="hidden h-3 w-3 fill-current group-hover:block" />
|
|
720
|
+
</Button>
|
|
721
|
+
) : (
|
|
722
|
+
<Button
|
|
723
|
+
type="submit"
|
|
724
|
+
size="icon-sm"
|
|
725
|
+
title="Send (Enter)"
|
|
726
|
+
aria-label="Send"
|
|
727
|
+
disabled={!input.trim() || !hasModel}
|
|
728
|
+
className="shrink-0 rounded-full"
|
|
729
|
+
>
|
|
730
|
+
<ArrowUp className="h-4 w-4" />
|
|
731
|
+
</Button>
|
|
732
|
+
)}
|
|
733
|
+
</div>
|
|
734
|
+
</form>
|
|
735
|
+
</div>
|
|
736
|
+
);
|
|
737
|
+
};
|