feat(desktop): platform-aware shell, design tokens and three-column layout

This commit is contained in:
zhayujie
2026-06-20 00:39:38 +08:00
parent 8bff4f1658
commit 90d9db0f83
14 changed files with 1093 additions and 417 deletions

View File

@@ -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()
})

View File

@@ -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,
})

View File

@@ -1,23 +1,28 @@
<!DOCTYPE html>
<html lang="zh" class="dark">
<html lang="zh">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline' data: blob: http://127.0.0.1:* http://localhost:*; img-src 'self' data: blob: http://127.0.0.1:* http://localhost:*; connect-src 'self' http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:*;" />
<title>CowAgent</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<!-- Local fonts & icons (offline, no CDN) served from publicDir -->
<link rel="stylesheet" href="./vendor/fonts/inter/inter.css" />
<link rel="stylesheet" href="./vendor/fontawesome/css/all.min.css" />
<script>
// Resolve theme before first paint to avoid flash-of-wrong-theme.
(function () {
var theme = localStorage.getItem('cow_theme') || 'dark';
if (theme === 'dark') document.documentElement.classList.add('dark');
try {
var pref = localStorage.getItem('cow_theme') || 'dark';
var resolved = pref === 'system'
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: pref;
if (resolved === 'dark') document.documentElement.classList.add('dark');
else document.documentElement.classList.remove('dark');
} catch (e) {
document.documentElement.classList.add('dark');
}
})();
</script>
<style>
body { margin: 0; }
</style>
</head>
<body class="h-screen overflow-hidden">
<div id="root"></div>

View File

