Installation
npx shadcn@latest add @boardcn/ai-chatnpx shadcn@latest add @boardcn/ai-chatnpm packages
- @remixicon/react
- react-aria-components
- shiki
BoardCN dependencies
The CLI installs these for you — you do not need to add them yourself.
What it composes
10 BoardCN components, installed automatically.
Source
The 6 files the CLI copies into your project.
"use client";
import { useCallback, useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { Dialog, Modal, ModalOverlay } from "react-aria-components";
import {
RiCloseLine,
RiCodeSSlashLine,
RiDeleteBinLine,
RiFileCopyLine,
RiFolderLine,
RiMenuLine,
RiMoreFill,
RiPencilLine,
RiShareForwardLine,
} from "@remixicon/react";
import { Button } from "@/components/base/buttons/button";
import { Dropdown, DropdownDivider, DropdownItem, DropdownPopover, DropdownTrigger } from "@/components/base/dropdown/dropdown";
import { Composer, StatusBar } from "@/components/blocks/composer/composer";
import { AgentSidebar } from "./agent-sidebar";
import { ChatThread, type ChatMessage } from "./chat-thread";
import { AiChatSidePanel, type CodePanelChangeSummary, type SidePanelView } from "./code-panel";
import { cx } from "@/utils/cx";
const DEFAULT_PANEL_WIDTH = 410;
const MIN_PANEL_WIDTH = 330;
const MAX_PANEL_WIDTH = 620;
const PANEL_KEYBOARD_STEP = 10;
function HeaderIcon({ label, children, onClick }: { label: string; children: ReactNode; onClick?: () => void }) {
return <button type="button" aria-label={label} onClick={onClick} className="group cursor-pointer rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring"><span className="block size-4 text-foreground-icon-secondary transition-colors group-hover:text-foreground-icon-hover">{children}</span></button>;
}
export interface AiChatTemplateProps {
className?: string;
initiallyWorking?: boolean;
/** Progress steps passed through to the chat thread while working. */
steps: string[];
messages: readonly ChatMessage[];
codeSource: readonly string[];
changeSummary: CodePanelChangeSummary;
terminalLines: readonly string[];
onShare?: () => void | Promise<void>;
onOpenProject?: () => void;
onRename?: () => void;
onDuplicate?: () => void;
onDelete?: () => void;
onTerminalOpenChange?: (open: boolean) => void;
onPanelExpandedChange?: (expanded: boolean) => void;
}
export function AiChatTemplate({
className,
initiallyWorking = false,
steps,
messages,
codeSource,
changeSummary,
terminalLines,
onShare,
onOpenProject,
onRename,
onDuplicate,
onDelete,
onTerminalOpenChange,
onPanelExpandedChange,
}: AiChatTemplateProps) {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [mobileNavigation, setMobileNavigation] = useState(false);
const [mobileCode, setMobileCode] = useState(false);
const [panelView, setPanelView] = useState<SidePanelView>("changes");
const [panelWidth, setPanelWidth] = useState(DEFAULT_PANEL_WIDTH);
const [working, setWorking] = useState(initiallyWorking);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const [terminalOpen, setTerminalOpen] = useState(false);
const [panelExpanded, setPanelExpanded] = useState(false);
const dragStart = useRef<{ x: number; width: number } | null>(null);
const navigationTriggerRef = useRef<HTMLButtonElement>(null);
const codeTriggerRef = useRef<HTMLButtonElement>(null);
const navigationWasOpen = useRef(false);
const codeWasOpen = useRef(false);
useEffect(() => {
if (navigationWasOpen.current && !mobileNavigation) navigationTriggerRef.current?.focus();
navigationWasOpen.current = mobileNavigation;
}, [mobileNavigation]);
useEffect(() => {
if (codeWasOpen.current && !mobileCode) codeTriggerRef.current?.focus();
codeWasOpen.current = mobileCode;
}, [mobileCode]);
const setBoundedPanelWidth = useCallback((width: number) => {
setPanelWidth(Math.min(MAX_PANEL_WIDTH, Math.max(MIN_PANEL_WIDTH, width)));
}, []);
const beginResize = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
dragStart.current = { x: event.clientX, width: panelWidth };
event.currentTarget.setPointerCapture(event.pointerId);
}, [panelWidth]);
const resize = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
if (!dragStart.current) return;
setBoundedPanelWidth(dragStart.current.width + dragStart.current.x - event.clientX);
}, [setBoundedPanelWidth]);
const resizeWithKeyboard = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
let nextWidth: number | undefined;
switch (event.key) {
case "ArrowLeft":
nextWidth = panelWidth + (event.shiftKey ? PANEL_KEYBOARD_STEP * 5 : PANEL_KEYBOARD_STEP);
break;
case "ArrowRight":
nextWidth = panelWidth - (event.shiftKey ? PANEL_KEYBOARD_STEP * 5 : PANEL_KEYBOARD_STEP);
break;
case "Home":
nextWidth = MIN_PANEL_WIDTH;
break;
case "End":
nextWidth = MAX_PANEL_WIDTH;
break;
default:
return;
}
event.preventDefault();
setBoundedPanelWidth(nextWidth);
}, [panelWidth, setBoundedPanelWidth]);
const submit = useCallback(async () => {
setWorking(true);
await new Promise((resolve) => window.setTimeout(resolve, 3200));
setWorking(false);
}, []);
const shareChat = useCallback(async () => {
if (onShare) {
await onShare();
return;
}
const shareData = { title: "Agentic chat", url: window.location.href };
if (navigator.share) {
await navigator.share(shareData);
} else {
await navigator.clipboard?.writeText(shareData.url);
}
}, [onShare]);
const selectMoreAction = useCallback((action?: () => void) => {
setMoreMenuOpen(false);
action?.();
}, []);
const changeTerminalOpen = useCallback((open: boolean) => {
setTerminalOpen(open);
onTerminalOpenChange?.(open);
}, [onTerminalOpenChange]);
const changePanelExpanded = useCallback((expanded: boolean) => {
setPanelExpanded(expanded);
onPanelExpandedChange?.(expanded);
}, [onPanelExpandedChange]);
return (
<div className={cx("relative flex h-dvh w-full gap-4 overflow-hidden bg-background-full p-3 text-text-primary", className)}>
<ModalOverlay isOpen={mobileNavigation} onOpenChange={setMobileNavigation} isDismissable className="fixed inset-0 z-50 flex bg-black/40 transition-opacity duration-300 data-[entering]:opacity-0 data-[exiting]:opacity-0 lg:hidden">
<Modal className="h-full w-[272px] py-3 pl-[6px] outline-none transition-transform duration-300 ease-in-out data-[entering]:-translate-x-[110%] data-[exiting]:-translate-x-[110%]">
<Dialog aria-label="Navigation" className="h-full outline-none">
<AgentSidebar className="w-[260px]" />
</Dialog>
</Modal>
</ModalOverlay>
<AgentSidebar collapsed={sidebarCollapsed} onCollapsedChange={setSidebarCollapsed} className="relative z-10 hidden lg:flex" />
<div className="relative z-20 flex min-w-0 flex-1 flex-col overflow-hidden bg-background-full lg:z-0">
<div className="flex min-h-0 min-w-0 flex-1 gap-3 overflow-hidden">
<div className={cx("relative flex min-w-0 flex-1 basis-0", panelExpanded && "xl:hidden")}>
<section className="flex h-full min-w-0 flex-1 flex-col overflow-hidden rounded-3xl bg-background-secondary-default">
<header className="flex h-12 shrink-0 items-center justify-between px-3 pt-[11px] xl:hidden">
<div className="flex min-w-0 items-center gap-2"><Button ref={navigationTriggerRef} variant="secondary" iconOnly leadingIcon={RiMenuLine} aria-label="Open navigation" onClick={() => setMobileNavigation(true)} className="rounded-full lg:hidden" /><span className="truncate px-1 text-headline-medium text-text-primary">Agentic chat</span></div>
<Button ref={codeTriggerRef} variant="secondary" iconOnly leadingIcon={RiCodeSSlashLine} aria-label="Open code" onClick={() => setMobileCode(true)} className="rounded-full" />
</header>
<header className="flex w-full items-center justify-between gap-2 px-4 pt-4">
<nav aria-label="Chat location" className="flex min-w-0 flex-1 items-center overflow-x-auto [scrollbar-width:none]"><ol className="flex items-center gap-2.5 px-1"><li><button type="button" aria-label="Open project: vibl coding project" onClick={onOpenProject} className="-mx-1 flex items-center gap-1.5 rounded-md px-1 py-0.5 text-caption-1-medium whitespace-nowrap text-text-tertiary hover:bg-background-primary-hover hover:text-text-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring"><RiFolderLine className="size-4" aria-hidden />vibl coding project</button></li><li aria-hidden className="text-text-tertiary">›</li><li aria-current="page" className="text-caption-1-medium whitespace-nowrap text-text-secondary">coding scenario</li></ol></nav>
<div className="flex shrink-0 items-center gap-2">
<HeaderIcon label="Share chat" onClick={() => { void shareChat(); }}><RiShareForwardLine className="size-4" /></HeaderIcon>
<Dropdown isOpen={moreMenuOpen} onOpenChange={setMoreMenuOpen}>
<DropdownTrigger aria-label="More options" className="group rounded-sm"><RiMoreFill className="size-4 text-foreground-icon-secondary transition-colors group-hover:text-foreground-icon-hover" aria-hidden /></DropdownTrigger>
<DropdownPopover aria-label="Chat options" placement="bottom end" className="w-44 p-2">
<DropdownItem onSelect={() => selectMoreAction(onRename)}><RiPencilLine className="size-4" aria-hidden />Rename</DropdownItem>
<DropdownItem onSelect={() => selectMoreAction(onDuplicate)}><RiFileCopyLine className="size-4" aria-hidden />Duplicate</DropdownItem>
<DropdownDivider />
<DropdownItem onSelect={() => selectMoreAction(onDelete)} className="text-red-600"><RiDeleteBinLine className="size-4" aria-hidden />Delete</DropdownItem>
</DropdownPopover>
</Dropdown>
</div>
</header>
<ChatThread working={working} steps={steps} messages={messages} />
<div className="flex w-full flex-col gap-2.5 px-2.5 pt-3 pb-2.5">
<Composer working={working} onSubmit={submit} className="[&_input[type=file]]:hidden" />
<StatusBar />
</div>
</section>
<div role="separator" aria-orientation="vertical" aria-label="Resize code panel" aria-valuemin={MIN_PANEL_WIDTH} aria-valuemax={MAX_PANEL_WIDTH} aria-valuenow={panelWidth} aria-valuetext={`${panelWidth} pixels`} tabIndex={0} onKeyDown={resizeWithKeyboard} onPointerDown={beginResize} onPointerMove={resize} onPointerUp={(event) => { dragStart.current = null; if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); }} onPointerCancel={() => { dragStart.current = null; }} className="group/drag absolute inset-y-0 -right-2.5 z-10 hidden w-5 cursor-col-resize touch-none justify-center rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring xl:flex">
<span className="absolute top-1/2 flex h-[25px] w-[15px] -translate-y-1/2 items-center justify-center gap-0.5 rounded-sm border border-border-button-default bg-background-primary-default opacity-0 shadow-xs transition-opacity group-hover/drag:opacity-100 group-focus-visible/drag:opacity-100"><span className="h-[13px] w-px bg-foreground-icon-quaternary" /><span className="h-[13px] w-px bg-foreground-icon-quaternary" /><span className="h-[13px] w-px bg-foreground-icon-quaternary" /></span>
</div>
</div>
<AiChatSidePanel view={panelView} onViewChange={setPanelView} terminalOpen={terminalOpen} onTerminalOpenChange={changeTerminalOpen} expanded={panelExpanded} onExpandedChange={changePanelExpanded} source={codeSource} changeSummary={changeSummary} terminalLines={terminalLines} className={cx("hidden xl:flex", panelExpanded ? "min-w-0 flex-1" : "shrink-0")} style={panelExpanded ? undefined : { width: panelWidth } as CSSProperties} />
</div>
</div>
<ModalOverlay isOpen={mobileCode} onOpenChange={setMobileCode} isDismissable className="fixed inset-0 z-50 flex justify-end bg-black/40 transition-opacity duration-300 data-[entering]:opacity-0 data-[exiting]:opacity-0 xl:hidden">
<Modal className="h-full w-[min(410px,calc(100%_-_12px))] bg-background-full p-3 shadow-sidebar outline-none transition-transform duration-300 ease-in-out data-[entering]:translate-x-[110%] data-[exiting]:translate-x-[110%]">
<Dialog aria-label="Code" className="flex h-full min-h-0 flex-col outline-none">
<div className="flex h-10 shrink-0 items-center justify-between px-1"><span className="text-headline-medium text-text-primary">Code</span><Button variant="secondary" iconOnly leadingIcon={RiCloseLine} aria-label="Close code" onClick={() => setMobileCode(false)} /></div>
<AiChatSidePanel view={panelView} onViewChange={setPanelView} terminalOpen={terminalOpen} onTerminalOpenChange={changeTerminalOpen} expanded={panelExpanded} onExpandedChange={changePanelExpanded} source={codeSource} changeSummary={changeSummary} terminalLines={terminalLines} onClose={() => setMobileCode(false)} className="min-h-0 flex-1" />
</Dialog>
</Modal>
</ModalOverlay>
</div>
);
}
export const AiChatShell = AiChatTemplate;"use client";
import { useCallback, useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { Dialog, Modal, ModalOverlay } from "react-aria-components";
import {
RiCloseLine,
RiCodeSSlashLine,
RiDeleteBinLine,
RiFileCopyLine,
RiFolderLine,
RiMenuLine,
RiMoreFill,
RiPencilLine,
RiShareForwardLine,
} from "@remixicon/react";
import { Button } from "@/components/base/buttons/button";
import { Dropdown, DropdownDivider, DropdownItem, DropdownPopover, DropdownTrigger } from "@/components/base/dropdown/dropdown";
import { Composer, StatusBar } from "@/components/blocks/composer/composer";
import { AgentSidebar } from "./agent-sidebar";
import { ChatThread, type ChatMessage } from "./chat-thread";
import { AiChatSidePanel, type CodePanelChangeSummary, type SidePanelView } from "./code-panel";
import { cx } from "@/utils/cx";
const DEFAULT_PANEL_WIDTH = 410;
const MIN_PANEL_WIDTH = 330;
const MAX_PANEL_WIDTH = 620;
const PANEL_KEYBOARD_STEP = 10;
function HeaderIcon({ label, children, onClick }: { label: string; children: ReactNode; onClick?: () => void }) {
return <button type="button" aria-label={label} onClick={onClick} className="group cursor-pointer rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring"><span className="block size-4 text-foreground-icon-secondary transition-colors group-hover:text-foreground-icon-hover">{children}</span></button>;
}
export interface AiChatTemplateProps {
className?: string;
initiallyWorking?: boolean;
/** Progress steps passed through to the chat thread while working. */
steps: string[];
messages: readonly ChatMessage[];
codeSource: readonly string[];
changeSummary: CodePanelChangeSummary;
terminalLines: readonly string[];
onShare?: () => void | Promise<void>;
onOpenProject?: () => void;
onRename?: () => void;
onDuplicate?: () => void;
onDelete?: () => void;
onTerminalOpenChange?: (open: boolean) => void;
onPanelExpandedChange?: (expanded: boolean) => void;
}
export function AiChatTemplate({
className,
initiallyWorking = false,
steps,
messages,
codeSource,
changeSummary,
terminalLines,
onShare,
onOpenProject,
onRename,
onDuplicate,
onDelete,
onTerminalOpenChange,
onPanelExpandedChange,
}: AiChatTemplateProps) {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [mobileNavigation, setMobileNavigation] = useState(false);
const [mobileCode, setMobileCode] = useState(false);
const [panelView, setPanelView] = useState<SidePanelView>("changes");
const [panelWidth, setPanelWidth] = useState(DEFAULT_PANEL_WIDTH);
const [working, setWorking] = useState(initiallyWorking);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
const [terminalOpen, setTerminalOpen] = useState(false);
const [panelExpanded, setPanelExpanded] = useState(false);
const dragStart = useRef<{ x: number; width: number } | null>(null);
const navigationTriggerRef = useRef<HTMLButtonElement>(null);
const codeTriggerRef = useRef<HTMLButtonElement>(null);
const navigationWasOpen = useRef(false);
const codeWasOpen = useRef(false);
useEffect(() => {
if (navigationWasOpen.current && !mobileNavigation) navigationTriggerRef.current?.focus();
navigationWasOpen.current = mobileNavigation;
}, [mobileNavigation]);
useEffect(() => {
if (codeWasOpen.current && !mobileCode) codeTriggerRef.current?.focus();
codeWasOpen.current = mobileCode;
}, [mobileCode]);
const setBoundedPanelWidth = useCallback((width: number) => {
setPanelWidth(Math.min(MAX_PANEL_WIDTH, Math.max(MIN_PANEL_WIDTH, width)));
}, []);
const beginResize = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
dragStart.current = { x: event.clientX, width: panelWidth };
event.currentTarget.setPointerCapture(event.pointerId);
}, [panelWidth]);
const resize = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
if (!dragStart.current) return;
setBoundedPanelWidth(dragStart.current.width + dragStart.current.x - event.clientX);
}, [setBoundedPanelWidth]);
const resizeWithKeyboard = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
let nextWidth: number | undefined;
switch (event.key) {
case "ArrowLeft":
nextWidth = panelWidth + (event.shiftKey ? PANEL_KEYBOARD_STEP * 5 : PANEL_KEYBOARD_STEP);
break;
case "ArrowRight":
nextWidth = panelWidth - (event.shiftKey ? PANEL_KEYBOARD_STEP * 5 : PANEL_KEYBOARD_STEP);
break;
case "Home":
nextWidth = MIN_PANEL_WIDTH;
break;
case "End":
nextWidth = MAX_PANEL_WIDTH;
break;
default:
return;
}
event.preventDefault();
setBoundedPanelWidth(nextWidth);
}, [panelWidth, setBoundedPanelWidth]);
const submit = useCallback(async () => {
setWorking(true);
await new Promise((resolve) => window.setTimeout(resolve, 3200));
setWorking(false);
}, []);
const shareChat = useCallback(async () => {
if (onShare) {
await onShare();
return;
}
const shareData = { title: "Agentic chat", url: window.location.href };
if (navigator.share) {
await navigator.share(shareData);
} else {
await navigator.clipboard?.writeText(shareData.url);
}
}, [onShare]);
const selectMoreAction = useCallback((action?: () => void) => {
setMoreMenuOpen(false);
action?.();
}, []);
const changeTerminalOpen = useCallback((open: boolean) => {
setTerminalOpen(open);
onTerminalOpenChange?.(open);
}, [onTerminalOpenChange]);
const changePanelExpanded = useCallback((expanded: boolean) => {
setPanelExpanded(expanded);
onPanelExpandedChange?.(expanded);
}, [onPanelExpandedChange]);
return (
<div className={cx("relative flex h-dvh w-full gap-4 overflow-hidden bg-background-full p-3 text-text-primary", className)}>
<ModalOverlay isOpen={mobileNavigation} onOpenChange={setMobileNavigation} isDismissable className="fixed inset-0 z-50 flex bg-black/40 transition-opacity duration-300 data-[entering]:opacity-0 data-[exiting]:opacity-0 lg:hidden">
<Modal className="h-full w-[272px] py-3 pl-[6px] outline-none transition-transform duration-300 ease-in-out data-[entering]:-translate-x-[110%] data-[exiting]:-translate-x-[110%]">
<Dialog aria-label="Navigation" className="h-full outline-none">
<AgentSidebar className="w-[260px]" />
</Dialog>
</Modal>
</ModalOverlay>
<AgentSidebar collapsed={sidebarCollapsed} onCollapsedChange={setSidebarCollapsed} className="relative z-10 hidden lg:flex" />
<div className="relative z-20 flex min-w-0 flex-1 flex-col overflow-hidden bg-background-full lg:z-0">
<div className="flex min-h-0 min-w-0 flex-1 gap-3 overflow-hidden">
<div className={cx("relative flex min-w-0 flex-1 basis-0", panelExpanded && "xl:hidden")}>
<section className="flex h-full min-w-0 flex-1 flex-col overflow-hidden rounded-3xl bg-background-secondary-default">
<header className="flex h-12 shrink-0 items-center justify-between px-3 pt-[11px] xl:hidden">
<div className="flex min-w-0 items-center gap-2"><Button ref={navigationTriggerRef} variant="secondary" iconOnly leadingIcon={RiMenuLine} aria-label="Open navigation" onClick={() => setMobileNavigation(true)} className="rounded-full lg:hidden" /><span className="truncate px-1 text-headline-medium text-text-primary">Agentic chat</span></div>
<Button ref={codeTriggerRef} variant="secondary" iconOnly leadingIcon={RiCodeSSlashLine} aria-label="Open code" onClick={() => setMobileCode(true)} className="rounded-full" />
</header>
<header className="flex w-full items-center justify-between gap-2 px-4 pt-4">
<nav aria-label="Chat location" className="flex min-w-0 flex-1 items-center overflow-x-auto [scrollbar-width:none]"><ol className="flex items-center gap-2.5 px-1"><li><button type="button" aria-label="Open project: vibl coding project" onClick={onOpenProject} className="-mx-1 flex items-center gap-1.5 rounded-md px-1 py-0.5 text-caption-1-medium whitespace-nowrap text-text-tertiary hover:bg-background-primary-hover hover:text-text-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring"><RiFolderLine className="size-4" aria-hidden />vibl coding project</button></li><li aria-hidden className="text-text-tertiary">›</li><li aria-current="page" className="text-caption-1-medium whitespace-nowrap text-text-secondary">coding scenario</li></ol></nav>
<div className="flex shrink-0 items-center gap-2">
<HeaderIcon label="Share chat" onClick={() => { void shareChat(); }}><RiShareForwardLine className="size-4" /></HeaderIcon>
<Dropdown isOpen={moreMenuOpen} onOpenChange={setMoreMenuOpen}>
<DropdownTrigger aria-label="More options" className="group rounded-sm"><RiMoreFill className="size-4 text-foreground-icon-secondary transition-colors group-hover:text-foreground-icon-hover" aria-hidden /></DropdownTrigger>
<DropdownPopover aria-label="Chat options" placement="bottom end" className="w-44 p-2">
<DropdownItem onSelect={() => selectMoreAction(onRename)}><RiPencilLine className="size-4" aria-hidden />Rename</DropdownItem>
<DropdownItem onSelect={() => selectMoreAction(onDuplicate)}><RiFileCopyLine className="size-4" aria-hidden />Duplicate</DropdownItem>
<DropdownDivider />
<DropdownItem onSelect={() => selectMoreAction(onDelete)} className="text-red-600"><RiDeleteBinLine className="size-4" aria-hidden />Delete</DropdownItem>
</DropdownPopover>
</Dropdown>
</div>
</header>
<ChatThread working={working} steps={steps} messages={messages} />
<div className="flex w-full flex-col gap-2.5 px-2.5 pt-3 pb-2.5">
<Composer working={working} onSubmit={submit} className="[&_input[type=file]]:hidden" />
<StatusBar />
</div>
</section>
<div role="separator" aria-orientation="vertical" aria-label="Resize code panel" aria-valuemin={MIN_PANEL_WIDTH} aria-valuemax={MAX_PANEL_WIDTH} aria-valuenow={panelWidth} aria-valuetext={`${panelWidth} pixels`} tabIndex={0} onKeyDown={resizeWithKeyboard} onPointerDown={beginResize} onPointerMove={resize} onPointerUp={(event) => { dragStart.current = null; if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); }} onPointerCancel={() => { dragStart.current = null; }} className="group/drag absolute inset-y-0 -right-2.5 z-10 hidden w-5 cursor-col-resize touch-none justify-center rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring xl:flex">
<span className="absolute top-1/2 flex h-[25px] w-[15px] -translate-y-1/2 items-center justify-center gap-0.5 rounded-sm border border-border-button-default bg-background-primary-default opacity-0 shadow-xs transition-opacity group-hover/drag:opacity-100 group-focus-visible/drag:opacity-100"><span className="h-[13px] w-px bg-foreground-icon-quaternary" /><span className="h-[13px] w-px bg-foreground-icon-quaternary" /><span className="h-[13px] w-px bg-foreground-icon-quaternary" /></span>
</div>
</div>
<AiChatSidePanel view={panelView} onViewChange={setPanelView} terminalOpen={terminalOpen} onTerminalOpenChange={changeTerminalOpen} expanded={panelExpanded} onExpandedChange={changePanelExpanded} source={codeSource} changeSummary={changeSummary} terminalLines={terminalLines} className={cx("hidden xl:flex", panelExpanded ? "min-w-0 flex-1" : "shrink-0")} style={panelExpanded ? undefined : { width: panelWidth } as CSSProperties} />
</div>
</div>
<ModalOverlay isOpen={mobileCode} onOpenChange={setMobileCode} isDismissable className="fixed inset-0 z-50 flex justify-end bg-black/40 transition-opacity duration-300 data-[entering]:opacity-0 data-[exiting]:opacity-0 xl:hidden">
<Modal className="h-full w-[min(410px,calc(100%_-_12px))] bg-background-full p-3 shadow-sidebar outline-none transition-transform duration-300 ease-in-out data-[entering]:translate-x-[110%] data-[exiting]:translate-x-[110%]">
<Dialog aria-label="Code" className="flex h-full min-h-0 flex-col outline-none">
<div className="flex h-10 shrink-0 items-center justify-between px-1"><span className="text-headline-medium text-text-primary">Code</span><Button variant="secondary" iconOnly leadingIcon={RiCloseLine} aria-label="Close code" onClick={() => setMobileCode(false)} /></div>
<AiChatSidePanel view={panelView} onViewChange={setPanelView} terminalOpen={terminalOpen} onTerminalOpenChange={changeTerminalOpen} expanded={panelExpanded} onExpandedChange={changePanelExpanded} source={codeSource} changeSummary={changeSummary} terminalLines={terminalLines} onClose={() => setMobileCode(false)} className="min-h-0 flex-1" />
</Dialog>
</Modal>
</ModalOverlay>
</div>
);
}
export const AiChatShell = AiChatTemplate;"use client";
import { useState, type ReactNode } from "react";
import {
RiArrowLeftDoubleLine,
RiArrowRightDoubleLine,
RiCustomerService2Line,
RiFolderLine,
RiGitBranchLine,
RiListSettingsLine,
RiMore2Line,
RiRobot2Line,
RiSearchLine,
RiSettings3Line,
} from "@remixicon/react";
import { Avatar } from "@/components/base/avatar/avatar";
import { Button } from "@/components/base/buttons/button";
import { ThemeToggle } from "@/components/blocks/theme/theme-toggle";
import { cx } from "@/utils/cx";
type Chat = { name: string; time: string };
type Repository = { name: string; chats: Chat[]; initiallyOpen?: boolean };
const repositories: Repository[] = [
{ name: "boardcn", chats: [{ name: "pro badge restyle", time: "2h" }, { name: "installation docs page", time: "1d" }] },
{ name: "vibl coding project", initiallyOpen: true, chats: [{ name: "landing page design", time: "34m" }, { name: "image generation", time: "now" }, { name: "coding scenario", time: "now" }, { name: "mobile app for vuejs...", time: "5h" }, { name: "code refactor dropdo...", time: "18h" }] },
{ name: "strider landing page work", chats: [{ name: "hero section animation", time: "3d" }, { name: "pricing table copy", time: "4d" }] },
{ name: "pirate mini game iOS", chats: [{ name: "cannon physics tuning", time: "1w" }, { name: "sprite sheet cleanup", time: "2w" }] },
];
function SidebarLink({ icon, children }: { icon: ReactNode; children: ReactNode }) {
return (
<a href="#" onClick={(event) => event.preventDefault()} className="flex w-full items-center gap-2 rounded-2lg p-2 transition-colors duration-150 ease hover:bg-background-secondary-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring">
<span className="size-5 shrink-0 text-foreground-icon-secondary">{icon}</span>
<span className="text-body-medium whitespace-nowrap text-text-secondary">{children}</span>
</a>
);
}
function RepositoryGroup({ repository }: { repository: Repository }) {
const [open, setOpen] = useState(Boolean(repository.initiallyOpen));
return (
<div className="flex w-full flex-col">
<button type="button" aria-expanded={open} onClick={() => setOpen((value) => !value)} className="flex w-full cursor-pointer items-center gap-2 rounded-2lg p-2 text-left transition-colors duration-150 ease hover:bg-background-secondary-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring">
<RiFolderLine className="size-5 shrink-0 text-foreground-icon-secondary" aria-hidden />
<span className="truncate text-body-medium whitespace-nowrap text-text-secondary">{repository.name}</span>
</button>
<div aria-hidden={!open} className={cx("grid transition-[grid-template-rows,opacity] duration-300 ease-in-out", open ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0")}>
<div className="overflow-hidden">
<div className="relative flex w-full flex-col gap-0.5 pt-0.5">
<span aria-hidden className="pointer-events-none absolute top-0 bottom-4 left-[16.5px] w-3 border-l border-foreground-icon-quaternary" />
{repository.chats.map((chat, index) => (
<a key={chat.name} href="#" tabIndex={open ? 0 : -1} onClick={(event) => event.preventDefault()} className="relative flex w-full items-center gap-2.5 rounded-2lg py-[5px] pr-2 pl-9 transition-colors duration-150 ease hover:bg-background-secondary-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring">
<span aria-hidden className="absolute left-[16.5px] top-1/2 h-px w-3 rounded-full bg-foreground-icon-quaternary" />
{index === repository.chats.length - 1 ? <span aria-hidden className="absolute top-1/2 bottom-0 left-4 w-1 bg-background-secondary-default" /> : null}
<span className="min-w-0 flex-1 truncate text-body-medium text-text-secondary">{chat.name}</span>
<span className="inline-flex shrink-0 items-center justify-center rounded-sm bg-background-tertiary-default px-1 py-px text-caption-1-medium whitespace-nowrap text-text-secondary">{chat.time}</span>
</a>
))}
</div>
</div>
</div>
</div>
);
}
export interface AgentSidebarProps {
className?: string;
collapsed?: boolean;
onCollapsedChange?: (collapsed: boolean) => void;
userName?: string;
userInitials?: string;
}
export function AgentSidebar({ className, collapsed = false, onCollapsedChange, userName = "Mertcan Esmergul", userInitials = "M" }: AgentSidebarProps) {
return (
<aside className={cx("flex h-full shrink-0 flex-col justify-between overflow-hidden border border-border-button-white bg-background-secondary-default shadow-sidebar transition-[width,padding,border-radius] duration-300", collapsed ? "w-[68px] rounded-2xl p-2" : "w-[260px] rounded-3xl p-3", className)}>
<div className="flex min-h-0 w-full flex-col gap-3">
<div className={cx("flex w-full items-center gap-2", collapsed ? "justify-center" : "justify-between")}>
<button type="button" aria-label={userName} className="flex min-w-0 cursor-pointer items-center gap-2 rounded-full outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring">
<Avatar initials={userInitials} />
{!collapsed ? <span className="flex min-w-0 items-center gap-0.5"><span className="truncate text-body-medium whitespace-nowrap text-text-primary">{userName}</span><RiMore2Line className="size-4 shrink-0 text-foreground-icon-tertiary" aria-hidden /></span> : null}
</button>
{!collapsed ? <button type="button" aria-label="Quick Search" className="flex size-9 shrink-0 items-center justify-center rounded-full bg-background-tertiary-default text-foreground-icon-secondary transition-colors hover:bg-background-tertiary-hover/55"><RiSearchLine className="size-5" aria-hidden /></button> : null}
</div>
{collapsed ? (
<div className="flex flex-col items-center gap-2">
<button type="button" aria-label="Quick Search" className="flex size-9 items-center justify-center rounded-2lg text-foreground-icon-secondary hover:bg-background-secondary-hover"><RiSearchLine className="size-5" aria-hidden /></button>
<button type="button" aria-label="New agent" className="flex size-9 items-center justify-center rounded-2lg text-foreground-icon-secondary hover:bg-background-secondary-hover"><RiRobot2Line className="size-5" aria-hidden /></button>
</div>
) : (
<div className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto [scrollbar-width:none]">
<nav className="flex w-full shrink-0 flex-col gap-1" aria-label="Agent actions">
<SidebarLink icon={<RiRobot2Line className="size-5" />}>New agent</SidebarLink>
<SidebarLink icon={<RiGitBranchLine className="size-5" />}>Automations</SidebarLink>
<SidebarLink icon={<RiListSettingsLine className="size-5" />}>Customize</SidebarLink>
</nav>
<div className="flex w-full flex-col gap-2.5">
<span className="text-body-medium text-text-secondary">Repositories</span>
<nav className="flex w-full flex-col gap-1" aria-label="Repositories">
{repositories.map((repository) => <RepositoryGroup key={repository.name} repository={repository} />)}
</nav>
</div>
</div>
)}
</div>
<div className={cx("flex shrink-0 flex-col", collapsed ? "items-center gap-2" : "gap-3")}>
<ThemeToggle appearance={collapsed ? "sidebar" : "sidebar-segmented"} collapsed={collapsed} />
{!collapsed ? (
<nav className="flex flex-col gap-1" aria-label="Help and settings">
<SidebarLink icon={<RiCustomerService2Line className="size-5" />}>Support</SidebarLink>
<SidebarLink icon={<RiSettings3Line className="size-5" />}>Settings</SidebarLink>
</nav>
) : null}
{!collapsed ? (
<div className="flex w-full items-center gap-2 rounded-xl bg-background-secondary-default py-2 pr-2 pl-2.5">
<span className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden"><Avatar initials="B" color="blue" /><span className="flex min-w-0 flex-col overflow-hidden"><span className="truncate text-body-medium text-text-primary">BoardCN team</span><span className="truncate text-body-regular text-text-secondary">Workspace</span></span></span>
<Button size="small" variant="secondary" className="shrink-0">Upgrade</Button>
</div>
) : null}
<button type="button" aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"} onClick={() => onCollapsedChange?.(!collapsed)} className={cx("flex h-8 items-center justify-center rounded-lg text-foreground-icon-secondary transition-colors hover:bg-background-secondary-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring", collapsed ? "w-9" : "w-full")}>
{collapsed ? <RiArrowRightDoubleLine className="size-4" aria-hidden /> : <><RiArrowLeftDoubleLine className="size-4" aria-hidden /><span className="ml-2 text-body-2-medium">Collapse sidebar</span></>}
</button>
</div>
</aside>
);
}"use client";
import { useState, type ReactNode } from "react";
import {
RiArrowLeftDoubleLine,
RiArrowRightDoubleLine,
RiCustomerService2Line,
RiFolderLine,
RiGitBranchLine,
RiListSettingsLine,
RiMore2Line,
RiRobot2Line,
RiSearchLine,
RiSettings3Line,
} from "@remixicon/react";
import { Avatar } from "@/components/base/avatar/avatar";
import { Button } from "@/components/base/buttons/button";
import { ThemeToggle } from "@/components/blocks/theme/theme-toggle";
import { cx } from "@/utils/cx";
type Chat = { name: string; time: string };
type Repository = { name: string; chats: Chat[]; initiallyOpen?: boolean };
const repositories: Repository[] = [
{ name: "boardcn", chats: [{ name: "pro badge restyle", time: "2h" }, { name: "installation docs page", time: "1d" }] },
{ name: "vibl coding project", initiallyOpen: true, chats: [{ name: "landing page design", time: "34m" }, { name: "image generation", time: "now" }, { name: "coding scenario", time: "now" }, { name: "mobile app for vuejs...", time: "5h" }, { name: "code refactor dropdo...", time: "18h" }] },
{ name: "strider landing page work", chats: [{ name: "hero section animation", time: "3d" }, { name: "pricing table copy", time: "4d" }] },
{ name: "pirate mini game iOS", chats: [{ name: "cannon physics tuning", time: "1w" }, { name: "sprite sheet cleanup", time: "2w" }] },
];
function SidebarLink({ icon, children }: { icon: ReactNode; children: ReactNode }) {
return (
<a href="#" onClick={(event) => event.preventDefault()} className="flex w-full items-center gap-2 rounded-2lg p-2 transition-colors duration-150 ease hover:bg-background-secondary-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring">
<span className="size-5 shrink-0 text-foreground-icon-secondary">{icon}</span>
<span className="text-body-medium whitespace-nowrap text-text-secondary">{children}</span>
</a>
);
}
function RepositoryGroup({ repository }: { repository: Repository }) {
const [open, setOpen] = useState(Boolean(repository.initiallyOpen));
return (
<div className="flex w-full flex-col">
<button type="button" aria-expanded={open} onClick={() => setOpen((value) => !value)} className="flex w-full cursor-pointer items-center gap-2 rounded-2lg p-2 text-left transition-colors duration-150 ease hover:bg-background-secondary-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring">
<RiFolderLine className="size-5 shrink-0 text-foreground-icon-secondary" aria-hidden />
<span className="truncate text-body-medium whitespace-nowrap text-text-secondary">{repository.name}</span>
</button>
<div aria-hidden={!open} className={cx("grid transition-[grid-template-rows,opacity] duration-300 ease-in-out", open ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0")}>
<div className="overflow-hidden">
<div className="relative flex w-full flex-col gap-0.5 pt-0.5">
<span aria-hidden className="pointer-events-none absolute top-0 bottom-4 left-[16.5px] w-3 border-l border-foreground-icon-quaternary" />
{repository.chats.map((chat, index) => (
<a key={chat.name} href="#" tabIndex={open ? 0 : -1} onClick={(event) => event.preventDefault()} className="relative flex w-full items-center gap-2.5 rounded-2lg py-[5px] pr-2 pl-9 transition-colors duration-150 ease hover:bg-background-secondary-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring">
<span aria-hidden className="absolute left-[16.5px] top-1/2 h-px w-3 rounded-full bg-foreground-icon-quaternary" />
{index === repository.chats.length - 1 ? <span aria-hidden className="absolute top-1/2 bottom-0 left-4 w-1 bg-background-secondary-default" /> : null}
<span className="min-w-0 flex-1 truncate text-body-medium text-text-secondary">{chat.name}</span>
<span className="inline-flex shrink-0 items-center justify-center rounded-sm bg-background-tertiary-default px-1 py-px text-caption-1-medium whitespace-nowrap text-text-secondary">{chat.time}</span>
</a>
))}
</div>
</div>
</div>
</div>
);
}
export interface AgentSidebarProps {
className?: string;
collapsed?: boolean;
onCollapsedChange?: (collapsed: boolean) => void;
userName?: string;
userInitials?: string;
}
export function AgentSidebar({ className, collapsed = false, onCollapsedChange, userName = "Mertcan Esmergul", userInitials = "M" }: AgentSidebarProps) {
return (
<aside className={cx("flex h-full shrink-0 flex-col justify-between overflow-hidden border border-border-button-white bg-background-secondary-default shadow-sidebar transition-[width,padding,border-radius] duration-300", collapsed ? "w-[68px] rounded-2xl p-2" : "w-[260px] rounded-3xl p-3", className)}>
<div className="flex min-h-0 w-full flex-col gap-3">
<div className={cx("flex w-full items-center gap-2", collapsed ? "justify-center" : "justify-between")}>
<button type="button" aria-label={userName} className="flex min-w-0 cursor-pointer items-center gap-2 rounded-full outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring">
<Avatar initials={userInitials} />
{!collapsed ? <span className="flex min-w-0 items-center gap-0.5"><span className="truncate text-body-medium whitespace-nowrap text-text-primary">{userName}</span><RiMore2Line className="size-4 shrink-0 text-foreground-icon-tertiary" aria-hidden /></span> : null}
</button>
{!collapsed ? <button type="button" aria-label="Quick Search" className="flex size-9 shrink-0 items-center justify-center rounded-full bg-background-tertiary-default text-foreground-icon-secondary transition-colors hover:bg-background-tertiary-hover/55"><RiSearchLine className="size-5" aria-hidden /></button> : null}
</div>
{collapsed ? (
<div className="flex flex-col items-center gap-2">
<button type="button" aria-label="Quick Search" className="flex size-9 items-center justify-center rounded-2lg text-foreground-icon-secondary hover:bg-background-secondary-hover"><RiSearchLine className="size-5" aria-hidden /></button>
<button type="button" aria-label="New agent" className="flex size-9 items-center justify-center rounded-2lg text-foreground-icon-secondary hover:bg-background-secondary-hover"><RiRobot2Line className="size-5" aria-hidden /></button>
</div>
) : (
<div className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto [scrollbar-width:none]">
<nav className="flex w-full shrink-0 flex-col gap-1" aria-label="Agent actions">
<SidebarLink icon={<RiRobot2Line className="size-5" />}>New agent</SidebarLink>
<SidebarLink icon={<RiGitBranchLine className="size-5" />}>Automations</SidebarLink>
<SidebarLink icon={<RiListSettingsLine className="size-5" />}>Customize</SidebarLink>
</nav>
<div className="flex w-full flex-col gap-2.5">
<span className="text-body-medium text-text-secondary">Repositories</span>
<nav className="flex w-full flex-col gap-1" aria-label="Repositories">
{repositories.map((repository) => <RepositoryGroup key={repository.name} repository={repository} />)}
</nav>
</div>
</div>
)}
</div>
<div className={cx("flex shrink-0 flex-col", collapsed ? "items-center gap-2" : "gap-3")}>
<ThemeToggle appearance={collapsed ? "sidebar" : "sidebar-segmented"} collapsed={collapsed} />
{!collapsed ? (
<nav className="flex flex-col gap-1" aria-label="Help and settings">
<SidebarLink icon={<RiCustomerService2Line className="size-5" />}>Support</SidebarLink>
<SidebarLink icon={<RiSettings3Line className="size-5" />}>Settings</SidebarLink>
</nav>
) : null}
{!collapsed ? (
<div className="flex w-full items-center gap-2 rounded-xl bg-background-secondary-default py-2 pr-2 pl-2.5">
<span className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden"><Avatar initials="B" color="blue" /><span className="flex min-w-0 flex-col overflow-hidden"><span className="truncate text-body-medium text-text-primary">BoardCN team</span><span className="truncate text-body-regular text-text-secondary">Workspace</span></span></span>
<Button size="small" variant="secondary" className="shrink-0">Upgrade</Button>
</div>
) : null}
<button type="button" aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"} onClick={() => onCollapsedChange?.(!collapsed)} className={cx("flex h-8 items-center justify-center rounded-lg text-foreground-icon-secondary transition-colors hover:bg-background-secondary-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring", collapsed ? "w-9" : "w-full")}>
{collapsed ? <RiArrowRightDoubleLine className="size-4" aria-hidden /> : <><RiArrowLeftDoubleLine className="size-4" aria-hidden /><span className="ml-2 text-body-2-medium">Collapse sidebar</span></>}
</button>
</div>
</aside>
);
}"use client";
import { useState } from "react";
import { RiCheckLine, RiFileCopyLine, RiThumbDownLine, RiThumbUpLine } from "@remixicon/react";
import { AgentThinking } from "@/components/blocks/agent-thinking/agent-thinking";
import { AgentProgress } from "@/components/blocks/agent-progress/agent-progress";
import { cx } from "@/utils/cx";
import { SyntaxCode } from "./syntax-code";
export interface ChatMessageCode {
language: string;
filename: string;
additions: number;
deletions: number;
lines: readonly string[];
}
export interface ChatMessage {
role: "user" | "assistant";
content: string;
code?: ChatMessageCode;
}
function CopyButton({ value, label }: { value: string; label: string }) {
const [copied, setCopied] = useState(false);
return <button type="button" aria-label={label} onClick={() => { void navigator.clipboard?.writeText(value); setCopied(true); window.setTimeout(() => setCopied(false), 1400); }} className="group flex size-6 items-center justify-center rounded-md transition-colors hover:bg-background-primary-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring">{copied ? <RiCheckLine className="size-3.5 text-emerald-600" aria-hidden /> : <RiFileCopyLine className="size-3.5 text-foreground-icon-secondary group-hover:text-foreground-icon-hover" aria-hidden />}</button>;
}
function InlineCodeCard({ code }: { code: ChatMessageCode }) {
const source = code.lines.join("\n");
return (
<div className="overflow-hidden rounded-2xl border border-separator-border bg-docs-code-background shadow-xs">
<div className="flex h-9 items-center justify-between gap-3 border-b border-separator-border px-2.5">
<div className="flex min-w-0 items-center gap-2"><span className="inline-flex h-3.5 items-center rounded-md border border-docs-file-chip-border bg-docs-file-chip-background px-1.5 font-mono text-[10px] leading-none font-medium text-docs-file-chip-foreground">{code.language}</span><span className="truncate font-mono text-[12px] text-text-secondary">{code.filename}</span></div>
<div className="flex shrink-0 items-center gap-2"><div className="flex items-center gap-1 font-mono text-[11px] leading-none"><span className="text-emerald-700">+{code.additions}</span><span className="text-red-600">-{code.deletions}</span></div><CopyButton value={source} label="Copy code" /></div>
</div>
<pre className="overflow-x-auto px-3 py-2.5 font-mono text-[11px] leading-[18px] [scrollbar-width:thin]"><SyntaxCode lines={code.lines} language={code.language} /></pre>
</div>
);
}
export interface ChatThreadProps {
working?: boolean;
/** Progress steps shown while `working` is true. */
steps: string[];
messages: readonly ChatMessage[];
className?: string;
}
export function ChatThread({ working = false, steps, messages, className }: ChatThreadProps) {
const [feedback, setFeedback] = useState<"up" | "down" | null>(null);
const userMessage = messages.find((message) => message.role === "user");
const assistantMessage = messages.find((message) => message.role === "assistant");
return (
<div className={cx("flex min-h-0 w-full flex-1 flex-col justify-end gap-3 overflow-y-auto px-4 pt-4 [scrollbar-width:thin]", className)} aria-live="polite">
{userMessage ? (
<div className="-mr-1.5 ml-auto flex w-fit max-w-[calc(50%+6px)] flex-col rounded-2xl bg-background-primary-default px-3 py-[11px] text-left text-body-regular text-text-primary shadow-card motion-safe:animate-[fade-in_.35s_ease-out]">
<p>{userMessage.content}</p>
</div>
) : null}
{working ? <div className="flex flex-col gap-3"><AgentProgress steps={steps} stepDuration={2600} /><AgentThinking variant="wave" /></div> : assistantMessage ? (
<div className="flex w-full flex-col gap-2 text-body-regular text-text-primary motion-safe:animate-[fade-in_.35s_ease-out]">
<p>{assistantMessage.content}</p>
{assistantMessage.code ? <InlineCodeCard code={assistantMessage.code} /> : null}
<div className="flex items-center gap-1 pt-0.5">
<button type="button" aria-label="Good response" aria-pressed={feedback === "up"} onClick={() => setFeedback(feedback === "up" ? null : "up")} className={cx("flex size-7 items-center justify-center rounded-md text-foreground-icon-secondary transition-colors hover:bg-background-primary-hover", feedback === "up" && "bg-background-tertiary-default text-accent-500")}><RiThumbUpLine className="size-4" aria-hidden /></button>
<button type="button" aria-label="Bad response" aria-pressed={feedback === "down"} onClick={() => setFeedback(feedback === "down" ? null : "down")} className={cx("flex size-7 items-center justify-center rounded-md text-foreground-icon-secondary transition-colors hover:bg-background-primary-hover", feedback === "down" && "bg-background-tertiary-default text-red-500")}><RiThumbDownLine className="size-4" aria-hidden /></button>
<CopyButton label="Copy response" value={assistantMessage.content} />
</div>
</div>
) : null}
</div>
);
}"use client";
import { useState } from "react";
import { RiCheckLine, RiFileCopyLine, RiThumbDownLine, RiThumbUpLine } from "@remixicon/react";
import { AgentThinking } from "@/components/blocks/agent-thinking/agent-thinking";
import { AgentProgress } from "@/components/blocks/agent-progress/agent-progress";
import { cx } from "@/utils/cx";
import { SyntaxCode } from "./syntax-code";
export interface ChatMessageCode {
language: string;
filename: string;
additions: number;
deletions: number;
lines: readonly string[];
}
export interface ChatMessage {
role: "user" | "assistant";
content: string;
code?: ChatMessageCode;
}
function CopyButton({ value, label }: { value: string; label: string }) {
const [copied, setCopied] = useState(false);
return <button type="button" aria-label={label} onClick={() => { void navigator.clipboard?.writeText(value); setCopied(true); window.setTimeout(() => setCopied(false), 1400); }} className="group flex size-6 items-center justify-center rounded-md transition-colors hover:bg-background-primary-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring">{copied ? <RiCheckLine className="size-3.5 text-emerald-600" aria-hidden /> : <RiFileCopyLine className="size-3.5 text-foreground-icon-secondary group-hover:text-foreground-icon-hover" aria-hidden />}</button>;
}
function InlineCodeCard({ code }: { code: ChatMessageCode }) {
const source = code.lines.join("\n");
return (
<div className="overflow-hidden rounded-2xl border border-separator-border bg-docs-code-background shadow-xs">
<div className="flex h-9 items-center justify-between gap-3 border-b border-separator-border px-2.5">
<div className="flex min-w-0 items-center gap-2"><span className="inline-flex h-3.5 items-center rounded-md border border-docs-file-chip-border bg-docs-file-chip-background px-1.5 font-mono text-[10px] leading-none font-medium text-docs-file-chip-foreground">{code.language}</span><span className="truncate font-mono text-[12px] text-text-secondary">{code.filename}</span></div>
<div className="flex shrink-0 items-center gap-2"><div className="flex items-center gap-1 font-mono text-[11px] leading-none"><span className="text-emerald-700">+{code.additions}</span><span className="text-red-600">-{code.deletions}</span></div><CopyButton value={source} label="Copy code" /></div>
</div>
<pre className="overflow-x-auto px-3 py-2.5 font-mono text-[11px] leading-[18px] [scrollbar-width:thin]"><SyntaxCode lines={code.lines} language={code.language} /></pre>
</div>
);
}
export interface ChatThreadProps {
working?: boolean;
/** Progress steps shown while `working` is true. */
steps: string[];
messages: readonly ChatMessage[];
className?: string;
}
export function ChatThread({ working = false, steps, messages, className }: ChatThreadProps) {
const [feedback, setFeedback] = useState<"up" | "down" | null>(null);
const userMessage = messages.find((message) => message.role === "user");
const assistantMessage = messages.find((message) => message.role === "assistant");
return (
<div className={cx("flex min-h-0 w-full flex-1 flex-col justify-end gap-3 overflow-y-auto px-4 pt-4 [scrollbar-width:thin]", className)} aria-live="polite">
{userMessage ? (
<div className="-mr-1.5 ml-auto flex w-fit max-w-[calc(50%+6px)] flex-col rounded-2xl bg-background-primary-default px-3 py-[11px] text-left text-body-regular text-text-primary shadow-card motion-safe:animate-[fade-in_.35s_ease-out]">
<p>{userMessage.content}</p>
</div>
) : null}
{working ? <div className="flex flex-col gap-3"><AgentProgress steps={steps} stepDuration={2600} /><AgentThinking variant="wave" /></div> : assistantMessage ? (
<div className="flex w-full flex-col gap-2 text-body-regular text-text-primary motion-safe:animate-[fade-in_.35s_ease-out]">
<p>{assistantMessage.content}</p>
{assistantMessage.code ? <InlineCodeCard code={assistantMessage.code} /> : null}
<div className="flex items-center gap-1 pt-0.5">
<button type="button" aria-label="Good response" aria-pressed={feedback === "up"} onClick={() => setFeedback(feedback === "up" ? null : "up")} className={cx("flex size-7 items-center justify-center rounded-md text-foreground-icon-secondary transition-colors hover:bg-background-primary-hover", feedback === "up" && "bg-background-tertiary-default text-accent-500")}><RiThumbUpLine className="size-4" aria-hidden /></button>
<button type="button" aria-label="Bad response" aria-pressed={feedback === "down"} onClick={() => setFeedback(feedback === "down" ? null : "down")} className={cx("flex size-7 items-center justify-center rounded-md text-foreground-icon-secondary transition-colors hover:bg-background-primary-hover", feedback === "down" && "bg-background-tertiary-default text-red-500")}><RiThumbDownLine className="size-4" aria-hidden /></button>
<CopyButton label="Copy response" value={assistantMessage.content} />
</div>
</div>
) : null}
</div>
);
}"use client";
import { useState, type CSSProperties, type ReactNode } from "react";
import {
RiCloseLine,
RiCodeSSlashLine,
RiExpandDiagonalLine,
RiGlobalLine,
RiInfinityLine,
RiLayoutLeft2Line,
RiTerminalBoxLine,
} from "@remixicon/react";
import { PillTab, PillTabList } from "@/components/base/tabs/pill-tab";
import { cx } from "@/utils/cx";
import { SyntaxCode } from "./syntax-code";
export type SidePanelView = "changes" | "browser";
function TinyAction({ label, children, onClick, pressed }: { label: string; children: ReactNode; onClick?: () => void; pressed?: boolean }) {
return <button type="button" aria-label={label} aria-pressed={pressed} onClick={onClick} className="group cursor-pointer rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring"><span className="block size-4 text-foreground-icon-secondary transition-colors group-hover:text-foreground-icon-hover">{children}</span></button>;
}
export interface CodePanelChangeSummary {
title: string;
additions: number;
deletions: number;
fileLabel: string;
fileAdditions: number;
badge: string;
}
function ChangeSummary({ summary }: { summary: CodePanelChangeSummary }) {
return (
<div className="flex w-full flex-col">
<div className="flex w-full flex-col justify-center rounded-t-2lg border-x border-t border-background-secondary-default px-2.5 pt-1.5 pb-3.5">
<div className="flex items-center justify-between"><span className="text-caption-1-medium text-text-secondary">{summary.title}</span><span className="flex items-center gap-1 font-mono text-[11px]"><span className="text-emerald-700">+{summary.additions}</span><span className="text-red-600">-{summary.deletions}</span></span></div>
</div>
<div className="z-10 -mt-[7px] flex w-full items-center justify-between rounded-2lg bg-background-secondary-default py-1 pr-[5px] pl-1.5">
<p className="min-w-0 truncate font-mono text-[11px] text-text-secondary">{summary.fileLabel} <span className="text-emerald-700">+{summary.fileAdditions}</span></p>
<span className="rounded-sm bg-emerald-100 px-1.5 py-0.5 text-[10px] font-medium text-emerald-700 dark:bg-emerald-950">{summary.badge}</span>
</div>
</div>
);
}
function CodeView({ source, changeSummary }: { source: readonly string[]; changeSummary: CodePanelChangeSummary }) {
return (
<><ChangeSummary summary={changeSummary} /><div className="pt-[3px]" /><div className="min-h-0 w-full flex-1 overflow-y-auto pl-1.5 font-mono text-[13px] leading-[23px] [scrollbar-width:thin]"><SyntaxCode lines={source} lineNumberClassName="w-5" /></div></>
);
}
export interface AiChatSidePanelProps {
view?: SidePanelView;
onViewChange?: (view: SidePanelView) => void;
terminalOpen?: boolean;
defaultTerminalOpen?: boolean;
onTerminalOpenChange?: (open: boolean) => void;
expanded?: boolean;
defaultExpanded?: boolean;
onExpandedChange?: (expanded: boolean) => void;
onClose?: () => void;
className?: string;
style?: CSSProperties;
source: readonly string[];
changeSummary: CodePanelChangeSummary;
terminalLines: readonly string[];
}
export function AiChatSidePanel({
view: controlledView,
onViewChange,
terminalOpen: controlledTerminalOpen,
defaultTerminalOpen = false,
onTerminalOpenChange,
expanded: controlledExpanded,
defaultExpanded = false,
onExpandedChange,
onClose,
className,
style,
source,
changeSummary,
terminalLines,
}: AiChatSidePanelProps) {
const [internalView, setInternalView] = useState<SidePanelView>("changes");
const [internalTerminalOpen, setInternalTerminalOpen] = useState(defaultTerminalOpen);
const [internalExpanded, setInternalExpanded] = useState(defaultExpanded);
const view = controlledView ?? internalView;
const terminalOpen = controlledTerminalOpen ?? internalTerminalOpen;
const expanded = controlledExpanded ?? internalExpanded;
const setView = (next: SidePanelView) => { if (controlledView === undefined) setInternalView(next); onViewChange?.(next); };
const setTerminalOpen = (next: boolean) => {
if (controlledTerminalOpen === undefined) setInternalTerminalOpen(next);
onTerminalOpenChange?.(next);
};
const setExpanded = (next: boolean) => {
if (controlledExpanded === undefined) setInternalExpanded(next);
onExpandedChange?.(next);
};
return (
<aside style={style} className={cx("flex h-full min-h-0 flex-col gap-2.5 overflow-hidden pt-2", className)}>
<div className="flex h-[30px] w-full shrink-0 items-center justify-between">
<PillTabList aria-label="Panel view"><PillTab icon={RiInfinityLine} isSelected={view === "changes"} onSelect={() => setView("changes")}>Changes</PillTab><PillTab icon={RiGlobalLine} isSelected={view === "browser"} onSelect={() => setView("browser")}>Browser</PillTab></PillTabList>
<div className="flex items-center gap-2 pr-px">
<TinyAction label={terminalOpen ? "Close terminal" : "Open terminal"} pressed={terminalOpen} onClick={() => setTerminalOpen(!terminalOpen)}><RiTerminalBoxLine className="size-4" /></TinyAction>
<TinyAction label={expanded ? "Collapse panel" : "Expand panel"} pressed={expanded} onClick={() => setExpanded(!expanded)}><RiExpandDiagonalLine className="size-4" /></TinyAction>
<TinyAction label="Toggle panel" onClick={onClose}>{onClose ? <RiCloseLine className="size-4" /> : <RiLayoutLeft2Line className="size-4" />}</TinyAction>
</div>
</div>
{view === "changes" ? <CodeView source={source} changeSummary={changeSummary} /> : <div className="flex w-full flex-1 items-center justify-center rounded-2lg bg-background-secondary-default"><span className="flex items-center gap-2 text-body-medium text-text-tertiary"><RiCodeSSlashLine className="size-4" aria-hidden />Browser preview</span></div>}
{terminalOpen ? (
<section aria-label="Terminal" className="flex h-36 shrink-0 flex-col overflow-hidden rounded-2lg bg-neutral-950 text-neutral-300 shadow-sm">
<div className="flex h-8 shrink-0 items-center justify-between border-b border-white/10 px-3 text-caption-1-medium text-neutral-400"><span>Terminal</span><span>zsh</span></div>
<pre className="min-h-0 flex-1 overflow-auto p-3 font-mono text-xs leading-5"><code>{terminalLines.map((line, index) => (<span key={index} className={index === 0 ? "text-emerald-400" : "text-neutral-500"}>{line}{index < terminalLines.length - 1 ? "\n" : null}</span>))}</code></pre>
</section>
) : null}
</aside>
);
}"use client";
import { useState, type CSSProperties, type ReactNode } from "react";
import {
RiCloseLine,
RiCodeSSlashLine,
RiExpandDiagonalLine,
RiGlobalLine,
RiInfinityLine,
RiLayoutLeft2Line,
RiTerminalBoxLine,
} from "@remixicon/react";
import { PillTab, PillTabList } from "@/components/base/tabs/pill-tab";
import { cx } from "@/utils/cx";
import { SyntaxCode } from "./syntax-code";
export type SidePanelView = "changes" | "browser";
function TinyAction({ label, children, onClick, pressed }: { label: string; children: ReactNode; onClick?: () => void; pressed?: boolean }) {
return <button type="button" aria-label={label} aria-pressed={pressed} onClick={onClick} className="group cursor-pointer rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring"><span className="block size-4 text-foreground-icon-secondary transition-colors group-hover:text-foreground-icon-hover">{children}</span></button>;
}
export interface CodePanelChangeSummary {
title: string;
additions: number;
deletions: number;
fileLabel: string;
fileAdditions: number;
badge: string;
}
function ChangeSummary({ summary }: { summary: CodePanelChangeSummary }) {
return (
<div className="flex w-full flex-col">
<div className="flex w-full flex-col justify-center rounded-t-2lg border-x border-t border-background-secondary-default px-2.5 pt-1.5 pb-3.5">
<div className="flex items-center justify-between"><span className="text-caption-1-medium text-text-secondary">{summary.title}</span><span className="flex items-center gap-1 font-mono text-[11px]"><span className="text-emerald-700">+{summary.additions}</span><span className="text-red-600">-{summary.deletions}</span></span></div>
</div>
<div className="z-10 -mt-[7px] flex w-full items-center justify-between rounded-2lg bg-background-secondary-default py-1 pr-[5px] pl-1.5">
<p className="min-w-0 truncate font-mono text-[11px] text-text-secondary">{summary.fileLabel} <span className="text-emerald-700">+{summary.fileAdditions}</span></p>
<span className="rounded-sm bg-emerald-100 px-1.5 py-0.5 text-[10px] font-medium text-emerald-700 dark:bg-emerald-950">{summary.badge}</span>
</div>
</div>
);
}
function CodeView({ source, changeSummary }: { source: readonly string[]; changeSummary: CodePanelChangeSummary }) {
return (
<><ChangeSummary summary={changeSummary} /><div className="pt-[3px]" /><div className="min-h-0 w-full flex-1 overflow-y-auto pl-1.5 font-mono text-[13px] leading-[23px] [scrollbar-width:thin]"><SyntaxCode lines={source} lineNumberClassName="w-5" /></div></>
);
}
export interface AiChatSidePanelProps {
view?: SidePanelView;
onViewChange?: (view: SidePanelView) => void;
terminalOpen?: boolean;
defaultTerminalOpen?: boolean;
onTerminalOpenChange?: (open: boolean) => void;
expanded?: boolean;
defaultExpanded?: boolean;
onExpandedChange?: (expanded: boolean) => void;
onClose?: () => void;
className?: string;
style?: CSSProperties;
source: readonly string[];
changeSummary: CodePanelChangeSummary;
terminalLines: readonly string[];
}
export function AiChatSidePanel({
view: controlledView,
onViewChange,
terminalOpen: controlledTerminalOpen,
defaultTerminalOpen = false,
onTerminalOpenChange,
expanded: controlledExpanded,
defaultExpanded = false,
onExpandedChange,
onClose,
className,
style,
source,
changeSummary,
terminalLines,
}: AiChatSidePanelProps) {
const [internalView, setInternalView] = useState<SidePanelView>("changes");
const [internalTerminalOpen, setInternalTerminalOpen] = useState(defaultTerminalOpen);
const [internalExpanded, setInternalExpanded] = useState(defaultExpanded);
const view = controlledView ?? internalView;
const terminalOpen = controlledTerminalOpen ?? internalTerminalOpen;
const expanded = controlledExpanded ?? internalExpanded;
const setView = (next: SidePanelView) => { if (controlledView === undefined) setInternalView(next); onViewChange?.(next); };
const setTerminalOpen = (next: boolean) => {
if (controlledTerminalOpen === undefined) setInternalTerminalOpen(next);
onTerminalOpenChange?.(next);
};
const setExpanded = (next: boolean) => {
if (controlledExpanded === undefined) setInternalExpanded(next);
onExpandedChange?.(next);
};
return (
<aside style={style} className={cx("flex h-full min-h-0 flex-col gap-2.5 overflow-hidden pt-2", className)}>
<div className="flex h-[30px] w-full shrink-0 items-center justify-between">
<PillTabList aria-label="Panel view"><PillTab icon={RiInfinityLine} isSelected={view === "changes"} onSelect={() => setView("changes")}>Changes</PillTab><PillTab icon={RiGlobalLine} isSelected={view === "browser"} onSelect={() => setView("browser")}>Browser</PillTab></PillTabList>
<div className="flex items-center gap-2 pr-px">
<TinyAction label={terminalOpen ? "Close terminal" : "Open terminal"} pressed={terminalOpen} onClick={() => setTerminalOpen(!terminalOpen)}><RiTerminalBoxLine className="size-4" /></TinyAction>
<TinyAction label={expanded ? "Collapse panel" : "Expand panel"} pressed={expanded} onClick={() => setExpanded(!expanded)}><RiExpandDiagonalLine className="size-4" /></TinyAction>
<TinyAction label="Toggle panel" onClick={onClose}>{onClose ? <RiCloseLine className="size-4" /> : <RiLayoutLeft2Line className="size-4" />}</TinyAction>
</div>
</div>
{view === "changes" ? <CodeView source={source} changeSummary={changeSummary} /> : <div className="flex w-full flex-1 items-center justify-center rounded-2lg bg-background-secondary-default"><span className="flex items-center gap-2 text-body-medium text-text-tertiary"><RiCodeSSlashLine className="size-4" aria-hidden />Browser preview</span></div>}
{terminalOpen ? (
<section aria-label="Terminal" className="flex h-36 shrink-0 flex-col overflow-hidden rounded-2lg bg-neutral-950 text-neutral-300 shadow-sm">
<div className="flex h-8 shrink-0 items-center justify-between border-b border-white/10 px-3 text-caption-1-medium text-neutral-400"><span>Terminal</span><span>zsh</span></div>
<pre className="min-h-0 flex-1 overflow-auto p-3 font-mono text-xs leading-5"><code>{terminalLines.map((line, index) => (<span key={index} className={index === 0 ? "text-emerald-400" : "text-neutral-500"}>{line}{index < terminalLines.length - 1 ? "\n" : null}</span>))}</code></pre>
</section>
) : null}
</aside>
);
}export { AiChatShell, AiChatTemplate, type AiChatTemplateProps } from "./ai-chat";
export { AgentSidebar, type AgentSidebarProps } from "./agent-sidebar";
export { ChatThread, type ChatMessage, type ChatMessageCode, type ChatThreadProps } from "./chat-thread";
export { AiChatSidePanel, type AiChatSidePanelProps, type CodePanelChangeSummary, type SidePanelView } from "./code-panel";export { AiChatShell, AiChatTemplate, type AiChatTemplateProps } from "./ai-chat";
export { AgentSidebar, type AgentSidebarProps } from "./agent-sidebar";
export { ChatThread, type ChatMessage, type ChatMessageCode, type ChatThreadProps } from "./chat-thread";
export { AiChatSidePanel, type AiChatSidePanelProps, type CodePanelChangeSummary, type SidePanelView } from "./code-panel";"use client";
import { useEffect, useState, type CSSProperties } from "react";
import { codeToTokensWithThemes, type BundledLanguage, type ThemedTokenWithVariants } from "shiki/bundle/web";
import { cx } from "@/utils/cx";
const LANGUAGE_ALIASES: Record<string, BundledLanguage> = {
js: "javascript",
jsx: "jsx",
ts: "typescript",
tsx: "tsx",
};
type TokenStyle = CSSProperties & {
"--syntax-dark": string;
"--syntax-light": string;
};
function tokenStyle(token: ThemedTokenWithVariants): TokenStyle {
const light = token.variants.light;
const dark = token.variants.dark;
const fontStyle = dark?.fontStyle ?? light?.fontStyle ?? 0;
return {
"--syntax-dark": dark?.color ?? "currentColor",
"--syntax-light": light?.color ?? "currentColor",
fontStyle: fontStyle & 1 ? "italic" : undefined,
fontWeight: fontStyle & 2 ? 700 : undefined,
textDecoration: fontStyle & 4 ? "underline" : undefined,
};
}
function languageId(language: string): BundledLanguage {
const normalized = language.toLowerCase();
return LANGUAGE_ALIASES[normalized] ?? (normalized as BundledLanguage);
}
export interface SyntaxCodeProps {
lines: readonly string[];
language?: string;
lineNumberClassName?: string;
}
export function SyntaxCode({ lines, language = "tsx", lineNumberClassName = "w-3" }: SyntaxCodeProps) {
const [highlighted, setHighlighted] = useState<ThemedTokenWithVariants[][] | null>(null);
const source = lines.join("\n");
useEffect(() => {
let cancelled = false;
void codeToTokensWithThemes(source, {
lang: languageId(language),
themes: { light: "github-light", dark: "github-dark" },
}).then((tokens) => {
if (!cancelled) setHighlighted(tokens);
}).catch(() => {
if (!cancelled) setHighlighted(null);
});
return () => {
cancelled = true;
};
}, [language, source]);
return (
<code className="block">
{lines.map((line, index) => (
<span key={index} className="flex min-w-max items-start gap-3">
<span className={cx(lineNumberClassName, "shrink-0 select-none text-right text-text-tertiary")}>{index + 1}</span>
<span className="whitespace-pre text-text-secondary">
{highlighted?.[index]?.map((token) => (
<span key={token.offset} className="[color:var(--syntax-light)] dark:[color:var(--syntax-dark)]" style={tokenStyle(token)}>{token.content}</span>
)) ?? (line || "\u00a0")}
</span>
</span>
))}
</code>
);
}"use client";
import { useEffect, useState, type CSSProperties } from "react";
import { codeToTokensWithThemes, type BundledLanguage, type ThemedTokenWithVariants } from "shiki/bundle/web";
import { cx } from "@/utils/cx";
const LANGUAGE_ALIASES: Record<string, BundledLanguage> = {
js: "javascript",
jsx: "jsx",
ts: "typescript",
tsx: "tsx",
};
type TokenStyle = CSSProperties & {
"--syntax-dark": string;
"--syntax-light": string;
};
function tokenStyle(token: ThemedTokenWithVariants): TokenStyle {
const light = token.variants.light;
const dark = token.variants.dark;
const fontStyle = dark?.fontStyle ?? light?.fontStyle ?? 0;
return {
"--syntax-dark": dark?.color ?? "currentColor",
"--syntax-light": light?.color ?? "currentColor",
fontStyle: fontStyle & 1 ? "italic" : undefined,
fontWeight: fontStyle & 2 ? 700 : undefined,
textDecoration: fontStyle & 4 ? "underline" : undefined,
};
}
function languageId(language: string): BundledLanguage {
const normalized = language.toLowerCase();
return LANGUAGE_ALIASES[normalized] ?? (normalized as BundledLanguage);
}
export interface SyntaxCodeProps {
lines: readonly string[];
language?: string;
lineNumberClassName?: string;
}
export function SyntaxCode({ lines, language = "tsx", lineNumberClassName = "w-3" }: SyntaxCodeProps) {
const [highlighted, setHighlighted] = useState<ThemedTokenWithVariants[][] | null>(null);
const source = lines.join("\n");
useEffect(() => {
let cancelled = false;
void codeToTokensWithThemes(source, {
lang: languageId(language),
themes: { light: "github-light", dark: "github-dark" },
}).then((tokens) => {
if (!cancelled) setHighlighted(tokens);
}).catch(() => {
if (!cancelled) setHighlighted(null);
});
return () => {
cancelled = true;
};
}, [language, source]);
return (
<code className="block">
{lines.map((line, index) => (
<span key={index} className="flex min-w-max items-start gap-3">
<span className={cx(lineNumberClassName, "shrink-0 select-none text-right text-text-tertiary")}>{index + 1}</span>
<span className="whitespace-pre text-text-secondary">
{highlighted?.[index]?.map((token) => (
<span key={token.offset} className="[color:var(--syntax-light)] dark:[color:var(--syntax-dark)]" style={tokenStyle(token)}>{token.content}</span>
)) ?? (line || "\u00a0")}
</span>
</span>
))}
</code>
);
}