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',