feat: add ToggleGroup

This commit is contained in:
Henry Li
2026-01-19 19:41:46 +08:00
parent 74d4a16492
commit 24ca87d650
9 changed files with 316 additions and 103 deletions

View File

@@ -27,6 +27,8 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@t3-oss/env-nextjs": "^0.12.0",

View File

@@ -41,6 +41,12 @@ importers:
'@radix-ui/react-slot':
specifier: ^1.2.4
version: 1.2.4(@types/react@19.2.8)(react@19.2.3)
'@radix-ui/react-toggle':
specifier: ^1.1.10
version: 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-toggle-group':
specifier: ^1.1.11
version: 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-tooltip':
specifier: ^1.2.8
version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -1088,6 +1094,32 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-toggle-group@1.1.11':
resolution: {integrity: sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-toggle@1.1.10':
resolution: {integrity: sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-tooltip@1.2.8':
resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==}
peerDependencies:
@@ -5615,6 +5647,32 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.8
'@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3)
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.8)(react@19.2.3)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.8)(react@19.2.3)
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
optionalDependencies:
'@types/react': 19.2.8
'@types/react-dom': 19.2.3(@types/react@19.2.8)
'@radix-ui/react-toggle@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.8)(react@19.2.3)
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
optionalDependencies:
'@types/react': 19.2.8
'@types/react-dom': 19.2.3(@types/react@19.2.8)
'@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@radix-ui/primitive': 1.1.3

View File

@@ -0,0 +1,83 @@
"use client";
import * as React from "react";
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
import { type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { toggleVariants } from "@/components/ui/toggle";
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number;
}
>({
size: "default",
variant: "default",
spacing: 0,
});
function ToggleGroup({
className,
variant,
size,
spacing = 0,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants> & {
spacing?: number;
}) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
className,
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
);
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext);
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
"w-auto min-w-0 shrink-0 cursor-pointer px-3 focus:z-10 focus-visible:z-10",
"data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l",
className,
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
);
}
export { ToggleGroup, ToggleGroupItem };

View File

@@ -0,0 +1,47 @@
"use client"
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }

View File

