refact(front): optimize front style (#1007)

This commit is contained in:
Haze
2026-05-12 18:37:40 +08:00
committed by GitHub
parent 5c1ed5e6ee
commit 7dc18bb6e7
19 changed files with 331 additions and 134 deletions

View File

@@ -62,7 +62,7 @@ interface ChannelConfigModalProps {
onChannelSaved?: (channelType: ChannelType) => void | Promise<void>;
}
const inputClasses = 'h-[44px] rounded-xl font-mono text-meta bg-surface-input border-black/10 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-blue-500/50 focus-visible:border-blue-500 shadow-sm transition-all text-foreground placeholder:text-foreground/40';
const inputClasses = 'h-[44px] rounded-xl font-mono text-meta bg-transparent border-black/10 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-blue-500/50 focus-visible:border-blue-500 shadow-sm transition-all text-foreground placeholder:text-foreground/40';
const labelClasses = 'text-sm text-foreground/80 font-bold';
const outlineButtonClasses = 'h-9 text-meta font-medium rounded-full px-4 border-black/10 dark:border-white/10 bg-transparent hover:bg-black/5 dark:hover:bg-white/5 shadow-none text-foreground/80 hover:text-foreground';
const primaryButtonClasses = 'h-9 text-meta font-medium rounded-full px-4 shadow-none';
@@ -539,7 +539,7 @@ export function ChannelConfigModal({
key={type}
onClick={() => setSelectedType(type)}
className={cn(
'group flex items-start gap-4 p-4 rounded-2xl transition-all text-left border relative overflow-hidden bg-surface-input shadow-sm',
'group flex items-start gap-4 p-4 rounded-2xl transition-all text-left border relative overflow-hidden bg-transparent shadow-sm',
isConfigured
? 'border-green-500/40 bg-green-500/5 dark:bg-green-500/10'
: 'border-black/5 dark:border-white/10 hover:bg-black/5 dark:hover:bg-white/5'
@@ -578,7 +578,7 @@ export function ChannelConfigModal({
</div>
) : qrCode ? (
<div className="text-center space-y-6">
<div className="bg-surface-input p-4 rounded-3xl inline-block shadow-sm border border-black/10 dark:border-white/10">
<div className="bg-transparent p-4 rounded-3xl inline-block shadow-sm border border-black/10 dark:border-white/10">
{qrCode.startsWith('data:image') || qrCode.startsWith('http://') || qrCode.startsWith('https://') ? (
<img src={qrCode} alt="Scan QR Code" className="w-64 h-64 object-contain rounded-2xl" />
) : (
@@ -604,7 +604,7 @@ export function ChannelConfigModal({
</div>
</div>
) : loadingConfig ? (
<div className="flex items-center justify-center py-10 rounded-2xl bg-surface-input border border-black/10 dark:border-white/10">
<div className="flex items-center justify-center py-10 rounded-2xl bg-transparent border border-black/10 dark:border-white/10">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground">{t('dialog.loadingConfig')}</span>
</div>
@@ -617,7 +617,7 @@ export function ChannelConfigModal({
</div>
)}
<div className="bg-surface-input p-4 rounded-2xl space-y-4 shadow-sm border border-black/10 dark:border-white/10">
<div className="bg-transparent p-4 rounded-2xl space-y-4 shadow-sm border border-black/10 dark:border-white/10">
<div className="flex items-center justify-between gap-3">
<div>
<p className={labelClasses}>{t('dialog.howToConnect')}</p>
@@ -851,7 +851,7 @@ function ConfigField({ field, value, onChange, showSecret, onToggleSecret }: Con
variant="outline"
size="icon"
onClick={onToggleSecret}
className="h-[44px] w-[44px] rounded-xl bg-surface-input border-black/10 dark:border-white/10 text-muted-foreground hover:text-foreground shrink-0 shadow-sm"
className="h-[44px] w-[44px] rounded-xl bg-transparent border-black/10 dark:border-white/10 text-muted-foreground hover:text-foreground shrink-0 shadow-sm"
>
{showSecret ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>

View File

@@ -138,7 +138,7 @@ function SkillFileSection({ title, description, files, onOpen }: SkillFileSectio
type="button"
onClick={() => onOpen(file)}
className={cn(
'flex items-center gap-2 rounded-xl border border-black/5 bg-surface-input px-3 py-2 text-left transition-colors',
'flex items-center gap-2 rounded-xl border border-black/5 bg-transparent px-3 py-2 text-left transition-colors',
'hover:border-primary/40 hover:bg-primary/5',
'dark:border-white/5 dark:hover:bg-white/10',
)}

View File

@@ -1,21 +1,33 @@
/**
* Main Layout Component
* TitleBar at top, then sidebar + content below.
* Platform-aware application shell.
*/
import { Outlet } from 'react-router-dom';
import { Sidebar } from './Sidebar';
import { TitleBar } from './TitleBar';
import { cn } from '@/lib/utils';
export function MainLayout() {
const platform = window.electron?.platform;
const isMac = platform === 'darwin';
return (
<div data-testid="main-layout" className="flex h-screen flex-col overflow-hidden bg-background">
{/* Title bar: drag region on macOS, icon + controls on Windows */}
<div
data-testid="main-layout"
data-platform={platform}
className={cn(
'flex h-screen overflow-hidden bg-background',
isMac ? 'flex-row' : 'flex-col',
)}
>
<TitleBar />
{/* Below the title bar: sidebar + content */}
<div className="flex min-h-0 flex-1 overflow-hidden">
<div className="flex min-h-0 flex-1 overflow-hidden bg-surface-sidebar">
<Sidebar />
<main data-testid="main-content" className="min-h-0 flex-1 overflow-auto p-6">
<main
data-testid="main-content"
className="min-h-0 flex-1 overflow-auto rounded-tl-2xl border-l border-t border-border/60 bg-background p-6"
>
<Outlet />
</main>
</div>

View File

@@ -3,7 +3,7 @@
* Navigation sidebar with menu items.
* No longer fixed - sits inside the flex layout below the title bar.
*/
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
import {
Network,
@@ -52,7 +52,7 @@ function NavItem({ to, icon, label, badge, collapsed, onClick, testId }: NavItem
data-testid={testId}
className={({ isActive }) =>
cn(
'flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium transition-colors',
'sidebar-nav-text flex items-center gap-2 rounded-lg px-2.5 py-1.5 transition-colors',
'hover:bg-black/5 dark:hover:bg-white/5 text-foreground/80',
isActive
? 'bg-black/5 dark:bg-white/10 text-foreground'
@@ -61,23 +61,21 @@ function NavItem({ to, icon, label, badge, collapsed, onClick, testId }: NavItem
)
}
>
{({ isActive }) => (
<>
<div className={cn("flex shrink-0 items-center justify-center", isActive ? "text-foreground" : "text-muted-foreground")}>
{icon}
</div>
{!collapsed && (
<>
<span className="flex-1 overflow-hidden text-ellipsis whitespace-nowrap">{label}</span>
{badge && (
<Badge variant="secondary" className="ml-auto shrink-0">
{badge}
</Badge>
)}
</>
)}
</>
)}
<>
<div className="flex shrink-0 items-center justify-center text-current [&_svg]:size-4">
{icon}
</div>
{!collapsed && (
<>
<span className="flex-1 overflow-hidden text-ellipsis whitespace-nowrap">{label}</span>
{badge && (
<Badge variant="secondary" className="ml-auto shrink-0">
{badge}
</Badge>
)}
</>
)}
</>
</NavLink>
);
}
@@ -91,9 +89,14 @@ function getAgentIdFromSessionKey(sessionKey: string): string {
}
export function Sidebar() {
const isMac = window.electron?.platform === 'darwin';
const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
const setSidebarCollapsed = useSettingsStore((state) => state.setSidebarCollapsed);
const sidebarWidth = useSettingsStore((state) => state.sidebarWidth);
const setSidebarWidth = useSettingsStore((state) => state.setSidebarWidth);
const devModeUnlocked = useSettingsStore((state) => state.devModeUnlocked);
const [isResizing, setIsResizing] = useState(false);
const stopResizeRef = useRef<(() => void) | null>(null);
const sessions = useChatStore((s) => s.sessions);
const currentSessionKey = useChatStore((s) => s.currentSessionKey);
@@ -169,6 +172,45 @@ export function Sidebar() {
void fetchAgents();
}, [fetchAgents]);
const stopResizing = useCallback(() => {
stopResizeRef.current?.();
stopResizeRef.current = null;
setIsResizing(false);
document.body.style.cursor = '';
document.body.style.userSelect = '';
}, []);
const handleResizePointerDown = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (sidebarCollapsed) return;
event.preventDefault();
event.stopPropagation();
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// Window listeners below keep dragging reliable even if capture is unavailable.
}
const onMove = (moveEvent: PointerEvent) => {
setSidebarWidth(moveEvent.clientX);
};
const onUp = () => stopResizing();
stopResizeRef.current = () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
setIsResizing(true);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
},
[setSidebarWidth, sidebarCollapsed, stopResizing],
);
useEffect(() => stopResizing, [stopResizing]);
const agentNameById = useMemo(
() => Object.fromEntries((agents ?? []).map((agent) => [agent.id, agent.name])),
[agents],
@@ -200,13 +242,13 @@ export function Sidebar() {
const extraNavItems = rendererExtensionRegistry.getExtraNavItems();
const coreNavItems = [
{ to: '/models', icon: <Cpu className="h-[18px] w-[18px]" strokeWidth={2} />, label: t('sidebar.models'), testId: 'sidebar-nav-models' },
{ to: '/agents', icon: <Bot className="h-[18px] w-[18px]" strokeWidth={2} />, label: t('sidebar.agents'), testId: 'sidebar-nav-agents' },
{ to: '/channels', icon: <Network className="h-[18px] w-[18px]" strokeWidth={2} />, label: t('sidebar.channels'), testId: 'sidebar-nav-channels' },
{ to: '/skills', icon: <Puzzle className="h-[18px] w-[18px]" strokeWidth={2} />, label: t('sidebar.skills'), testId: 'sidebar-nav-skills' },
{ to: '/cron', icon: <Clock className="h-[18px] w-[18px]" strokeWidth={2} />, label: t('sidebar.cronTasks'), testId: 'sidebar-nav-cron' },
{ to: '/models', icon: <Cpu className="h-4 w-4" strokeWidth={2} />, label: t('sidebar.models'), testId: 'sidebar-nav-models' },
{ to: '/agents', icon: <Bot className="h-4 w-4" strokeWidth={2} />, label: t('sidebar.agents'), testId: 'sidebar-nav-agents' },
{ to: '/channels', icon: <Network className="h-4 w-4" strokeWidth={2} />, label: t('sidebar.channels'), testId: 'sidebar-nav-channels' },
{ to: '/skills', icon: <Puzzle className="h-4 w-4" strokeWidth={2} />, label: t('sidebar.skills'), testId: 'sidebar-nav-skills' },
{ to: '/cron', icon: <Clock className="h-4 w-4" strokeWidth={2} />, label: t('sidebar.cronTasks'), testId: 'sidebar-nav-cron' },
...(devModeUnlocked
? [{ to: '/dreams', icon: <Moon className="h-[18px] w-[18px]" strokeWidth={2} />, label: t('common:sidebar.openClawDreams'), testId: 'sidebar-nav-dreams' }]
? [{ to: '/dreams', icon: <Moon className="h-4 w-4" strokeWidth={2} />, label: t('common:sidebar.openClawDreams'), testId: 'sidebar-nav-dreams' }]
: []),
];
@@ -214,7 +256,7 @@ export function Sidebar() {
...coreNavItems.filter((item) => !hiddenRoutes.has(item.to)),
...extraNavItems.map((item) => ({
to: item.to,
icon: <item.icon className="h-[18px] w-[18px]" strokeWidth={2} />,
icon: <item.icon className="h-4 w-4" strokeWidth={2} />,
label: item.labelI18nKey ? t(item.labelI18nKey) : item.label,
testId: item.testId,
})),
@@ -224,12 +266,19 @@ export function Sidebar() {
<aside
data-testid="sidebar"
className={cn(
'flex min-h-0 shrink-0 flex-col overflow-hidden border-r bg-surface-sidebar/60 transition-all duration-300',
sidebarCollapsed ? 'w-16' : 'w-64'
'relative flex min-h-0 shrink-0 flex-col overflow-hidden bg-surface-sidebar',
isResizing ? 'transition-none' : 'transition-[width] duration-300',
)}
style={{ width: sidebarCollapsed ? 64 : sidebarWidth }}
>
{/* Top Header Toggle */}
<div className={cn("flex items-center p-2 h-12", sidebarCollapsed ? "justify-center" : "justify-between")}>
<div
className={cn(
'flex items-center p-2 h-12',
isMac && 'drag-region h-[4.75rem] items-end pt-10',
sidebarCollapsed ? 'justify-center' : 'justify-between',
)}
>
{!sidebarCollapsed && (
<div className="flex items-center gap-2 px-2 overflow-hidden">
<img src={logoSvg} alt="ClawX" className="h-5 w-auto shrink-0" />
@@ -241,7 +290,7 @@ export function Sidebar() {
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10"
className="no-drag h-8 w-8 shrink-0 text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10"
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
>
{sidebarCollapsed ? (
@@ -253,7 +302,7 @@ export function Sidebar() {
</div>
{/* Navigation */}
<nav className="flex flex-col px-2 gap-0.5">
<nav className="flex flex-col gap-0 px-2">
<button
data-testid="sidebar-new-chat"
onClick={() => {
@@ -262,13 +311,13 @@ export function Sidebar() {
navigate('/');
}}
className={cn(
'flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium transition-colors mb-2',
'bg-black/5 dark:bg-accent shadow-none border border-transparent text-foreground',
'sidebar-nav-text mb-1 flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 transition-colors',
'border border-transparent text-foreground/80 shadow-none hover:bg-black/5 hover:text-foreground dark:hover:bg-white/5',
sidebarCollapsed && 'justify-center px-0',
)}
>
<div className="flex shrink-0 items-center justify-center text-foreground/80">
<Plus className="h-[18px] w-[18px]" strokeWidth={2} />
<div className="flex shrink-0 items-center justify-center text-current [&_svg]:size-4">
<Plus className="h-4 w-4" strokeWidth={2} />
</div>
{!sidebarCollapsed && <span className="flex-1 text-left overflow-hidden text-ellipsis whitespace-nowrap">{t('sidebar.newChat')}</span>}
</button>
@@ -353,21 +402,19 @@ export function Sidebar() {
data-testid="sidebar-nav-settings"
className={({ isActive }) =>
cn(
'flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium transition-colors',
'sidebar-nav-text flex items-center gap-2 rounded-lg px-2.5 py-1.5 transition-colors',
'hover:bg-black/5 dark:hover:bg-white/5 text-foreground/80',
isActive && 'bg-black/5 dark:bg-white/10 text-foreground',
sidebarCollapsed ? 'justify-center px-0' : ''
)
}
>
{({ isActive }) => (
<>
<div className={cn("flex shrink-0 items-center justify-center", isActive ? "text-foreground" : "text-muted-foreground")}>
<SettingsIcon className="h-[18px] w-[18px]" strokeWidth={2} />
</div>
{!sidebarCollapsed && <span className="flex-1 overflow-hidden text-ellipsis whitespace-nowrap">{t('sidebar.settings')}</span>}
</>
)}
<>
<div className="flex shrink-0 items-center justify-center text-current [&_svg]:size-4">
<SettingsIcon className="h-4 w-4" strokeWidth={2} />
</div>
{!sidebarCollapsed && <span className="flex-1 overflow-hidden text-ellipsis whitespace-nowrap">{t('sidebar.settings')}</span>}
</>
</NavLink>
{devModeUnlocked && (
@@ -375,25 +422,44 @@ export function Sidebar() {
data-testid="sidebar-open-dev-console"
variant="ghost"
className={cn(
'flex items-center gap-2.5 rounded-lg px-2.5 py-2 h-auto text-sm font-medium transition-colors w-full mt-1',
'sidebar-nav-text mt-0.5 flex h-auto w-full items-center gap-2 rounded-lg px-2.5 py-1.5 transition-colors',
'hover:bg-black/5 dark:hover:bg-white/5 text-foreground/80',
sidebarCollapsed ? 'justify-center px-0' : 'justify-start'
)}
onClick={openDevConsole}
>
<div className="flex shrink-0 items-center justify-center text-muted-foreground">
<Terminal className="h-[18px] w-[18px]" strokeWidth={2} />
<div className="flex shrink-0 items-center justify-center text-current [&_svg]:size-4">
<Terminal className="h-4 w-4" strokeWidth={2} />
</div>
{!sidebarCollapsed && (
<>
<span className="flex-1 text-left overflow-hidden text-ellipsis whitespace-nowrap">{t('common:sidebar.openClawPage')}</span>
<ExternalLink className="h-3 w-3 shrink-0 ml-auto opacity-50 text-muted-foreground" />
<ExternalLink className="ml-auto h-3 w-3 shrink-0 opacity-50 text-current" />
</>
)}
</Button>
)}
</div>
{!sidebarCollapsed && (
<div
data-testid="sidebar-resize-handle"
role="separator"
aria-orientation="vertical"
aria-valuemin={220}
aria-valuemax={420}
aria-valuenow={sidebarWidth}
title="Drag to resize sidebar"
onPointerDown={handleResizePointerDown}
className="no-drag group absolute inset-y-0 right-0 z-20 w-2 translate-x-1/2 cursor-col-resize select-none"
>
<span
aria-hidden
className="pointer-events-none absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-transparent transition-colors group-hover:bg-primary/40"
/>
</div>
)}
<ConfirmDialog
open={!!sessionToDelete}
title={t('common:actions.confirm')}

View File

@@ -12,8 +12,8 @@ export function TitleBar() {
const platform = window.electron?.platform;
if (platform === 'darwin') {
// macOS: just a drag region, traffic lights are native
return <div className="drag-region h-10 shrink-0 border-b bg-background" />;
// macOS traffic lights live inside the sidebar area; keep the shell left/right.
return null;
}
// Linux keeps the native frame/title bar for better IME compatibility.
@@ -51,7 +51,10 @@ function WindowsTitleBar() {
};
return (
<div className="drag-region flex h-10 shrink-0 items-center justify-end border-b bg-background">
<div
data-testid="windows-titlebar"
className="drag-region flex h-10 shrink-0 items-center justify-end bg-background"
>
{/* Right: Window Controls */}
<div className="no-drag flex h-full">

View File

@@ -55,7 +55,7 @@ import { useSettingsStore } from '@/stores/settings';
import { hostApiFetch } from '@/lib/host-api';
import { subscribeHostEvent } from '@/lib/host-events';
const inputClasses = 'h-[44px] rounded-xl font-mono text-meta bg-surface-input border-black/10 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-blue-500/50 focus-visible:border-blue-500 shadow-sm transition-all text-foreground placeholder:text-foreground/40';
const inputClasses = 'h-[44px] rounded-xl font-mono text-meta bg-transparent border-black/10 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-blue-500/50 focus-visible:border-blue-500 shadow-sm transition-all text-foreground placeholder:text-foreground/40';
const labelClasses = 'text-sm text-foreground/80 font-bold';
type ArkMode = 'apikey' | 'codeplan';
@@ -790,7 +790,7 @@ function ProviderCard({
placeholder={t('aiProviders.dialog.fallbackModelIdsPlaceholder')}
className={isDefault
? "min-h-24 w-full rounded-xl border border-black/10 dark:border-white/10 bg-white dark:bg-card px-3 py-2 text-meta font-mono outline-none focus-visible:ring-2 focus-visible:ring-blue-500/50 shadow-sm"
: "min-h-24 w-full rounded-xl border border-black/10 dark:border-white/10 bg-surface-input px-3 py-2 text-meta font-mono outline-none focus-visible:ring-2 focus-visible:ring-blue-500/50 focus-visible:border-blue-500 shadow-sm transition-all text-foreground placeholder:text-foreground/40"}
: "min-h-24 w-full rounded-xl border border-black/10 dark:border-white/10 bg-transparent px-3 py-2 text-meta font-mono outline-none focus-visible:ring-2 focus-visible:ring-blue-500/50 focus-visible:border-blue-500 shadow-sm transition-all text-foreground placeholder:text-foreground/40"}
/>
<p className="text-xs text-muted-foreground">
{t('aiProviders.dialog.fallbackModelIdsHelp')}
@@ -801,7 +801,7 @@ function ProviderCard({
{fallbackOptions.length === 0 ? (
<p className="text-meta text-muted-foreground">{t('aiProviders.dialog.noFallbackOptions')}</p>
) : (
<div className={cn("space-y-2 rounded-xl border border-black/10 dark:border-white/10 p-3 shadow-sm", isDefault ? "bg-white dark:bg-card" : "bg-surface-input")}>
<div className={cn("space-y-2 rounded-xl border border-black/10 dark:border-white/10 p-3 shadow-sm", isDefault ? "bg-white dark:bg-card" : "bg-transparent")}>
{fallbackOptions.map((candidate) => (
<label key={candidate.account.id} className="flex items-center gap-3 text-meta cursor-pointer group/label">
<input
@@ -883,7 +883,7 @@ function ProviderCard({
"rounded-xl px-4 border-black/10 dark:border-white/10",
isDefault
? "h-[40px] bg-white dark:bg-card hover:bg-black/5 dark:hover:bg-white/10"
: "h-[44px] bg-surface-input hover:bg-black/5 dark:hover:bg-white/10 shadow-sm"
: "h-[44px] bg-transparent hover:bg-black/5 dark:hover:bg-white/10 shadow-sm"
)}
disabled={
validating
@@ -913,7 +913,7 @@ function ProviderCard({
"p-0 rounded-xl",
isDefault
? "h-[40px] w-[40px] hover:bg-black/5 dark:hover:bg-white/10"
: "h-[44px] w-[44px] bg-surface-input border border-black/10 dark:border-white/10 hover:bg-black/5 dark:hover:bg-white/10 shadow-sm text-muted-foreground hover:text-foreground"
: "h-[44px] w-[44px] bg-transparent border border-black/10 dark:border-white/10 hover:bg-black/5 dark:hover:bg-white/10 shadow-sm text-muted-foreground hover:text-foreground"
)}
>
<X className="h-4 w-4" />
@@ -1314,7 +1314,7 @@ function AddProviderDialog({
</div>
) : (
<div className="space-y-6">
<div className="flex items-center gap-3 p-4 rounded-2xl bg-white dark:bg-card border border-black/5 dark:border-white/5 shadow-sm">
<div className="flex items-center gap-3 p-4 rounded-2xl bg-transparent border border-black/5 dark:border-white/5 shadow-sm">
<div className="h-10 w-10 shrink-0 flex items-center justify-center bg-black/5 dark:bg-white/5 rounded-xl">
{getProviderIconUrl(selectedType!) ? (
<img src={getProviderIconUrl(selectedType!)} alt={typeInfo?.name} className={cn('h-6 w-6', shouldInvertInDark(selectedType!) && 'dark:invert')} />
@@ -1370,7 +1370,7 @@ function AddProviderDialog({
{/* Auth mode toggle for providers supporting both */}
{isOAuth && supportsApiKey && (
<div className="flex rounded-xl border border-black/10 dark:border-white/10 overflow-hidden text-meta font-medium shadow-sm bg-surface-input p-1 gap-1">
<div className="flex rounded-xl border border-black/10 dark:border-white/10 overflow-hidden text-meta font-medium shadow-sm bg-transparent p-1 gap-1">
<button
onClick={() => setAuthMode('oauth')}
className={cn(
@@ -1662,7 +1662,7 @@ function AddProviderDialog({
</div>
</div>
<div className="flex items-center justify-center gap-3 p-4 bg-surface-input border border-black/5 dark:border-white/5 rounded-xl shadow-inner">
<div className="flex items-center justify-center gap-3 p-4 bg-transparent border border-black/5 dark:border-white/5 rounded-xl shadow-inner">
<code className="text-3xl font-mono tracking-[0.2em] font-bold text-foreground">
{oauthData.userCode}
</code>

View File

@@ -332,8 +332,8 @@ function AgentCard({
);
}
const inputClasses = 'h-[44px] rounded-xl font-mono text-meta bg-surface-input border-black/10 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-blue-500/50 focus-visible:border-blue-500 shadow-sm transition-all text-foreground placeholder:text-foreground/40';
const selectClasses = 'h-[44px] w-full rounded-xl font-mono text-meta bg-surface-input border border-black/10 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-blue-500/50 focus-visible:border-blue-500 shadow-sm transition-all text-foreground px-3';
const inputClasses = 'h-[44px] rounded-xl font-mono text-meta bg-transparent border-black/10 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-blue-500/50 focus-visible:border-blue-500 shadow-sm transition-all text-foreground placeholder:text-foreground/40';
const selectClasses = 'h-[44px] w-full rounded-xl font-mono text-meta bg-transparent border border-black/10 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-blue-500/50 focus-visible:border-blue-500 shadow-sm transition-all text-foreground px-3';
const labelClasses = 'text-sm text-foreground/80 font-bold';
function ChannelLogo({ type }: { type: ChannelType }) {
@@ -541,7 +541,7 @@ function AgentSettingsModal({
variant="outline"
onClick={() => void handleSaveName()}
disabled={savingName || !name.trim() || name.trim() === agent.name}
className="h-[44px] text-meta font-medium rounded-xl px-4 border-black/10 dark:border-white/10 bg-surface-input hover:bg-black/5 dark:hover:bg-white/5 shadow-none text-foreground/80 hover:text-foreground"
className="h-[44px] text-meta font-medium rounded-xl px-4 border-black/10 dark:border-white/10 bg-transparent hover:bg-black/5 dark:hover:bg-white/5 shadow-none text-foreground/80 hover:text-foreground"
>
{savingName ? (
<RefreshCw className="h-4 w-4 animate-spin" />

View File

@@ -671,11 +671,21 @@ export function Chat() {
}
}, [userRunCards, messages, currentSessionKey]);
const platform = window.electron?.platform;
const isMac = platform === 'darwin';
const isWindows = platform === 'win32';
return (
<div
ref={splitContainerRef}
className={cn('relative flex min-h-0 -m-6 transition-colors duration-500 dark:bg-background')}
style={{ height: 'calc(100vh - 2.5rem)' }}
data-testid="chat-page"
className={cn(
'relative flex min-h-0 -m-6 overflow-hidden transition-colors duration-500',
'bg-background',
isMac && 'rounded-tl-2xl shadow-[inset_1px_1px_0_hsl(var(--border)/0.55)]',
isWindows && 'rounded-tl-2xl',
)}
style={{ height: isMac ? '100vh' : 'calc(100vh - 2.5rem)' }}
>
{/* Left column: chat */}
<div className="flex min-w-0 flex-1 flex-col">

View File

@@ -442,7 +442,7 @@ function TaskDialog({ job, configuredChannels, onClose, onSave }: TaskDialogProp
placeholder={t('dialog.taskNamePlaceholder')}
value={name}
onChange={(e) => setName(e.target.value)}
className="h-[44px] rounded-xl font-mono text-meta bg-surface-input border-black/10 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:border-primary shadow-sm transition-all text-foreground placeholder:text-foreground/40"
className="h-[44px] rounded-xl font-mono text-meta bg-transparent border-black/10 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:border-primary shadow-sm transition-all text-foreground placeholder:text-foreground/40"
/>
</div>
@@ -455,7 +455,7 @@ function TaskDialog({ job, configuredChannels, onClose, onSave }: TaskDialogProp
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={3}
className="rounded-xl font-mono text-meta bg-surface-input border-black/10 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:border-primary shadow-sm transition-all text-foreground placeholder:text-foreground/40 resize-none"
className="rounded-xl font-mono text-meta bg-transparent border-black/10 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:border-primary shadow-sm transition-all text-foreground placeholder:text-foreground/40 resize-none"
/>
</div>
@@ -468,7 +468,7 @@ function TaskDialog({ job, configuredChannels, onClose, onSave }: TaskDialogProp
onChange={(e) => {
setSelectedAgentId(e.target.value);
}}
className="h-[44px] rounded-xl border-black/10 dark:border-white/10 bg-surface-input text-meta"
className="h-[44px] rounded-xl border-black/10 dark:border-white/10 bg-transparent text-meta"
>
{agents.map((agent) => (
<option key={agent.id} value={agent.id}>
@@ -494,7 +494,7 @@ function TaskDialog({ job, configuredChannels, onClose, onSave }: TaskDialogProp
"justify-start h-10 rounded-xl font-medium text-meta transition-all",
schedule === preset.value
? "bg-primary hover:bg-primary/90 text-primary-foreground shadow-sm border-transparent"
: "bg-surface-input border-black/10 dark:border-white/10 hover:bg-black/5 dark:hover:bg-white/5 text-foreground/80 hover:text-foreground"
: "bg-transparent border-black/10 dark:border-white/10 hover:bg-black/5 dark:hover:bg-white/5 text-foreground/80 hover:text-foreground"
)}
>
<Timer className="h-4 w-4 mr-2 opacity-70" />
@@ -507,7 +507,7 @@ function TaskDialog({ job, configuredChannels, onClose, onSave }: TaskDialogProp
placeholder={t('dialog.cronPlaceholder')}
value={customSchedule}
onChange={(e) => setCustomSchedule(e.target.value)}
className="h-[44px] rounded-xl font-mono text-meta bg-surface-input border-black/10 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:border-primary shadow-sm transition-all text-foreground placeholder:text-foreground/40"
className="h-[44px] rounded-xl font-mono text-meta bg-transparent border-black/10 dark:border-white/10 focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:border-primary shadow-sm transition-all text-foreground placeholder:text-foreground/40"
/>
)}
<div className="flex items-center justify-between mt-2">
@@ -543,7 +543,7 @@ function TaskDialog({ job, configuredChannels, onClose, onSave }: TaskDialogProp
'justify-start h-auto min-h-12 rounded-xl px-4 py-3 text-left whitespace-normal',
deliveryMode === 'none'
? 'bg-primary hover:bg-primary/90 text-primary-foreground border-transparent'
: 'bg-surface-input border-black/10 dark:border-white/10 hover:bg-black/5 dark:hover:bg-white/5 text-foreground/80 hover:text-foreground',
: 'bg-transparent border-black/10 dark:border-white/10 hover:bg-black/5 dark:hover:bg-white/5 text-foreground/80 hover:text-foreground',
)}
>
<div>
@@ -560,7 +560,7 @@ function TaskDialog({ job, configuredChannels, onClose, onSave }: TaskDialogProp
'justify-start h-auto min-h-12 rounded-xl px-4 py-3 text-left whitespace-normal',
deliveryMode === 'announce'
? 'bg-primary hover:bg-primary/90 text-primary-foreground border-transparent'
: 'bg-surface-input border-black/10 dark:border-white/10 hover:bg-black/5 dark:hover:bg-white/5 text-foreground/80 hover:text-foreground',
: 'bg-transparent border-black/10 dark:border-white/10 hover:bg-black/5 dark:hover:bg-white/5 text-foreground/80 hover:text-foreground',
)}
>
<div>
@@ -571,7 +571,7 @@ function TaskDialog({ job, configuredChannels, onClose, onSave }: TaskDialogProp
</div>
{deliveryMode === 'announce' && (
<div className="space-y-3 rounded-2xl border border-black/5 dark:border-white/5 bg-surface-input p-4 shadow-sm">
<div className="space-y-3 rounded-2xl border border-black/5 dark:border-white/5 bg-transparent p-4 shadow-sm">
<div className="space-y-2">
<Label htmlFor="delivery-channel" className="text-meta text-foreground/80 font-bold">
{t('dialog.deliveryChannel')}
@@ -662,7 +662,7 @@ function TaskDialog({ job, configuredChannels, onClose, onSave }: TaskDialogProp
</div>
{/* Enabled */}
<div className="flex items-center justify-between bg-surface-input p-4 rounded-2xl shadow-sm border border-black/5 dark:border-white/5">
<div className="flex items-center justify-between bg-transparent p-4 rounded-2xl shadow-sm border border-black/5 dark:border-white/5">
<div>
<Label className="text-sm text-foreground/80 font-bold">{t('dialog.enableImmediately')}</Label>
<p className="text-meta text-muted-foreground mt-0.5">

View File

@@ -129,12 +129,19 @@ function buildDreamingEnabledPatchRaw(enabled: boolean): string {
},
});
}
const PANEL_CLASS = 'border-black/10 bg-surface-modal shadow-sm dark:border-white/10';
const INSET_CLASS = 'border-black/10 bg-surface-input dark:border-white/10';
const QUIET_BUTTON_CLASS = 'border-black/10 bg-surface-input text-foreground/80 shadow-none hover:bg-black/5 hover:text-foreground dark:border-white/10 dark:hover:bg-white/5';
const PANEL_CLASS = 'rounded-2xl border-black/10 bg-surface-modal shadow-sm dark:border-white/10';
const INSET_CLASS = 'rounded-xl border-black/10 bg-transparent dark:border-white/10';
const QUIET_BUTTON_CLASS = 'border-black/10 bg-transparent text-foreground/80 shadow-none hover:bg-black/5 hover:text-foreground dark:border-white/10 dark:hover:bg-white/5';
const STATUS_BADGE_CLASS = 'border-black/10 bg-black/5 text-foreground/80 dark:border-white/10 dark:bg-white/10 dark:text-foreground/80';
const SUCCESS_NOTICE_CLASS = 'border-black/10 bg-black/5 text-foreground/80 dark:border-white/10 dark:bg-white/10';
// Header pill buttons — mirrors the Agents/Cron page header style so
// the top-right action cluster looks consistent across pages. Outline
// is used for secondary actions (refresh, disable, open-full-UI); the
// primary is the brand-filled pill (enable / new task / add agent).
const HEADER_PILL_OUTLINE_CLASS = 'h-9 rounded-full px-4 text-meta font-medium border-black/10 dark:border-white/10 bg-transparent hover:bg-black/5 dark:hover:bg-white/5 shadow-none text-foreground/80 hover:text-foreground transition-colors';
const HEADER_PILL_PRIMARY_CLASS = 'h-9 rounded-full px-4 text-meta font-medium shadow-none';
function isRecord(value: unknown): value is Record<string, unknown> {
return value != null && typeof value === 'object' && !Array.isArray(value);
}
@@ -411,38 +418,35 @@ export function Dreams() {
</div>
<p className="mt-2 text-subtitle font-medium text-foreground/60">{t('subtitle')}</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<div className="flex shrink-0 items-center gap-3">
<Button
data-testid={dreaming?.enabled ? 'dreams-disable' : 'dreams-enable'}
variant={dreaming?.enabled ? 'outline' : 'default'}
size="sm"
onClick={() => void setDreamingEnabled(!dreaming?.enabled)}
disabled={!dreamsReady || busy || loading}
className={dreaming?.enabled ? QUIET_BUTTON_CLASS : undefined}
className={dreaming?.enabled ? HEADER_PILL_OUTLINE_CLASS : HEADER_PILL_PRIMARY_CLASS}
>
{runningToggle ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Power className="mr-2 h-4 w-4" />}
{runningToggle ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <Power className="mr-2 h-3.5 w-3.5" />}
{dreaming?.enabled ? t('actions.disable') : t('actions.enable')}
</Button>
<Button
data-testid="dreams-refresh"
variant="outline"
size="sm"
onClick={() => void refreshAll({ force: true })}
disabled={!dreamsReady}
className={QUIET_BUTTON_CLASS}
className={HEADER_PILL_OUTLINE_CLASS}
>
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RefreshCw className="mr-2 h-4 w-4" />}
{loading ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="mr-2 h-3.5 w-3.5" />}
{t('common:actions.refresh')}
</Button>
<Button
data-testid="dreams-open-full-ui"
variant="secondary"
size="sm"
variant="outline"
onClick={() => void openFullDreams()}
disabled={openingFullUi || !gatewayRunning}
className="border border-black/10 bg-card text-foreground shadow-sm hover:bg-black/5 dark:border-white/10 dark:bg-card dark:hover:bg-white/5"
className={HEADER_PILL_OUTLINE_CLASS}
>
{openingFullUi ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <ExternalLink className="mr-2 h-4 w-4" />}
{openingFullUi ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <ExternalLink className="mr-2 h-3.5 w-3.5" />}
{t('openFullUi')}
</Button>
</div>
@@ -450,19 +454,19 @@ export function Dreams() {
<main className="min-h-0 flex-1 overflow-auto px-10 pb-10">
{!dreamsReady && (
<div className="mb-4 rounded-lg border border-black/10 bg-surface-input px-4 py-3 text-sm text-foreground/70 dark:border-white/10">
<div className="mb-4 rounded-xl border border-black/10 bg-transparent px-4 py-3 text-sm text-foreground/70 dark:border-white/10">
{t('gatewayNotReady')}
</div>
)}
{error && (
<div data-testid="dreams-error" className="mb-4 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
<div data-testid="dreams-error" className="mb-4 rounded-xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error}
</div>
)}
{lastActionMessage && (
<div data-testid="dreams-action-message" className={cn('mb-4 rounded-lg border px-4 py-3 text-sm', SUCCESS_NOTICE_CLASS)}>
<div data-testid="dreams-action-message" className={cn('mb-4 rounded-xl border px-4 py-3 text-sm', SUCCESS_NOTICE_CLASS)}>
{lastActionMessage}
</div>
)}
@@ -473,7 +477,7 @@ export function Dreams() {
return (
<Card key={metric.label} className={PANEL_CLASS}>
<CardContent className="flex items-center gap-3 p-4">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-surface-input">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-transparent">
<Icon className="h-4 w-4 text-muted-foreground" />
</div>
<div className="min-w-0">
@@ -499,12 +503,12 @@ export function Dreams() {
</CardHeader>
<CardContent className="space-y-3 p-4 pt-0">
{diaryEntries.length === 0 ? (
<div data-testid="dreams-empty-diary" className="rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground">
<div data-testid="dreams-empty-diary" className="rounded-xl border border-dashed p-6 text-center text-sm text-muted-foreground">
{t('diary.empty')}
</div>
) : (
diaryEntries.map((entry) => (
<article key={entry.id} className={cn('rounded-md border p-3', INSET_CLASS)}>
<article key={entry.id} className={cn('border p-3', INSET_CLASS)}>
<div className="mb-1 flex items-center gap-2 text-xs text-muted-foreground">
<Clock className="h-3.5 w-3.5" />
<span>{entry.date || t('diary.undated')}</span>
@@ -586,7 +590,7 @@ export function Dreams() {
{phases.map((phase) => {
const value = dreaming?.phases?.[phase.key];
return (
<div key={phase.key} className={cn('flex items-center justify-between gap-3 rounded-md border px-3 py-2', INSET_CLASS)}>
<div key={phase.key} className={cn('flex items-center justify-between gap-3 border px-3 py-2', INSET_CLASS)}>
<div className="min-w-0">
<div className="text-sm font-medium">{phase.label}</div>
<div className="truncate text-xs text-muted-foreground">{value?.cron || t('phases.noSchedule')}</div>
@@ -613,17 +617,17 @@ export function Dreams() {
</CardHeader>
<CardContent className="space-y-2 p-4 pt-0">
{(dreaming?.storeError || dreaming?.phaseSignalError) && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<div className="rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{dreaming.storeError || dreaming.phaseSignalError}
</div>
)}
{recentSignals.length === 0 ? (
<div className="rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground">
<div className="rounded-xl border border-dashed p-6 text-center text-sm text-muted-foreground">
{t('signals.empty')}
</div>
) : (
recentSignals.map((entry, index) => (
<div key={`${entry.key || entry.path || 'signal'}-${index}`} className={cn('rounded-md border p-3', INSET_CLASS)}>
<div key={`${entry.key || entry.path || 'signal'}-${index}`} className={cn('border p-3', INSET_CLASS)}>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 truncate text-xs text-muted-foreground">
{entry.path || entry.key || t('signals.unknownSource')}

View File

@@ -429,13 +429,13 @@ export function Models() {
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1.5 text-meta font-medium text-muted-foreground">
{entry.usageStatus === 'available' || entry.usageStatus === undefined ? (
<>
<span className="flex items-center gap-1.5"><div className="w-2 h-2 rounded-full bg-sky-500"></div>{t('dashboard:recentTokenHistory.input', { value: formatTokenCount(entry.inputTokens) })}</span>
<span className="flex items-center gap-1.5"><div className="w-2 h-2 rounded-full bg-violet-500"></div>{t('dashboard:recentTokenHistory.output', { value: formatTokenCount(entry.outputTokens) })}</span>
<span className="flex items-center gap-1.5"><div className="w-2 h-2 rounded-full bg-usage-input"></div>{t('dashboard:recentTokenHistory.input', { value: formatTokenCount(entry.inputTokens) })}</span>
<span className="flex items-center gap-1.5"><div className="w-2 h-2 rounded-full bg-usage-output"></div>{t('dashboard:recentTokenHistory.output', { value: formatTokenCount(entry.outputTokens) })}</span>
{entry.cacheReadTokens > 0 && (
<span className="flex items-center gap-1.5"><div className="w-2 h-2 rounded-full bg-amber-500"></div>{t('dashboard:recentTokenHistory.cacheRead', { value: formatTokenCount(entry.cacheReadTokens) })}</span>
<span className="flex items-center gap-1.5"><div className="w-2 h-2 rounded-full bg-usage-cache"></div>{t('dashboard:recentTokenHistory.cacheRead', { value: formatTokenCount(entry.cacheReadTokens) })}</span>
)}
{entry.cacheWriteTokens > 0 && (
<span className="flex items-center gap-1.5"><div className="w-2 h-2 rounded-full bg-amber-500"></div>{t('dashboard:recentTokenHistory.cacheWrite', { value: formatTokenCount(entry.cacheWriteTokens) })}</span>
<span className="flex items-center gap-1.5"><div className="w-2 h-2 rounded-full bg-usage-cache"></div>{t('dashboard:recentTokenHistory.cacheWrite', { value: formatTokenCount(entry.cacheWriteTokens) })}</span>
)}
</>
) : (
@@ -572,15 +572,15 @@ function UsageBarChart({
<div className="space-y-4 bg-transparent p-5 rounded-2xl border border-black/10 dark:border-white/10">
<div className="flex flex-wrap gap-4 text-meta font-medium text-muted-foreground mb-2">
<span className="inline-flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full bg-sky-500" />
<span className="h-2.5 w-2.5 rounded-full bg-usage-input" />
{inputLabel}
</span>
<span className="inline-flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full bg-violet-500" />
<span className="h-2.5 w-2.5 rounded-full bg-usage-output" />
{outputLabel}
</span>
<span className="inline-flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full bg-amber-500" />
<span className="h-2.5 w-2.5 rounded-full bg-usage-cache" />
{cacheLabel}
</span>
</div>
@@ -603,19 +603,19 @@ function UsageBarChart({
>
{group.inputTokens > 0 && (
<div
className="h-full bg-sky-500"
className="h-full bg-usage-input"
style={{ width: `${(group.inputTokens / group.totalTokens) * 100}%` }}
/>
)}
{group.outputTokens > 0 && (
<div
className="h-full bg-violet-500"
className="h-full bg-usage-output"
style={{ width: `${(group.outputTokens / group.totalTokens) * 100}%` }}
/>
)}
{group.cacheTokens > 0 && (
<div
className="h-full bg-amber-500"
className="h-full bg-usage-cache"
style={{ width: `${(group.cacheTokens / group.totalTokens) * 100}%` }}
/>
)}

View File

@@ -177,7 +177,7 @@ function SkillDetailDialog({ skill, isOpen, onClose, onToggle, onUninstall, onOp
<Input
value={skill.baseDir || t('detail.pathUnavailable')}
readOnly
className="h-[38px] font-mono text-xs bg-surface-input border-black/10 dark:border-white/10 rounded-xl text-foreground/70"
className="h-[38px] font-mono text-xs bg-transparent border-black/10 dark:border-white/10 rounded-xl text-foreground/70"
/>
<Button
variant="outline"

View File

@@ -36,6 +36,7 @@ interface SettingsState {
// UI State
sidebarCollapsed: boolean;
sidebarWidth: number;
devModeUnlocked: boolean;
// Setup
@@ -60,6 +61,7 @@ interface SettingsState {
setAutoCheckUpdate: (value: boolean) => void;
setAutoDownloadUpdate: (value: boolean) => void;
setSidebarCollapsed: (value: boolean) => void;
setSidebarWidth: (value: number) => void;
setDevModeUnlocked: (value: boolean) => void;
markSetupComplete: () => void;
resetSettings: () => void;
@@ -83,10 +85,13 @@ const defaultSettings = {
autoCheckUpdate: true,
autoDownloadUpdate: false,
sidebarCollapsed: false,
sidebarWidth: 280,
devModeUnlocked: false,
setupComplete: false,
};
const clampSidebarWidth = (value: number) => Math.min(420, Math.max(220, Math.round(value)));
export const useSettingsStore = create<SettingsState>()(
persist(
(set) => ({
@@ -102,6 +107,9 @@ export const useSettingsStore = create<SettingsState>()(
...state,
...settings,
...(resolvedLanguage ? { language: resolvedLanguage } : {}),
...(typeof settings.sidebarWidth === 'number'
? { sidebarWidth: clampSidebarWidth(settings.sidebarWidth) }
: {}),
}));
if (resolvedLanguage) {
i18n.changeLanguage(resolvedLanguage);
@@ -167,6 +175,7 @@ export const useSettingsStore = create<SettingsState>()(
setAutoCheckUpdate: (autoCheckUpdate) => set({ autoCheckUpdate }),
setAutoDownloadUpdate: (autoDownloadUpdate) => set({ autoDownloadUpdate }),
setSidebarCollapsed: (sidebarCollapsed) => set({ sidebarCollapsed }),
setSidebarWidth: (sidebarWidth) => set({ sidebarWidth: clampSidebarWidth(sidebarWidth) }),
setDevModeUnlocked: (devModeUnlocked) => {
set({ devModeUnlocked });
void hostApiFetch('/api/settings/devModeUnlocked', {

View File

@@ -33,9 +33,9 @@
@layer base {
:root {
/* ── shadcn semantic tokens (light) ─────────────────────────── */
/* #f1ede1 — ClawX cream page background; gentler than pure white
/* Lightened ClawX cream page background; gentler than pure white
* for long sessions in front of the editor. */
--background: 45 36.4% 91.4%;
--background: 45 30% 96.6%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
@@ -64,16 +64,37 @@
* heavy shadows. Pixel values are extracted from the hex literals
* that used to be sprinkled across components.
*
* --surface-modal = #f3f1e9 large rounded modal panels (top)
* was: bg-[#f3f1e9] dark:bg-card
* --surface-input = #eeece3 inputs / selects / code panes
* was: bg-[#eeece3] dark:bg-muted
* --surface-sidebar = #eae8e1 left navigation rail (often /60)
* was: bg-[#eae8e1]/60 dark:bg-background
* --surface-modal = lifted cream large rounded modal panels (top);
* slightly lighter than --background
* so dialogs feel raised
* --surface-input = recessed cream inputs / selects / code panes;
* slightly darker than --background
* so fields feel inset
* --surface-sidebar = sidebar cream left navigation rail (often /60)
* ─────────────────────────────────────────────────────────── */
--surface-modal: 48 29% 93%;
--surface-input: 49 24% 91%;
--surface-sidebar: 47 18% 90%;
--surface-modal: 45 30% 97.6%;
--surface-input: 45 22% 94%;
--surface-sidebar: 45 16% 94%;
/* ── ClawX usage accents (light) ─────────────────────────────
*
* Three semantic accents reused everywhere that we visualise
* token usage / activity. Mirrors the Cron page convention so
* the Models bar chart, legend dots, and any future "input /
* output / cache" indicator pulls from the same palette:
*
* --usage-input = blue (matches Cron "Total" tile / primary)
* --usage-output = green (matches Cron "Active" tile / `bg-green-500`)
* --usage-cache = yellow (matches Cron "Paused" tile)
*
* Anchored to Tailwind's *-500 shades so chart bars read at the
* same brightness as the `bg-green-500` "configured" status dot
* and the Cron stat-tile icon halos. Going darker (*-600/*-700)
* makes the bars look duller than the dots they sit next to.
* ─────────────────────────────────────────────────────────── */
--usage-input: 217 91% 60%;
--usage-output: 142 71% 45%;
--usage-cache: 48 96% 53%;
}
/* Neutral deep-gray dark palette */
@@ -109,6 +130,13 @@
--surface-modal: 240 3% 14%;
--surface-input: 240 3% 18%;
--surface-sidebar: 240 4% 11%;
/* Usage accents — lifted *-400-ish shades so bars stay vivid
* against the deep-gray dark surfaces (see light block above
* for semantic mapping). */
--usage-input: 217 91% 65%;
--usage-output: 142 71% 52%;
--usage-cache: 48 96% 60%;
}
}
@@ -123,6 +151,12 @@
}
}
@layer components {
.sidebar-nav-text.sidebar-nav-text {
@apply text-sm font-normal leading-4;
}
}
/* Custom scrollbar: keep scrollbars visually quiet until the user hovers a scroll area. */
* {
scrollbar-width: thin;

View File

@@ -254,6 +254,17 @@ module.exports = {
input: 'hsl(var(--surface-input) / <alpha-value>)',
sidebar: 'hsl(var(--surface-sidebar) / <alpha-value>)',
},
// ── D. ClawX usage accents ──────────────────────────────────
// Semantic chart palette shared by the Models token-usage
// visualisation and any future input/output/cache indicator.
// Mirrors Cron's stat-tile palette (blue / green / yellow).
// Values live in globals.css; dark mode brightens each one.
usage: {
input: 'hsl(var(--usage-input) / <alpha-value>)',
output: 'hsl(var(--usage-output) / <alpha-value>)',
cache: 'hsl(var(--usage-cache) / <alpha-value>)',
},
},
borderRadius: {
lg: 'var(--radius)',

View File

@@ -8,6 +8,10 @@ test.describe('ClawX main navigation without setup flow', () => {
const page = await getStableWindow(app);
await expect(page.getByTestId('main-layout')).toBeVisible();
await expect(page.getByTestId('chat-page')).toBeVisible();
await expect(page.getByTestId('main-content')).toBeVisible();
await expect(page.getByTestId('sidebar-resize-handle')).toBeVisible();
await expect(page.getByTestId('main-content')).toHaveCSS('border-top-left-radius', '16px');
await page.getByTestId('sidebar-nav-models').click();
await expect(page.getByTestId('models-page')).toBeVisible();

View File

@@ -0,0 +1,29 @@
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MainLayout } from '@/components/layout/MainLayout';
vi.mock('@/components/layout/Sidebar', () => ({
Sidebar: () => <aside data-testid="sidebar" />,
}));
vi.mock('@/components/layout/TitleBar', () => ({
TitleBar: () => <div data-testid="titlebar" />,
}));
describe('MainLayout platform layout', () => {
it('uses a left/right shell on macOS', () => {
window.electron.platform = 'darwin';
render(<MainLayout />);
expect(screen.getByTestId('main-layout')).toHaveClass('flex-row');
});
it('keeps a top titlebar column shell on Windows', () => {
window.electron.platform = 'win32';
render(<MainLayout />);
expect(screen.getByTestId('main-layout')).toHaveClass('flex-col');
});
});

View File

@@ -12,6 +12,7 @@ describe('Settings Store', () => {
theme: 'system',
language: 'en',
sidebarCollapsed: false,
sidebarWidth: 280,
devModeUnlocked: false,
gatewayAutoStart: true,
gatewayPort: 18789,
@@ -41,6 +42,19 @@ describe('Settings Store', () => {
setSidebarCollapsed(true);
expect(useSettingsStore.getState().sidebarCollapsed).toBe(true);
});
it('should clamp sidebar width', () => {
const { setSidebarWidth } = useSettingsStore.getState();
setSidebarWidth(320);
expect(useSettingsStore.getState().sidebarWidth).toBe(320);
setSidebarWidth(100);
expect(useSettingsStore.getState().sidebarWidth).toBe(220);
setSidebarWidth(600);
expect(useSettingsStore.getState().sidebarWidth).toBe(420);
});
it('should unlock dev mode', () => {
const invoke = vi.mocked(window.electron.ipcRenderer.invoke);

View File

@@ -14,12 +14,12 @@ describe('TitleBar platform behavior', () => {
invokeIpcMock.mockResolvedValue(false);
});
it('renders macOS drag region', () => {
it('does not render a standalone title bar on macOS', () => {
window.electron.platform = 'darwin';
const { container } = render(<TitleBar />);
expect(container.querySelector('.drag-region')).toBeInTheDocument();
expect(container.firstChild).toBeNull();
expect(screen.queryByTitle('Minimize')).not.toBeInTheDocument();
expect(invokeIpcMock).not.toHaveBeenCalled();
});
@@ -32,6 +32,7 @@ describe('TitleBar platform behavior', () => {
expect(screen.getByTitle('Minimize')).toBeInTheDocument();
expect(screen.getByTitle('Maximize')).toBeInTheDocument();
expect(screen.getByTitle('Close')).toBeInTheDocument();
expect(screen.getByTestId('windows-titlebar')).not.toHaveClass('border-b');
await waitFor(() => {
expect(invokeIpcMock).toHaveBeenCalledWith('window:isMaximized');