Merge pull request #93 from DenchHQ/markrachapoom/design

feat: new design system
This commit is contained in:
Kumar Abhirup 2026-03-14 19:47:57 -07:00 committed by GitHub
commit ce40a9632f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 2756 additions and 1448 deletions

View File

@ -1259,7 +1259,10 @@ function ToolStep({
: "var(--color-text-secondary)",
}}
>
<span className={`break-all${status === "running" ? " animate-pulse" : ""}`}>{label}</span>
<span
className={`break-all${status === "running" ? " animate-pulse" : ""}${outputText && !isSingleMedia && !diffText && status === "done" ? " cursor-pointer hover:underline" : ""}`}
onClick={outputText && !isSingleMedia && !diffText && status === "done" ? () => setShowOutput((v) => !v) : undefined}
>{label}</span>
{/* Exit code badge for exec tools */}
{kind === "exec" && status === "done" && output?.exitCode !== undefined && (
<span
@ -1456,22 +1459,6 @@ function ToolStep({
!isSingleMedia &&
!diffText && (
<div className="mt-1">
{status === "done" && (
<button
type="button"
onClick={() =>
setShowOutput((v) => !v)
}
className="text-[11px] hover:underline cursor-pointer"
style={{
color: "var(--color-accent)",
}}
>
{showOutput
? "Hide output"
: "Show output"}
</button>
)}
{(showOutput || status === "running") && (
<pre
className="mt-1 text-[11px] font-mono rounded-lg px-2.5 py-2 overflow-x-auto whitespace-pre-wrap break-all max-h-96 overflow-y-auto leading-relaxed"

View File

@ -784,7 +784,7 @@ export const ChatMessage = memo(function ChatMessage({ message, isStreaming, onS
if (attachmentInfo) {
return (
<div className="flex flex-col items-end gap-1.5 py-2">
<AttachedFilesCard paths={attachmentInfo.paths} />
{!richHtml && <AttachedFilesCard paths={attachmentInfo.paths} />}
{(attachmentInfo.message || richHtml) && (
<div
className="max-w-[80%] w-fit rounded-2xl rounded-br-sm px-3 py-2 text-sm leading-6 break-words chat-message-font"

View File

@ -11,7 +11,6 @@ import {
useRef,
useState,
} from "react";
import { motion, LayoutGroup } from "framer-motion";
import {
Mail, Users, DollarSign, Calendar, Zap, FileText, Database,
Code, Bug, Clock, BarChart3, PenTool, Globe, Search, Sparkles,
@ -813,6 +812,8 @@ type ChatPanelProps = {
subagentLabel?: string;
/** Back button handler (subagent mode only). */
onBack?: () => void;
/** Hide the header action buttons (when they're rendered elsewhere, e.g. tab bar). */
hideHeaderActions?: boolean;
};
export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
@ -834,6 +835,7 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
subagentTask,
subagentLabel,
onBack,
hideHeaderActions,
},
ref,
) {
@ -853,6 +855,9 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
const [showFilePicker, setShowFilePicker] =
useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => { setMounted(true); }, []);
// ── Reconnection state ──
const [isReconnecting, setIsReconnecting] = useState(false);
const reconnectAbortRef = useRef<AbortController | null>(null);
@ -881,10 +886,13 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
const [rawView, _setRawView] = useState(false);
// ── Hero state (new chat screen) ──
const [greeting, setGreeting] = useState("");
const [visiblePrompts, setVisiblePrompts] = useState<typeof PROMPT_SUGGESTIONS>([]);
const [greeting, setGreeting] = useState("How can I help?");
const [visiblePrompts, setVisiblePrompts] = useState(PROMPT_SUGGESTIONS.slice(0, 7));
const heroInitRef = useRef(false);
useEffect(() => {
if (heroInitRef.current) return;
heroInitRef.current = true;
const greetings = [
"Ready to build?",
"Let's automate something?",
@ -901,9 +909,6 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
};
const allGreetings = [getTimeGreeting(), ...greetings];
setGreeting(allGreetings[Math.floor(Math.random() * allGreetings.length)]);
}, []);
useEffect(() => {
const shuffled = [...PROMPT_SUGGESTIONS].sort(() => 0.5 - Math.random());
setVisiblePrompts(shuffled.slice(0, 7));
}, []);
@ -1683,7 +1688,10 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
`/api/web-sessions/${sessionId}`,
);
if (!response.ok) {
throw new Error("Failed to load session");
console.warn(`Session ${sessionId} not found (${response.status}), starting fresh.`);
setMessages([]);
setLoadingSession(false);
return;
}
const data = await response.json();
@ -2059,7 +2067,7 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
)}
</div>
<div className="flex items-center gap-1.5">
{isStreaming && (
{isStreaming ? (
<button
type="button"
onClick={() => handleStop()}
@ -2071,25 +2079,6 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
<rect width="10" height="10" rx="1.5" />
</svg>
</button>
)}
{isStreaming ? (
<button
type="button"
onClick={() => editorRef.current?.submit()}
disabled={(editorEmpty && attachedFiles.length === 0) || loadingSession}
className="h-7 px-3 rounded-full flex items-center gap-1.5 text-[12px] font-medium disabled:opacity-40 disabled:cursor-not-allowed"
style={{
background: !editorEmpty || attachedFiles.length > 0 ? "var(--color-accent)" : "var(--color-surface-hover)",
color: !editorEmpty || attachedFiles.length > 0 ? "white" : "var(--color-text-muted)",
}}
title="Add to queue"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 10 4 15 9 20" />
<path d="M20 4v7a4 4 0 0 1-4 4H4" />
</svg>
Queue
</button>
) : (
<button
type="button"
@ -2116,7 +2105,7 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
const inputBarContainer = (onDragOverHandler: React.DragEventHandler, onDragLeaveHandler: React.DragEventHandler, onDropHandler: React.DragEventHandler) => (
<div
data-chat-drop-target=""
className="rounded-3xl overflow-hidden border shadow-[0_0_32px_rgba(0,0,0,0.07)] transition-[outline,box-shadow] duration-150 ease-out data-drag-hover:outline-2 data-drag-hover:outline-dashed data-drag-hover:outline-(--color-accent) data-drag-hover:-outline-offset-2 data-drag-hover:shadow-[0_0_0_4px_color-mix(in_srgb,var(--color-accent)_15%,transparent),0_0_32px_rgba(0,0,0,0.07)]!"
className={`${compact ? "rounded-2xl" : "rounded-3xl"} overflow-hidden border shadow-[0_0_32px_rgba(0,0,0,0.07)] transition-[outline,box-shadow,border-color] duration-150 ease-out focus-within:border-[var(--color-border-strong)]! data-drag-hover:outline-2 data-drag-hover:outline-dashed data-drag-hover:outline-(--color-accent) data-drag-hover:-outline-offset-2 data-drag-hover:shadow-[0_0_0_4px_color-mix(in_srgb,var(--color-accent)_15%,transparent),0_0_32px_rgba(0,0,0,0.07)]!`}
style={{
background: "var(--color-surface)",
borderColor: "var(--color-border)",
@ -2169,7 +2158,6 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
// ── Render ──
return (
<LayoutGroup>
<div
className="h-full flex flex-col"
style={{ background: "var(--color-main-bg)" }}
@ -2177,9 +2165,6 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
{/* Header — sticky glass bar */}
<header
className={`${compact ? "px-3 py-2" : "px-3 py-2 md:px-6 md:py-3"} flex items-center ${isSubagentMode ? "gap-3" : "justify-between"} z-20`}
style={{
background: "var(--color-bg-glass)",
}}
>
{isSubagentMode ? (
<>
@ -2216,19 +2201,18 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
>
Chat: {fileContext.filename}
</h2>
) : (
) : currentSessionId ? (
<h2
className="text-sm font-semibold"
style={{
color: "var(--color-text)",
}}
>
{currentSessionId
? (sessionTitle || "Chat Session")
: "New Chat"}
{sessionTitle || "Chat Session"}
</h2>
)}
) : null}
</div>
{!hideHeaderActions && (
<div className="flex items-center gap-1 shrink-0">
{currentSessionId && onDeleteSession && (
<DropdownMenu>
@ -2259,13 +2243,12 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
onSelect={() => onDeleteSession(currentSessionId)}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" /><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" /></svg>
Delete
Delete this chat
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{compact && (
<button
<button
type="button"
onClick={() => handleNewSession()}
className="p-1.5 rounded-lg"
@ -2288,8 +2271,8 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
<path d="M5 12h14" />
</svg>
</button>
)}
</div>
)}
</>
)}
</header>
@ -2337,6 +2320,7 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
<div
ref={scrollContainerRef}
className="flex-1 overflow-y-auto min-h-0"
style={{ scrollbarGutter: "stable" }}
>
{/* Messages */}
<div
@ -2360,66 +2344,27 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
</p>
</div>
</div>
) : (showHeroState && !mounted) ? (
<div className="flex items-center justify-center h-full min-h-[60vh]" />
) : showHeroState ? (
<div className="flex flex-col items-center justify-center min-h-[75vh] py-12">
{/* Hero greeting */}
{greeting && (
<motion.h1
<h1
className="text-4xl md:text-5xl font-light tracking-normal font-instrument mb-10 text-center"
style={{ color: "var(--color-text)" }}
initial="hidden"
animate="visible"
variants={{
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: 0.12, delayChildren: 0.2 },
},
}}
>
{greeting.split(" ").map((word, i) => (
<motion.span
key={i}
className="inline-block mr-2"
variants={{
hidden: { opacity: 0, y: 20, filter: "blur(8px)" },
visible: {
opacity: 1,
y: 0,
filter: "blur(0px)",
transition: { duration: 0.8, ease: [0.2, 0.65, 0.3, 0.9] },
},
}}
>
{word}
</motion.span>
))}
</motion.h1>
{greeting}
</h1>
)}
{/* Centered input bar */}
<motion.div
className="w-full max-w-[720px] mx-auto px-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.8, ease: [0.22, 1, 0.36, 1] }}
>
<motion.div
layout
layoutId="chat-input-bar"
transition={{ type: "spring", stiffness: 260, damping: 30 }}
>
{inputBarContainer(handleInputDragOver, handleInputDragLeave, handleInputDrop)}
</motion.div>
</motion.div>
<div className="w-full max-w-[720px] mx-auto px-4">
{inputBarContainer(handleInputDragOver, handleInputDragLeave, handleInputDrop)}
</div>
{/* Prompt suggestion pills */}
<motion.div
className="mt-6 flex flex-col gap-2.5 w-full max-w-[720px] mx-auto px-4"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 1.0, ease: [0.22, 1, 0.36, 1] }}
>
<div className="mt-6 flex flex-col gap-2.5 w-full max-w-[720px] mx-auto px-4">
<div className="flex items-center justify-center gap-2 flex-wrap">
{visiblePrompts.slice(0, 3).map((template) => {
const Icon = template.icon;
@ -2462,7 +2407,7 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
);
})}
</div>
</motion.div>
</div>
</div>
) : messages.length === 0 ? (
<div className="flex items-center justify-center h-full min-h-[60vh]">
@ -2560,13 +2505,7 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
style={{ background: "var(--color-bg-glass)" }}
>
<div className={compact ? "" : "max-w-[720px] mx-auto"}>
<motion.div
layout
layoutId="chat-input-bar"
transition={{ type: "spring", stiffness: 260, damping: 30 }}
>
{inputBarContainer(handleInputDragOver, handleInputDragLeave, handleInputDrop)}
</motion.div>
{inputBarContainer(handleInputDragOver, handleInputDragLeave, handleInputDrop)}
</div>
</div>
)}
@ -2583,7 +2522,6 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
)}
</div>
</LayoutGroup>
);
},
);

View File

@ -83,7 +83,7 @@ export const FileMentionNode = Node.create({
},
HTMLAttributes,
),
`@${label}`,
label,
];
},
});