@@ -4,8 +4,9 @@ import {
SquareArrowOutUpRightIcon,
XIcon,
} from "lucide-react";
import { useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Streamdown } from "streamdown";
import {
Artifact,
@@ -22,13 +23,14 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useArtifactContent } from "@/core/artifacts/hooks";
import { urlOfArtifact } from "@/core/artifacts/utils";
import { checkCodeFile, getFileName } from "@/core/utils/files";
import { cn } from "@/lib/utils";
import { useArtifacts } from "./context";
import { FileViewer } from "./file-viewer";
export function ArtifactFileDetail({
className,
@@ -50,23 +52,34 @@ export function ArtifactFileDetail({
}
return filepathFromProps;
}, [filepathFromProps, isWriteFile]);
const { isCodeFile } = useMemo(() => {
const { isCodeFile, language } = useMemo(() => {
if (isWriteFile) {
let language = checkCodeFile(filepath).language;
language ??= "markdown";
language ??= "text";
return { isCodeFile: true, language };
}
return checkCodeFile(filepath);
}, [filepath, isWriteFile]);
const previewable = useMemo(() => {
return (language === "html" && !isWriteFile) || language === "markdown";
}, [isWriteFile, language]);
const { content } = useArtifactContent({
threadId,
filepath: filepathFromProps,
enabled: isCodeFile && !isWriteFile,
});
const [viewMode, setViewMode] = useState<"code" | "preview">("code");
useEffect(() => {
if (previewable) {
setViewMode("preview");
} else {
setViewMode("code");
}
}, [previewable]);
return (
<Artifact className={cn("rounded-none", className)}>
<ArtifactHeader className="px-2">
<div>
<div className="flex items-center gap-2">
<ArtifactTitle>
{isWriteFile ? (
<div className="px-2">{getFileName(filepath)}</div>
@@ -88,6 +101,23 @@ export function ArtifactFileDetail({
)}
</ArtifactTitle>
</div>
<div className="flex min-w-0 grow items-center justify-center">
{previewable && (
<ToggleGroup
className="mx-auto"
type="single"
variant="outline"
size="sm"
value={viewMode}
onValueChange={(value) =>
setViewMode(value as "code" | "preview")
}
>
<ToggleGroupItem value="code">Code</ToggleGroupItem>
<ToggleGroupItem value="preview">Preview</ToggleGroupItem>
</ToggleGroup>
)}
</div>
<div className="flex items-center gap-2">
<ArtifactActions>
{!isWriteFile && (
@@ -139,12 +169,57 @@ export function ArtifactFileDetail({
</div>
</ArtifactHeader>
<ArtifactContent className="p-0">
<FileViewer
className="size-full"
threadId={threadId}
filepath={filepathFromProps}
/>
{previewable && viewMode === "preview" && (
<ArtifactFilePreview
filepath={filepath}
threadId={threadId}
content={content}
language={language ?? "text"}
/>
)}
{isCodeFile && viewMode === "code" && (
<Textarea
className="size-full resize-none rounded-none border-none"
readOnly
value={content ?? ""}
/>
)}
{!isCodeFile && (
<iframe
className="size-full"
src={urlOfArtifact({ filepath, threadId })}
/>
)}
</ArtifactContent>
</Artifact>
);
}
export function ArtifactFilePreview({
filepath,
threadId,
content,
language,
}: {
filepath: string;
threadId: string;
content: string;
language: string;
}) {
if (language === "markdown") {
return (
<div className="size-full px-4">
<Streamdown className="size-full">{content ?? ""}</Streamdown>
</div>
);
}
if (language === "html") {
return (
<iframe
className="size-full"
src={urlOfArtifact({ filepath, threadId })}
/>
);
}
return null;
}

View File

@@ -1,71 +0,0 @@
import { useMemo } from "react";
import type { BundledLanguage } from "shiki";
import { CodeBlock } from "@/components/ai-elements/code-block";
import { useArtifactContent } from "@/core/artifacts/hooks";
import { urlOfArtifact } from "@/core/artifacts/utils";
import { checkCodeFile } from "@/core/utils/files";
import { cn } from "@/lib/utils";
export function FileViewer({
className,
filepath,
threadId,
}: {
className?: string;
filepath: string;
threadId: string;
}) {
const isWriteFile = useMemo(() => {
return filepath.startsWith("write-file:");
}, [filepath]);
const { isCodeFile, language } = useMemo(() => {
if (isWriteFile) {
const url = new URL(filepath);
const path = decodeURIComponent(url.pathname);
return checkCodeFile(path);
}
return checkCodeFile(filepath);
}, [filepath, isWriteFile]);
if (isWriteFile || (isCodeFile && language !== "html")) {
return (
<CodeFileView
language={language ?? "markdown"}
filepath={filepath}
threadId={threadId}
/>
);
}
return (
<div className={cn("size-full border-none", className)}>
<iframe
className={cn("size-full border-none", className)}
src={urlOfArtifact({ filepath, threadId })}
></iframe>
</div>
);
}
function CodeFileView({
language,
filepath,
threadId,
}: {
language: BundledLanguage;
filepath: string;
threadId: string;
}) {
const { content: code } = useArtifactContent({
filepath,
threadId,
});
if (code) {
return (
<CodeBlock
className="size-full rounded-none border-none"
language={language}
code={code}
/>
);
}
}

View File

@@ -3,7 +3,7 @@ import { useMemo } from "react";
import { useThread } from "@/components/workspace/messages/context";
import { loadArtifactContent } from "./loader";
import { loadArtifactContent, loadArtifactContentFromToolCall } from "./loader";
export function useArtifactContent({
filepath,
@@ -20,25 +20,10 @@ export function useArtifactContent({
const { thread } = useThread();
const content = useMemo(() => {
if (isWriteFile) {
const url = new URL(filepath);
const toolCallId = url.searchParams.get("tool_call_id");
const messageId = url.searchParams.get("message_id");
if (messageId && toolCallId) {
const message = thread.messages.find(
(message) => message.id === messageId,
);
if (message?.type === "ai" && message.tool_calls) {
const toolCall = message.tool_calls.find(
(toolCall) => toolCall.id === toolCallId,
);
if (toolCall) {
return toolCall.args.content;
}
}
}
return loadArtifactContentFromToolCall({ url: filepath, thread });
}
return null;
}, [filepath, isWriteFile, thread.messages]);
}, [filepath, isWriteFile, thread]);
const { data, isLoading, error } = useQuery({
queryKey: ["artifact", filepath, threadId],
queryFn: () => {

View File

@@ -1,3 +1,7 @@
import type { UseStream } from "@langchain/langgraph-sdk/react";
import type { AgentThreadState } from "../threads";
import { urlOfArtifact } from "./utils";
export async function loadArtifactContent({
@@ -12,3 +16,26 @@ export async function loadArtifactContent({
const text = await response.text();
return text;
}
export function loadArtifactContentFromToolCall({
url: urlString,
thread,
}: {
url: string;
thread: UseStream<AgentThreadState>;
}) {
const url = new URL(urlString);
const toolCallId = url.searchParams.get("tool_call_id");
const messageId = url.searchParams.get("message_id");
if (messageId && toolCallId) {
const message = thread.messages.find((message) => message.id === messageId);
if (message?.type === "ai" && message.tool_calls) {
const toolCall = message.tool_calls.find(
(toolCall) => toolCall.id === toolCallId,
);
if (toolCall) {
return toolCall.args.content;
}
}
}
}

View File

@@ -1,6 +1,13 @@
import type { BundledLanguage } from "shiki";
const extensionMap: Record<string, string> = {
// Text
txt: "text",
csv: "csv",
log: "text",
conf: "text",
config: "text",
properties: "text",
props: "text",
const extensionMap: Record<string, BundledLanguage> = {
// JavaScript/TypeScript ecosystem
js: "javascript",
jsx: "jsx",
@@ -137,14 +144,14 @@ export function getFileExtension(filepath: string) {
export function checkCodeFile(
filepath: string,
):
| { isCodeFile: true; language: BundledLanguage }
| { isCodeFile: true; language: string }
| { isCodeFile: false; language: null } {
const extension = getFileExtension(filepath);
const isCodeFile = extension in extensionMap;
if (isCodeFile) {
return {
isCodeFile: true,
language: extensionMap[extension] as unknown as BundledLanguage,
language: extensionMap[extension] ?? "text",
};
}
return {