@@ -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<string, { group: string; page: string }> = {
'/': { 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 <StatusScreen status={backend.status} error={backend.error} onRetry={backend.restart} />
}
const meta = VIEW_META[location.pathname] || VIEW_META['/']
const isChat = location.pathname === '/'
const showSessions = isChat && !sessionsCollapsed
return (
<div className="flex h-screen overflow-hidden">
<Sidebar version={APP_VERSION} />
<div className="flex h-screen overflow-hidden bg-base text-content">
<NavRail onLangChange={handleLangChange} />
{showSessions && <SessionList />}
<div className="flex-1 flex flex-col min-w-0 h-screen">
{/* Top Header */}
<header className="h-[52px] flex items-center gap-3 px-4 border-b border-slate-200 dark:border-white/10 bg-white dark:bg-[#1A1A1A] flex-shrink-0 z-10 titlebar-drag">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm min-w-0 titlebar-no-drag">
<span className="text-slate-400 dark:text-slate-500 truncate">{t(meta.group)}</span>
<i className="fas fa-chevron-right text-[10px] text-slate-300 dark:text-slate-600" />
<span className="font-medium text-slate-700 dark:text-slate-200 truncate">{t(meta.page)}</span>
</div>
<div className="flex-1" />
{/* Language Toggle */}
{/* Top titlebar strip — drag region + Windows controls */}
<header className="h-[44px] flex items-center gap-1 px-2 flex-shrink-0 titlebar-drag bg-base border-b border-default">
{isChat && sessionsCollapsed && (
<button
onClick={toggleLanguage}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10 cursor-pointer transition-colors duration-150 titlebar-no-drag"
onClick={toggleSessions}
title={t('nav_expand')}
className="titlebar-no-drag inline-flex items-center justify-center w-7 h-7 rounded-btn text-content-tertiary hover:text-content hover:bg-surface-2 cursor-pointer transition-colors"
>
<i className="fas fa-globe text-xs" />
<span>{getLang() === 'zh' ? 'EN' : '中'}</span>
<PanelLeftOpen size={16} />
</button>
{/* Theme Toggle */}
<button
onClick={toggleTheme}
className="p-2 rounded-lg text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10 cursor-pointer transition-colors duration-150 titlebar-no-drag"
>
<i className={`fas ${theme === 'dark' ? 'fa-sun' : 'fa-moon'}`} />
</button>
{/* Docs */}
<a
href="https://docs.cowagent.ai"
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-lg text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10 cursor-pointer transition-colors duration-150 titlebar-no-drag"
>
<i className="fas fa-book text-base" />
</a>
{/* GitHub */}
<a
href="https://github.com/zhayujie/chatgpt-on-wechat"
target="_blank"
rel="noopener noreferrer"
className="p-2 rounded-lg text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10 cursor-pointer transition-colors duration-150 titlebar-no-drag"
>
<i className="fab fa-github text-lg" />
</a>
)}
<div className="flex-1 min-w-0" />
{isWin && <WindowControls />}
</header>
{/* Content Area */}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
{/* Content */}
<div className="flex-1 flex flex-col min-h-0 overflow-hidden bg-base">
<Routes>
<Route path="/" element={<ChatPage baseUrl={backend.baseUrl} />} />
<Route path="/config" element={<ConfigPage baseUrl={backend.baseUrl} />} />
<Route path="/skills" element={<SkillsPage baseUrl={backend.baseUrl} />} />
<Route path="/knowledge" element={<PlaceholderPage title={t('menu_knowledge')} />} />
<Route path="/memory" element={<MemoryPage baseUrl={backend.baseUrl} />} />
<Route path="/skills" element={<SkillsPage baseUrl={backend.baseUrl} />} />
<Route path="/models" element={<PlaceholderPage title={t('menu_models')} />} />
<Route path="/channels" element={<ChannelsPage baseUrl={backend.baseUrl} />} />
<Route path="/tasks" element={<TasksPage baseUrl={backend.baseUrl} />} />
<Route path="/settings" element={<ConfigPage baseUrl={backend.baseUrl} />} />
<Route path="/logs" element={<LogsPage baseUrl={backend.baseUrl} />} />
</Routes>
</div>

View File

@@ -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<SidebarProps> = ({ version }) => {
const location = useLocation()
const navigate = useNavigate()
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({
chat: true,
manage: true,
monitor: true,
})
const toggleGroup = (key: string) => {
setOpenGroups((prev) => ({ ...prev, [key]: !prev[key] }))
}
return (
<aside className="w-64 bg-[#0A0A0A] text-neutral-400 flex flex-col flex-shrink-0 h-full">
{/* Logo area with traffic light spacing on macOS */}
<div className="flex items-center gap-3 pl-20 pr-5 h-[52px] border-b border-white/10 flex-shrink-0 titlebar-drag">
<img src="./logo.jpg" alt="CowAgent" className="w-8 h-8 rounded-lg flex-shrink-0" />
<div className="flex flex-col min-w-0">
<span className="text-white font-semibold text-sm truncate">CowAgent</span>
<span className="text-neutral-500 text-xs">{t('console')}</span>
</div>
</div>
{/* Navigation */}
<nav className="flex-1 overflow-y-auto py-4 px-3 space-y-1">
{menuGroups.map((group) => {
const isOpen = openGroups[group.key] !== false
return (
<div key={group.key} className={`menu-group ${isOpen ? 'open' : ''}`}>
<button
onClick={() => toggleGroup(group.key)}
className="w-full flex items-center gap-2 px-3 py-2 text-xs font-semibold uppercase tracking-wider text-neutral-500 hover:text-neutral-300 cursor-pointer transition-colors duration-150"
>
<i className="fas fa-chevron-right text-[10px] chevron" />
<span>{t(group.labelKey)}</span>
</button>
<div className="menu-group-items pl-2">
{group.items.map((item) => {
const isActive = location.pathname === item.path
return (
<a
key={item.path}
onClick={() => navigate(item.path)}
className={`sidebar-item flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 hover:bg-white/5 hover:text-neutral-200 text-[14px] ${
isActive ? 'active' : ''
}`}
>
<i className={`${item.icon} item-icon text-xs w-5 text-center`} />
<span>{t(item.labelKey)}</span>
</a>
)
})}
</div>
</div>
)
})}
</nav>
{/* Footer */}
<div className="px-4 py-3 border-t border-white/10 flex-shrink-0">
<div className="flex items-center gap-2 text-xs text-neutral-600">
<i className="fas fa-circle text-[6px] text-primary-400" />
<a
href="https://github.com/zhayujie/chatgpt-on-wechat/releases"
target="_blank"
rel="noopener noreferrer"
className="hover:text-primary-400 transition-colors duration-150 cursor-pointer"
>
{version}
</a>
</div>
</div>
</aside>
)
}
export default Sidebar

View File

@@ -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 */
}

View File

@@ -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 <html>
* so CSS can branch on platform (titlebar layout, scrollbars, etc.).
*/
export function usePlatform(): { platform: Platform; isMac: boolean; isWin: boolean } {
const [platform] = useState<Platform>(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' }
}

View File

@@ -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<Theme>(() => {
const saved = localStorage.getItem('cow_theme')
if (saved === 'dark' || saved === 'light') return saved
return 'dark'
})
const [pref, setPref] = useState<ThemePref>(readStored)
const [resolved, setResolved] = useState<ResolvedTheme>(() =>
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 }
}

View File

@@ -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;
}

