mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +08:00
feat(desktop): platform-aware shell, design tokens and three-column layout
This commit is contained in:
@@ -38,15 +38,50 @@ function getIconPath(ext: string = 'png'): string | undefined {
|
|||||||
return 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() {
|
function createWindow() {
|
||||||
|
const state = loadWindowState()
|
||||||
|
|
||||||
mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
width: 1280,
|
width: state.width,
|
||||||
height: 800,
|
height: state.height,
|
||||||
|
x: state.x,
|
||||||
|
y: state.y,
|
||||||
minWidth: 900,
|
minWidth: 900,
|
||||||
minHeight: 600,
|
minHeight: 600,
|
||||||
titleBarStyle: 'hiddenInset',
|
// macOS: native traffic lights inset into our custom titlebar.
|
||||||
trafficLightPosition: { x: 12, y: 18 },
|
// Windows: fully frameless; we render custom window controls in-app.
|
||||||
backgroundColor: '#111111',
|
titleBarStyle: isMac ? 'hiddenInset' : 'hidden',
|
||||||
|
trafficLightPosition: isMac ? { x: 14, y: 16 } : undefined,
|
||||||
|
frame: isMac ? undefined : false,
|
||||||
|
backgroundColor: '#0e0e10',
|
||||||
icon: getIconPath(),
|
icon: getIconPath(),
|
||||||
show: false,
|
show: false,
|
||||||
webPreferences: {
|
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')
|
const rendererHtml = path.join(__dirname, '../renderer/index.html')
|
||||||
|
|
||||||
if (isDev) {
|
if (isDev) {
|
||||||
@@ -143,6 +184,22 @@ function setupIPC() {
|
|||||||
})
|
})
|
||||||
return result.canceled ? null : result.filePaths[0]
|
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 () => {
|
app.whenReady().then(async () => {
|
||||||
@@ -180,5 +237,6 @@ app.on('window-all-closed', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
app.on('before-quit', () => {
|
app.on('before-quit', () => {
|
||||||
|
saveWindowState()
|
||||||
pythonBackend?.stop()
|
pythonBackend?.stop()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,5 +15,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
ipcRenderer.on('backend-log', (_event, line) => callback(line))
|
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,
|
platform: process.platform,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,23 +1,28 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh" class="dark">
|
<html lang="zh">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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>
|
<title>CowAgent</title>
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
<!-- Local fonts & icons (offline, no CDN) served from publicDir -->
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="stylesheet" href="./vendor/fonts/inter/inter.css" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="stylesheet" href="./vendor/fontawesome/css/all.min.css" />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
|
||||||
<script>
|
<script>
|
||||||
|
// Resolve theme before first paint to avoid flash-of-wrong-theme.
|
||||||
(function () {
|
(function () {
|
||||||
var theme = localStorage.getItem('cow_theme') || 'dark';
|
try {
|
||||||
if (theme === 'dark') document.documentElement.classList.add('dark');
|
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');
|
else document.documentElement.classList.remove('dark');
|
||||||
|
} catch (e) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<style>
|
|
||||||
body { margin: 0; }
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body class="h-screen overflow-hidden">
|
<body class="h-screen overflow-hidden">
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -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 { 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 StatusScreen from './components/StatusScreen'
|
||||||
import { useTheme } from './hooks/useTheme'
|
|
||||||
import { useBackend } from './hooks/useBackend'
|
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 ChatPage from './pages/ChatPage'
|
||||||
import ConfigPage from './pages/ConfigPage'
|
import ConfigPage from './pages/ConfigPage'
|
||||||
import SkillsPage from './pages/SkillsPage'
|
import SkillsPage from './pages/SkillsPage'
|
||||||
@@ -12,99 +17,61 @@ import MemoryPage from './pages/MemoryPage'
|
|||||||
import ChannelsPage from './pages/ChannelsPage'
|
import ChannelsPage from './pages/ChannelsPage'
|
||||||
import TasksPage from './pages/TasksPage'
|
import TasksPage from './pages/TasksPage'
|
||||||
import LogsPage from './pages/LogsPage'
|
import LogsPage from './pages/LogsPage'
|
||||||
|
import PlaceholderPage from './pages/PlaceholderPage'
|
||||||
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' },
|
|
||||||
}
|
|
||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
const { theme, toggleTheme } = useTheme()
|
|
||||||
const backend = useBackend()
|
const backend = useBackend()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
const { isWin } = usePlatform()
|
||||||
|
const { sessionsCollapsed, toggleSessions } = useUIStore()
|
||||||
const [, forceUpdate] = useState(0)
|
const [, forceUpdate] = useState(0)
|
||||||
|
|
||||||
const toggleLanguage = useCallback(() => {
|
useEffect(() => {
|
||||||
const next: Lang = getLang() === 'zh' ? 'en' : 'zh'
|
if (backend.status === 'ready') apiClient.setBaseUrl(backend.baseUrl)
|
||||||
setLang(next)
|
}, [backend.status, backend.baseUrl])
|
||||||
forceUpdate((n) => n + 1)
|
|
||||||
}, [])
|
const handleLangChange = useCallback(() => forceUpdate((n) => n + 1), [])
|
||||||
|
|
||||||
if (backend.status !== 'ready') {
|
if (backend.status !== 'ready') {
|
||||||
return <StatusScreen status={backend.status} error={backend.error} onRetry={backend.restart} />
|
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 (
|
return (
|
||||||
<div className="flex h-screen overflow-hidden">
|
<div className="flex h-screen overflow-hidden bg-base text-content">
|
||||||
<Sidebar version={APP_VERSION} />
|
<NavRail onLangChange={handleLangChange} />
|
||||||
|
|
||||||
|
{showSessions && <SessionList />}
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col min-w-0 h-screen">
|
<div className="flex-1 flex flex-col min-w-0 h-screen">
|
||||||
{/* Top Header */}
|
{/* Top titlebar strip — drag region + Windows controls */}
|
||||||
<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">
|
<header className="h-[44px] flex items-center gap-1 px-2 flex-shrink-0 titlebar-drag bg-base border-b border-default">
|
||||||
{/* Breadcrumb */}
|
{isChat && sessionsCollapsed && (
|
||||||
<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 */}
|
|
||||||
<button
|
<button
|
||||||
onClick={toggleLanguage}
|
onClick={toggleSessions}
|
||||||
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"
|
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" />
|
<PanelLeftOpen size={16} />
|
||||||
<span>{getLang() === 'zh' ? 'EN' : '中'}</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
{/* Theme Toggle */}
|
<div className="flex-1 min-w-0" />
|
||||||
<button
|
{isWin && <WindowControls />}
|
||||||
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>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Content Area */}
|
{/* Content */}
|
||||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
<div className="flex-1 flex flex-col min-h-0 overflow-hidden bg-base">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<ChatPage baseUrl={backend.baseUrl} />} />
|
<Route path="/" element={<ChatPage baseUrl={backend.baseUrl} />} />
|
||||||
<Route path="/config" element={<ConfigPage baseUrl={backend.baseUrl} />} />
|
<Route path="/knowledge" element={<PlaceholderPage title={t('menu_knowledge')} />} />
|
||||||
<Route path="/skills" element={<SkillsPage baseUrl={backend.baseUrl} />} />
|
|
||||||
<Route path="/memory" element={<MemoryPage baseUrl={backend.baseUrl} />} />
|
<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="/channels" element={<ChannelsPage baseUrl={backend.baseUrl} />} />
|
||||||
<Route path="/tasks" element={<TasksPage 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} />} />
|
<Route path="/logs" element={<LogsPage baseUrl={backend.baseUrl} />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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
|
|
||||||
189
desktop/src/renderer/src/highlight.css
Normal file
189
desktop/src/renderer/src/highlight.css
Normal 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 */
|
||||||
|
|
||||||
|
}
|
||||||
29
desktop/src/renderer/src/hooks/usePlatform.ts
Normal file
29
desktop/src/renderer/src/hooks/usePlatform.ts
Normal 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' }
|
||||||
|
}
|
||||||
@@ -1,27 +1,57 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
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() {
|
export function useTheme() {
|
||||||
const [theme, setThemeState] = useState<Theme>(() => {
|
const [pref, setPref] = useState<ThemePref>(readStored)
|
||||||
const saved = localStorage.getItem('cow_theme')
|
const [resolved, setResolved] = useState<ResolvedTheme>(() =>
|
||||||
if (saved === 'dark' || saved === 'light') return saved
|
readStored() === 'system' ? getSystemTheme() : (readStored() as ResolvedTheme)
|
||||||
return 'dark'
|
)
|
||||||
})
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement
|
const next: ResolvedTheme = pref === 'system' ? getSystemTheme() : pref
|
||||||
if (theme === 'dark') {
|
setResolved(next)
|
||||||
root.classList.add('dark')
|
applyTheme(next)
|
||||||
} else {
|
localStorage.setItem(STORAGE_KEY, pref)
|
||||||
root.classList.remove('dark')
|
}, [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)
|
mq.addEventListener('change', handler)
|
||||||
}, [theme])
|
return () => mq.removeEventListener('change', handler)
|
||||||
|
}, [pref])
|
||||||
|
|
||||||
const toggleTheme = useCallback(() => {
|
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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,185 @@
|
|||||||
|
@import './highlight.css';
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@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;
|
box-sizing: border-box;
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: #94a3b8 transparent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
html,
|
||||||
::-webkit-scrollbar-track { background: transparent; }
|
body {
|
||||||
::-webkit-scrollbar-thumb { background: #94a3b8; border-radius: 3px; }
|
margin: 0;
|
||||||
::-webkit-scrollbar-thumb:hover { background: #64748b; }
|
height: 100%;
|
||||||
.dark ::-webkit-scrollbar-thumb { background: #475569; }
|
}
|
||||||
.dark ::-webkit-scrollbar-thumb:hover { background: #64748b; }
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-gray-50 text-slate-800 overflow-hidden;
|
background: var(--bg-base);
|
||||||
font-family: 'Inter', system-ui, -apple-system, 'PingFang SC', 'Hiragino Sans GB', sans-serif;
|
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 {
|
/* Smooth theme transition (respect reduced motion) */
|
||||||
@apply bg-[#111111] text-slate-200;
|
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 {
|
@keyframes blink {
|
||||||
0%, 100% { opacity: 1; }
|
0%, 100% { opacity: 1; }
|
||||||
50% { opacity: 0; }
|
50% { opacity: 0; }
|
||||||
@@ -33,211 +189,150 @@ body {
|
|||||||
animation: blink 1s step-end infinite;
|
animation: blink 1s step-end infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sidebar */
|
/* AI typing indicator — 3-dot pulse */
|
||||||
.sidebar-item.active {
|
@keyframes typingPulse {
|
||||||
background: rgba(255, 255, 255, 0.08);
|
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
|
||||||
color: #fff;
|
40% { transform: scale(1); opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-item.active .item-icon {
|
.typing-dot {
|
||||||
color: #4ABE6E;
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: var(--text-tertiary);
|
||||||
|
animation: typingPulse 1.4s infinite ease-in-out both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-group .chevron {
|
.typing-dot:nth-child(2) { animation-delay: 0.16s; }
|
||||||
transition: transform 0.25s ease;
|
.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 {
|
.animate-reveal {
|
||||||
transform: rotate(90deg);
|
animation: fadeInUp 0.25s ease both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-group-items {
|
/* Skeleton shimmer */
|
||||||
max-height: 0;
|
@keyframes shimmer {
|
||||||
overflow: hidden;
|
0% { background-position: -200% 0; }
|
||||||
transition: max-height 0.25s ease;
|
100% { background-position: 200% 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-group.open .menu-group-items {
|
.skeleton {
|
||||||
max-height: 500px;
|
background: linear-gradient(
|
||||||
transition: max-height 0.35s ease;
|
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 {
|
Markdown content (assistant messages, memory/knowledge viewers)
|
||||||
@apply rounded-lg overflow-x-auto my-3;
|
============================================================ */
|
||||||
background: #f1f5f9;
|
|
||||||
|
.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 {
|
.msg-content a { color: var(--accent); text-decoration: none; }
|
||||||
background: #111111;
|
.msg-content a:hover { text-decoration: underline; }
|
||||||
}
|
|
||||||
|
|
||||||
.msg-content :not(pre) > code {
|
.msg-content blockquote {
|
||||||
padding: 2px 6px;
|
padding-left: 0.9em;
|
||||||
border-radius: 4px;
|
margin: 0.6em 0;
|
||||||
font-size: 0.875em;
|
border-left: 3px solid var(--accent);
|
||||||
background: rgba(74, 190, 110, 0.1);
|
color: var(--text-secondary);
|
||||||
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 table {
|
.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 {
|
.msg-content th, .msg-content td {
|
||||||
@apply border px-3 py-2 text-sm;
|
border: 1px solid var(--border-default);
|
||||||
border-color: #e2e8f0;
|
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 {
|
/* Fenced code blocks */
|
||||||
border-color: rgba(255, 255, 255, 0.1);
|
.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 .code-block-header {
|
||||||
.msg-content th {
|
display: flex;
|
||||||
@apply font-medium;
|
align-items: center;
|
||||||
background: #f1f5f9;
|
justify-content: space-between;
|
||||||
|
padding: 5px 10px 5px 12px;
|
||||||
|
background: var(--bg-surface-2);
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
}
|
}
|
||||||
|
.msg-content .code-block-lang {
|
||||||
.dark .msg-content th {
|
font-size: 11px;
|
||||||
background: #111111;
|
font-weight: 500;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
text-transform: lowercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
.msg-content .code-copy-btn {
|
||||||
.msg-content blockquote {
|
font-size: 11px;
|
||||||
@apply pl-4 my-3;
|
color: var(--text-tertiary);
|
||||||
border-left: 4px solid #4ABE6E;
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 5px;
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
}
|
}
|
||||||
|
.msg-content .code-copy-btn:hover { color: var(--text-secondary); background: var(--bg-inset); }
|
||||||
.dark .msg-content blockquote {
|
.msg-content .code-copy-btn.copied { color: var(--accent); }
|
||||||
background: rgba(74, 190, 110, 0.08);
|
.msg-content .code-block-wrapper pre {
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px 14px;
|
||||||
|
overflow-x: auto;
|
||||||
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
.msg-content .code-block-wrapper pre code,
|
||||||
.msg-content hr {
|
.msg-content pre code.hljs {
|
||||||
@apply my-4;
|
font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
|
||||||
border-color: #e2e8f0;
|
font-size: 12.5px;
|
||||||
}
|
line-height: 1.6;
|
||||||
|
background: transparent;
|
||||||
.dark .msg-content hr {
|
padding: 0;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
126
desktop/src/renderer/src/layout/NavRail.tsx
Normal file
126
desktop/src/renderer/src/layout/NavRail.tsx
Normal 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
|
||||||
180
desktop/src/renderer/src/layout/SessionList.tsx
Normal file
180
desktop/src/renderer/src/layout/SessionList.tsx
Normal 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
|
||||||
41
desktop/src/renderer/src/layout/WindowControls.tsx
Normal file
41
desktop/src/renderer/src/layout/WindowControls.tsx
Normal 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
|
||||||
20
desktop/src/renderer/src/pages/PlaceholderPage.tsx
Normal file
20
desktop/src/renderer/src/pages/PlaceholderPage.tsx
Normal 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
|
||||||
@@ -5,10 +5,13 @@ module.exports = {
|
|||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
fontFamily: {
|
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'],
|
mono: ['"JetBrains Mono"', '"Fira Code"', 'Consolas', 'monospace'],
|
||||||
},
|
},
|
||||||
colors: {
|
colors: {
|
||||||
|
'danger-soft': 'var(--danger-soft)',
|
||||||
|
'danger-border': 'var(--danger-border)',
|
||||||
|
// Brand accent (kept for backward compat + explicit accent usage)
|
||||||
primary: {
|
primary: {
|
||||||
50: '#EDFDF3',
|
50: '#EDFDF3',
|
||||||
100: '#D4FAE2',
|
100: '#D4FAE2',
|
||||||
@@ -21,6 +24,46 @@ module.exports = {
|
|||||||
800: '#1A5532',
|
800: '#1A5532',
|
||||||
900: '#16462A',
|
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: {
|
animation: {
|
||||||
'pulse-dot': 'pulseDot 1.4s infinite ease-in-out both',
|
'pulse-dot': 'pulseDot 1.4s infinite ease-in-out both',
|
||||||
|
|||||||
Reference in New Issue
Block a user