View File

@ -0,0 +1,47 @@
"use client";
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 outline-none ring-0 border-none",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline"
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9"
}
},
defaultVariants: {
variant: "default",
size: "default"
}
}
);
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
}
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@ -0,0 +1,36 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("rounded-xl border bg-card text-card-foreground shadow", className)} {...props} />
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => <h3 ref={ref} className={cn("font-semibold leading-none tracking-tight", className)} {...props} />
);
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => <p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
);
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
));
CardFooter.displayName = "CardFooter";
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };

View File

@ -49,7 +49,7 @@ function DropdownMenuContent({
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-[100] outline-none"
className="isolate z-[10000] outline-none"
align={align}
alignOffset={alignOffset}
side={side}
@ -58,7 +58,7 @@ function DropdownMenuContent({
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn(
"bg-neutral-100/[0.67] border border-white backdrop-blur-md text-[var(--color-text)] z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-3xl p-1 shadow-[0_0_25px_0_rgba(0,0,0,0.16)] outline-none",
"bg-neutral-100/[0.67] dark:bg-neutral-900/[0.67] border border-white dark:border-white/10 backdrop-blur-md text-[var(--color-text)] z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-3xl p-1 shadow-[0_0_25px_0_rgba(0,0,0,0.16)] outline-none",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}

View File

@ -0,0 +1,21 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
);
});
Input.displayName = "Input";
export { Input };

View File

@ -0,0 +1,18 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const labelVariants = cva("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70");
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View File

@ -0,0 +1,28 @@
"use client";
import * as React from "react";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { cn } from "@/lib/utils";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };

View File

