From 90d9db0f83d051f63011b7a30f406178970de31e Mon Sep 17 00:00:00 2001 From: zhayujie Date: Sat, 20 Jun 2026 00:39:38 +0800 Subject: [PATCH] feat(desktop): platform-aware shell, design tokens and three-column layout --- desktop/src/main/index.ts | 68 ++- desktop/src/main/preload.ts | 9 + desktop/src/renderer/index.html | 27 +- desktop/src/renderer/src/App.tsx | 119 ++--- .../src/renderer/src/components/Sidebar.tsx | 116 ----- desktop/src/renderer/src/highlight.css | 189 +++++++ desktop/src/renderer/src/hooks/usePlatform.ts | 29 ++ desktop/src/renderer/src/hooks/useTheme.ts | 62 ++- desktop/src/renderer/src/index.css | 479 +++++++++++------- desktop/src/renderer/src/layout/NavRail.tsx | 126 +++++ .../src/renderer/src/layout/SessionList.tsx | 180 +++++++ .../renderer/src/layout/WindowControls.tsx | 41 ++ .../renderer/src/pages/PlaceholderPage.tsx | 20 + desktop/tailwind.config.js | 45 +- 14 files changed, 1093 insertions(+), 417 deletions(-) delete mode 100644 desktop/src/renderer/src/components/Sidebar.tsx create mode 100644 desktop/src/renderer/src/highlight.css create mode 100644 desktop/src/renderer/src/hooks/usePlatform.ts create mode 100644 desktop/src/renderer/src/layout/NavRail.tsx create mode 100644 desktop/src/renderer/src/layout/SessionList.tsx create mode 100644 desktop/src/renderer/src/layout/WindowControls.tsx create mode 100644 desktop/src/renderer/src/pages/PlaceholderPage.tsx diff --git a/desktop/src/main/index.ts b/desktop/src/main/index.ts index a3e15c71..c08c5941 100644 --- a/desktop/src/main/index.ts +++ b/desktop/src/main/index.ts @@ -38,15 +38,50 @@ function getIconPath(ext: string = 'png'): string | undefined { return undefined } +const isMac = process.platform === 'darwin' +const isWin = process.platform === 'win32' + +// Persisted window bounds +const windowStateFile = () => path.join(app.getPath('userData'), 'window-state.json') + +function loadWindowState(): { width: number; height: number; x?: number; y?: number } { + try { + const raw = fs.readFileSync(windowStateFile(), 'utf-8') + const s = JSON.parse(raw) + if (typeof s.width === 'number' && typeof s.height === 'number') return s + } catch { + /* first run or unreadable */ + } + return { width: 1280, height: 800 } +} + +function saveWindowState() { + if (!mainWindow || mainWindow.isDestroyed()) return + if (mainWindow.isMinimized() || mainWindow.isFullScreen()) return + const b = mainWindow.getBounds() + try { + fs.writeFileSync(windowStateFile(), JSON.stringify(b)) + } catch { + /* ignore */ + } +} + function createWindow() { + const state = loadWindowState() + mainWindow = new BrowserWindow({ - width: 1280, - height: 800, + width: state.width, + height: state.height, + x: state.x, + y: state.y, minWidth: 900, minHeight: 600, - titleBarStyle: 'hiddenInset', - trafficLightPosition: { x: 12, y: 18 }, - backgroundColor: '#111111', + // macOS: native traffic lights inset into our custom titlebar. + // Windows: fully frameless; we render custom window controls in-app. + titleBarStyle: isMac ? 'hiddenInset' : 'hidden', + trafficLightPosition: isMac ? { x: 14, y: 16 } : undefined, + frame: isMac ? undefined : false, + backgroundColor: '#0e0e10', icon: getIconPath(), show: false, webPreferences: { @@ -56,6 +91,12 @@ function createWindow() { }, }) + const persist = () => saveWindowState() + mainWindow.on('resize', persist) + mainWindow.on('move', persist) + mainWindow.on('maximize', emitMaximizeState) + mainWindow.on('unmaximize', emitMaximizeState) + const rendererHtml = path.join(__dirname, '../renderer/index.html') if (isDev) { @@ -143,6 +184,22 @@ function setupIPC() { }) return result.canceled ? null : result.filePaths[0] }) + + // Custom window controls (used by Windows frameless titlebar) + ipcMain.handle('window-minimize', () => mainWindow?.minimize()) + ipcMain.handle('window-maximize', () => { + if (!mainWindow) return false + if (mainWindow.isMaximized()) mainWindow.unmaximize() + else mainWindow.maximize() + return mainWindow.isMaximized() + }) + ipcMain.handle('window-close', () => mainWindow?.close()) + ipcMain.handle('window-is-maximized', () => mainWindow?.isMaximized() ?? false) +} + +function emitMaximizeState() { + const max = mainWindow?.isMaximized() ?? false + mainWindow?.webContents.send('window-maximize-changed', max) } app.whenReady().then(async () => { @@ -180,5 +237,6 @@ app.on('window-all-closed', () => { }) app.on('before-quit', () => { + saveWindowState() pythonBackend?.stop() }) diff --git a/desktop/src/main/preload.ts b/desktop/src/main/preload.ts index a19e2b61..642e2014 100644 --- a/desktop/src/main/preload.ts +++ b/desktop/src/main/preload.ts @@ -15,5 +15,14 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.on('backend-log', (_event, line) => callback(line)) }, + // Window controls (custom titlebar on Windows) + windowMinimize: () => ipcRenderer.invoke('window-minimize'), + windowMaximize: () => ipcRenderer.invoke('window-maximize'), + windowClose: () => ipcRenderer.invoke('window-close'), + windowIsMaximized: () => ipcRenderer.invoke('window-is-maximized'), + onMaximizeChange: (callback: (maximized: boolean) => void) => { + ipcRenderer.on('window-maximize-changed', (_event, max) => callback(max)) + }, + platform: process.platform, }) diff --git a/desktop/src/renderer/index.html b/desktop/src/renderer/index.html index 83767c9f..183267de 100644 --- a/desktop/src/renderer/index.html +++ b/desktop/src/renderer/index.html @@ -1,23 +1,28 @@ - + + CowAgent - - - - + + + -
diff --git a/desktop/src/renderer/src/App.tsx b/desktop/src/renderer/src/App.tsx index 1c0cf00b..cc268348 100644 --- a/desktop/src/renderer/src/App.tsx +++ b/desktop/src/renderer/src/App.tsx @@ -1,10 +1,15 @@ -import React, { useState, useCallback } from 'react' +import React, { useState, useCallback, useEffect } from 'react' import { Routes, Route, useLocation } from 'react-router-dom' -import Sidebar from './components/Sidebar' +import { PanelLeftOpen } from 'lucide-react' +import NavRail from './layout/NavRail' +import SessionList from './layout/SessionList' +import WindowControls from './layout/WindowControls' import StatusScreen from './components/StatusScreen' -import { useTheme } from './hooks/useTheme' import { useBackend } from './hooks/useBackend' -import { t, getLang, setLang, Lang } from './i18n' +import { usePlatform } from './hooks/usePlatform' +import { useUIStore } from './store/uiStore' +import apiClient from './api/client' +import { t } from './i18n' import ChatPage from './pages/ChatPage' import ConfigPage from './pages/ConfigPage' import SkillsPage from './pages/SkillsPage' @@ -12,99 +17,61 @@ import MemoryPage from './pages/MemoryPage' import ChannelsPage from './pages/ChannelsPage' import TasksPage from './pages/TasksPage' import LogsPage from './pages/LogsPage' - -const APP_VERSION = 'v2.0.3' - -const VIEW_META: Record = { - '/': { group: 'nav_chat', page: 'menu_chat' }, - '/config': { group: 'nav_manage', page: 'menu_config' }, - '/skills': { group: 'nav_manage', page: 'menu_skills' }, - '/memory': { group: 'nav_manage', page: 'menu_memory' }, - '/channels': { group: 'nav_manage', page: 'menu_channels' }, - '/tasks': { group: 'nav_manage', page: 'menu_tasks' }, - '/logs': { group: 'nav_monitor', page: 'menu_logs' }, -} +import PlaceholderPage from './pages/PlaceholderPage' const App: React.FC = () => { - const { theme, toggleTheme } = useTheme() const backend = useBackend() const location = useLocation() + const { isWin } = usePlatform() + const { sessionsCollapsed, toggleSessions } = useUIStore() const [, forceUpdate] = useState(0) - const toggleLanguage = useCallback(() => { - const next: Lang = getLang() === 'zh' ? 'en' : 'zh' - setLang(next) - forceUpdate((n) => n + 1) - }, []) + useEffect(() => { + if (backend.status === 'ready') apiClient.setBaseUrl(backend.baseUrl) + }, [backend.status, backend.baseUrl]) + + const handleLangChange = useCallback(() => forceUpdate((n) => n + 1), []) if (backend.status !== 'ready') { return } - const meta = VIEW_META[location.pathname] || VIEW_META['/'] + const isChat = location.pathname === '/' + const showSessions = isChat && !sessionsCollapsed return ( -
- +
+ + + {showSessions && } +
- {/* Top Header */} -
- {/* Breadcrumb */} -
- {t(meta.group)} - - {t(meta.page)} -
- -
- - {/* Language Toggle */} - - - {/* Theme Toggle */} - - - {/* Docs */} - - - - - {/* GitHub */} - - - + {/* Top titlebar strip — drag region + Windows controls */} +
+ {isChat && sessionsCollapsed && ( + + )} +
+ {isWin && }
- {/* Content Area */} -
+ {/* Content */} +
} /> - } /> - } /> + } /> } /> + } /> + } /> } /> } /> + } /> } />
diff --git a/desktop/src/renderer/src/components/Sidebar.tsx b/desktop/src/renderer/src/components/Sidebar.tsx deleted file mode 100644 index 65ec3e05..00000000 --- a/desktop/src/renderer/src/components/Sidebar.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import React, { useState } from 'react' -import { useLocation, useNavigate } from 'react-router-dom' -import { t } from '../i18n' - -interface SidebarProps { - version: string -} - -interface MenuGroup { - key: string - labelKey: string - items: { path: string; labelKey: string; icon: string }[] -} - -const menuGroups: MenuGroup[] = [ - { - key: 'chat', - labelKey: 'nav_chat', - items: [{ path: '/', labelKey: 'menu_chat', icon: 'fas fa-message' }], - }, - { - key: 'manage', - labelKey: 'nav_manage', - items: [ - { path: '/config', labelKey: 'menu_config', icon: 'fas fa-sliders' }, - { path: '/skills', labelKey: 'menu_skills', icon: 'fas fa-bolt' }, - { path: '/memory', labelKey: 'menu_memory', icon: 'fas fa-brain' }, - { path: '/channels', labelKey: 'menu_channels', icon: 'fas fa-tower-broadcast' }, - { path: '/tasks', labelKey: 'menu_tasks', icon: 'fas fa-clock' }, - ], - }, - { - key: 'monitor', - labelKey: 'nav_monitor', - items: [{ path: '/logs', labelKey: 'menu_logs', icon: 'fas fa-terminal' }], - }, -] - -const Sidebar: React.FC = ({ version }) => { - const location = useLocation() - const navigate = useNavigate() - const [openGroups, setOpenGroups] = useState>({ - chat: true, - manage: true, - monitor: true, - }) - - const toggleGroup = (key: string) => { - setOpenGroups((prev) => ({ ...prev, [key]: !prev[key] })) - } - - return ( - - ) -} - -export default Sidebar diff --git a/desktop/src/renderer/src/highlight.css b/desktop/src/renderer/src/highlight.css new file mode 100644 index 00000000..4391c026 --- /dev/null +++ b/desktop/src/renderer/src/highlight.css @@ -0,0 +1,189 @@ +/* highlight.js github themes, scoped for light/dark. Generated; do not edit. */ +pre code.hljs { + display: block; + overflow-x: auto; + padding: 1em +} +code.hljs { + padding: 3px 5px +} +/*! + Theme: GitHub + Description: Light theme as seen on github.com + Author: github.com + Maintainer: @Hirse + Updated: 2021-05-15 + + Outdated base version: https://github.com/primer/github-syntax-light + Current colors taken from GitHub's CSS +*/ +.hljs { + color: #24292e; + background: #ffffff +} +.hljs-doctag, +.hljs-keyword, +.hljs-meta .hljs-keyword, +.hljs-template-tag, +.hljs-template-variable, +.hljs-type, +.hljs-variable.language_ { + /* prettylights-syntax-keyword */ + color: #d73a49 +} +.hljs-title, +.hljs-title.class_, +.hljs-title.class_.inherited__, +.hljs-title.function_ { + /* prettylights-syntax-entity */ + color: #6f42c1 +} +.hljs-attr, +.hljs-attribute, +.hljs-literal, +.hljs-meta, +.hljs-number, +.hljs-operator, +.hljs-variable, +.hljs-selector-attr, +.hljs-selector-class, +.hljs-selector-id { + /* prettylights-syntax-constant */ + color: #005cc5 +} +.hljs-regexp, +.hljs-string, +.hljs-meta .hljs-string { + /* prettylights-syntax-string */ + color: #032f62 +} +.hljs-built_in, +.hljs-symbol { + /* prettylights-syntax-variable */ + color: #e36209 +} +.hljs-comment, +.hljs-code, +.hljs-formula { + /* prettylights-syntax-comment */ + color: #6a737d +} +.hljs-name, +.hljs-quote, +.hljs-selector-tag, +.hljs-selector-pseudo { + /* prettylights-syntax-entity-tag */ + color: #22863a +} +.hljs-subst { + /* prettylights-syntax-storage-modifier-import */ + color: #24292e +} +.hljs-section { + /* prettylights-syntax-markup-heading */ + color: #005cc5; + font-weight: bold +} +.hljs-bullet { + /* prettylights-syntax-markup-list */ + color: #735c0f +} +.hljs-emphasis { + /* prettylights-syntax-markup-italic */ + color: #24292e; + font-style: italic +} +.hljs-strong { + /* prettylights-syntax-markup-bold */ + color: #24292e; + font-weight: bold +} +.hljs-addition { + /* prettylights-syntax-markup-inserted */ + color: #22863a; + background-color: #f0fff4 +} +.hljs-deletion { + /* prettylights-syntax-markup-deleted */ + color: #b31d28; + background-color: #ffeef0 +} +.hljs-char.escape_, +.hljs-link, +.hljs-params, +.hljs-property, +.hljs-punctuation, +.hljs-tag { + /* purposely ignored */ + +} +.dark pre code.hljs { + display: block; + overflow-x: auto; + padding: 1em +}.dark code.hljs { + padding: 3px 5px +}.dark /*! + Theme: GitHub Dark + Description: Dark theme as seen on github.com + Author: github.com + Maintainer: @Hirse + Updated: 2021-05-15 + + Outdated base version: https://github.com/primer/github-syntax-dark + Current colors taken from GitHub's CSS +*/ +.hljs { + color: #c9d1d9; + background: #0d1117 +}.dark .hljs-doctag, .dark .hljs-keyword, .dark .hljs-meta .hljs-keyword, .dark .hljs-template-tag, .dark .hljs-template-variable, .dark .hljs-type, .dark .hljs-variable.language_ { + /* prettylights-syntax-keyword */ + color: #ff7b72 +}.dark .hljs-title, .dark .hljs-title.class_, .dark .hljs-title.class_.inherited__, .dark .hljs-title.function_ { + /* prettylights-syntax-entity */ + color: #d2a8ff +}.dark .hljs-attr, .dark .hljs-attribute, .dark .hljs-literal, .dark .hljs-meta, .dark .hljs-number, .dark .hljs-operator, .dark .hljs-variable, .dark .hljs-selector-attr, .dark .hljs-selector-class, .dark .hljs-selector-id { + /* prettylights-syntax-constant */ + color: #79c0ff +}.dark .hljs-regexp, .dark .hljs-string, .dark .hljs-meta .hljs-string { + /* prettylights-syntax-string */ + color: #a5d6ff +}.dark .hljs-built_in, .dark .hljs-symbol { + /* prettylights-syntax-variable */ + color: #ffa657 +}.dark .hljs-comment, .dark .hljs-code, .dark .hljs-formula { + /* prettylights-syntax-comment */ + color: #8b949e +}.dark .hljs-name, .dark .hljs-quote, .dark .hljs-selector-tag, .dark .hljs-selector-pseudo { + /* prettylights-syntax-entity-tag */ + color: #7ee787 +}.dark .hljs-subst { + /* prettylights-syntax-storage-modifier-import */ + color: #c9d1d9 +}.dark .hljs-section { + /* prettylights-syntax-markup-heading */ + color: #1f6feb; + font-weight: bold +}.dark .hljs-bullet { + /* prettylights-syntax-markup-list */ + color: #f2cc60 +}.dark .hljs-emphasis { + /* prettylights-syntax-markup-italic */ + color: #c9d1d9; + font-style: italic +}.dark .hljs-strong { + /* prettylights-syntax-markup-bold */ + color: #c9d1d9; + font-weight: bold +}.dark .hljs-addition { + /* prettylights-syntax-markup-inserted */ + color: #aff5b4; + background-color: #033a16 +}.dark .hljs-deletion { + /* prettylights-syntax-markup-deleted */ + color: #ffdcd7; + background-color: #67060c +}.dark .hljs-char.escape_, .dark .hljs-link, .dark .hljs-params, .dark .hljs-property, .dark .hljs-punctuation, .dark .hljs-tag { + /* purposely ignored */ + +} diff --git a/desktop/src/renderer/src/hooks/usePlatform.ts b/desktop/src/renderer/src/hooks/usePlatform.ts new file mode 100644 index 00000000..b99dbe05 --- /dev/null +++ b/desktop/src/renderer/src/hooks/usePlatform.ts @@ -0,0 +1,29 @@ +import { useEffect, useState } from 'react' + +export type Platform = 'mac' | 'win' | 'linux' + +function detectPlatform(): Platform { + const p = window.electronAPI?.platform + if (p === 'darwin') return 'mac' + if (p === 'win32') return 'win' + if (p === 'linux') return 'linux' + // Fallback for browser dev without electron + if (typeof navigator !== 'undefined' && /Mac/i.test(navigator.platform)) return 'mac' + return 'win' +} + +/** + * Resolves the host platform and applies a `.platform-*` class on + * so CSS can branch on platform (titlebar layout, scrollbars, etc.). + */ +export function usePlatform(): { platform: Platform; isMac: boolean; isWin: boolean } { + const [platform] = useState(detectPlatform) + + useEffect(() => { + const root = document.documentElement + root.classList.remove('platform-mac', 'platform-win', 'platform-linux') + root.classList.add(`platform-${platform}`) + }, [platform]) + + return { platform, isMac: platform === 'mac', isWin: platform === 'win' } +} diff --git a/desktop/src/renderer/src/hooks/useTheme.ts b/desktop/src/renderer/src/hooks/useTheme.ts index e4d8a812..47f19270 100644 --- a/desktop/src/renderer/src/hooks/useTheme.ts +++ b/desktop/src/renderer/src/hooks/useTheme.ts @@ -1,27 +1,57 @@ import { useState, useEffect, useCallback } from 'react' -type Theme = 'light' | 'dark' +export type ThemePref = 'light' | 'dark' | 'system' +export type ResolvedTheme = 'light' | 'dark' + +const STORAGE_KEY = 'cow_theme' + +function getSystemTheme(): ResolvedTheme { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' +} + +function readStored(): ThemePref { + const saved = localStorage.getItem(STORAGE_KEY) + if (saved === 'dark' || saved === 'light' || saved === 'system') return saved + // Default to dark to match the app's flagship look + return 'dark' +} + +function applyTheme(resolved: ResolvedTheme) { + const root = document.documentElement + root.classList.toggle('dark', resolved === 'dark') +} export function useTheme() { - const [theme, setThemeState] = useState(() => { - const saved = localStorage.getItem('cow_theme') - if (saved === 'dark' || saved === 'light') return saved - return 'dark' - }) + const [pref, setPref] = useState(readStored) + const [resolved, setResolved] = useState(() => + readStored() === 'system' ? getSystemTheme() : (readStored() as ResolvedTheme) + ) useEffect(() => { - const root = document.documentElement - if (theme === 'dark') { - root.classList.add('dark') - } else { - root.classList.remove('dark') + const next: ResolvedTheme = pref === 'system' ? getSystemTheme() : pref + setResolved(next) + applyTheme(next) + localStorage.setItem(STORAGE_KEY, pref) + }, [pref]) + + // Follow system changes only when preference is "system" + useEffect(() => { + if (pref !== 'system') return + const mq = window.matchMedia('(prefers-color-scheme: dark)') + const handler = () => { + const next = getSystemTheme() + setResolved(next) + applyTheme(next) } - localStorage.setItem('cow_theme', theme) - }, [theme]) + mq.addEventListener('change', handler) + return () => mq.removeEventListener('change', handler) + }, [pref]) const toggleTheme = useCallback(() => { - setThemeState((prev) => (prev === 'dark' ? 'light' : 'dark')) - }, []) + setPref(resolved === 'dark' ? 'light' : 'dark') + }, [resolved]) - return { theme, toggleTheme } + const setTheme = useCallback((next: ThemePref) => setPref(next), []) + + return { theme: resolved, pref, toggleTheme, setTheme } } diff --git a/desktop/src/renderer/src/index.css b/desktop/src/renderer/src/index.css index 11230013..ef115f03 100644 --- a/desktop/src/renderer/src/index.css +++ b/desktop/src/renderer/src/index.css @@ -1,29 +1,185 @@ +@import './highlight.css'; + @tailwind base; @tailwind components; @tailwind utilities; +/* ============================================================ + Design tokens — semantic CSS variables driving both themes. + Components reference these via Tailwind semantic classes + (bg-surface, text-primary, border-default, etc.), so theme + switching only swaps variable values, never component code. + ============================================================ */ + +:root { + /* Brand accent (CowAgent green), used sparingly */ + --accent: #4abe6e; + --accent-hover: #35a85b; + --accent-active: #228547; + --accent-soft: rgba(74, 190, 110, 0.12); + --accent-contrast: #ffffff; + + /* Status colors */ + --success: #4abe6e; + --warning: #f59e0b; + --danger: #ef4444; + --danger-soft: rgba(239, 68, 68, 0.1); + --danger-border: rgba(239, 68, 68, 0.3); + --info: #3b82f6; + + /* Light theme — layered neutral surfaces */ + --bg-base: #fafafa; /* app background */ + --bg-surface: #ffffff; /* panels, cards */ + --bg-surface-2: #f4f4f5; /* nested surfaces, hover fills */ + --bg-elevated: #ffffff; /* popovers, menus, modals */ + --bg-inset: #f4f4f5; /* inputs, code blocks */ + + --text-primary: #18181b; /* headings, primary text (contrast > 4.5:1) */ + --text-secondary: #52525b; /* body, labels */ + --text-tertiary: #71717a; /* hints, captions */ + --text-disabled: #a1a1aa; + + --border-default: #e4e4e7; + --border-strong: #d4d4d8; + --border-subtle: #f0f0f1; + + --overlay: rgba(0, 0, 0, 0.4); + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04), 0 1px 3px rgba(0, 0, 0, 0.06); + --shadow-md: 0 2px 8px rgba(0, 0, 0, 0.06), 0 4px 16px rgba(0, 0, 0, 0.08); + --shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.12); + + /* Chat-specific tokens (AI-Native UI) */ + --user-bubble-bg: var(--accent-soft); + --ai-bubble-bg: transparent; + --message-gap: 16px; + + color-scheme: light; +} + +.dark { + /* Dark theme — layered greys instead of harsh pure black */ + --bg-base: #0e0e10; + --bg-surface: #161618; + --bg-surface-2: #1c1c1f; + --bg-elevated: #1f1f23; + --bg-inset: #161618; + + --text-primary: #f4f4f5; /* contrast > 7:1 on bg-base */ + --text-secondary: #c4c4cc; + --text-tertiary: #8e8e96; + --text-disabled: #5a5a62; + + --border-default: rgba(255, 255, 255, 0.08); + --border-strong: rgba(255, 255, 255, 0.14); + --border-subtle: rgba(255, 255, 255, 0.04); + + --accent-contrast: #0e0e10; + + --overlay: rgba(0, 0, 0, 0.6); + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); + --shadow-md: 0 2px 8px rgba(0, 0, 0, 0.4), 0 4px 16px rgba(0, 0, 0, 0.3); + --shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.5); + + --user-bubble-bg: rgba(74, 190, 110, 0.16); + + color-scheme: dark; +} + +/* ============================================================ + Base + ============================================================ */ + * { box-sizing: border-box; - scrollbar-width: thin; - scrollbar-color: #94a3b8 transparent; } -::-webkit-scrollbar { width: 6px; height: 6px; } -::-webkit-scrollbar-track { background: transparent; } -::-webkit-scrollbar-thumb { background: #94a3b8; border-radius: 3px; } -::-webkit-scrollbar-thumb:hover { background: #64748b; } -.dark ::-webkit-scrollbar-thumb { background: #475569; } -.dark ::-webkit-scrollbar-thumb:hover { background: #64748b; } +html, +body { + margin: 0; + height: 100%; +} body { - @apply bg-gray-50 text-slate-800 overflow-hidden; - font-family: 'Inter', system-ui, -apple-system, 'PingFang SC', 'Hiragino Sans GB', sans-serif; + background: var(--bg-base); + color: var(--text-primary); + font-family: 'Inter', system-ui, -apple-system, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; + font-size: 14px; + line-height: 1.6; + overflow: hidden; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; } -.dark body { - @apply bg-[#111111] text-slate-200; +/* Smooth theme transition (respect reduced motion) */ +body, +body * { + transition-property: background-color, border-color, color, fill, stroke; + transition-duration: 200ms; + transition-timing-function: ease; } +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } +} + +/* ============================================================ + Scrollbar — thin, low-key, theme-aware + ============================================================ */ + +* { + scrollbar-width: thin; + scrollbar-color: var(--border-strong) transparent; +} + +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: 4px; + border: 2px solid transparent; + background-clip: padding-box; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-tertiary); + background-clip: padding-box; +} + +/* Windows scrollbars are slightly wider/more visible */ +.platform-win ::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +/* ============================================================ + Titlebar drag regions (frameless window) + ============================================================ */ + +.titlebar-drag { + -webkit-app-region: drag; +} + +.titlebar-no-drag { + -webkit-app-region: no-drag; +} + +/* ============================================================ + Animations + ============================================================ */ + @keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } @@ -33,211 +189,150 @@ body { animation: blink 1s step-end infinite; } -/* Sidebar */ -.sidebar-item.active { - background: rgba(255, 255, 255, 0.08); - color: #fff; +/* AI typing indicator — 3-dot pulse */ +@keyframes typingPulse { + 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; } + 40% { transform: scale(1); opacity: 1; } } -.sidebar-item.active .item-icon { - color: #4ABE6E; +.typing-dot { + width: 6px; + height: 6px; + border-radius: 9999px; + background: var(--text-tertiary); + animation: typingPulse 1.4s infinite ease-in-out both; } -.menu-group .chevron { - transition: transform 0.25s ease; +.typing-dot:nth-child(2) { animation-delay: 0.16s; } +.typing-dot:nth-child(3) { animation-delay: 0.32s; } + +/* Smooth content reveal */ +@keyframes fadeInUp { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } } -.menu-group.open .chevron { - transform: rotate(90deg); +.animate-reveal { + animation: fadeInUp 0.25s ease both; } -.menu-group-items { - max-height: 0; - overflow: hidden; - transition: max-height 0.25s ease; +/* Skeleton shimmer */ +@keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } } -.menu-group.open .menu-group-items { - max-height: 500px; - transition: max-height 0.35s ease; +.skeleton { + background: linear-gradient( + 90deg, + var(--bg-surface-2) 25%, + var(--border-subtle) 50%, + var(--bg-surface-2) 75% + ); + background-size: 200% 100%; + animation: shimmer 1.5s infinite; + border-radius: 6px; } -/* Markdown content */ -.msg-content pre { - @apply rounded-lg overflow-x-auto my-3; - background: #f1f5f9; +/* ============================================================ + Markdown content (assistant messages, memory/knowledge viewers) + ============================================================ */ + +.msg-content > *:first-child { margin-top: 0; } +.msg-content > *:last-child { margin-bottom: 0; } +.msg-content p { margin: 0.5em 0; line-height: 1.7; } +.msg-content ul, .msg-content ol { padding-left: 1.4em; margin: 0.5em 0; } +.msg-content li { margin: 0.25em 0; } +.msg-content h1, .msg-content h2, .msg-content h3, .msg-content h4 { + font-weight: 600; + margin: 0.8em 0 0.4em; + color: var(--text-primary); } +.msg-content h1 { font-size: 1.4em; } +.msg-content h2 { font-size: 1.25em; } +.msg-content h3 { font-size: 1.1em; } -.dark .msg-content pre { - background: #111111; -} +.msg-content a { color: var(--accent); text-decoration: none; } +.msg-content a:hover { text-decoration: underline; } -.msg-content :not(pre) > code { - padding: 2px 6px; - border-radius: 4px; - font-size: 0.875em; - background: rgba(74, 190, 110, 0.1); - color: #1C6B3B; -} - -.dark .msg-content :not(pre) > code { - background: rgba(74, 190, 110, 0.15); - color: #74E9A4; -} - -.msg-content p { @apply my-2 leading-relaxed; } -.msg-content ul, .msg-content ol { @apply pl-6 my-2; } -.msg-content li { @apply my-1; } - -.msg-content a { - color: #35A85B; -} - -.msg-content a:hover { - color: #228547; +.msg-content blockquote { + padding-left: 0.9em; + margin: 0.6em 0; + border-left: 3px solid var(--accent); + color: var(--text-secondary); } .msg-content table { - @apply border-collapse w-full my-3; + border-collapse: collapse; + width: 100%; + margin: 0.8em 0; + font-size: 0.9em; } - .msg-content th, .msg-content td { - @apply border px-3 py-2 text-sm; - border-color: #e2e8f0; + border: 1px solid var(--border-default); + padding: 0.4em 0.7em; + text-align: left; +} +.msg-content th { background: var(--bg-surface-2); font-weight: 600; } + +.msg-content hr { border: none; border-top: 1px solid var(--border-default); margin: 1em 0; } + +/* Inline code */ +.msg-content :not(pre) > code { + padding: 0.12em 0.4em; + border-radius: 5px; + background: var(--bg-inset); + border: 1px solid var(--border-subtle); + font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace; + font-size: 0.85em; } -.dark .msg-content th, .dark .msg-content td { - border-color: rgba(255, 255, 255, 0.1); +/* Fenced code blocks */ +.msg-content .code-block-wrapper { + margin: 0.7em 0; + border-radius: 10px; + overflow: hidden; + border: 1px solid var(--border-default); + background: var(--bg-inset); } - -.msg-content th { - @apply font-medium; - background: #f1f5f9; +.msg-content .code-block-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 5px 10px 5px 12px; + background: var(--bg-surface-2); + border-bottom: 1px solid var(--border-subtle); } - -.dark .msg-content th { - background: #111111; +.msg-content .code-block-lang { + font-size: 11px; + font-weight: 500; + color: var(--text-tertiary); + text-transform: lowercase; + letter-spacing: 0.02em; } - -.msg-content blockquote { - @apply pl-4 my-3; - border-left: 4px solid #4ABE6E; +.msg-content .code-copy-btn { + font-size: 11px; + color: var(--text-tertiary); + background: transparent; + border: none; + cursor: pointer; + padding: 2px 6px; + border-radius: 5px; + transition: color 0.15s, background 0.15s; } - -.dark .msg-content blockquote { - background: rgba(74, 190, 110, 0.08); +.msg-content .code-copy-btn:hover { color: var(--text-secondary); background: var(--bg-inset); } +.msg-content .code-copy-btn.copied { color: var(--accent); } +.msg-content .code-block-wrapper pre { + margin: 0; + padding: 12px 14px; + overflow-x: auto; + background: transparent; } - -.msg-content hr { - @apply my-4; - border-color: #e2e8f0; -} - -.dark .msg-content hr { - border-color: rgba(255, 255, 255, 0.1); -} - -/* Tool steps */ -.tool-header { - @apply cursor-pointer transition-colors duration-150; -} - -.tool-header:hover { - background: rgba(0, 0, 0, 0.02); -} - -.dark .tool-header:hover { - background: rgba(255, 255, 255, 0.02); -} - -.tool-detail { - background: rgba(0, 0, 0, 0.02); - border: 1px solid rgba(0, 0, 0, 0.04); -} - -.dark .tool-detail { - background: rgba(255, 255, 255, 0.02); - border-color: rgba(255, 255, 255, 0.04); -} - -/* Config dropdown */ -.cfg-dropdown { @apply relative; } - -.cfg-dropdown-selected { - @apply flex items-center justify-between px-3 rounded-lg border cursor-pointer transition-colors text-sm; - height: 40px; - border-color: #e2e8f0; - background: #f8fafc; -} - -.dark .cfg-dropdown-selected { - border-color: #475569; - background: rgba(255, 255, 255, 0.05); - color: #f1f5f9; -} - -.cfg-dropdown-menu { - @apply absolute left-0 right-0 z-50 rounded-lg border shadow-lg overflow-y-auto hidden; - top: calc(100% + 4px); - max-height: 240px; - padding: 4px; - border-color: #e2e8f0; - background: #fff; -} - -.dark .cfg-dropdown-menu { - border-color: #334155; - background: #1e1e1e; -} - -.cfg-dropdown.open .cfg-dropdown-menu { - @apply block; -} - -.cfg-dropdown-item { - @apply px-3 py-2 rounded-md text-sm cursor-pointer transition-colors; - color: #334155; -} - -.cfg-dropdown-item:hover { - background: #f1f5f9; -} - -.dark .cfg-dropdown-item { - color: #cbd5e1; -} - -.dark .cfg-dropdown-item:hover { - background: rgba(255, 255, 255, 0.08); -} - -.cfg-dropdown-item.active { - background: rgba(74, 190, 110, 0.1); - color: #4ABE6E; -} - -.dark .cfg-dropdown-item.active { - background: rgba(74, 190, 110, 0.15); - color: #74E9A4; -} - -/* API Key mask */ -.cfg-key-masked { - -webkit-text-security: disc; -} - -/* Chat input */ -#chat-input { - height: 42px; - max-height: 180px; - resize: none; -} - -/* Titlebar drag region for macOS */ -.titlebar-drag { - -webkit-app-region: drag; -} - -.titlebar-no-drag { - -webkit-app-region: no-drag; +.msg-content .code-block-wrapper pre code, +.msg-content pre code.hljs { + font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace; + font-size: 12.5px; + line-height: 1.6; + background: transparent; + padding: 0; } diff --git a/desktop/src/renderer/src/layout/NavRail.tsx b/desktop/src/renderer/src/layout/NavRail.tsx new file mode 100644 index 00000000..ed8d8516 --- /dev/null +++ b/desktop/src/renderer/src/layout/NavRail.tsx @@ -0,0 +1,126 @@ +import React from 'react' +import { useLocation, useNavigate } from 'react-router-dom' +import { + MessageSquare, + BookOpen, + Brain, + Zap, + Radio, + Clock, + Cpu, + Settings, + PanelLeftClose, + PanelLeftOpen, + Sun, + Moon, +} from 'lucide-react' +import type { LucideIcon } from 'lucide-react' +import { t, getLang, setLang, Lang } from '../i18n' +import { useUIStore } from '../store/uiStore' +import { useTheme } from '../hooks/useTheme' + +interface NavItem { + path: string + labelKey: string + icon: LucideIcon +} + +const NAV_ITEMS: NavItem[] = [ + { path: '/', labelKey: 'menu_chat', icon: MessageSquare }, + { path: '/knowledge', labelKey: 'menu_knowledge', icon: BookOpen }, + { path: '/memory', labelKey: 'menu_memory', icon: Brain }, + { path: '/skills', labelKey: 'menu_skills', icon: Zap }, + { path: '/models', labelKey: 'menu_models', icon: Cpu }, + { path: '/channels', labelKey: 'menu_channels', icon: Radio }, + { path: '/tasks', labelKey: 'menu_tasks', icon: Clock }, + { path: '/settings', labelKey: 'menu_settings', icon: Settings }, +] + +interface NavRailProps { + onLangChange: () => void +} + +const NavRail: React.FC = ({ onLangChange }) => { + const location = useLocation() + const navigate = useNavigate() + const { navCollapsed, toggleNav } = useUIStore() + const { theme, toggleTheme } = useTheme() + + const collapsed = navCollapsed + const width = collapsed ? 'w-[56px]' : 'w-[208px]' + + const toggleLanguage = () => { + const next: Lang = getLang() === 'zh' ? 'en' : 'zh' + setLang(next) + onLangChange() + } + + return ( + + ) +} + +const FooterBtn: React.FC<{ + collapsed: boolean + onClick: () => void + title: string + children: React.ReactNode +}> = ({ collapsed, onClick, title, children }) => ( + +) + +export default NavRail diff --git a/desktop/src/renderer/src/layout/SessionList.tsx b/desktop/src/renderer/src/layout/SessionList.tsx new file mode 100644 index 00000000..b969832d --- /dev/null +++ b/desktop/src/renderer/src/layout/SessionList.tsx @@ -0,0 +1,180 @@ +import React, { useEffect, useMemo, useState } from 'react' +import { Plus, MessageSquare, Pencil, Trash2, Check, X, PanelLeftClose } from 'lucide-react' +import { t } from '../i18n' +import { useSessionStore } from '../store/sessionStore' +import { useUIStore } from '../store/uiStore' +import type { SessionItem } from '../types' + +function groupByTime(sessions: SessionItem[]): { label: string; items: SessionItem[] }[] { + const now = new Date() + const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000 + const startOfYesterday = startOfToday - 86400 + + const today: SessionItem[] = [] + const yesterday: SessionItem[] = [] + const earlier: SessionItem[] = [] + + for (const s of sessions) { + const ts = s.last_active || s.created_at + if (ts >= startOfToday) today.push(s) + else if (ts >= startOfYesterday) yesterday.push(s) + else earlier.push(s) + } + + return [ + { label: t('session_today'), items: today }, + { label: t('session_yesterday'), items: yesterday }, + { label: t('session_earlier'), items: earlier }, + ].filter((g) => g.items.length > 0) +} + +const SessionList: React.FC = () => { + const { sessions, activeId, loading, loadSessions, loadMore, hasMore, setActive, newSession, rename, remove } = + useSessionStore() + const toggleSessions = useUIStore((s) => s.toggleSessions) + const [editingId, setEditingId] = useState(null) + const [editValue, setEditValue] = useState('') + + useEffect(() => { + loadSessions(1) + }, [loadSessions]) + + const groups = useMemo(() => groupByTime(sessions), [sessions]) + + const startEdit = (s: SessionItem) => { + setEditingId(s.session_id) + setEditValue(s.title || '') + } + + const commitEdit = async () => { + if (editingId && editValue.trim()) { + await rename(editingId, editValue.trim()) + } + setEditingId(null) + } + + return ( +
+ {/* Header */} +
+ + +
+ + {/* List */} +
{ + const el = e.currentTarget + if (el.scrollHeight - el.scrollTop - el.clientHeight < 80 && hasMore && !loading) loadMore() + }} + > + {sessions.length === 0 && !loading && ( +
+ +

{t('session_empty')}

+
+ )} + + {groups.map((group) => ( +
+
+ {group.label} +
+ {group.items.map((s) => { + const isActive = s.session_id === activeId + const isEditing = editingId === s.session_id + return ( +
!isEditing && setActive(s.session_id)} + className={`group flex items-center gap-2 px-2 h-9 rounded-btn cursor-pointer transition-colors ${ + isActive ? 'bg-accent-soft' : 'hover:bg-surface-2' + }`} + > + {isEditing ? ( + setEditValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') commitEdit() + if (e.key === 'Escape') setEditingId(null) + }} + onClick={(e) => e.stopPropagation()} + className="flex-1 min-w-0 bg-inset border border-strong rounded px-1.5 py-0.5 text-[13px] text-content focus:outline-none focus:border-accent" + /> + ) : ( + + {s.title || s.session_id} + + )} + + {isEditing ? ( +
+ { e.stopPropagation(); commitEdit() }}> + { e.stopPropagation(); setEditingId(null) }}> +
+ ) : ( +
+ { e.stopPropagation(); startEdit(s) }} title={t('session_rename')}> + + + { e.stopPropagation(); remove(s.session_id) }} title={t('session_delete')} danger> + + +
+ )} +
+ ) + })} +
+ ))} + + {loading && ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+ )} +
+
+ ) +} + +const IconBtn: React.FC<{ + onClick: (e: React.MouseEvent) => void + title?: string + danger?: boolean + children: React.ReactNode +}> = ({ onClick, title, danger, children }) => ( + +) + +export default SessionList diff --git a/desktop/src/renderer/src/layout/WindowControls.tsx b/desktop/src/renderer/src/layout/WindowControls.tsx new file mode 100644 index 00000000..82c3edc1 --- /dev/null +++ b/desktop/src/renderer/src/layout/WindowControls.tsx @@ -0,0 +1,41 @@ +import React, { useEffect, useState } from 'react' +import { Minus, Square, Copy, X } from 'lucide-react' + +/** + * Custom window controls for the frameless Windows titlebar. + * On macOS the system renders traffic lights, so this returns null there. + */ +const WindowControls: React.FC = () => { + const [maximized, setMaximized] = useState(false) + const api = window.electronAPI + + useEffect(() => { + api?.windowIsMaximized().then(setMaximized) + api?.onMaximizeChange(setMaximized) + }, [api]) + + if (api?.platform === 'darwin') return null + + const btn = + 'titlebar-no-drag inline-flex items-center justify-center w-11 h-full text-content-tertiary hover:text-content cursor-pointer transition-colors' + + return ( +
+ + + +
+ ) +} + +export default WindowControls diff --git a/desktop/src/renderer/src/pages/PlaceholderPage.tsx b/desktop/src/renderer/src/pages/PlaceholderPage.tsx new file mode 100644 index 00000000..e1d414b4 --- /dev/null +++ b/desktop/src/renderer/src/pages/PlaceholderPage.tsx @@ -0,0 +1,20 @@ +import React from 'react' +import { Construction } from 'lucide-react' + +interface PlaceholderPageProps { + title: string + hint?: string +} + +/** Temporary page for routes that will be implemented in later phases. */ +const PlaceholderPage: React.FC = ({ title, hint }) => ( +
+
+ +
+

{title}

+

{hint || 'Coming soon in this iteration.'}

+
+) + +export default PlaceholderPage diff --git a/desktop/tailwind.config.js b/desktop/tailwind.config.js index c867c21a..81789719 100644 --- a/desktop/tailwind.config.js +++ b/desktop/tailwind.config.js @@ -5,10 +5,13 @@ module.exports = { theme: { extend: { fontFamily: { - sans: ['Inter', 'system-ui', '-apple-system', '"PingFang SC"', '"Hiragino Sans GB"', 'sans-serif'], + sans: ['Inter', 'system-ui', '-apple-system', '"PingFang SC"', '"Hiragino Sans GB"', '"Microsoft YaHei"', 'sans-serif'], mono: ['"JetBrains Mono"', '"Fira Code"', 'Consolas', 'monospace'], }, colors: { + 'danger-soft': 'var(--danger-soft)', + 'danger-border': 'var(--danger-border)', + // Brand accent (kept for backward compat + explicit accent usage) primary: { 50: '#EDFDF3', 100: '#D4FAE2', @@ -21,6 +24,46 @@ module.exports = { 800: '#1A5532', 900: '#16462A', }, + // Semantic tokens — driven by CSS variables, theme-aware + accent: { + DEFAULT: 'var(--accent)', + hover: 'var(--accent-hover)', + active: 'var(--accent-active)', + soft: 'var(--accent-soft)', + contrast: 'var(--accent-contrast)', + }, + base: 'var(--bg-base)', + surface: { + DEFAULT: 'var(--bg-surface)', + 2: 'var(--bg-surface-2)', + }, + elevated: 'var(--bg-elevated)', + inset: 'var(--bg-inset)', + content: { + DEFAULT: 'var(--text-primary)', + secondary: 'var(--text-secondary)', + tertiary: 'var(--text-tertiary)', + disabled: 'var(--text-disabled)', + }, + success: 'var(--success)', + warning: 'var(--warning)', + danger: 'var(--danger)', + info: 'var(--info)', + }, + borderColor: { + DEFAULT: 'var(--border-default)', + default: 'var(--border-default)', + strong: 'var(--border-strong)', + subtle: 'var(--border-subtle)', + }, + boxShadow: { + sm: 'var(--shadow-sm)', + md: 'var(--shadow-md)', + lg: 'var(--shadow-lg)', + }, + borderRadius: { + card: '12px', + btn: '8px', }, animation: { 'pulse-dot': 'pulseDot 1.4s infinite ease-in-out both',