View File

@@ -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<NavRailProps> = ({ 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 (
<aside className={`${width} flex flex-col flex-shrink-0 h-full bg-base transition-[width] duration-200`}>
{/* Top: full-width drag strip; reserve space for macOS traffic lights.
No right border here so the divider doesn't cut across the traffic lights. */}
<div className="titlebar-drag h-[44px] flex-shrink-0" />
{/* Content area carries the right divider, starting below the titlebar */}
<div className="flex-1 flex flex-col min-h-0 border-r border-default">
{/* Nav items */}
<nav className="flex-1 overflow-y-auto px-2 py-2 space-y-0.5">
{NAV_ITEMS.map((item) => {
const Icon = item.icon
const isActive = location.pathname === item.path
return (
<button
key={item.path}
onClick={() => navigate(item.path)}
title={collapsed ? t(item.labelKey) : undefined}
className={`group w-full flex items-center gap-3 rounded-btn cursor-pointer transition-colors h-9 ${
collapsed ? 'justify-center px-0' : 'px-3'
} ${
isActive
? 'bg-accent-soft text-accent'
: 'text-content-secondary hover:bg-surface-2 hover:text-content'
}`}
>
<Icon size={18} strokeWidth={isActive ? 2.2 : 1.8} className="flex-shrink-0" />
{!collapsed && <span className="text-[13px] truncate">{t(item.labelKey)}</span>}
</button>
)
})}
</nav>
{/* Footer actions */}
<div className={`flex-shrink-0 px-2 py-2 border-t border-subtle ${collapsed ? 'space-y-0.5' : 'flex items-center gap-1'}`}>
<FooterBtn collapsed={collapsed} onClick={toggleTheme} title={theme === 'dark' ? 'Light' : 'Dark'}>
{theme === 'dark' ? <Sun size={17} /> : <Moon size={17} />}
</FooterBtn>
<FooterBtn collapsed={collapsed} onClick={toggleLanguage} title="Language">
<span className="text-[13px] font-medium w-[18px] text-center">{getLang() === 'zh' ? 'EN' : '中'}</span>
</FooterBtn>
<div className={collapsed ? '' : 'flex-1'} />
<FooterBtn collapsed={collapsed} onClick={toggleNav} title={collapsed ? t('nav_expand') : t('nav_collapse')}>
{collapsed ? <PanelLeftOpen size={17} /> : <PanelLeftClose size={17} />}
</FooterBtn>
</div>
</div>
</aside>
)
}
const FooterBtn: React.FC<{
collapsed: boolean
onClick: () => void
title: string
children: React.ReactNode
}> = ({ collapsed, onClick, title, children }) => (
<button
onClick={onClick}
title={title}
className={`inline-flex items-center gap-1.5 rounded-btn text-content-tertiary hover:text-content hover:bg-surface-2 cursor-pointer transition-colors ${
collapsed ? 'w-full h-9 justify-center' : 'h-8 px-2'
}`}
>
{children}
</button>
)
export default NavRail

View File

@@ -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<string | null>(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 (
<div className="w-[240px] flex-shrink-0 flex flex-col h-full bg-surface border-r border-default">
{/* Header */}
<div className="flex items-center justify-between px-2 h-[44px] flex-shrink-0 titlebar-drag">
<button
onClick={toggleSessions}
title={t('nav_collapse')}
className="titlebar-no-drag inline-flex items-center justify-center w-7 h-7 rounded-btn text-content-tertiary hover:text-content hover:bg-surface-2 cursor-pointer transition-colors"
>
<PanelLeftClose size={16} />
</button>
<button
onClick={() => newSession()}
title={t('session_new')}
className="titlebar-no-drag inline-flex items-center gap-1.5 px-2.5 h-7 rounded-btn text-[12px] font-medium text-accent hover:bg-accent-soft cursor-pointer transition-colors"
>
<Plus size={15} />
{t('session_new')}
</button>
</div>
{/* List */}
<div
className="flex-1 overflow-y-auto px-2 pb-2"
onScroll={(e) => {
const el = e.currentTarget
if (el.scrollHeight - el.scrollTop - el.clientHeight < 80 && hasMore && !loading) loadMore()
}}
>
{sessions.length === 0 && !loading && (
<div className="flex flex-col items-center justify-center h-40 text-center px-4">
<MessageSquare size={22} className="text-content-disabled mb-2" />
<p className="text-xs text-content-tertiary">{t('session_empty')}</p>
</div>
)}
{groups.map((group) => (
<div key={group.label} className="mb-2">
<div className="px-2 pt-2 pb-1 text-[11px] font-medium uppercase tracking-wide text-content-disabled">
{group.label}
</div>
{group.items.map((s) => {
const isActive = s.session_id === activeId
const isEditing = editingId === s.session_id
return (
<div
key={s.session_id}
onClick={() => !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 ? (
<input
autoFocus
value={editValue}
onChange={(e) => 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"
/>
) : (
<span
className={`flex-1 min-w-0 truncate text-[13px] ${
isActive ? 'text-accent font-medium' : 'text-content-secondary'
}`}
>
{s.title || s.session_id}
</span>
)}
{isEditing ? (
<div className="flex items-center gap-0.5">
<IconBtn onClick={(e) => { e.stopPropagation(); commitEdit() }}><Check size={13} /></IconBtn>
<IconBtn onClick={(e) => { e.stopPropagation(); setEditingId(null) }}><X size={13} /></IconBtn>
</div>
) : (
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<IconBtn onClick={(e) => { e.stopPropagation(); startEdit(s) }} title={t('session_rename')}>
<Pencil size={13} />
</IconBtn>
<IconBtn onClick={(e) => { e.stopPropagation(); remove(s.session_id) }} title={t('session_delete')} danger>
<Trash2 size={13} />
</IconBtn>
</div>
)}
</div>
)
})}
</div>
))}
{loading && (
<div className="px-2 py-2 space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="skeleton h-7 w-full" />
))}
</div>
)}
</div>
</div>
)
}
const IconBtn: React.FC<{
onClick: (e: React.MouseEvent) => void
title?: string
danger?: boolean
children: React.ReactNode
}> = ({ onClick, title, danger, children }) => (
<button
onClick={onClick}
title={title}
className={`inline-flex items-center justify-center w-6 h-6 rounded cursor-pointer transition-colors text-content-tertiary ${
danger ? 'hover:text-danger hover:bg-danger-soft' : 'hover:text-content hover:bg-surface'
}`}
>
{children}
</button>
)
export default SessionList