@ -9,7 +9,7 @@ import {
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
type WebSession = {
export type WebSession = {
id: string;
title: string;
createdAt: number;
@ -55,6 +55,8 @@ type ChatSessionsSidebarProps = {
onCollapse?: () => void;
/** When true, show a loader instead of empty state (e.g. initial sessions fetch). */
loading?: boolean;
/** When true, renders just the content without the aside wrapper (for embedding in another sidebar). */
embedded?: boolean;
};
/** Format a timestamp into a human-readable relative time string. */
@ -164,6 +166,7 @@ export function ChatSessionsSidebar({
onClose,
width: widthProp,
loading = false,
embedded = false,
}: ChatSessionsSidebarProps) {
const [hoveredId, setHoveredId] = useState<string | null>(null);
const [renamingId, setRenamingId] = useState<string | null>(null);
@ -229,24 +232,13 @@ export function ChatSessionsSidebar({
const grouped = groupSessions(filteredSessions);
const width = mobile ? "280px" : (widthProp ?? 260);
const headerHeight = 40; // px — match padding so list content clears the overlay
const sidebar = (
<aside
className={`flex flex-col h-full shrink-0 ${mobile ? "drawer-right" : "border-l"}`}
style={{
width: typeof width === "number" ? `${width}px` : width,
minWidth: typeof width === "number" ? `${width}px` : width,
borderColor: "var(--color-border)",
background: "var(--color-sidebar-bg)",
}}
>
{/* Scrollable list fills the sidebar; header overlays the top with blur */}
<div className="flex-1 min-h-0 relative">
{/* Session list — scrolls under the header */}
<div
className="absolute inset-0 overflow-y-auto"
style={{ paddingTop: headerHeight }}
>
const headerHeight = embedded ? 36 : 40;
const content = (
<div className="flex-1 min-h-0 relative">
<div
className="absolute inset-0 overflow-y-auto"
style={{ paddingTop: headerHeight }}
>
{loading && sessions.length === 0 ? (
<div className="px-4 py-8 flex flex-col items-center justify-center min-h-[120px]">
<UnicodeSpinner
@ -460,11 +452,12 @@ export function ChatSessionsSidebar({
</div>
{/* Header overlay: backdrop blur + 80% bg; list scrolls under it */}
<div
className="absolute top-0 left-0 right-0 z-10 flex items-center justify-between border-b px-4 py-2 backdrop-blur-md"
className={`absolute top-0 left-0 right-0 z-10 flex items-center justify-between px-4 backdrop-blur-md ${embedded ? "" : "border-b"}`}
style={{
height: headerHeight,
borderColor: "var(--color-border)",
background: "color-mix(in srgb, var(--color-sidebar-bg) 80%, transparent)",
borderColor: embedded ? undefined : "var(--color-border)",
background: "var(--color-sidebar-bg)",
boxShadow: embedded ? "inset 0 -1px 0 0 var(--color-border)" : undefined,
}}
>
<div className="min-w-0 flex-1 flex items-center gap-1.5">
@ -492,10 +485,10 @@ export function ChatSessionsSidebar({
<button
type="button"
onClick={onNewSession}
className="flex items-center gap-1 px-2 py-1 rounded-md text-[11px] font-medium transition-colors cursor-pointer shrink-0 ml-1.5"
className={`flex items-center gap-1 px-2 py-1 rounded-full text-[11px] font-medium transition-all cursor-pointer shrink-0 ml-1.5 ${embedded ? "hover:bg-neutral-400/15" : ""}`}
style={{
color: "var(--color-chat-sidebar-active-text)",
background: "var(--color-chat-sidebar-active-bg)",
color: embedded ? "var(--color-text)" : "var(--color-chat-sidebar-active-text)",
background: embedded ? "transparent" : "var(--color-chat-sidebar-active-bg)",
}}
title="New chat"
>
@ -504,6 +497,23 @@ export function ChatSessionsSidebar({
</button>
</div>
</div>
);
if (embedded) {
return content;
}
const sidebar = (
<aside
className={`flex flex-col h-full shrink-0 ${mobile ? "drawer-right" : "border-l"}`}
style={{
width: typeof width === "number" ? `${width}px` : width,
minWidth: typeof width === "number" ? `${width}px` : width,
borderColor: "var(--color-border)",
background: "var(--color-sidebar-bg)",
}}
>
{content}
</aside>
);

View File

@ -160,14 +160,10 @@ export function ContextMenu({ x, y, target, onAction, onClose }: ContextMenuProp
return createPortal(
<div
ref={menuRef}
className="fixed z-[9999] min-w-[200px] py-1 rounded-lg shadow-xl border"
className="fixed z-[9999] min-w-[200px] p-1 rounded-2xl bg-neutral-100/[0.67] dark:bg-neutral-900/[0.67] border border-white dark:border-white/10 backdrop-blur-md shadow-[0_0_25px_0_rgba(0,0,0,0.16)]"
style={{
left: x,
top: y,
background: "var(--color-surface)",
borderColor: "var(--color-border)",
backdropFilter: "blur(20px)",
WebkitBackdropFilter: "blur(20px)",
animation: "contextMenuFadeIn 100ms ease-out",
}}
role="menu"
@ -188,8 +184,7 @@ export function ContextMenu({ x, y, target, onAction, onClose }: ContextMenuProp
return (
<div
key={`sep-${i}`}
className="my-1 mx-2 border-t"
style={{ borderColor: "var(--color-border)" }}
className="my-0.5 mx-1 h-px bg-neutral-400/15"
/>
);
}
@ -203,25 +198,13 @@ export function ContextMenu({ x, y, target, onAction, onClose }: ContextMenuProp
type="button"
role="menuitem"
disabled={isDisabled}
className="w-full flex items-center gap-2 px-3 py-1.5 text-[13px] text-left transition-colors"
className={`w-full flex items-center gap-2 px-2.5 py-1.5 text-[13px] text-left rounded-xl transition-all ${isDisabled ? "opacity-50" : "hover:bg-neutral-400/15"}`}
style={{
color: isDisabled
? "var(--color-text-muted)"
: menuItem.danger
? "#ef4444"
? "var(--color-error)"
: "var(--color-text)",
opacity: isDisabled ? 0.5 : 1,
cursor: isDisabled ? "default" : "pointer",
}}
onMouseEnter={(e) => {
if (!isDisabled) {
(e.currentTarget as HTMLElement).style.background = menuItem.danger
? "rgba(239, 68, 68, 0.1)"
: "var(--color-surface-hover)";
}
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.background = "transparent";
}}
onClick={() => handleItemClick(menuItem.action, isDisabled)}
>

View File

@ -32,6 +32,15 @@ import {
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { rankItem } from "@tanstack/match-sorter-utils";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuCheckboxItem,
} from "../ui/dropdown-menu";
import { cn } from "@/lib/utils";
/* ─── Types ─── */
@ -197,11 +206,9 @@ export function DataTable<TData, TValue>({
setColumnVisibility(initialColumnVisibility ?? {});
}, [initialColumnVisibility]);
const [internalRowSelection, setInternalRowSelection] = useState<Record<string, boolean>>({});
const [showColumnsMenu, setShowColumnsMenu] = useState(false);
const [stickyFirstColumn, setStickyFirstColumn] = useState(stickyFirstProp);
const [isScrolled, setIsScrolled] = useState(false);
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: defaultPageSize });
const columnsMenuRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const rowSelectionState = externalRowSelection !== undefined ? externalRowSelection : internalRowSelection;
@ -262,19 +269,6 @@ export function DataTable<TData, TValue>({
setIsScrolled(e.currentTarget.scrollLeft > 0);
}, []);
// Close columns menu on click outside
useEffect(() => {
function handleClick(e: MouseEvent) {
if (columnsMenuRef.current && !columnsMenuRef.current.contains(e.target as Node)) {
setShowColumnsMenu(false);
}
}
if (showColumnsMenu) {
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}
}, [showColumnsMenu]);
// Build selection column
const selectionColumn: ColumnDef<TData> | null = enableRowSelection
? {
@ -391,16 +385,16 @@ export function DataTable<TData, TValue>({
// ─── Render ───
return (
<div className="flex flex-col h-full">
<div className="w-full h-full flex flex-col overflow-hidden" style={{ overscrollBehavior: "contain" }}>
{/* Toolbar */}
<div
className="flex items-center gap-2 px-4 py-2.5 flex-shrink-0 flex-wrap"
style={{ borderBottom: "1px solid var(--color-border)" }}
className="flex items-center gap-3 px-3 py-2 shrink-0 flex-wrap backdrop-blur-md"
style={{ background: "var(--color-glass)", borderBottom: "1px solid var(--color-border)" }}
>
{title && (
<div className="flex items-center gap-2 mr-2">
<div className="flex items-center gap-2 mr-1">
{titleIcon}
<span className="text-sm font-semibold" style={{ color: "var(--color-text)" }}>
<span className="text-xs font-semibold" style={{ color: "var(--color-text)" }}>
{title}
</span>
</div>
@ -408,8 +402,11 @@ export function DataTable<TData, TValue>({
{/* Search */}
{enableGlobalFilter && (
<div className="relative flex-1 min-w-[180px] max-w-[320px]">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="absolute left-3 top-1/2 -translate-y-1/2" style={{ color: "var(--color-text-muted)" }}>
<div
className="flex items-center gap-2 h-8 px-3 backdrop-blur-sm rounded-full focus-within:ring-2 focus-within:ring-(--color-accent)/30 transition-shadow max-w-[260px] min-w-[140px] shadow-[0_0_21px_0_rgba(0,0,0,0.05)]"
style={{ border: "1px solid var(--color-border)", background: "var(--color-surface)" }}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="shrink-0" style={{ color: "var(--color-text-muted)", opacity: 0.5 }}>
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" />
</svg>
<input
@ -420,21 +417,17 @@ export function DataTable<TData, TValue>({
onServerSearch?.(e.target.value);
}}
placeholder={searchPlaceholder}
className="w-full pl-9 pr-3 py-1.5 text-xs rounded-full outline-none"
style={{
background: "var(--color-surface-hover)",
color: "var(--color-text)",
border: "1px solid var(--color-border)",
}}
className="w-full h-full text-xs bg-transparent outline-none border-0 p-0"
style={{ color: "var(--color-text)" }}
/>
{globalFilter && (
<button
type="button"
onClick={() => { setGlobalFilter(""); onServerSearch?.(""); }}
className="absolute right-2.5 top-1/2 -translate-y-1/2"
className="shrink-0 h-5 w-5 rounded-full flex items-center justify-center cursor-pointer transition-colors"
style={{ color: "var(--color-text-muted)" }}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 6 6 18" /><path d="m6 6 12 12" /></svg>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 6 6 18" /><path d="m6 6 12 12" /></svg>
</button>
)}
</div>
@ -454,98 +447,66 @@ export function DataTable<TData, TValue>({
{toolbarExtra}
{/* Refresh */}
{/* Columns menu */}
<DropdownMenu>
<DropdownMenuTrigger
className="h-8 px-3 flex items-center gap-1.5 rounded-full text-xs cursor-pointer transition-colors backdrop-blur-sm shadow-[0_0_21px_0_rgba(0,0,0,0.05)] outline-none focus:outline-none"
style={{ color: "var(--color-text-muted)", border: "1px solid var(--color-border)", background: "var(--color-surface)" }}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect width="18" height="18" x="3" y="3" rx="2" /><path d="M9 3v18" /><path d="M15 3v18" />
</svg>
Columns
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={6}>
<DropdownMenuCheckboxItem
checked={stickyFirstColumn}
onSelect={() => setStickyFirstColumn((v) => !v)}
>
Freeze first column
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
{visibleColumns.length === 0 ? (
<div className="px-2 py-1.5 text-xs opacity-50">No toggleable columns</div>
) : (
table.getAllLeafColumns()
.filter((c) => c.id !== "select" && c.id !== "actions" && c.getCanHide())
.map((column) => (
<DropdownMenuCheckboxItem
key={column.id}
checked={column.getIsVisible()}
onSelect={() => column.toggleVisibility(!column.getIsVisible())}
>
{typeof column.columnDef.header === "string"
? column.columnDef.header
: String((column.columnDef.meta as Record<string, string> | undefined)?.label ?? column.id)}
</DropdownMenuCheckboxItem>
))
)}
</DropdownMenuContent>
</DropdownMenu>
{/* Refresh button */}
{onRefresh && (
<button
type="button"
onClick={onRefresh}
className="p-1.5 rounded-lg"
style={{ color: "var(--color-text-muted)" }}
title="Refresh"
className="h-8 w-8 rounded-full flex items-center justify-center cursor-pointer transition-colors backdrop-blur-sm shadow-[0_0_21px_0_rgba(0,0,0,0.05)]"
style={{ border: "1px solid var(--color-border)", background: "var(--color-surface)", color: "var(--color-text-muted)" }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" /><path d="M21 3v5h-5" /><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" /><path d="M3 21v-5h5" />
</svg>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" /><path d="M3 3v5h5" /><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" /><path d="M16 21h5v-5" /></svg>
</button>
)}
{/* Columns menu */}
<div className="relative" ref={columnsMenuRef}>
<button
type="button"
onClick={() => setShowColumnsMenu((v) => !v)}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium"
style={{
color: "var(--color-text-muted)",
border: "1px solid var(--color-border)",
background: showColumnsMenu ? "var(--color-surface-hover)" : "transparent",
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect width="18" height="18" x="3" y="3" rx="2" /><path d="M9 3v18" /><path d="M15 3v18" />
</svg>
Columns
</button>
{showColumnsMenu && (
<div
className="absolute right-0 top-full mt-1 z-50 min-w-[200px] rounded-xl overflow-hidden py-1"
style={{
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
boxShadow: "var(--shadow-lg)",
}}
>
{/* Sticky first col toggle */}
<label
className="flex items-center gap-2 px-3 py-2 text-xs cursor-pointer"
style={{ color: "var(--color-text-muted)", borderBottom: "1px solid var(--color-border)" }}
>
<input
type="checkbox"
checked={stickyFirstColumn}
onChange={() => setStickyFirstColumn((v) => !v)}
className="w-3.5 h-3.5 rounded accent-[var(--color-accent)]"
/>
Freeze first column
</label>
{visibleColumns.length === 0 ? (
<div className="px-3 py-2 text-xs" style={{ color: "var(--color-text-muted)" }}>
No toggleable columns
</div>
) : (
table.getAllLeafColumns()
.filter((c) => c.id !== "select" && c.id !== "actions" && c.getCanHide())
.map((column) => (
<label
key={column.id}
className="flex items-center gap-2 px-3 py-1.5 text-xs cursor-pointer"
style={{ color: "var(--color-text)" }}
>
<input
type="checkbox"
checked={column.getIsVisible()}
onChange={column.getToggleVisibilityHandler()}
className="w-3.5 h-3.5 rounded accent-[var(--color-accent)]"
/>
{typeof column.columnDef.header === "string"
? column.columnDef.header
: String((column.columnDef.meta as Record<string, string> | undefined)?.label ?? column.id)}
</label>
))
)}
</div>
)}
</div>
{/* Add button */}
{onAdd && (
<button
type="button"
onClick={onAdd}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium"
className="h-8 px-3 flex items-center gap-1.5 rounded-full text-xs font-medium cursor-pointer transition-colors shadow-[0_0_21px_0_rgba(0,0,0,0.05)]"
style={{
background: "var(--color-accent)",
color: "white",
color: "#fff",
}}
>
{addButtonLabel}
@ -556,24 +517,44 @@ export function DataTable<TData, TValue>({
{/* Table */}
<div
ref={scrollContainerRef}
className="flex-1 overflow-auto min-w-0"
className="overflow-auto flex-1 min-h-0 max-h-full relative"
onScroll={handleScroll}
style={{ overscrollBehavior: "contain" }}
>
{loading ? (
<LoadingSkeleton columnCount={allColumns.length} />
) : data.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 gap-3">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ color: "var(--color-text-muted)", opacity: 0.4 }}>
<rect width="18" height="18" x="3" y="3" rx="2" /><path d="M3 9h18" /><path d="M3 15h18" /><path d="M9 3v18" />
</svg>
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>No data</p>
<div className="flex flex-col items-center justify-center py-24 gap-4">
<div
className="rounded-full p-4 mb-2 backdrop-blur-sm"
style={{ background: "var(--color-glass)", border: "1px solid var(--color-border)", boxShadow: "var(--shadow-sm)" }}
>
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ color: "var(--color-text-muted)" }}>
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" />
</svg>
</div>
<div className="text-center">
<h3 className="text-base font-semibold mb-1" style={{ color: "var(--color-text)" }}>No results found</h3>
<p className="text-sm max-w-xs" style={{ color: "var(--color-text-muted)" }}>
{globalFilter
? "Try adjusting your search or filter criteria."
: "No data available yet. Create your first entry to get started."}
</p>
</div>
</div>
) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<table className="w-full text-sm" style={{ borderCollapse: "separate", borderSpacing: 0 }}>
<thead>
<table
className="w-full caption-bottom text-sm"
style={{ tableLayout: "fixed", minWidth: table.getCenterTotalSize() }}
>
<thead className="[&_tr]:border-b sticky top-0 z-30" style={{ background: "var(--color-surface)" }}>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
<tr
key={headerGroup.id}
style={{ borderColor: "var(--color-border)" }}
className="border-b-2 backdrop-blur-sm"
>
<SortableContext items={columnOrder} strategy={horizontalListSortingStrategy}>
{headerGroup.headers.map((header, colIdx) => {
const isFirstData = colIdx === (enableRowSelection ? 1 : 0);
@ -581,14 +562,18 @@ export function DataTable<TData, TValue>({
const isSelectCol = header.id === "select";
const isActionsCol = header.id === "actions";
const canSort = header.column.getCanSort();
const isSorted = header.column.getIsSorted();
const isLastCol = colIdx === headerGroup.headers.length - 1;
const headerStyle: React.CSSProperties = {
borderColor: "var(--color-border)",
background: "var(--color-surface)",
position: "sticky",
top: 0,
zIndex: isSticky || isSelectCol ? 31 : 30,
...(isSticky ? { left: enableRowSelection ? 40 : 0, boxShadow: isScrolled ? "4px 0 8px -2px rgba(0,0,0,0.08)" : "none" } : {}),
...(isSticky ? {
left: enableRowSelection ? 40 : 0,
boxShadow: isScrolled ? "4px 0 12px -2px rgba(0,0,0,0.15), 2px 0 4px -1px rgba(0,0,0,0.08)" : "none",
} : {}),
...(isSelectCol ? { left: 0, position: "sticky", zIndex: 31, width: 40 } : {}),
width: header.getSize(),
};
@ -597,21 +582,37 @@ export function DataTable<TData, TValue>({
? null
: flexRender(header.column.columnDef.header, header.getContext());
const thClassName = cn(
"h-11 text-left align-middle font-medium text-xs uppercase tracking-wider whitespace-nowrap p-0 group select-none relative box-border",
!isLastCol && "border-r",
isSticky && isScrolled && "border-r-2!",
);
const innerClassName = cn(
"flex items-center gap-1 h-full px-4 transition-colors",
canSort && "cursor-pointer hover:bg-[var(--color-surface-hover)]",
isSorted && "bg-[var(--color-surface-hover)]",
);
if (enableColumnReordering && !isSelectCol && !isActionsCol) {
return (
<SortableHeader
key={header.id}
id={header.id}
style={headerStyle}
className="text-left px-3 py-2.5 font-medium text-xs uppercase tracking-wider whitespace-nowrap border-b select-none"
className={thClassName}
>
<span
className={`flex items-center gap-1 ${canSort ? "cursor-pointer" : ""}`}
className={innerClassName}
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
style={{ color: "var(--color-text-muted)" }}
>
{content}
{canSort && <SortIcon direction={header.column.getIsSorted()} />}
{canSort && <SortIcon direction={isSorted} />}
</span>
{isSticky && isScrolled && (
<div className="absolute top-0 right-0 bottom-0 w-4 translate-x-full pointer-events-none bg-linear-to-r from-black/4 to-transparent z-100" />
)}
</SortableHeader>
);
}
@ -619,17 +620,23 @@ export function DataTable<TData, TValue>({
return (
<th
key={header.id}
style={headerStyle}
className="text-left px-3 py-2.5 font-medium text-xs uppercase tracking-wider whitespace-nowrap border-b select-none"
style={{
...headerStyle,
borderColor: "var(--color-border)",
}}
className={thClassName}
>
<span
className={`flex items-center gap-1 ${canSort ? "cursor-pointer" : ""}`}
className={innerClassName}
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
style={{ color: "var(--color-text-muted)" }}
>
{content}
{canSort && <SortIcon direction={header.column.getIsSorted()} />}
{canSort && <SortIcon direction={isSorted} />}
</span>
{isSticky && isScrolled && (
<div className="absolute top-0 right-0 bottom-0 w-4 translate-x-full pointer-events-none bg-linear-to-r from-black/4 to-transparent z-100" />
)}
</th>
);
})}
@ -637,19 +644,26 @@ export function DataTable<TData, TValue>({
</tr>
))}
</thead>
<tbody>
<tbody className="[&_tr:last-child]:border-0">
{table.getRowModel().rows.map((row, rowIdx) => {
const isSelected = row.getIsSelected();
const visibleCells = row.getVisibleCells();
return (
<tr
key={row.id}
className={`transition-colors duration-75 ${onRowClick ? "cursor-pointer" : ""}`}
data-state={isSelected ? "selected" : undefined}
className={cn(
"border-b transition-all duration-150 group/row relative",
onRowClick && "cursor-pointer",
isSelected && "data-[state=selected]:bg-(--color-accent-light)",
)}
style={{
borderColor: "var(--color-border)",
background: isSelected
? "var(--color-accent-light)"
: rowIdx % 2 === 0
? "transparent"
: "var(--color-surface)",
? "var(--color-surface)"
: "var(--color-bg)",
}}
onClick={() => onRowClick?.(row.original, rowIdx)}
onMouseEnter={(e) => {
@ -659,13 +673,20 @@ export function DataTable<TData, TValue>({
onMouseLeave={(e) => {
if (!isSelected)
{(e.currentTarget as HTMLElement).style.background =
rowIdx % 2 === 0 ? "transparent" : "var(--color-surface)";}
rowIdx % 2 === 0 ? "var(--color-surface)" : "var(--color-bg)";}
}}
>
{row.getVisibleCells().map((cell, colIdx) => {
{visibleCells.map((cell, colIdx) => {
const isFirstData = colIdx === (enableRowSelection ? 1 : 0);
const isSticky = stickyFirstColumn && isFirstData;
const isSelectCol = cell.column.id === "select";
const isLastCol = colIdx === visibleCells.length - 1;
const rowBg = isSelected
? "var(--color-accent-light)"
: rowIdx % 2 === 0
? "var(--color-surface)"
: "var(--color-bg)";
const cellStyle: React.CSSProperties = {
borderColor: "var(--color-border)",
@ -674,10 +695,8 @@ export function DataTable<TData, TValue>({
position: "sticky" as const,
left: enableRowSelection ? 40 : 0,
zIndex: 20,
background: isSelected
? "var(--color-accent-light)"
: "var(--color-bg)",
boxShadow: isScrolled ? "4px 0 8px -2px rgba(0,0,0,0.08)" : "none",
background: rowBg,
boxShadow: isScrolled ? "4px 0 12px -2px rgba(0,0,0,0.12), 2px 0 4px -1px rgba(0,0,0,0.06)" : "none",
}
: {}),
...(isSelectCol
@ -685,9 +704,7 @@ export function DataTable<TData, TValue>({
position: "sticky" as const,
left: 0,
zIndex: 20,
background: isSelected
? "var(--color-accent-light)"
: "var(--color-bg)",
background: rowBg,
width: 40,
}
: {}),
@ -696,10 +713,19 @@ export function DataTable<TData, TValue>({
return (
<td
key={cell.id}
className="px-3 py-2 border-b whitespace-nowrap"
className={cn(
"px-3 py-1.5 align-middle whitespace-nowrap text-xs border-b transition-colors box-border",
!isLastCol && "border-r",
isSticky && isScrolled && "border-r-2!",
)}
style={cellStyle}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
<div className="overflow-hidden">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</div>
{isSticky && isScrolled && (
<div className="absolute top-0 right-0 bottom-0 w-4 translate-x-full pointer-events-none bg-linear-to-r from-black/4 to-transparent z-100" />
)}
</td>
);
})}
@ -715,45 +741,48 @@ export function DataTable<TData, TValue>({
{/* Pagination footer */}
{!loading && data.length > 0 && (
<div
className="flex items-center justify-between px-4 py-2 text-xs flex-shrink-0"
className="flex items-center justify-between px-3 py-2 text-xs shrink-0 backdrop-blur-xl"
style={{
borderTop: "1px solid var(--color-border)",
color: "var(--color-text-muted)",
background: "var(--color-glass)",
}}
>
<span>
<span className="text-xs font-medium">
{serverPagination
? `Showing ${(serverPagination.page - 1) * serverPagination.pageSize + 1}${Math.min(serverPagination.page * serverPagination.pageSize, serverPagination.totalCount)} of ${serverPagination.totalCount} results`
: `Showing ${table.getRowModel().rows.length} of ${data.length} results`}
{selectedCount > 0 && ` (${selectedCount} selected)`}
</span>
<div className="flex items-center gap-2">
<span>Rows per page</span>
<select
value={serverPagination ? serverPagination.pageSize : pagination.pageSize}
onChange={(e) => {
const newSize = Number(e.target.value);
if (serverPagination) {
serverPagination.onPageSizeChange(newSize);
} else {
setPagination((p) => ({ ...p, pageSize: newSize, pageIndex: 0 }));
}
}}
className="px-1.5 py-0.5 rounded-md text-xs outline-none"
style={{
background: "var(--color-surface-hover)",
color: "var(--color-text)",
border: "1px solid var(--color-border)",
}}
>
{[20, 50, 100, 250, 500].map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
<span>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<span className="text-xs font-medium">Rows per page</span>
<select
value={serverPagination ? serverPagination.pageSize : pagination.pageSize}
onChange={(e) => {
const newSize = Number(e.target.value);
if (serverPagination) {
serverPagination.onPageSizeChange(newSize);
} else {
setPagination((p) => ({ ...p, pageSize: newSize, pageIndex: 0 }));
}
}}
className="h-7 px-2 py-0 rounded-full text-xs outline-none shadow-[0_0_21px_0_rgba(0,0,0,0.05)] transition-colors"
style={{
background: "var(--color-surface)",
color: "var(--color-text)",
border: "1px solid var(--color-border)",
}}
>
{[20, 50, 100, 250, 500].map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</div>
<span className="text-xs font-medium min-w-[80px] text-center">
Page {serverPagination ? serverPagination.page : pagination.pageIndex + 1} of {table.getPageCount()}
</span>
<div className="flex gap-0.5">
<div className="flex gap-1">
{serverPagination ? (
<>
<PaginationButton onClick={() => serverPagination.onPageChange(1)} disabled={serverPagination.page <= 1} label="&laquo;" />
@ -785,8 +814,8 @@ function PaginationButton({ onClick, disabled, label }: { onClick: () => void; d
type="button"
onClick={onClick}
disabled={disabled}
className="w-6 h-6 rounded flex items-center justify-center text-xs disabled:opacity-30"
style={{ color: "var(--color-text-muted)", border: "1px solid var(--color-border)" }}
className="h-7 w-7 rounded-full flex items-center justify-center text-xs disabled:opacity-30 cursor-pointer transition-colors backdrop-blur-sm shadow-[0_0_21px_0_rgba(0,0,0,0.05)]"
style={{ color: "var(--color-text-muted)", border: "1px solid var(--color-border)", background: "var(--color-surface)" }}
// biome-ignore lint: using html entity label
dangerouslySetInnerHTML={{ __html: label }}
/>
@ -794,75 +823,68 @@ function PaginationButton({ onClick, disabled, label }: { onClick: () => void; d
}
function RowActionsMenu<TData>({ row, actions }: { row: TData; actions: RowAction<TData>[] }) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
if (open) {
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}
}, [open]);
return (
<div className="relative" ref={ref}>
<button
type="button"
onClick={(e) => { e.stopPropagation(); setOpen((v) => !v); }}
className="p-1 rounded-md"
<DropdownMenu>
<DropdownMenuTrigger
className="p-1 rounded-md cursor-pointer"
style={{ color: "var(--color-text-muted)" }}
onClick={(e) => e.stopPropagation()}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="1.5" /><circle cx="12" cy="12" r="1.5" /><circle cx="12" cy="19" r="1.5" /></svg>
</button>
{open && (
<div
className="absolute right-0 top-full mt-1 z-50 min-w-[140px] rounded-xl overflow-hidden py-1"
style={{
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
boxShadow: "var(--shadow-lg)",
}}
>
{actions.map((action, i) => (
<button
key={i}
type="button"
onClick={(e) => {
e.stopPropagation();
action.onClick?.(row);
setOpen(false);
}}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left"
style={{
color: action.variant === "destructive" ? "var(--color-error)" : "var(--color-text)",
}}
>
{action.icon}
{action.label}
</button>
))}
</div>
)}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={4}>
{actions.map((action, i) => (
<DropdownMenuItem
key={i}
variant={action.variant === "destructive" ? "destructive" : "default"}
onSelect={() => action.onClick?.(row)}
>
{action.icon}
{action.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
function LoadingSkeleton({ columnCount }: { columnCount: number }) {
const widths = ["w-full", "w-3/4", "w-5/6", "w-2/3"];
return (
<div className="p-4 space-y-2">
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="flex gap-3">
<div className="w-full">
{/* Skeleton header */}
<div className="flex gap-0 border-b-2" style={{ borderColor: "var(--color-border)", background: "var(--color-surface)" }}>
{Array.from({ length: Math.min(columnCount, 6) }).map((_col, j) => (
<div key={j} className="flex-1 h-11 px-4 flex items-center" style={{ borderRight: j < Math.min(columnCount, 6) - 1 ? "1px solid var(--color-border)" : "none" }}>
<div
className="h-3 w-16 rounded animate-pulse"
style={{ background: "var(--color-surface-hover)", animationDelay: `${j * 50}ms`, animationDuration: "1.5s" }}
/>
</div>
))}
</div>
{/* Skeleton rows */}
{Array.from({ length: 15 }).map((_, i) => (
<div
key={i}
className="flex gap-0 border-b"
style={{ borderColor: "var(--color-border)" }}
>
{Array.from({ length: Math.min(columnCount, 6) }).map((_col, j) => (
<div
key={j}
className="h-8 rounded-lg animate-pulse flex-1"
style={{ background: "var(--color-surface-hover)", animationDelay: `${j * 50}ms` }}
/>
className="flex-1 px-3 py-2.5 flex items-center"
style={{ borderRight: j < Math.min(columnCount, 6) - 1 ? "1px solid var(--color-border)" : "none" }}
>
<div
className={cn("h-3.5 rounded animate-pulse", widths[(i + j) % widths.length])}
style={{
background: "var(--color-surface-hover)",
animationDelay: `${(i * 50) + (j * 30)}ms`,
animationDuration: "1.5s",
}}
/>
</div>
))}
</div>
))}

View File

@ -372,7 +372,7 @@ export function DatabaseViewer({ dbPath, filename }: DatabaseViewerProps) {
<div className="flex items-center justify-center h-full gap-3">
<div
className="w-5 h-5 border-2 rounded-full animate-spin"
style={{ borderColor: "var(--color-border)", borderTopColor: "var(--color-accent)" }}
style={{ borderRightColor: "var(--color-border)", borderBottomColor: "var(--color-border)", borderLeftColor: "var(--color-border)", borderTopColor: "var(--color-accent)" }}
/>
<span className="text-sm" style={{ color: "var(--color-text-muted)" }}>
Loading database...
@ -651,7 +651,7 @@ function TableDataPanel({
<div className="flex items-center justify-center h-32">
<div
className="w-5 h-5 border-2 rounded-full animate-spin"
style={{ borderColor: "var(--color-border)", borderTopColor: "var(--color-accent)" }}
style={{ borderRightColor: "var(--color-border)", borderBottomColor: "var(--color-border)", borderLeftColor: "var(--color-border)", borderTopColor: "var(--color-accent)" }}
/>
</div>
) : data.length === 0 ? (
@ -779,7 +779,7 @@ function QueryPanel({
{queryRunning ? (
<div
className="w-3.5 h-3.5 border-2 rounded-full animate-spin"
style={{ borderColor: "rgba(255,255,255,0.3)", borderTopColor: "white" }}
style={{ borderRightColor: "rgba(255,255,255,0.3)", borderBottomColor: "rgba(255,255,255,0.3)", borderLeftColor: "rgba(255,255,255,0.3)", borderTopColor: "white" }}
/>
) : (
<PlayIcon />

View File

@ -107,17 +107,39 @@ export function DocumentView({
// Intercept workspace-internal links in read mode (delegated click handler)
const handleLinkClick = useCallback(
(event: ReactMouseEvent<HTMLDivElement>) => {
if (!onNavigate) {return;}
const target = event.target as HTMLElement;
const link = target.closest("a");
if (!link) {return;}
const href = link.getAttribute("href");
if (!href) {return;}
if (isWorkspaceLink(href)) {
if (href.startsWith("#")) {
event.preventDefault();
event.stopPropagation();
const slug = href.slice(1);
const container = (event.currentTarget as HTMLElement);
const allHeadings = Array.from(container.querySelectorAll("h1, h2, h3, h4, h5, h6"));
const match = allHeadings.find((h) => {
const text = (h.textContent || "").trim().toLowerCase()
.replace(/[^\w\s-]/g, "").replace(/\s+/g, "-");
return text === slug;
}) ?? container.querySelector(`[id="${CSS.escape(slug)}"]`);
match?.scrollIntoView({ behavior: "smooth", block: "start" });
return;
}
if (!onNavigate) {return;}
if (isWorkspaceLink(href) || (!href.startsWith("http://") && !href.startsWith("https://") && !href.startsWith("mailto:"))) {
event.preventDefault();
event.stopPropagation();
onNavigate(href);
return;
}
event.preventDefault();
event.stopPropagation();
window.open(href, "_blank", "noopener,noreferrer");
},
[onNavigate],
);

View File

@ -106,16 +106,20 @@ function FolderIcon({ open }: { open?: boolean }) {
function TableIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3v18" /><rect width="18" height="18" x="3" y="3" rx="2" /><path d="M3 9h18" /><path d="M3 15h18" />
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="1.5" y="2.5" width="13" height="11" rx="2" fill="#42a97a" fillOpacity="0.15" stroke="#42a97a" strokeWidth="1.2" />
<path d="M1.5 6.5h13" stroke="#42a97a" strokeWidth="1.2" />
<path d="M6 6.5v7" stroke="#42a97a" strokeWidth="1.2" />
</svg>
);
}
function KanbanIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect width="6" height="14" x="2" y="5" rx="1" /><rect width="6" height="10" x="9" y="5" rx="1" /><rect width="6" height="16" x="16" y="3" rx="1" />
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="1.5" y="2.5" width="3.5" height="9.5" rx="1" fill="#8b7cf6" fillOpacity="0.18" stroke="#8b7cf6" strokeWidth="1.1" />
<rect x="6.25" y="2.5" width="3.5" height="6.5" rx="1" fill="#8b7cf6" fillOpacity="0.18" stroke="#8b7cf6" strokeWidth="1.1" />
<rect x="11" y="2.5" width="3.5" height="11" rx="1" fill="#8b7cf6" fillOpacity="0.18" stroke="#8b7cf6" strokeWidth="1.1" />
</svg>
);
}
@ -624,13 +628,6 @@ function DraggableNode({
</span>
)}
{/* Type badge for objects */}
{node.type === "object" && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full flex-shrink-0"
style={{ background: "var(--color-accent-light)", color: "var(--color-accent)" }}>
{node.defaultView === "kanban" ? "board" : "table"}
</span>
)}
</div>
{/* Children */}

View File

@ -27,16 +27,20 @@ function FolderIcon({ open }: { open?: boolean }) {
function TableIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3v18" /><rect width="18" height="18" x="3" y="3" rx="2" /><path d="M3 9h18" /><path d="M3 15h18" />
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="1.5" y="2.5" width="13" height="11" rx="2" fill="#42a97a" fillOpacity="0.15" stroke="#42a97a" strokeWidth="1.2" />
<path d="M1.5 6.5h13" stroke="#42a97a" strokeWidth="1.2" />
<path d="M6 6.5v7" stroke="#42a97a" strokeWidth="1.2" />
</svg>
);
}
function KanbanIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect width="6" height="14" x="2" y="5" rx="1" /><rect width="6" height="10" x="9" y="5" rx="1" /><rect width="6" height="16" x="16" y="3" rx="1" />
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<rect x="1.5" y="2.5" width="3.5" height="9.5" rx="1" fill="#8b7cf6" fillOpacity="0.18" stroke="#8b7cf6" strokeWidth="1.1" />
<rect x="6.25" y="2.5" width="3.5" height="6.5" rx="1" fill="#8b7cf6" fillOpacity="0.18" stroke="#8b7cf6" strokeWidth="1.1" />
<rect x="11" y="2.5" width="3.5" height="11" rx="1" fill="#8b7cf6" fillOpacity="0.18" stroke="#8b7cf6" strokeWidth="1.1" />
</svg>
);
}
@ -199,18 +203,6 @@ function TreeNodeItem({
{node.name.replace(/\.md$/, "")}
</span>
{/* Type badge for objects */}
{node.type === "object" && (
<span
className="text-[10px] px-1.5 py-0.5 rounded-full flex-shrink-0"
style={{
background: "var(--color-accent-light)",
color: "var(--color-accent)",
}}
>
{node.defaultView === "kanban" ? "board" : "table"}
</span>
)}
</button>
{/* Children */}

View File

@ -239,12 +239,46 @@ export function MarkdownEditor({
const href = link.getAttribute("href");
if (!href) {return;}
// Intercept workspace links to handle via client-side state
// Anchor links: scroll to heading within the editor
if (href.startsWith("#")) {
event.preventDefault();
event.stopPropagation();
const slug = href.slice(1);
const editorEl = editor.view.dom;
const heading = editorEl.querySelector(`[id="${CSS.escape(slug)}"]`)
?? editorEl.querySelector(`h1, h2, h3, h4, h5, h6`);
if (heading) {
const allHeadings = Array.from(editorEl.querySelectorAll("h1, h2, h3, h4, h5, h6"));
const match = allHeadings.find((h) => {
const text = (h.textContent || "").trim().toLowerCase()
.replace(/[^\w\s-]/g, "").replace(/\s+/g, "-");
return text === slug;
});
(match ?? heading).scrollIntoView({ behavior: "smooth", block: "start" });
}
return;
}
// Workspace links: navigate via client-side state
if (isWorkspaceLink(href)) {
event.preventDefault();
event.stopPropagation();
onNavigate(href);
return;
}
// Relative links (not http/https): treat as workspace file navigation
if (!href.startsWith("http://") && !href.startsWith("https://") && !href.startsWith("mailto:")) {
event.preventDefault();
event.stopPropagation();
onNavigate(href);
return;
}
// External links: open in new tab
event.preventDefault();
event.stopPropagation();
window.open(href, "_blank", "noopener,noreferrer");
};
const editorElement = editor.view.dom;

View File

@ -199,69 +199,48 @@ export function ProfileSwitcher({
{showSwitcher && isOpen && (
<div
className="absolute left-0 top-full mt-1 w-64 rounded-lg overflow-hidden z-50"
style={{
background: "var(--color-surface-raised)",
border: "1px solid var(--color-border)",
boxShadow: "var(--shadow-lg)",
}}
className="absolute left-0 top-full mt-1.5 w-64 rounded-2xl overflow-hidden z-50 p-1 bg-neutral-100/[0.67] dark:bg-neutral-900/[0.67] border border-white dark:border-white/10 backdrop-blur-md shadow-[0_0_25px_0_rgba(0,0,0,0.16)]"
>
{/* Header */}
<div
className="px-3 py-2 text-xs font-medium"
style={{
color: "var(--color-text-muted)",
borderBottom: "1px solid var(--color-border)",
}}
className="px-2.5 py-1.5 text-[11px] font-medium"
style={{ color: "var(--color-text-muted)" }}
>
Workspaces
</div>
{/* Workspace list */}
<div className="py-1 max-h-64 overflow-y-auto">
<div className="max-h-64 overflow-y-auto">
{workspaces.map((workspace) => {
const isCurrent = workspace.name === activeWorkspace;
return (
<div key={workspace.name} className="flex items-center gap-1 px-1.5 py-0.5">
<div key={workspace.name} className="flex items-center gap-0.5">
<button
onClick={() => void handleSwitch(workspace.name)}
disabled={switching || !!deletingWorkspace}
className="flex-1 min-w-0 flex items-center gap-2 px-1.5 py-1.5 rounded text-left text-sm transition-colors hover:bg-[var(--color-surface-hover)] disabled:opacity-50"
className="flex-1 min-w-0 flex items-center gap-2 px-2.5 py-1.5 rounded-xl text-left text-sm transition-all hover:bg-neutral-400/15 disabled:opacity-50 cursor-pointer"
style={{ color: "var(--color-text)" }}
>
{/* Active indicator */}
<span
className="w-2 h-2 rounded-full flex-shrink-0"
className="w-2 h-2 rounded-full shrink-0"
style={{
background: isCurrent ? "var(--color-success)" : "transparent",
border: isCurrent ? "none" : "1px solid var(--color-border-strong)",
}}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="truncate font-medium">
{workspace.name}
</span>
</div>
<div
className="text-xs truncate mt-0.5"
<span className="truncate font-medium text-[13px] block">
{workspace.name}
</span>
<span
className="text-[11px] truncate block mt-0.5"
style={{ color: "var(--color-text-muted)" }}
>
{workspace.workspaceDir
? shortenPath(workspace.workspaceDir)
: "No workspace yet"}
</div>
</span>
</div>
{isCurrent && (
<span
className="text-xs px-1.5 py-0.5 rounded"
style={{
background: "var(--color-accent-light)",
color: "var(--color-accent)",
}}
>
<span className="text-[10px] px-1.5 py-0.5 rounded-full shrink-0 bg-neutral-400/15" style={{ color: "var(--color-text-muted)" }}>
Active
</span>
)}
@ -272,28 +251,15 @@ export function ProfileSwitcher({
onClick={() => void handleDeleteWorkspace(workspace.name)}
disabled={switching || !!deletingWorkspace}
title={`Delete workspace ${workspace.name}`}
className="p-1.5 rounded transition-colors hover:bg-[var(--color-surface-hover)] disabled:opacity-50"
className="p-1.5 rounded-xl transition-all hover:bg-neutral-400/15 disabled:opacity-50 shrink-0 cursor-pointer"
style={{
color: deletingWorkspace === workspace.name
? "var(--color-text-muted)"
: "var(--color-error)",
}}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 6h18" />
<path d="M8 6V4h8v2" />
<path d="M19 6l-1 14H6L5 6" />
<path d="M10 11v6" />
<path d="M14 11v6" />
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 6h18" /><path d="M8 6V4h8v2" /><path d="M19 6l-1 14H6L5 6" />
</svg>
</button>
)}
@ -304,7 +270,7 @@ export function ProfileSwitcher({
{actionError && (
<p
className="mx-3 mb-2 mt-1 rounded px-2 py-1 text-xs"
className="mx-2 mb-1 mt-1 rounded-xl px-2.5 py-1.5 text-xs"
style={{
background: "rgba(220, 38, 38, 0.08)",
color: "var(--color-error)",
@ -314,17 +280,16 @@ export function ProfileSwitcher({
</p>
)}
{/* Create new */}
<div style={{ borderTop: "1px solid var(--color-border)" }}>
<div className="border-t border-neutral-400/15 mt-0.5 pt-0.5">
<button
onClick={() => {
setIsOpen(false);
onCreateWorkspace?.();
}}
className="w-full flex items-center gap-2 px-3 py-2.5 text-sm transition-colors hover:bg-[var(--color-surface-hover)]"
className="w-full flex items-center gap-2 px-2.5 py-1.5 text-sm rounded-xl transition-all hover:bg-neutral-400/15 cursor-pointer"
style={{ color: "var(--color-accent)" }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 5v14" /><path d="M5 12h14" />
</svg>
New Workspace

View File

@ -1,7 +1,14 @@
"use client";
import { useState, useRef, useCallback, useEffect } from "react";
import { useState, useCallback, useEffect, useMemo } from "react";
import { type Tab, HOME_TAB_ID } from "@/lib/tab-state";
import dynamic from "next/dynamic";
const Tabs = dynamic(
() => import("@sinm/react-chrome-tabs").then((mod) => mod.Tabs),
{ ssr: false },
);
import { appServeUrl } from "./app-viewer";
type TabBarProps = {
@ -14,6 +21,9 @@ type TabBarProps = {
onCloseAll: () => void;
onReorder: (fromIndex: number, toIndex: number) => void;
onTogglePin: (tabId: string) => void;
onNewTab?: () => void;
leftContent?: React.ReactNode;
rightContent?: React.ReactNode;
};
type ContextMenuState = {
@ -22,6 +32,24 @@ type ContextMenuState = {
y: number;
} | null;
function tabToFaviconClass(tab: Tab): string | undefined {
switch (tab.type) {
case "home": return "dench-favicon-home";
case "chat": return "dench-favicon-chat";
case "app": return "dench-favicon-app";
case "cron": return "dench-favicon-cron";
case "object": return "dench-favicon-object";
default: return "dench-favicon-file";
}
}
function tabToFavicon(tab: Tab): string | boolean | undefined {
if (tab.icon && tab.path && /\.(png|svg|jpe?g|webp)$/i.test(tab.icon)) {
return appServeUrl(tab.path, tab.icon);
}
return false;
}
export function TabBar({
tabs,
activeTabId,
@ -32,11 +60,22 @@ export function TabBar({
onCloseAll,
onReorder,
onTogglePin,
onNewTab,
leftContent,
rightContent,
}: TabBarProps) {
const [contextMenu, setContextMenu] = useState<ContextMenuState>(null);
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const [isDark, setIsDark] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(prefers-color-scheme: dark)");
setIsDark(document.documentElement.classList.contains("dark") || mq.matches);
const handler = () => setIsDark(document.documentElement.classList.contains("dark") || mq.matches);
mq.addEventListener("change", handler);
const obs = new MutationObserver(handler);
obs.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
return () => { mq.removeEventListener("change", handler); obs.disconnect(); };
}, []);
useEffect(() => {
if (!contextMenu) return;
@ -49,44 +88,36 @@ export function TabBar({
};
}, [contextMenu]);
const handleContextMenu = useCallback((e: React.MouseEvent, tabId: string) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({ tabId, x: e.clientX, y: e.clientY });
const handleContextMenu = useCallback((tabId: string, event: MouseEvent) => {
if (!tabId || tabId === HOME_TAB_ID) return;
event.preventDefault();
event.stopPropagation();
setContextMenu({ tabId, x: event.clientX, y: event.clientY });
}, []);
const handleMiddleClick = useCallback((e: React.MouseEvent, tabId: string) => {
if (e.button === 1) {
e.preventDefault();
onClose(tabId);
}
}, [onClose]);
const homeTab = tabs.find((t) => t.id === HOME_TAB_ID);
const nonHomeTabs = useMemo(() => tabs.filter((t) => t.id !== HOME_TAB_ID), [tabs]);
const handleDragStart = useCallback((e: React.DragEvent, index: number) => {
setDragIndex(index);
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", String(index));
}, []);
const chromeTabs = useMemo(() => {
return nonHomeTabs.map((tab) => ({
id: tab.id,
title: tab.title,
active: tab.id === activeTabId,
favicon: tabToFavicon(tab),
faviconClass: tabToFaviconClass(tab),
isCloseIconVisible: !tab.pinned,
}));
}, [nonHomeTabs, activeTabId]);
const handleDragOver = useCallback((e: React.DragEvent, index: number) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDragOverIndex(index);
}, []);
const handleDrop = useCallback((e: React.DragEvent, toIndex: number) => {
e.preventDefault();
if (dragIndex !== null && dragIndex !== toIndex) {
onReorder(dragIndex, toIndex);
}
setDragIndex(null);
setDragOverIndex(null);
}, [dragIndex, onReorder]);
const handleDragEnd = useCallback(() => {
setDragIndex(null);
setDragOverIndex(null);
}, []);
const handleActive = useCallback((id: string) => onActivate(id), [onActivate]);
const handleClose = useCallback((id: string) => onClose(id), [onClose]);
const handleReorder = useCallback(
(tabId: string, _fromIndex: number, toIndex: number) => {
const fromIndex = tabs.findIndex((t) => t.id === tabId);
if (fromIndex >= 0 && fromIndex !== toIndex) onReorder(fromIndex, toIndex);
},
[tabs, onReorder],
);
if (tabs.length === 0) return null;
@ -94,92 +125,59 @@ export function TabBar({
return (
<>
<div
ref={scrollRef}
className="flex items-end overflow-x-auto flex-shrink-0"
style={{
background: "var(--color-surface)",
borderBottom: "1px solid var(--color-border)",
scrollbarWidth: "none",
}}
>
{tabs.map((tab, index) => {
const isActive = tab.id === activeTabId;
const isDragOver = dragOverIndex === index && dragIndex !== index;
const isHome = tab.id === HOME_TAB_ID;
return (
<button
key={tab.id}
type="button"
draggable={!isHome}
onClick={() => onActivate(tab.id)}
onMouseDown={isHome ? undefined : (e) => handleMiddleClick(e, tab.id)}
onContextMenu={isHome ? undefined : (e) => handleContextMenu(e, tab.id)}
onDragStart={isHome ? undefined : (e) => handleDragStart(e, index)}
onDragOver={isHome ? undefined : (e) => handleDragOver(e, index)}
onDrop={isHome ? undefined : (e) => handleDrop(e, index)}
onDragEnd={isHome ? undefined : handleDragEnd}
className={`group flex items-center gap-1.5 h-[34px] text-[12.5px] font-medium cursor-pointer flex-shrink-0 relative transition-colors duration-75 select-none ${isHome ? "px-2.5" : "pl-3 pr-1.5"}`}
style={{
color: isActive ? "var(--color-text)" : "var(--color-text-muted)",
background: isActive ? "var(--color-bg)" : "transparent",
borderBottom: isActive ? "2px solid var(--color-accent)" : "2px solid transparent",
borderLeft: isDragOver && !isHome ? "2px solid var(--color-accent)" : "2px solid transparent",
opacity: dragIndex === index ? 0.5 : 1,
maxWidth: isHome ? undefined : 200,
borderRight: isHome ? "1px solid var(--color-border)" : undefined,
}}
title={isHome ? "Home (New Chat)" : undefined}
>
{isHome ? (
<HomeIcon />
) : (
<>
{tab.pinned && <PinIcon />}
<TabIcon type={tab.type} icon={tab.icon} appPath={tab.path} />
<span className="truncate max-w-[140px]">{tab.title}</span>
{!tab.pinned && (
<span
role="button"
tabIndex={-1}
onClick={(e) => { e.stopPropagation(); onClose(tab.id); }}
onKeyDown={(e) => { if (e.key === "Enter") { e.stopPropagation(); onClose(tab.id); } }}
className="ml-0.5 p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
style={{ color: "var(--color-text-muted)" }}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.background = "transparent";
}}
>
<CloseIcon />
</span>
)}
</>
)}
</button>
);
})}
<div className="dench-chrome-tabs-wrapper flex items-center shrink-0 relative">
{leftContent && (
<div className="flex items-center px-1.5 shrink-0 z-10">
{leftContent}
</div>
)}
<div className="flex-1 min-w-0">
<Tabs
darkMode={isDark}
tabs={chromeTabs}
draggable
onTabActive={handleActive}
onTabClose={handleClose}
onTabReorder={handleReorder}
onContextMenu={handleContextMenu}
pinnedRight={onNewTab ? (
<div className="flex items-center gap-1.5 ml-1.5">
{nonHomeTabs.length > 0 && nonHomeTabs[nonHomeTabs.length - 1].id !== activeTabId && (
<div className="w-px h-4 shrink-0" style={{ background: "var(--color-border)" }} />
)}
<button
type="button"
onClick={onNewTab}
className="flex items-center justify-center w-7 h-7 rounded-full shrink-0 cursor-pointer transition-colors hover:bg-black/5 dark:hover:bg-white/5"
style={{ color: "var(--color-text-muted)" }}
title="New chat"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 5v14" /><path d="M5 12h14" />
</svg>
</button>
</div>
) : undefined}
/>
</div>
{rightContent && (
<div className="flex items-center gap-0.5 px-2 shrink-0 z-10">
{rightContent}
</div>
)}
</div>
{/* Context menu */}
{contextMenu && contextTab && (
<div
className="fixed z-[9999] min-w-[180px] rounded-lg border py-1 shadow-lg"
style={{
left: contextMenu.x,
top: contextMenu.y,
background: "var(--color-surface)",
borderColor: "var(--color-border)",
}}
className="fixed z-9999 min-w-[180px] rounded-2xl p-1 bg-neutral-100/67 dark:bg-neutral-900/67 border border-white dark:border-white/10 backdrop-blur-md shadow-[0_0_25px_0_rgba(0,0,0,0.16)]"
style={{ left: contextMenu.x, top: contextMenu.y }}
>
<ContextMenuItem
label={contextTab.pinned ? "Unpin Tab" : "Pin Tab"}
onClick={() => { onTogglePin(contextMenu.tabId); setContextMenu(null); }}
/>
<div className="h-px my-1" style={{ background: "var(--color-border)" }} />
<div className="h-px my-0.5 mx-1 bg-neutral-400/15" />
<ContextMenuItem
label="Close"
shortcut="⌘W"
@ -220,14 +218,8 @@ function ContextMenuItem({
type="button"
disabled={disabled}
onClick={onClick}
className="w-full flex items-center justify-between px-3 py-1.5 text-[12.5px] text-left transition-colors disabled:opacity-40 cursor-pointer disabled:cursor-default"
className="w-full flex items-center justify-between px-2.5 py-1.5 text-[12.5px] text-left rounded-xl transition-all disabled:opacity-40 hover:bg-neutral-400/15"
style={{ color: "var(--color-text)" }}
onMouseEnter={(e) => {
if (!disabled) (e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.background = "transparent";
}}
>
<span>{label}</span>
{shortcut && (
@ -238,102 +230,3 @@ function ContextMenuItem({
</button>
);
}
function TabIcon({ type, icon, appPath }: { type: string; icon?: string; appPath?: string }) {
if (icon && appPath && (icon.endsWith(".png") || icon.endsWith(".svg") || icon.endsWith(".jpg") || icon.endsWith(".jpeg") || icon.endsWith(".webp"))) {
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={appServeUrl(appPath, icon)}
alt=""
width={14}
height={14}
className="rounded-sm flex-shrink-0"
style={{ objectFit: "cover" }}
/>
);
}
switch (type) {
case "home":
return <HomeIcon />;
case "app":
return <AppIcon />;
case "chat":
return <ChatIcon />;
case "cron":
return <CronIcon />;
case "object":
return <ObjectIcon />;
default:
return <FileIcon />;
}
}
function CloseIcon() {
return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
</svg>
);
}
function PinIcon() {
return (
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor" stroke="none" className="flex-shrink-0" style={{ opacity: 0.5 }}>
<circle cx="12" cy="12" r="4" />
</svg>
);
}
function HomeIcon() {
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0" style={{ opacity: 0.7 }}>
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
);
}
function FileIcon() {
return (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0" style={{ opacity: 0.6 }}>
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
</svg>
);
}
function AppIcon() {
return (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0" style={{ opacity: 0.6 }}>
<rect width="7" height="7" x="3" y="3" rx="1" /><rect width="7" height="7" x="14" y="3" rx="1" />
<rect width="7" height="7" x="3" y="14" rx="1" /><rect width="7" height="7" x="14" y="14" rx="1" />
</svg>
);
}
function ChatIcon() {
return (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0" style={{ opacity: 0.6 }}>
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
);
}
function CronIcon() {
return (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0" style={{ opacity: 0.6 }}>
<circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
</svg>
);
}
function ObjectIcon() {
return (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0" style={{ opacity: 0.6 }}>
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M3 9h18" /><path d="M9 21V9" />
</svg>
);
}

View File

@ -76,10 +76,7 @@ type ViewTypeSwitcherProps = {
export function ViewTypeSwitcher({ value, onChange }: ViewTypeSwitcherProps) {
return (
<div
className="flex items-center rounded-lg border overflow-hidden"
style={{ borderColor: "var(--color-border)" }}
>
<div className="flex items-center gap-1">
{VIEW_TYPES.map((vt) => {
const meta = VIEW_TYPE_META[vt];
const Icon = meta.icon;
@ -89,11 +86,11 @@ export function ViewTypeSwitcher({ value, onChange }: ViewTypeSwitcherProps) {
key={vt}
type="button"
onClick={() => onChange(vt)}
className="flex items-center gap-1 px-2.5 py-1.5 text-[11px] transition-colors"
className="flex items-center gap-1.5 px-2.5 py-1 text-[12px] rounded-md transition-colors cursor-pointer"
style={{
background: isActive ? "var(--color-accent)" : "var(--color-surface)",
color: isActive ? "#fff" : "var(--color-text-muted)",
borderRight: vt !== "list" ? "1px solid var(--color-border)" : undefined,
background: isActive ? "var(--color-surface-hover)" : "transparent",
color: isActive ? "var(--color-text)" : "var(--color-text-muted)",
fontWeight: isActive ? 500 : 400,
}}
title={meta.label}
>

View File

@ -1,10 +1,12 @@
"use client";
import { useEffect, useState, useRef, useCallback } from "react";
import { useTheme } from "next-themes";
import { FileManagerTree, type TreeNode } from "./file-manager-tree";
import { ProfileSwitcher } from "./profile-switcher";
import { CreateWorkspaceDialog } from "./create-workspace-dialog";
import { UnicodeSpinner } from "../unicode-spinner";
import { ChatSessionsSidebar, type WebSession, type SidebarSubagentInfo } from "./chat-sessions-sidebar";
/** Shape returned by /api/workspace/suggest-files */
type SuggestItem = {
@ -52,6 +54,22 @@ type WorkspaceSidebarProps = {
activeWorkspace?: string | null;
/** Called after workspace switches or workspace creation so parent can refresh state. */
onWorkspaceChanged?: () => void;
/** Chat sessions for the Chats tab. */
chatSessions?: WebSession[];
activeChatSessionId?: string | null;
activeChatSessionTitle?: string;
chatStreamingSessionIds?: Set<string>;
chatSubagents?: SidebarSubagentInfo[];
chatActiveSubagentKey?: string | null;
chatSessionsLoading?: boolean;
onSelectChatSession?: (sessionId: string) => void;
onNewChatSession?: () => void;
onSelectChatSubagent?: (sessionKey: string) => void;
onDeleteChatSession?: (sessionId: string) => void;
onRenameChatSession?: (sessionId: string, newTitle: string) => void;
/** Which tab is active. Controlled from parent if provided. */
activeTab?: "files" | "chats";
onTabChange?: (tab: "files" | "chats") => void;
};
function HomeIcon() {
@ -88,66 +106,32 @@ function FolderOpenIcon() {
/* ─── Theme toggle ─── */
function ThemeToggle() {
const [isDark, setIsDark] = useState(false);
const { resolvedTheme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
useEffect(() => {
setIsDark(document.documentElement.classList.contains("dark"));
}, []);
if (!mounted) return <div className="w-[28px] h-[28px]" />;
const toggle = () => {
const next = !isDark;
setIsDark(next);
if (next) {
document.documentElement.classList.add("dark");
localStorage.setItem("theme", "dark");
} else {
document.documentElement.classList.remove("dark");
localStorage.setItem("theme", "light");
}
};
const isDark = resolvedTheme === "dark";
return (
<button
type="button"
onClick={toggle}
onClick={() => setTheme(isDark ? "light" : "dark")}
className="p-1.5 rounded-lg"
style={{ color: "var(--color-text-muted)" }}
title={isDark ? "Switch to light mode" : "Switch to dark mode"}
>
{isDark ? (
/* Sun icon */
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.93 4.93 1.41 1.41" />
<path d="m17.66 17.66 1.41 1.41" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.34 17.66-1.41 1.41" />
<path d="m19.07 4.93-1.41 1.41" />
<path d="M12 2v2" /><path d="M12 20v2" />
<path d="m4.93 4.93 1.41 1.41" /><path d="m17.66 17.66 1.41 1.41" />
<path d="M2 12h2" /><path d="M20 12h2" />
<path d="m6.34 17.66-1.41 1.41" /><path d="m19.07 4.93-1.41 1.41" />
</svg>
) : (
/* Moon icon */
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
</svg>
)}
@ -412,10 +396,31 @@ export function WorkspaceSidebar({
onCollapse,
activeWorkspace,
onWorkspaceChanged,
chatSessions,
activeChatSessionId,
activeChatSessionTitle,
chatStreamingSessionIds,
chatSubagents,
chatActiveSubagentKey,
chatSessionsLoading,
onSelectChatSession,
onNewChatSession,
onSelectChatSubagent,
onDeleteChatSession,
onRenameChatSession,
activeTab: activeTabProp,
onTabChange,
}: WorkspaceSidebarProps) {
const isBrowsing = browseDir != null;
const width = mobile ? "280px" : (widthProp ?? 260);
const [createWorkspaceOpen, setCreateWorkspaceOpen] = useState(false);
const hasChatProps = chatSessions !== undefined;
const [internalTab, setInternalTab] = useState<"files" | "chats">(activeTabProp ?? "files");
const currentTab = activeTabProp ?? internalTab;
const setTab = useCallback((tab: "files" | "chats") => {
setInternalTab(tab);
onTabChange?.(tab);
}, [onTabChange]);
const sidebar = (
<aside
@ -429,44 +434,31 @@ export function WorkspaceSidebar({
>
{/* Header */}
<div
className="flex items-center gap-2 px-3 py-2.5 border-b"
className="flex items-center gap-2 px-3 h-[36px] border-b"
style={{ borderColor: "var(--color-border)" }}
>
{isBrowsing ? (
<>
<span
className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
style={{
background: "var(--color-surface-hover)",
color: "var(--color-text-muted)",
}}
>
<FolderOpenIcon />
</span>
<div className="flex-1 min-w-0">
<div
className="text-sm font-medium truncate"
<div className="flex-1 min-w-0 flex items-center gap-1.5">
<span
className="shrink-0"
style={{ color: "var(--color-text-muted)" }}
>
<FolderOpenIcon />
</span>
<span
className="text-[12px] font-medium truncate"
style={{ color: "var(--color-text)" }}
title={browseDir}
>
{dirDisplayName(browseDir)}
</div>
<div
className="text-[11px] truncate"
style={{
color: "var(--color-text-muted)",
}}
title={browseDir}
>
{browseDir}
</div>
</span>
</div>
{/* Home button to return to workspace */}
{onGoHome && (
<button
type="button"
onClick={onGoHome}
className="p-1.5 rounded-lg flex-shrink-0"
className="p-1 rounded-md shrink-0 transition-colors hover:bg-stone-200 dark:hover:bg-stone-700"
style={{ color: "var(--color-text-muted)" }}
title="Return to workspace"
>
@ -476,66 +468,48 @@ export function WorkspaceSidebar({
</>
) : (
<>
<button
type="button"
onClick={() => void onGoToChat?.()}
className="w-7 h-7 rounded-lg flex items-center justify-center shrink-0 cursor-pointer transition-colors hover:bg-stone-200 dark:hover:bg-stone-700"
style={{
background: "transparent",
color: "var(--color-text-muted)",
}}
title="All Chats"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
</button>
<div className="flex-1 min-w-0 px-1.5">
<div className="text-[13px] font-semibold truncate text-stone-700 dark:text-stone-200">
{orgName || "Workspace"}
</div>
<div className="mt-1">
<ProfileSwitcher
activeWorkspaceHint={activeWorkspace ?? null}
onWorkspaceSwitch={() => {
onWorkspaceChanged?.();
}}
onWorkspaceDelete={() => {
onWorkspaceChanged?.();
}}
onCreateWorkspace={() => {
setCreateWorkspaceOpen(true);
}}
trigger={({ onClick, activeWorkspace: workspaceName, switching }) => (
<button
type="button"
onClick={onClick}
disabled={switching}
className="text-[11px] flex items-center gap-1 truncate w-full transition-colors"
<div className="flex-1 min-w-0">
<ProfileSwitcher
activeWorkspaceHint={activeWorkspace ?? null}
onWorkspaceSwitch={() => {
onWorkspaceChanged?.();
}}
onWorkspaceDelete={() => {
onWorkspaceChanged?.();
}}
onCreateWorkspace={() => {
setCreateWorkspaceOpen(true);
}}
trigger={({ onClick, activeWorkspace: workspaceName, switching }) => (
<button
type="button"
onClick={onClick}
disabled={switching}
className="text-[12px] flex items-center gap-1.5 truncate w-full transition-colors font-medium rounded-md px-1.5 py-1 -mx-1.5 hover:bg-stone-200/60 dark:hover:bg-stone-700/60"
style={{ color: "var(--color-text)" }}
title="Switch workspace"
>
<span className="truncate">{orgName || "Workspace"}</span>
<span className="px-1 py-px rounded text-[10px] leading-tight shrink-0 bg-stone-200 text-stone-600 dark:bg-stone-700 dark:text-stone-300">
{workspaceName || "-"}
</span>
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="shrink-0"
style={{ color: "var(--color-text-muted)" }}
title="Switch workspace"
>
<span>Workspace</span>
<span className="px-1 py-0.5 rounded text-[10px] shrink-0 bg-stone-200 text-stone-600 dark:bg-stone-700 dark:text-stone-300">
{workspaceName || "-"}
</span>
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m6 9 6 6 6-6" />
</svg>
</button>
)}
/>
</div>
<path d="m6 9 6 6 6-6" />
</svg>
</button>
)}
/>
</div>
</>
)}
@ -555,12 +529,10 @@ export function WorkspaceSidebar({
)}
</div>
{/* File search */}
{onFileSearchSelect && (
<FileSearch onSelect={onFileSearchSelect} />
)}
{/* Tree */}
<div className="flex-1 overflow-y-auto px-1">
{loading ? (
<div className="flex items-center justify-center py-12">
@ -571,17 +543,17 @@ export function WorkspaceSidebar({
/>
</div>
) : (
<FileManagerTree
tree={tree}
activePath={activePath}
onSelect={onSelect}
onRefresh={onRefresh}
parentDir={parentDir}
onNavigateUp={onNavigateUp}
browseDir={browseDir}
workspaceRoot={workspaceRoot}
onExternalDrop={onExternalDrop}
/>
<FileManagerTree
tree={tree}
activePath={activePath}
onSelect={onSelect}
onRefresh={onRefresh}
parentDir={parentDir}
onNavigateUp={onNavigateUp}
browseDir={browseDir}
workspaceRoot={workspaceRoot}
onExternalDrop={onExternalDrop}
/>
)}
</div>

View File

@ -1,4 +1,8 @@
@import "tailwindcss";
@import "@sinm/react-chrome-tabs/css/chrome-tabs.css";
@import "@sinm/react-chrome-tabs/css/chrome-tabs-dark-theme.css";
@custom-variant dark (&:is(.dark *));
/* ============================================================
Theme System Light (default) & Dark (.dark)
@ -8,9 +12,9 @@
/* Background / Surface */
--color-bg: #f5f5f4;
--color-surface: #ffffff;
--color-sidebar-bg: #ffffff;
--color-main-bg: rgba(250, 250, 249, 0.5);
--color-surface-hover: #f5f4f1;
--color-sidebar-bg: #f5f5f4;
--color-main-bg: #fafaf9;
--color-surface-hover: #ececea;
--color-surface-raised: #ffffff;
/* Borders */
@ -22,10 +26,10 @@
--color-text-secondary: #44443e;
--color-text-muted: #8a8a82;
/* Accent (blue) */
--color-accent: rgba(37, 99, 235, 0.9);
--color-accent-hover: #1d4ed8;
--color-accent-light: rgba(37, 99, 235, 0.08);
/* Accent */
--color-accent: #4FA1EE;
--color-accent-hover: #3B8FDE;
--color-accent-light: rgba(79, 161, 238, 0.08);
/* Chat */
--color-user-bubble: #eae8e4;
@ -70,6 +74,28 @@
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.08);
--shadow-xl: 0 8px 32px rgba(0, 0, 0, 0.1);
/* Dench UI tokens (used by @denchhq/ui-react primitives) */
--background: 0 0% 90%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--radius: 0.5rem;
}
.dark {
@ -90,10 +116,10 @@
--color-text-secondary: #b8b8b0;
--color-text-muted: #78776f;
/* Accent (blue, brighter for dark) */
--color-accent: #3b82f6;
--color-accent-hover: #60a5fa;
--color-accent-light: rgba(59, 130, 246, 0.12);
/* Accent */
--color-accent: #4FA1EE;
--color-accent-hover: #6BB3F5;
--color-accent-light: rgba(79, 161, 238, 0.12);
/* Chat */
--color-user-bubble: #1e1e1c;
@ -138,6 +164,28 @@
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.4);
--shadow-xl: 0 8px 32px rgba(0, 0, 0, 0.5);
/* Dench UI tokens (used by @denchhq/ui-react primitives) */
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--radius: 0.5rem;
}
/* Disable iframe pointer events during sidebar resize so the
@ -224,6 +272,9 @@ body {
.chat-user-html-content p:empty {
min-height: 1em;
}
.chat-user-html-content p:empty:last-child {
display: none;
}
.chat-user-html-content strong {
font-weight: 700;
}
@ -278,6 +329,17 @@ a,
transition-timing-function: ease;
}
/* Restore pointer cursor for interactive elements (Tailwind v4 preflight resets to default) */
button:not(:disabled),
[type="button"]:not(:disabled),
[role="button"]:not(:disabled),
a[href],
select:not(:disabled),
summary,
label {
cursor: pointer !important;
}
/* ============================================================
Scrollbar
============================================================ */
@ -1867,3 +1929,79 @@ body {
.spreadsheet-editor-grid .Spreadsheet__data-editor input {
padding: 4px 8px;
}
/* ── @sinm/react-chrome-tabs theme overrides ── */
.dench-chrome-tabs-wrapper {
background: var(--color-bg);
min-height: 36px;
}
.dench-chrome-tabs-wrapper .chrome-tabs {
background: var(--color-bg);
height: 36px;
padding: 2px 3px 0 3px;
border-radius: 0;
font-family: inherit;
}
.dench-chrome-tabs-wrapper .chrome-tabs .chrome-tab {
height: 34px;
cursor: default;
}
.dench-chrome-tabs-wrapper .chrome-tabs .chrome-tab .chrome-tab-background > svg .chrome-tab-geometry {
fill: var(--color-bg);
}
.dench-chrome-tabs-wrapper .chrome-tabs .chrome-tab[active] .chrome-tab-background > svg .chrome-tab-geometry {
fill: var(--color-surface);
}
.dench-chrome-tabs-wrapper .chrome-tabs .chrome-tab .chrome-tab-title {
color: var(--color-text-muted);
font-size: 12.5px;
}
.dench-chrome-tabs-wrapper .chrome-tabs .chrome-tab[active] .chrome-tab-title {
color: var(--color-text);
}
.dench-chrome-tabs-wrapper .chrome-tabs .chrome-tab .chrome-tab-dividers::before,
.dench-chrome-tabs-wrapper .chrome-tabs .chrome-tab .chrome-tab-dividers::after {
background: var(--color-border);
}
.dench-chrome-tabs-wrapper .chrome-tabs .chrome-tabs-bottom-bar {
background: var(--color-surface);
height: 1px;
}
.dench-chrome-tabs-wrapper .chrome-tabs-optional-shadow-below-bottom-bar {
display: none;
}
/* Favicon icon classes (SVG data-uri with currentColor replaced by hex) */
.dench-favicon-home,
.dench-favicon-chat,
.dench-favicon-file,
.dench-favicon-app,
.dench-favicon-cron,
.dench-favicon-object {
background-size: 14px 14px !important;
background-position: center !important;
background-repeat: no-repeat !important;
opacity: 0.55;
}
.dench-favicon-home {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z'/%3E%3Cpolyline points='9 22 9 12 15 12 15 22'/%3E%3C/svg%3E") !important;
}
.dench-favicon-chat {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z'/%3E%3C/svg%3E") !important;
}
.dench-favicon-file {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z'/%3E%3Cpath d='M14 2v4a2 2 0 0 0 2 2h4'/%3E%3C/svg%3E") !important;
}
body.resizing .sidebar-animate {
transition: none !important;
}
.dench-favicon-app {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect width='7' height='7' x='3' y='3' rx='1'/%3E%3Crect width='7' height='7' x='14' y='3' rx='1'/%3E%3Crect width='7' height='7' x='3' y='14' rx='1'/%3E%3Crect width='7' height='7' x='14' y='14' rx='1'/%3E%3C/svg%3E") !important;
}
.dench-favicon-cron {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpolyline points='12 6 12 12 16 14'/%3E%3C/svg%3E") !important;
}
.dench-favicon-object {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect width='18' height='18' x='3' y='3' rx='2'/%3E%3Cpath d='M3 9h18'/%3E%3Cpath d='M9 21V9'/%3E%3C/svg%3E") !important;
}

View File

@ -1,5 +1,6 @@
import type { Metadata, Viewport } from "next";
import { Suspense } from "react";
import { ThemeProvider } from "next-themes";
import { getOrCreateAnonymousId } from "@/lib/telemetry";
import { PostHogProvider } from "./components/posthog-provider";
import "./globals.css";
@ -38,19 +39,15 @@ export default function RootLayout({
href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Inter:wght@300;400;500;600;700&display=swap"
rel="stylesheet"
/>
{/* Inline script to prevent FOUC — reads localStorage or system preference */}
<script
dangerouslySetInnerHTML={{
__html: `try{if(localStorage.theme==="dark"||(!("theme" in localStorage)&&window.matchMedia("(prefers-color-scheme: dark)").matches)){document.documentElement.classList.add("dark")}else{document.documentElement.classList.remove("dark")}}catch(e){}`,
}}
/>
</head>
<body className="antialiased">
<Suspense fallback={null}>
<PostHogProvider anonymousId={anonymousId}>
{children}
</PostHogProvider>
</Suspense>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
<Suspense fallback={null}>
<PostHogProvider anonymousId={anonymousId}>
{children}
</PostHogProvider>
</Suspense>
</ThemeProvider>
</body>
</html>
);

File diff suppressed because it is too large Load Diff

View File

@ -16,6 +16,10 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@sinm/react-chrome-tabs": "^2.6.3",
"@tanstack/match-sorter-utils": "^8.19.4",
"@tanstack/react-table": "^8.21.3",
"@tiptap/core": "^3.19.0",
@ -45,8 +49,10 @@
"@xterm/xterm": "^6.0.0",
"ai": "^6.0.73",
"chokidar": "^5.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"draggabilly": "^3.0.0",
"framer-motion": "^12.34.0",
"fuse.js": "^7.1.0",
"html-to-docx": "^1.8.0",
@ -54,6 +60,7 @@
"mammoth": "^1.11.0",
"monaco-editor": "^0.55.1",
"next": "^15.3.3",
"next-themes": "^0.4.6",
"node-pty": "^1.1.0",
"posthog-js": "^1.358.1",
"posthog-node": "^5.27.1",

File diff suppressed because one or more lines are too long

1160
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff