From fbfdee21a53aa869c3f93359cda05f1d9ceb7af6 Mon Sep 17 00:00:00 2001 From: Mark Date: Thu, 12 Mar 2026 13:30:04 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20redesign=20workspace=20UI=20=E2=80=94?= =?UTF-8?q?=20glassmorphism=20dropdowns,=20Chrome-style=20tabs,=20chat=20p?= =?UTF-8?q?opover?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move chat history from left sidebar to floating popover on tab bar - Add dench-ui components (Button, Card, Input, Label, Switch) with deps - Glassmorphism styling for all dropdowns/context menus with dark mode - Chrome-style active tab that merges with content area - Align sidebar header with tab bar (34px) - Condense sidebar header to single line - Move sidebar expand button into tab bar - Add next-themes for proper dark mode with system preference support - Add Tailwind v4 class-based dark mode via @custom-variant - Add dench-ui CSS tokens (light + dark) - Restore pointer cursor for all interactive elements - New chat button always visible in tab bar - "Delete this chat" label in dropdown menu Made-with: Cursor --- apps/web/app/components/chat-panel.tsx | 19 +- apps/web/app/components/ui/button.tsx | 47 +++ apps/web/app/components/ui/card.tsx | 36 +++ apps/web/app/components/ui/dropdown-menu.tsx | 2 +- apps/web/app/components/ui/input.tsx | 21 ++ apps/web/app/components/ui/label.tsx | 18 ++ apps/web/app/components/ui/switch.tsx | 28 ++ .../workspace/chat-sessions-sidebar.tsx | 6 +- .../app/components/workspace/context-menu.tsx | 25 +- .../components/workspace/profile-switcher.tsx | 77 ++--- apps/web/app/components/workspace/tab-bar.tsx | 47 +-- .../workspace/workspace-sidebar.tsx | 292 ++++++------------ apps/web/app/globals.css | 57 ++++ apps/web/app/layout.tsx | 19 +- apps/web/app/workspace/workspace-content.tsx | 146 ++++++++- apps/web/package.json | 5 + pnpm-lock.yaml | 273 ++++++++++++++++ 17 files changed, 781 insertions(+), 337 deletions(-) create mode 100644 apps/web/app/components/ui/button.tsx create mode 100644 apps/web/app/components/ui/card.tsx create mode 100644 apps/web/app/components/ui/input.tsx create mode 100644 apps/web/app/components/ui/label.tsx create mode 100644 apps/web/app/components/ui/switch.tsx diff --git a/apps/web/app/components/chat-panel.tsx b/apps/web/app/components/chat-panel.tsx index e6fbb562c5d..18030a8d989 100644 --- a/apps/web/app/components/chat-panel.tsx +++ b/apps/web/app/components/chat-panel.tsx @@ -813,6 +813,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( @@ -834,6 +836,7 @@ export const ChatPanel = forwardRef( subagentTask, subagentLabel, onBack, + hideHeaderActions, }, ref, ) { @@ -2229,6 +2232,7 @@ export const ChatPanel = forwardRef( )} + {!hideHeaderActions && (
{currentSessionId && onDeleteSession && ( @@ -2259,13 +2263,12 @@ export const ChatPanel = forwardRef( onSelect={() => onDeleteSession(currentSessionId)} > - Delete + Delete this chat )} - {compact && ( - - )}
+ )} )} @@ -2365,6 +2368,7 @@ export const ChatPanel = forwardRef( {/* Hero greeting */} {greeting && ( ( transition: { staggerChildren: 0.12, delayChildren: 0.2 }, }, }} + transition={{ layout: { type: "spring", stiffness: 260, damping: 30 } }} > {greeting.split(" ").map((word, i) => ( ( {/* Centered input bar */} ( {/* Prompt suggestion pills */}
{visiblePrompts.slice(0, 3).map((template) => { diff --git a/apps/web/app/components/ui/button.tsx b/apps/web/app/components/ui/button.tsx new file mode 100644 index 00000000000..3aec3a7d3c1 --- /dev/null +++ b/apps/web/app/components/ui/button.tsx @@ -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, VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ; + } +); + +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/apps/web/app/components/ui/card.tsx b/apps/web/app/components/ui/card.tsx new file mode 100644 index 00000000000..89d4105d04f --- /dev/null +++ b/apps/web/app/components/ui/card.tsx @@ -0,0 +1,36 @@ +"use client"; + +import * as React from "react"; +import { cn } from "@/lib/utils"; + +const Card = React.forwardRef>(({ className, ...props }, ref) => ( +
+)); +Card.displayName = "Card"; + +const CardHeader = React.forwardRef>(({ className, ...props }, ref) => ( +
+)); +CardHeader.displayName = "CardHeader"; + +const CardTitle = React.forwardRef>( + ({ className, ...props }, ref) =>

+); +CardTitle.displayName = "CardTitle"; + +const CardDescription = React.forwardRef>( + ({ className, ...props }, ref) =>

+); +CardDescription.displayName = "CardDescription"; + +const CardContent = React.forwardRef>(({ className, ...props }, ref) => ( +

+)); +CardContent.displayName = "CardContent"; + +const CardFooter = React.forwardRef>(({ className, ...props }, ref) => ( +
+)); +CardFooter.displayName = "CardFooter"; + +export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }; diff --git a/apps/web/app/components/ui/dropdown-menu.tsx b/apps/web/app/components/ui/dropdown-menu.tsx index ff3f3d16af0..86cf37cd687 100644 --- a/apps/web/app/components/ui/dropdown-menu.tsx +++ b/apps/web/app/components/ui/dropdown-menu.tsx @@ -58,7 +58,7 @@ function DropdownMenuContent({ >(({ className, type, ...props }, ref) => { + return ( + + ); +}); +Input.displayName = "Input"; + +export { Input }; diff --git a/apps/web/app/components/ui/label.tsx b/apps/web/app/components/ui/label.tsx new file mode 100644 index 00000000000..f2383a12c82 --- /dev/null +++ b/apps/web/app/components/ui/label.tsx @@ -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, + React.ComponentPropsWithoutRef & VariantProps +>(({ className, ...props }, ref) => ( + +)); +Label.displayName = LabelPrimitive.Root.displayName; + +export { Label }; diff --git a/apps/web/app/components/ui/switch.tsx b/apps/web/app/components/ui/switch.tsx new file mode 100644 index 00000000000..7bf70bb283b --- /dev/null +++ b/apps/web/app/components/ui/switch.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +Switch.displayName = SwitchPrimitives.Root.displayName; + +export { Switch }; diff --git a/apps/web/app/components/workspace/chat-sessions-sidebar.tsx b/apps/web/app/components/workspace/chat-sessions-sidebar.tsx index 1edb752928e..ed92f88a561 100644 --- a/apps/web/app/components/workspace/chat-sessions-sidebar.tsx +++ b/apps/web/app/components/workspace/chat-sessions-sidebar.tsx @@ -452,11 +452,11 @@ export function ChatSessionsSidebar({
{/* Header overlay: backdrop blur + 80% bg; list scrolls under it */}
diff --git a/apps/web/app/components/workspace/context-menu.tsx b/apps/web/app/components/workspace/context-menu.tsx index 8d3e295c12b..fc3e4a672be 100644 --- a/apps/web/app/components/workspace/context-menu.tsx +++ b/apps/web/app/components/workspace/context-menu.tsx @@ -160,14 +160,10 @@ export function ContextMenu({ x, y, target, onAction, onClose }: ContextMenuProp return createPortal(
); } @@ -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)} > diff --git a/apps/web/app/components/workspace/profile-switcher.tsx b/apps/web/app/components/workspace/profile-switcher.tsx index 0f44c66d2d1..a3a67a232c9 100644 --- a/apps/web/app/components/workspace/profile-switcher.tsx +++ b/apps/web/app/components/workspace/profile-switcher.tsx @@ -199,69 +199,48 @@ export function ProfileSwitcher({ {showSwitcher && isOpen && (
- {/* Header */}
Workspaces
- {/* Workspace list */} -
+
{workspaces.map((workspace) => { const isCurrent = workspace.name === activeWorkspace; return ( -
+
)} @@ -304,7 +270,7 @@ export function ProfileSwitcher({ {actionError && (

)} - {/* Create new */} -

+
); })} +
+ {rightContent && ( +
+ {rightContent} +
+ )}
{/* Context menu */} {contextMenu && contextTab && (
{ onTogglePin(contextMenu.tabId); setContextMenu(null); }} /> -
+
{ - if (!disabled) (e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)"; - }} - onMouseLeave={(e) => { - (e.currentTarget as HTMLElement).style.background = "transparent"; - }} > {label} {shortcut && ( diff --git a/apps/web/app/components/workspace/workspace-sidebar.tsx b/apps/web/app/components/workspace/workspace-sidebar.tsx index 56eb99744c1..1507d016f47 100644 --- a/apps/web/app/components/workspace/workspace-sidebar.tsx +++ b/apps/web/app/components/workspace/workspace-sidebar.tsx @@ -1,6 +1,7 @@ "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"; @@ -105,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
; - 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 ( -
-
- {orgName || "Workspace"} -
-
- { - onWorkspaceChanged?.(); - }} - onWorkspaceDelete={() => { - onWorkspaceChanged?.(); - }} - onCreateWorkspace={() => { - setCreateWorkspaceOpen(true); - }} - trigger={({ onClick, activeWorkspace: workspaceName, switching }) => ( - - )} - /> -
+ + + + )} + />
)} @@ -593,83 +529,33 @@ export function WorkspaceSidebar({ )}
- {/* Tab switcher */} - {hasChatProps && !isBrowsing && ( -
- - -
+ {onFileSearchSelect && ( + )} - {/* Tab content */} - {currentTab === "files" || !hasChatProps ? ( - <> - {onFileSearchSelect && ( - - )} -
- {loading ? ( -
- -
- ) : ( - - )} +
+ {loading ? ( +
+
- - ) : ( - {})} - onNewSession={onNewChatSession ?? (() => {})} - onSelectSubagent={onSelectChatSubagent} - onDeleteSession={onDeleteChatSession} - onRenameSession={onRenameChatSession} - embedded - /> - )} + ) : ( + + )} +
{/* Footer */}
- {/* Inline script to prevent FOUC — reads localStorage or system preference */} -