View File

@@ -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 (
<div className="flex items-stretch h-full">
<button className={`${btn} hover:bg-surface-2`} onClick={() => api?.windowMinimize()} aria-label="Minimize">
<Minus size={15} strokeWidth={2} />
</button>
<button className={`${btn} hover:bg-surface-2`} onClick={() => api?.windowMaximize()} aria-label="Maximize">
{maximized ? <Copy size={12} strokeWidth={2} /> : <Square size={12} strokeWidth={2} />}
</button>
<button
className={`${btn} hover:bg-danger hover:text-white`}
onClick={() => api?.windowClose()}
aria-label="Close"
>
<X size={16} strokeWidth={2} />
</button>
</div>
)
}
export default WindowControls

View File

@@ -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<PlaceholderPageProps> = ({ title, hint }) => (
<div className="flex flex-col items-center justify-center h-full text-center px-8">
<div className="w-14 h-14 rounded-2xl bg-surface-2 flex items-center justify-center mb-4">
<Construction size={26} className="text-content-tertiary" />
</div>
<h2 className="text-lg font-semibold text-content mb-1">{title}</h2>
<p className="text-sm text-content-tertiary max-w-sm">{hint || 'Coming soon in this iteration.'}</p>
</div>
)
export default PlaceholderPage

View File

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