Add 'continue in your own agent' CTA to dashboard AI chats

Co-Authored-By: Konstantin Wohlwend <n2d4xc@gmail.com>
This commit is contained in:
Devin AI 2026-07-17 19:58:44 +00:00
parent 970c01998c
commit d47d801609
3 changed files with 101 additions and 3 deletions

View File

@ -1,6 +1,7 @@
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { Button, useToast } from "@/components/ui";
import { getPublicEnvVar } from "@/lib/env";
import { cn } from "@/lib/utils";
import {
validateComposerImageCount,
@ -19,13 +20,14 @@ import {
useMessage,
useThreadRuntime,
type PendingAttachment,
type ThreadMessage,
} from "@assistant-ui/react";
import { ArrowClockwiseIcon, ArrowDownIcon, CaretLeftIcon, CaretRightIcon, CheckIcon, CopyIcon, ImageIcon, PaperPlaneRightIcon, PencilSimpleIcon, WarningCircle, XIcon } from "@phosphor-icons/react";
import { ArrowClockwiseIcon, ArrowDownIcon, CaretLeftIcon, CaretRightIcon, CheckIcon, CopyIcon, ImageIcon, PaperPlaneRightIcon, PencilSimpleIcon, PlugsConnectedIcon, WarningCircle, XIcon } from "@phosphor-icons/react";
import {
MAX_IMAGES_PER_MESSAGE,
} from "@hexclave/shared/dist/ai/image-limits";
import { runAsynchronously } from "@hexclave/shared/dist/utils/promises";
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ComponentProps, type FC, type ReactNode } from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ComponentProps, type FC, type ReactNode } from "react";
type AssistantContentComponents = ComponentProps<typeof MessagePrimitive.Content>["components"];
const AssistantContentComponentsContext = createContext<AssistantContentComponents | undefined>(undefined);
@ -68,7 +70,14 @@ export const Thread: FC<{
/** Overrides for the assistant message content slots (Text / tools / etc.). */
assistantContentComponents?: AssistantContentComponents,
autoFocusComposer?: boolean,
}> = ({ useOffWhiteLightMode = false, composerPlaceholder, hideMessageActions = false, runningStatusMessages, composerAttachments = false, attachmentAdapter, welcome, assistantContentComponents, autoFocusComposer = true }) => {
/**
* Shows a subtle line beneath the composer inviting the user to continue the
* conversation in their own coding agent (via the Hexclave MCP server) or to
* copy the chat transcript so they can paste it elsewhere. Enable this on the
* general-purpose dashboard AI chats (command center, companion widget).
*/
agentEjectFooter?: boolean,
}> = ({ useOffWhiteLightMode = false, composerPlaceholder, hideMessageActions = false, runningStatusMessages, composerAttachments = false, attachmentAdapter, welcome, assistantContentComponents, autoFocusComposer = true, agentEjectFooter = false }) => {
return (
<HideMessageActionsContext.Provider value={hideMessageActions}>
<HasRunningStatusContext.Provider value={!!runningStatusMessages}>
@ -111,6 +120,7 @@ export const Thread: FC<{
<div className="sticky bottom-0 mt-2 flex w-full max-w-[var(--thread-max-width)] flex-col items-center justify-end pt-6 pb-3">
<ThreadScrollToBottom />
<Composer placeholder={composerPlaceholder} autoFocus={autoFocusComposer} />
{agentEjectFooter && <AgentEjectFooter />}
</div>
</ThreadPrimitive.Viewport>
</ThreadPrimitive.Root>
@ -547,6 +557,92 @@ const Composer: FC<{ placeholder?: ComposerPlaceholder, autoFocus?: boolean }> =
);
};
function serializeThreadToMarkdown(messages: readonly ThreadMessage[]): string {
const blocks: string[] = [];
for (const message of messages) {
if (message.role !== "user" && message.role !== "assistant") continue;
const text = (typeof message.content === "string"
? message.content
: message.content.map((part) => (part.type === "text" ? part.text : "")).join("")
).trim();
if (text.length === 0) continue;
blocks.push(`## ${message.role === "user" ? "User" : "Assistant"}\n\n${text}`);
}
return blocks.join("\n\n");
}
/**
* Subtle call-to-action beneath the composer that lets a user "eject" a chat
* they started in the dashboard into their own coding agent either by wiring
* up the Hexclave MCP server (live docs + tools) or by copying the transcript
* to paste wherever they like.
*/
const AgentEjectFooter: FC = () => {
const threadRuntime = useThreadRuntime();
const { toast } = useToast();
const [copied, setCopied] = useState(false);
const [messageCount, setMessageCount] = useState(() => threadRuntime.getState().messages.length);
useEffect(() => {
const sync = () => setMessageCount(threadRuntime.getState().messages.length);
sync();
return threadRuntime.subscribe(sync);
}, [threadRuntime]);
const mcpDocsUrl = useMemo(() => {
const base = (getPublicEnvVar("NEXT_PUBLIC_STACK_DOCS_BASE_URL") ?? "https://docs.hexclave.com").replace(/\/$/, "");
return `${base}/guides/getting-started/ai-integration#option-3-mcp`;
}, []);
const hasMessages = messageCount > 0;
const handleCopy = useCallback(async () => {
const markdown = serializeThreadToMarkdown(threadRuntime.getState().messages);
if (markdown.length === 0) {
toast({ description: "No chat history to copy yet." });
return;
}
await navigator.clipboard.writeText(markdown);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}, [threadRuntime, toast]);
const linkClass = "inline-flex items-center gap-1 font-medium underline decoration-foreground/20 underline-offset-2 transition-colors hover:transition-none hover:text-foreground hover:decoration-foreground/40";
return (
<div className="mt-2 flex flex-wrap items-center justify-center gap-x-1.5 gap-y-0.5 px-2 text-center text-[11px] leading-relaxed text-muted-foreground/50">
<span>Want to continue in your own agent?</span>
<a
href={mcpDocsUrl}
target="_blank"
rel="noopener noreferrer"
className={cn(linkClass, "text-foreground/70")}
>
<PlugsConnectedIcon className="h-3 w-3" weight="bold" />
Install our MCP server
</a>
<span className="text-muted-foreground/40">or</span>
<button
type="button"
onClick={() => runAsynchronously(handleCopy())}
disabled={!hasMessages}
className={cn(
hasMessages
? cn(linkClass, "text-foreground/70")
: "inline-flex cursor-not-allowed items-center gap-1 font-medium text-muted-foreground/40",
)}
>
{copied ? (
<CheckIcon className="h-3 w-3 text-green-500" weight="bold" />
) : (
<CopyIcon className="h-3 w-3" />
)}
{copied ? "Copied!" : "Copy chat history"}
</button>
</div>
);
};
const ComposerAction: FC = () => {
return (
<div className="flex items-center gap-2">

View File

@ -88,6 +88,7 @@ const AIChatPreviewInner = memo(function AIChatPreview({
composerAttachments
attachmentAdapter={attachmentAdapter}
autoFocusComposer={false}
agentEjectFooter
/>
</div>
</AssistantRuntimeProvider>

View File

@ -527,6 +527,7 @@ function AIChatWidgetInner({
welcome={<AskAiWelcome />}
composerAttachments
attachmentAdapter={attachmentAdapter}
agentEjectFooter
/>
</div>
</AssistantRuntimeProvider>