diff --git a/.DS_Store b/.DS_Store index 587010c..d9a4863 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e81ba9..6d5840f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '22' + node-version: '24' cache: 'pnpm' - name: Install dependencies diff --git a/.github/workflows/deploy-gh-pages.yml b/.github/workflows/deploy-gh-pages.yml new file mode 100644 index 0000000..cf93ff8 --- /dev/null +++ b/.github/workflows/deploy-gh-pages.yml @@ -0,0 +1,40 @@ +name: Deploy to gh-pages + +on: + push: + branches: [dev] + +permissions: + contents: write + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 9 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build client + run: pnpm build:client + + - name: Deploy to gh-pages + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: packages/client/dist + publish_branch: gh-pages + force_orphan: true diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 594693a..db4b103 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -32,7 +32,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '22' + node-version: '24' cache: 'pnpm' - name: Install dependencies @@ -43,6 +43,8 @@ jobs: - name: Configure Pages uses: actions/configure-pages@v5 + with: + enablement: true - name: Upload Packages Client uses: actions/upload-pages-artifact@v3 diff --git a/packages/.DS_Store b/packages/.DS_Store index 408d345..6a5f32f 100644 Binary files a/packages/.DS_Store and b/packages/.DS_Store differ diff --git a/packages/client/.DS_Store b/packages/client/.DS_Store new file mode 100644 index 0000000..16ea0d3 Binary files /dev/null and b/packages/client/.DS_Store differ diff --git a/packages/client/assets/call-code.png b/packages/client/assets/call-code.png new file mode 100644 index 0000000..e8cbfbd Binary files /dev/null and b/packages/client/assets/call-code.png differ diff --git a/packages/client/src/App.tsx b/packages/client/src/App.tsx index 0f7f962..81fc869 100644 --- a/packages/client/src/App.tsx +++ b/packages/client/src/App.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; +import { HeaderBar } from './components/HeaderBar'; import { Sidebar } from './components/Sidebar'; import { MainPanel } from './components/MainPanel'; import { loadWebExport } from './data'; @@ -14,7 +15,9 @@ const initialTheme = (): Theme => { export default function App() { const [data, setData] = useState(null); - const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading'); + const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>( + 'loading', + ); const [theme, setTheme] = useState(initialTheme); const [activeId, setActiveId] = useState(null); const [query, setQuery] = useState(''); @@ -29,9 +32,12 @@ export default function App() { } setData(result); - const requestedId = new URLSearchParams(window.location.search).get('session'); + const requestedId = new URLSearchParams(window.location.search).get( + 'session', + ); setActiveId( - requestedId && result?.sessions.some((session) => session.id === requestedId) + requestedId && + result?.sessions.some((session) => session.id === requestedId) ? requestedId : (result?.sessions[0]?.id ?? null), ); @@ -64,33 +70,52 @@ export default function App() { if (loadState === 'loading') { return ( -
- 正在读取会话 +
+
+ 正在读取会话 +
); } if (loadState === 'error' || !data) { return ( -
- 无法读取会话数据 +
+
+ 无法读取会话数据 +
); } return ( -
-
- +
+ - +
+ + +
); diff --git a/packages/client/src/components/HeaderBar.tsx b/packages/client/src/components/HeaderBar.tsx new file mode 100644 index 0000000..a6a652a --- /dev/null +++ b/packages/client/src/components/HeaderBar.tsx @@ -0,0 +1,67 @@ +import type { Theme, WebSession } from '../types'; +import { entryRole } from '../utils'; +import logoUrl from '../../assets/call-code.png'; + +interface HeaderBarProps { + sessions: WebSession[]; + theme: Theme; + onThemeChange: (theme: Theme) => void; +} + +export function HeaderBar({ sessions, theme, onThemeChange }: HeaderBarProps) { + const count = sessions.reduce( + (acc, session) => { + acc.messages += session.entries.length; + acc.tools += session.entries.filter( + (entry) => entryRole(entry) === 'tool', + ).length; + return acc; + }, + { messages: 0, tools: 0 }, + ); + + return ( +
+
+ Call Code +
+

+ Call Code +

+
+ {sessions.length} 个会话 · {count.messages} 条消息 · {count.tools}{' '} + 次工具 +
+
+
+ +
+ {(['light', 'dark'] as const).map((value) => ( + + ))} +
+
+ ); +} diff --git a/packages/client/src/components/MainPanel.tsx b/packages/client/src/components/MainPanel.tsx index ddab0c5..5277646 100644 --- a/packages/client/src/components/MainPanel.tsx +++ b/packages/client/src/components/MainPanel.tsx @@ -1,5 +1,5 @@ import type { Filter, WebFact, WebRecord, WebSession } from '../types'; -import { filterEntries, formatTime, getSessionMode, getSessionTitle } from '../utils'; +import { filterEntries, formatTime, getSessionTitle } from '../utils'; import { MessageItem } from './MessageItem'; interface MainPanelProps { @@ -15,8 +15,25 @@ const filters: Array<{ value: Filter; label: string }> = [ { value: 'tool', label: '工具' }, ]; -const glassPanel = - 'flex min-h-0 flex-col overflow-hidden rounded-lg border border-black/10 bg-white/70 shadow-[0_18px_50px_rgba(15,23,42,0.12)] backdrop-blur-2xl lg:h-[calc(100vh-2rem)] dark:border-white/10 dark:bg-[#0d1116]/75 dark:shadow-[0_18px_60px_rgba(0,0,0,0.42)]'; +function StatRow({ session }: { session: WebSession }) { + const items = [ + ['消息', `${session.entries.length}`], + ['tokens', session.stats?.totalTokens?.toLocaleString() ?? '0'], + ['成本', `$${(session.stats?.costTotal ?? 0).toFixed(3)}`], + ['目录', session.cwd], + ] as const; + + return ( +
+ {items.map(([label, value]) => ( + + {label} + {value} + + ))} +
+ ); +} function FactGrid({ facts }: { facts: WebFact[] }) { if (facts.length === 0) { @@ -24,19 +41,32 @@ function FactGrid({ facts }: { facts: WebFact[] }) { } return ( -
- {facts.slice(0, 12).map((fact, index) => ( -
-
{fact.kind}
-
- {fact.value || fact.key || String(fact.seq)} +
+
+ 事实 +
+
+ {facts.slice(0, 12).map((fact, index) => ( +
+
+ {fact.kind} +
+
+ {fact.value || fact.key || String(fact.seq)} +
-
- ))} -
+ ))} +
+ ); } @@ -45,44 +75,50 @@ const payloadText = (payload: unknown): string => { return payload; } if (payload && typeof payload === 'object') { - return JSON.stringify(payload); + return JSON.stringify(payload, null, 2); } return String(payload ?? ''); }; -function RecordGrid({ records }: { records: WebRecord[] }) { +function RecordList({ records }: { records: WebRecord[] }) { if (records.length === 0) { return null; } return ( -
-
- 运行记录 ({records.length}) +
+
+ 运行记录 · 最近 {Math.min(20, records.length)} 条
-
+
{records .slice(-20) .reverse() .map((record) => ( -
-
- +
+
+ {record.type} {record.opKind ? ` / ${record.opKind}` : ''} - {formatTime(record.timestamp)} + {formatTime(record.timestamp)}
-
+
{payloadText(record.payload)}
))}
-
+
); } @@ -90,48 +126,31 @@ export function MainPanel({ session, filter, onFilterChange }: MainPanelProps) { const entries = session ? filterEntries(session.entries, filter) : []; return ( -
-
-
-

- {session ? getSessionTitle(session) : 'Call Code'} -

-
- {session ? ( - <> - - {getSessionMode(session).toUpperCase()} - - - {session.entries.length} 条消息 - - - tokens {session.stats?.totalTokens ?? 0} - - - 成本 {session.stats?.costTotal ?? 0} - - - {session.cwd} - - - ) : null} -
-
-
+
+ {/* 标题栏 */} +
+

+ {session ? getSessionTitle(session) : '暂无会话'} +

+ {session ? : null} +
-
-
+ {/* 过滤器 */} +
+
{filters.map((item) => ( @@ -139,24 +158,35 @@ export function MainPanel({ session, filter, onFilterChange }: MainPanelProps) {
-
- {!session ? ( -
- 暂无会话数据 -
- ) : entries.length === 0 ? ( -
- 没有匹配的消息 -
- ) : ( -
- {entries.map((entry) => ( - - ))} - {session.facts ? : null} - {session.records ? : null} -
- )} + {/* 消息列表 */} +
+
+ {!session ? ( +
+ 暂无会话数据 +
+ ) : entries.length === 0 ? ( +
+ 没有匹配的消息 +
+ ) : ( +
+ {entries.map((entry) => ( + + ))} + {session.facts ? : null} + {session.records ? ( + + ) : null} +
+ )} +
); diff --git a/packages/client/src/components/MessageItem.tsx b/packages/client/src/components/MessageItem.tsx index 79c65f1..f1c2cf6 100644 --- a/packages/client/src/components/MessageItem.tsx +++ b/packages/client/src/components/MessageItem.tsx @@ -1,16 +1,17 @@ import type { WebEntry } from '../types'; import { entryRole, entryText, formatTime } from '../utils'; +import type { EntryRole } from '../types'; interface MessageItemProps { entry: WebEntry; } -const roleMeta = { - user: { avatar: 'U', label: '用户', className: 'bg-teal-600 text-white dark:bg-cyan-400 dark:text-cyan-950' }, - assistant: { avatar: 'A', label: '助手', className: 'bg-amber-600 text-white dark:bg-amber-400 dark:text-amber-950' }, - tool: { avatar: 'T', label: '工具', className: 'bg-slate-200 text-slate-600 dark:bg-white/10 dark:text-slate-300' }, - system: { avatar: 'S', label: '系统', className: 'bg-slate-200 text-slate-600 dark:bg-white/10 dark:text-slate-300' }, -} as const; +const roleMeta: Record = { + user: { label: '用户', dotClass: 'role-dot role-dot--user', bubbleClass: 'msg-bubble--user' }, + assistant: { label: '助手', dotClass: 'role-dot role-dot--assistant', bubbleClass: 'msg-bubble--assistant' }, + tool: { label: '工具', dotClass: 'role-dot role-dot--tool', bubbleClass: 'msg-bubble--tool' }, + system: { label: '系统', dotClass: 'role-dot role-dot--system', bubbleClass: '' }, +}; export function MessageItem({ entry }: MessageItemProps) { const role = entryRole(entry); @@ -19,34 +20,35 @@ export function MessageItem({ entry }: MessageItemProps) { const isCode = role === 'tool' || text.length > 320; return ( -
-
- {meta.avatar} +
+ {/* 角色指示点 */} +
+
-
-
-
- {meta.label} - {entry.tool ? ( - - {entry.tool} - - ) : null} -
-
- {formatTime(entry.timestamp)} -
+
+ {/* 元信息行 */} +
+ {meta.label} + {entry.tool ? ( + {entry.tool} + ) : null} + {formatTime(entry.timestamp)}
+ {/* 内容 */} {isCode ? ( -
+          
             {text}
           
) : ( -
+
{text}
)} diff --git a/packages/client/src/components/Sidebar.tsx b/packages/client/src/components/Sidebar.tsx index 7f7a1b0..0ddcb19 100644 --- a/packages/client/src/components/Sidebar.tsx +++ b/packages/client/src/components/Sidebar.tsx @@ -1,135 +1,137 @@ -import { useMemo } from 'react'; -import type { Theme, WebSession } from '../types'; -import { entryRole, formatTime, getSessionMode, getSessionTitle } from '../utils'; +import type { WebSession } from '../types'; +import { entryRole, formatTime, getSessionTitle } from '../utils'; interface SidebarProps { sessions: WebSession[]; activeId: string | null; query: string; - theme: Theme; onSelect: (id: string) => void; onQueryChange: (query: string) => void; - onThemeChange: (theme: Theme) => void; } -const glassPanel = - 'flex max-h-[46vh] min-h-0 flex-col overflow-hidden rounded-lg border border-black/10 bg-white/70 shadow-[0_18px_50px_rgba(15,23,42,0.12)] backdrop-blur-2xl lg:sticky lg:top-4 lg:h-[calc(100vh-2rem)] lg:max-h-none dark:border-white/10 dark:bg-[#0d1116]/75 dark:shadow-[0_18px_60px_rgba(0,0,0,0.42)]'; - export function Sidebar({ sessions, activeId, query, - theme, onSelect, onQueryChange, - onThemeChange, }: SidebarProps) { - const stats = useMemo(() => { - const messages = sessions.reduce((sum, session) => sum + session.entries.length, 0); - const tools = sessions.reduce( - (sum, session) => - sum + session.entries.filter((entry) => entryRole(entry) === 'tool').length, - 0, - ); - return { sessions: sessions.length, messages, tools }; - }, [sessions]); - - const statDefs = [ - ['会话', stats.sessions], - ['消息', stats.messages], - ['工具', stats.tools], - ] as const; - return ( - ); diff --git a/packages/client/src/global.css b/packages/client/src/global.css index fe85281..60c2e72 100644 --- a/packages/client/src/global.css +++ b/packages/client/src/global.css @@ -1,38 +1,339 @@ -@import "tailwindcss"; +@import 'tailwindcss'; @custom-variant dark (&:where(.dark, .dark *)); :root { color-scheme: light; --font-ui: - "Avenir Next", - "PingFang SC", - "Hiragino Sans GB", - "Source Han Sans SC", - "Microsoft YaHei", - sans-serif; + 'SF Pro Text', 'PingFang SC', 'Hiragino Sans GB', 'Source Han Sans SC', + 'Microsoft YaHei', sans-serif; + --font-mono: + 'SF Mono', ui-monospace, 'JetBrains Mono', 'Menlo', 'Consolas', monospace; + + /* 基底背景 */ + --bg-a: #f3f4f7; + --bg-b: #fbfbfd; + + /* 中性光晕,仅保留极淡的层次感 */ + --glow-1: 148 148 158; + --glow-2: 148 148 158; + --glow-3: 148 148 158; + + /* 毛玻璃面板 */ + --panel-bg: 255 255 255 / 0.42; + --panel-border: 145 145 155 / 0.14; + --panel-shadow: + 0 1px 0 rgb(255 255 255 / 0.75) inset, 0 1px 3px rgb(17 24 39 / 0.06), + 0 20px 50px -16px rgb(17 24 39 / 0.1); + + /* 侧边栏选中态 */ + --sidebar-active: 255 255 255 / 0.88; + --sidebar-active-shadow: + 0 1px 3px rgb(17 24 39 / 0.07), 0 8px 24px -6px rgb(17 24 39 / 0.12); + --sidebar-hover: 255 255 255 / 0.45; + + /* 标签/芯片 */ + --chip-bg: 255 255 255 / 0.46; + --chip-border: 145 145 155 / 0.12; + + /* 文字 */ + --accent: #111827; + --accent-soft: rgb(17 24 39 / 0.07); + --text-primary: #18181b; + --text-secondary: #52525b; + --text-tertiary: #8e8e93; + + /* 中性角色色 */ + --role-user: #71717a; + --role-assistant: #52525b; + --role-tool: #3f3f46; + --role-system: #a1a1aa; + + /* 分段控件选中态 */ + --segment-active: 255 255 255 / 0.92; + --segment-active-text: #18181b; + --segment-active-shadow: + 0 1px 3px rgb(17 24 39 / 0.08), 0 4px 12px -4px rgb(17 24 39 / 0.08); } :root.dark { color-scheme: dark; + + --bg-a: #0a0a0c; + --bg-b: #111114; + + --glow-1: 138 138 146; + --glow-2: 138 138 146; + --glow-3: 138 138 146; + + --panel-bg: 18 18 22 / 0.52; + --panel-border: 255 255 255 / 0.1; + --panel-shadow: + 0 1px 0 rgb(255 255 255 / 0.04) inset, 0 1px 3px rgb(0 0 0 / 0.45), + 0 40px 80px -20px rgb(0 0 0 / 0.55); + + --sidebar-active: 255 255 255 / 0.12; + --sidebar-active-shadow: + 0 1px 0 rgb(255 255 255 / 0.05) inset, 0 8px 28px -6px rgb(0 0 0 / 0.6); + --sidebar-hover: 255 255 255 / 0.07; + + --chip-bg: 255 255 255 / 0.06; + --chip-border: 255 255 255 / 0.1; + + --accent: #f4f4f5; + --accent-soft: rgb(244 244 245 / 0.1); + --text-primary: #f4f4f5; + --text-secondary: #a1a1aa; + --text-tertiary: #6f6f76; + + --role-user: #d4d4d8; + --role-assistant: #a1a1aa; + --role-tool: #71717a; + --role-system: #52525b; + + --segment-active: 255 255 255 / 0.12; + --segment-active-text: #fafafa; + --segment-active-shadow: + 0 1px 0 rgb(255 255 255 / 0.05) inset, 0 1px 3px rgb(0 0 0 / 0.45), + 0 4px 14px -6px rgb(0 0 0 / 0.5); } html { font-family: var(--font-ui); text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + font-feature-settings: 'ss01', 'cv11'; } body { margin: 0; min-height: 100vh; - -webkit-font-smoothing: antialiased; + color: var(--text-primary); } * { scrollbar-width: thin; - scrollbar-color: rgba(100, 116, 139, 0.35) transparent; + scrollbar-color: rgb(var(--glow-1) / 0.2) transparent; +} + +/* 中性背景,让毛玻璃面板保持透明层次 */ +body { + background: linear-gradient(180deg, var(--bg-a), var(--bg-b)); + background-attachment: fixed; +} + +/* 外壳 */ +.app-shell { + position: relative; + min-height: 100vh; + padding: 12px 12px 20px; +} + +@media (min-width: 1024px) { + .app-shell { + padding: 16px 20px 24px; + } +} + +/* 噪点纹理 */ +.app-shell::before { + content: ''; + position: fixed; + inset: 0; + pointer-events: none; + opacity: 0.35; + mix-blend-mode: overlay; + background-image: url("data:image/svg+xml;utf8,"); + z-index: 0; +} + +/* 整体框架:顶栏与工作区收进同一个毛玻璃容器 */ +.app-frame { + display: flex; + flex-direction: column; + position: relative; + z-index: 1; + width: 100%; + max-width: 1480px; + min-height: calc(100vh - 28px); + margin: 0 auto; + overflow: hidden; + border-radius: 16px; + border: 1px solid rgb(var(--panel-border)); + background: rgb(var(--panel-bg)); + box-shadow: var(--panel-shadow); + backdrop-filter: blur(24px) saturate(180%); + -webkit-backdrop-filter: blur(24px) saturate(180%); +} + +@media (min-width: 1024px) { + .app-frame { + height: calc(100vh - 40px); + } +} + +/* 顶部产品栏 */ +.header-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 14px; + border-bottom: 1px solid rgb(var(--panel-border)); + background: rgb(var(--chip-bg)); +} + +@media (min-width: 1024px) { + .header-bar { + padding: 12px 18px; + } +} + +/* 下方工作区:侧栏与主面板零间距拼接 */ +.app-workspace { + display: grid; + flex: 1; + grid-template-columns: 1fr; + min-height: 0; +} + +@media (min-width: 1024px) { + .app-workspace { + grid-template-columns: 300px minmax(0, 1fr); + } +} + +.sidebar-panel, +.main-panel { + position: relative; + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +.sidebar-panel { + max-height: 50vh; + border-bottom: 1px solid rgb(var(--panel-border)); +} + +@media (min-width: 1024px) { + .sidebar-panel { + max-height: none; + border-right: 1px solid rgb(var(--panel-border)); + border-bottom: 0; + } +} + +/* 芯片标签 */ +.chip { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 2px 9px; + border-radius: 999px; + border: 1px solid rgb(var(--chip-border)); + background: rgb(var(--chip-bg)); + font-size: 11px; + font-weight: 500; + color: var(--text-secondary); + white-space: nowrap; + backdrop-filter: blur(8px); +} + +/* 分段控件 */ +.segmented { + display: inline-flex; + padding: 3px; + border-radius: 10px; + border: 1px solid rgb(var(--chip-border)); + background: rgb(var(--chip-bg)); + backdrop-filter: blur(10px); +} + +.segmented button { + padding: 4px 10px; + font-size: 11px; + font-weight: 500; + border-radius: 7px; + color: var(--text-secondary); + transition: all 0.2s ease; +} + +.segmented button:hover { + color: var(--text-primary); +} + +.segmented button[aria-pressed='true'] { + background: rgb(var(--segment-active)); + color: var(--segment-active-text); + box-shadow: var(--segment-active-shadow); +} + +/* 等宽字体 */ +.mono { + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.55; +} + +::selection { + background: rgb(var(--glow-1) / 0.2); +} + +/* 角色指示点 */ +.role-dot { + display: block; + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.role-dot--user { + background: var(--role-user); + box-shadow: 0 0 6px rgb(var(--glow-1) / 0.4); +} +.role-dot--assistant { + background: var(--role-assistant); + box-shadow: 0 0 6px rgb(var(--glow-2) / 0.4); +} +.role-dot--tool { + background: var(--role-tool); + box-shadow: 0 0 6px 0 rgb(var(--glow-3) / 0.35); +} +.role-dot--system { + background: var(--role-system); +} + +/* 消息气泡 */ +.msg-bubble { + border-radius: 12px; + border: 1px solid rgb(var(--chip-border)); + background: rgb(var(--chip-bg)); + backdrop-filter: blur(8px); +} + +.msg-bubble--user { + border-left: 2px solid var(--role-user); +} + +.msg-bubble--assistant { + border-left: 2px solid var(--role-assistant); +} + +.msg-bubble--tool { + border-left: 2px solid var(--role-tool); +} + +/* 搜索框聚焦高亮 */ +.search-box { + transition: + border-color 0.2s, + box-shadow 0.2s; } -:root.dark * { - scrollbar-color: rgba(148, 163, 184, 0.28) transparent; +.search-box:focus-within { + border-color: rgb(var(--glow-1) / 0.35) !important; + box-shadow: 0 0 0 3px rgb(var(--glow-1) / 0.08); } diff --git a/tests/client-ui.spec.ts b/tests/client-ui.spec.ts new file mode 100644 index 0000000..9d894f5 --- /dev/null +++ b/tests/client-ui.spec.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from 'vitest'; +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { HeaderBar } from '../packages/client/src/components/HeaderBar'; +import type { WebSession } from '../packages/client/src/types'; + +vi.mock('../packages/client/assets/call-code.png', () => ({ + default: 'call-code.png', +})); + +const makeSession = (overrides: Partial = {}): WebSession => ({ + id: 's1', + createdAt: '2026-08-05T00:00:00.000Z', + cwd: '/tmp/project', + parentSessionId: null, + metadata: {}, + stats: { + messageCount: 2, + cachedTokens: 0, + uncachedTokens: 0, + totalTokens: 0, + costTotal: 0, + }, + entries: [ + { + seq: 1, + id: 'e1', + parentId: null, + type: 'user', + role: 'user', + timestamp: '2026-08-05T00:00:00.000Z', + payload: {}, + }, + { + seq: 2, + id: 'e2', + parentId: null, + type: 'tool', + role: 'tool', + timestamp: '2026-08-05T00:00:00.000Z', + tool: 'read_file', + payload: {}, + }, + ], + records: [], + facts: [], + ...overrides, +}); + +describe('client HeaderBar', () => { + it('顶部栏展示产品名、会话统计和主题切换', () => { + const html = renderToStaticMarkup( + React.createElement(HeaderBar, { + sessions: [makeSession()], + theme: 'dark', + onThemeChange: () => undefined, + }), + ); + + expect(html).toContain('Call Code'); + expect(html).toContain('1 个会话'); + expect(html).toContain('2 条消息'); + expect(html).toContain('1 次工具'); + expect(html).toContain('高级黑'); + }); + + it('统计按多个会话累加', () => { + const second = makeSession({ id: 's2' }); + const html = renderToStaticMarkup( + React.createElement(HeaderBar, { + sessions: [makeSession(), second], + theme: 'light', + onThemeChange: () => undefined, + }), + ); + + expect(html).toContain('2 个会话'); + expect(html).toContain('4 条消息'); + expect(html).toContain('2 次工具'); + expect(html).toContain('毛玻璃'); + }); +});