| name | CodePilot | ||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| description | Multi-model AI Agent desktop client. Settings shell — bordered cards, inset dividers, charcoal monochrome accent. | ||||||||||||||||||||||||||||||||||||||||||||||||
| version | 0.2-settings-providers-models | ||||||||||||||||||||||||||||||||||||||||||||||||
| colors |
|
||||||||||||||||||||||||||||||||||||||||||||||||
| typography |
|
||||||||||||||||||||||||||||||||||||||||||||||||
| rounded |
|
||||||||||||||||||||||||||||||||||||||||||||||||
| spacing |
|
Concrete patterns extracted from Settings > Providers and Settings > Models. Anything in here is implemented and shipping; treat it as the canonical surface for new Settings work.
CodePilot 的 CSS token 分两层。本节是项目级原则,虽然 design.md 主要服务 Settings,但 token 分类规则对所有 UI 改动都适用。任何引入新 token 或改 token 用法的 PR 在 review 时必须能回答"这是产品 token 还是平台 token"。
声明位置:src/app/globals.css :root / .dark,以及 themes/*.json。
涵盖:
- 色板:
--background/--foreground/--card/--popover/--sidebar/--muted/--accent/--border/--primary等 - 圆角:
--radius(产品组件圆角,目前1rem)— 注意 design.md 顶部 frontmatter 仍以 8px 作为 outer card 的画板规范;个别 card 仍按本规范的rounded-lg处理 - 状态色:
--status-success-*/--status-warning-*/--status-error-*/--status-info-* - 字体:
--font-geist-sans/--font-geist-mono - Terminal / 代码块、Composer shadow、Context dot 等业务专用 token
用在:组件内层(按钮、表单字段、表格、内容卡片、对话框 body、消息气泡)。
声明位置:src/app/globals.css 的 :root / html[data-platform="darwin"][data-platform-style="auto"]。
前缀:--platform-*。当前可用 token:
--platform-font-ui—— chrome UI 字体(默认 Geist;macOS 切到 SF/system-ui 栈)--platform-radius-window/--platform-radius-control—— 窗口和控件圆角--platform-hover-alpha—— 壳层 hover 强度(默认 1,macOS 软化到 0.7)--platform-surface-sidebar/--platform-surface-bar/--platform-surface-popover/--platform-surface-hud/--platform-surface-tooltip—— 壳层与浮层表面--platform-border-subtle—— chrome 边缘细线
用在:窗口 chrome / 顶栏 / 侧栏 / Composer 外壳 / Popover / Menu / HUD / Tooltip 这类"壳层 + 浮层"。
| 你要改的元素 | 用哪一层 |
|---|---|
| 按钮、输入框、表单字段、表格、内容卡片、对话框 body | 产品 token |
| 顶栏、侧栏、Composer 外壳、Popover/Menu/HUD/Tooltip 的表面材质 | 平台 token |
| 圆角:组件圆角 | --radius(产品) |
| 圆角:窗口/侧栏外框 | --platform-radius-window(平台) |
| 字体:正文 / 代码 | --font-geist-sans / --font-geist-mono(产品) |
| 字体:chrome UI(菜单、状态栏) | --platform-font-ui(平台) |
- 平台 token 默认值等价于对应的产品 token —— 引入平台 token 层本身不产生视觉 diff。
- macOS 覆盖只在
html[data-platform="darwin"][data-platform-style="auto"]生效。其它平台或data-platform-style="neutral"时,平台 token 仍 fall back 到默认(= 产品 token 等价值)。 - 平台 token 不允许出现在内容层(Apple HIG: Liquid Glass 用于 controls / navigation,不用于 content)。如果在 PR 里看到
--platform-surface-*被绑定到内容卡片,直接 reject。
参考:docs/exec-plans/completed/phase-7b-macos-native-visual-profile.md / docs/handover/macos-visual-profile.md。
完整 semantic alias 字典 + HugeIcons 候选 + 冲突裁决决策日志见
docs/handover/icon-system.md;产品理由见docs/insights/icon-system.md。本节只给 design 层的使用规则。
业务代码用 <CodePilotIcon name="..." /> 表达产品概念,不直接 import vendor icon 名。一个概念对应一个 glyph,由 src/components/ui/semantic-icon.tsx 的 SEMANTIC_MAP 单点裁决。
已裁决的冲突(不要回退):
| 概念 | glyph | 说明 |
|---|---|---|
model |
Cube | Brain 让给 memory,不再表示模型 |
runtime |
Chip | Lightning 已退役 |
memory |
Brain | 记忆才是"脑" |
cli |
CommandLine | 命令工具目录,不等于 terminal |
terminal |
Terminal | shell 会话,不等于 cli |
skill |
MagicWand | 可调用能力,不等于 plugin |
plugin |
Puzzle | 安装包 / 容器,不等于 skill |
规则:
- 品牌图标用 LobeHub(Anthropic / OpenAI / OpenRouter / Kimi …),只出现在 provider / runtime brand 场景;不得用通用 glyph 伪装品牌。
- 尺寸 token:
sm(14) toolbar /md(16) inline-row 默认 /lg(20) card header /xl(24) empty state。优先用 token,raw 数字是 escape hatch。 - 颜色:默认
text-muted-foreground(次要 affordance,不与相邻文字标签抢注意力);anchor(左栏 / 侧栏快捷动作 / Settings 左导航)和 active/selected 才用text-inherit跟随父级深色;状态图标走--status-*。 - 只用 CodePilotIcon 语义层:业务组件不直引 Phosphor / Lucide(eslint 守卫:Brain/Lightning/Terminal 名导入 error;Phosphor/Lucide 直引重定向到 CodePilotIcon)。结构性图标(CaretDown / CheckCircle / X 等 53 个)暂留
ui/icon.tsx兼容层。
设计系统页 /design-system 的 "Icon Semantics" 区有可视样例。
完整 surface matrix + Electron vibrancy 选型 + 分层依据见
docs/handover/macos-visual-profile.md;产品理由见docs/insights/macos-visual-profile.md。本节只给 design 层的边界规则。
一句话边界:macOS profile 只动外壳和控件层,不动内容层。同一套产品结构(聊天 / Settings / 卡片 / 消息 / Widget / 按钮顺序)在 mac / win / linux / web 上完全一致,不开平台分支。
用户能看到的差别(仅 macOS):
- 窗口是无边框圆角悬浮,红绿灯按钮嵌在左上,顶栏可拖动。
- 侧栏 / 顶栏 / 浮层(菜单、tooltip、HUD)半透明,透出背后桌面的模糊(vibrancy)。
- 内容区(聊天正文、代码、Settings 卡片、Widget)保持不透明——阅读画布不做玻璃化。
哪些层允许平台化(其余一律不动):
| 层 | 允许 | 例子 |
|---|---|---|
| chrome(窗口 / 顶栏 / composer 外壳) | 半透明 + vibrancy | 顶栏、composer hood |
| navigation(左右侧栏) | 半透明 + backdrop-blur | ChatListPanel / SettingsSidebar / WorkspaceSidebar |
| floating control(浮层) | CSS 材质模拟 | Popover / Menu / Tooltip / RunCockpit HUD |
| content(内容) | 不透明、跨平台一致 | 聊天正文、代码、卡片、Widget、Dialog 正文 |
规则:
- 内容层永远不透明。玻璃化内容会毁掉可读性和 artifact 保真;Dialog 正文 / 卡片 / 消息一律实色(
bg-background/bg-card/bg-popover)。 - DOM 浮层只是 CSS 材质模拟,不是真原生 vibrancy。Electron 的
vibrancy是窗口级(titleBarStyle: 'hiddenInset'+vibrancy: 'sidebar');Radix popover / tooltip / RunCockpit 这些 DOM 面在 webview 里裁剪,不要宣称"原生 popover 行为"。 - 平台差异一律走
--platform-*token,不在组件里写isMac分支(见上方「平台 token」节)。darwin profile 把--platform-surface-bar设transparent、--platform-surface-sidebar设半透明,让 vibrancy 透上来;off-mac 这些 token 退回实色,同一份 DOM 直接当普通块渲染。 - 顶栏保留拖动区 + 红绿灯安全间距;嵌套按钮要标
no-drag。
实现路径(实现者):profile 由 data-platform / data-platform-style(anti-FOUC <script> 在 hydration 前盖到 <html>)驱动;token 在 globals.css 的 darwin profile 块(--platform-surface-*);Electron 窗口在 electron/main.ts。
抽象边界 + 四卡接入点 + gutter 几何验收见
docs/handover/macos-visual-profile.md的 Phase 7c 节。
macOS profile 下,外壳是几张悬浮卡片并排:左侧栏 / 主聊天 / 右工作区 / 文件树(assistant rail 复用同一套 primitive)。每张卡圆角 14px、独立投影、卡间留窄缝(缝里一条可拖拽细线调宽)。off-mac 圆角归 0、退回普通分栏块——同一份 DOM。
用户能看到的:
- 几块内容像浮在窗口里的卡片,彼此有缝、有投影。
- 缝中间一条 2px 细线,悬停时跟着光标亮起渐变,拖动改相邻两卡宽度,双击重置。
- 缝永远落在两卡正中(不偏移)。
三个 primitive(职责单一,不许串味):
| primitive | 干什么 | 不许干 |
|---|---|---|
CardFrame |
投影 + 圆角 + 布局槽位(kind="main" 吃 flex-1,其余 shrink-0 + 固定宽) |
裁剪(必须 overflow visible 让投影画全) |
CardSurface |
背景 + clip-path 圆角裁剪 + backdrop-filter + 内容槽位 | 画外层投影(那是 frame 的事) |
ResizeGutter |
8px 宽 row-level 兄弟,2px 线靠 justify-center 永落缝中心 |
出现在 CardFrame 内部 |
规则:
- frame 画影、surface 裁剪、gutter 调宽——三件事分给三个组件;不要在一个 div 上又投影又裁剪(投影会被自身
overflow:hidden切掉)。 - ResizeGutter 永远是 CardFrame 的兄弟、不是孩子。卡间可见缝全部由 gutter 的 8px 宽度提供(content-row gap 设 0),隐藏的卡旁边不留多余缝。
- 半透明只给 sidebar / workspace kind(backdrop-blur +
--platform-surface-sidebar);main / fileTree / assistant 不透明(bg-background)。 - 宽度 state 留在业务面板,primitive 只收
widthprop + 转发onResize回调;面板组件(ChatListPanel 等)只出内层内容,不再自己 wrap aside / data-attribute / bg / clip。 - 约束被
card-primitives.test.ts的 source pin 锁死,别绕过。
实现路径(实现者):src/components/layout/card-primitives.tsx;圆角 14px / clip-path 在 globals.css darwin profile(off-mac radius 0、clip no-op);四卡接入点 = AppShell.tsx(sidebar) / ChatContentRow(main + workspace) / PanelZone(fileTree);RESIZE_GUTTER_WIDTH_PX = 8。
composer 属 chrome 层,macOS profile 下透出 vibrancy;surface matrix 见
docs/handover/macos-visual-profile.md。
聊天输入框是一个悬浮 hood:顶部一排已选上下文胶囊(文件 / CLI / 目录引用),中间多行文本框,底部一排操作(左侧加料 + 模型 / effort 选择,右侧发送)。
用户能看到的:
- 输入区像浮在聊天底部的一块面板(macOS 下半透明、透出背后模糊)。
- 添加的附件 / CLI / 目录引用以胶囊排在文本框上方,可单独删。
- 底部左边是「加料」菜单(插入命令 / 调用 CLI / 加上下文)+ 模型选择 +(模型支持时)effort 选择;右边是发送按钮。
规则:
- 外壳走平台 token:
bg-[var(--platform-surface-bar)] backdrop-blur-lg。default profile 该 token = 实色--background;darwin =transparent,让 vibrancy 透过 composer hood。文本框本身保持清晰,不要在文字输入区堆模糊(伤打字专注)。 - 可调控件安静优先:模型 / effort 这类下拉默认无边框无填充,hover 或处于非默认值时才浮出结构(见
feedback:composer 控件 invisible-until-hover)。右侧发送是唯一常驻强调的动作。 - 工具图标走语义层:
<CodePilotIcon name="code" />/name="cli"等,不直引 vendor icon(见「图标语义」节)。 - 运行状态(Runtime / Auto / Context 占用)不在 composer 里——由 ChatView 级的状态读出(
RuntimeSelector/ModeIndicator/ 上下文 popover)承担。composer 只管「写什么 + 发送」,只读状态归状态区,别混进输入栏。
实现路径(实现者):src/components/chat/MessageInput.tsx(外壳 + footer),MessageInputParts.tsx(胶囊行),ModelSelectorDropdown / EffortSelectorDropdown;运行状态读出在 ChatView.tsx(ModeIndicator / RuntimeSelector)。
Every Settings sub-page uses the same outer container:
<div className="max-w-4xl mx-auto space-y-10">
<Section />
<Section />
</div>max-w-4xl= 896px for config pages;max-w-5xl= 1024px for Overview (it carries a 2-col card grid + a 365-day heatmap and needs the extra width). Page list: Overview / General / Appearance / Providers / Models / Runtime / Usage / Assistant / About. Overview at the top is a status dashboard, not a config page — three layers: (1) a "Getting started" checklist that auto-hides once 4/4 items are done (provider connected / models enabled / runtime verified / workspace configured); (2) a 2-col grid of 6 status cards — Runtime / Providers / Models / Assistant Workspace / Update & About / Setup & Diagnostics — where attention-needed cards pick upstatus-warning-mutedaccent and configured cards stay flat (so the page reads as a status dashboard, not a wall of black tiles); (3) GitHub-contribution-style token-usage heatmap with 30/90/365D range pills + total / most-active day / longest streak / current streak summary. About at the bottom carries version + update check + platform info + account + diagnostics + external links. The middle stays the three-layer mental model: Providers (assets) → Models (exposure) → Runtime (environment). General is strictly application behavior; visual customization is its own page (Appearance).mx-autocenters horizontally. Don't left-align.space-y-10(40px) between top-level sections gives Luma-style breathing room.- Within a section:
space-y-6(24px) for header → body,space-y-3(12px) for sub-blocks.
The single rule for display cards:
<div className="rounded-lg bg-card border border-border/50 p-5">
…content…
</div>rounded-lg(8px). Notrounded-2xl, notrounded-3xl. Outer cards are medium-radius.border border-border/50— softened, not solidborder-border. The deeper border felt heavy.bg-card— same base as the page background; the border is what makes the card visible.- No shadow by default. Shadows imply depth this surface doesn't earn.
- Padding
p-5. Section 0-style strip cards usepx-5 py-4(less vertical for single rows).
When a card holds a list-of-rows or a metadata block, the inner block uses:
<div className="rounded-md bg-muted/40">
<div className="px-3.5 divide-y divide-border/50">
{rows.map(r => <div className="py-2.5 flex items-center justify-between">{…}</div>)}
</div>
</div>rounded-md(6px) — one step smaller than the outer card, preserves hierarchy.bg-muted/40— distinct frombg-cardso the user perceives it as a child block.- Inset dividers are mandatory:
divide-ylives on apx-3.5wrapper, not on the outer rounded box. This is what gives the "doesn't touch the edge" look.
The same inset-divider rule applies to outer cards that host stacked rows (e.g. the Service Settings card with diagnostics + default model):
<div className="rounded-lg bg-card border border-border/50">
<div className="px-5 divide-y divide-border/50">
<div className="py-4 …">…row 1…</div>
<div className="py-4 …">…row 2…</div>
</div>
</div>For lists of installable / configurable items (Skills, MCP servers, CLI tools, marketplace skills) the card is a button that opens a detail dialog. It inherits the outer-card chrome above and adds:
<div
role="button"
tabIndex={0}
onClick={onOpen}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onOpen();
}
}}
aria-label={`${item.name} — ${item.description}`}
className="rounded-lg bg-card border border-border/50 p-5 cursor-pointer transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
…
</div>role="button"+tabIndex={0}+ Enter/SpaceonKeyDown— keyboard + screen-reader parity. The whole card is one activatable target; never split "click name to view, click pencil to edit" — that's the anti-pattern that triggered the MCP card unification work in 2026-05.hover:bg-muted/40— the only hover affordance. No border-color shift, no shadow.focus-visible:ring-2 focus-visible:ring-ring— required for keyboard discovery.aria-labelincludes name + a short description — icon-only or short-name cards are illegible to screen readers without it.- Inner action buttons (toggle switch, delete-confirm, install) must
e.stopPropagation()so they don't trigger the card-level open.
Card body layout convention (top-to-bottom, all sections optional except name):
- Identity row —
flex items-center gap-2 flex-wrap. Name + inline pills (transport / source / status / Preview tag) +tool counttail. Pills are inline next to the name, never on a separate row — that's the rule that aligned built-in MCP and user-installed MCP cards. - Description —
text-xs text-muted-foreground mt-2 leading-relaxed line-clamp-3. Three lines is the max; if a card needs more, it belongs in the detail dialog. - Footer row (optional) —
flex items-center justify-between mt-3 gap-2. Left: secondary metric (agent-friendliness stars, last-used time). Right: shrink-0 action button (install / delete / reconnect). Footer only renders when at least one of those is present.
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{items.map(item => <CatalogueCard … />)}
</div>md:grid-cols-2is the canonical breakpoint. Don't addlg:grid-cols-3— at three columns the description gets line-clamped to oblivion and the page reads like a table-of-tables.gap-4matches the outer card'sp-5; tighter gaps make adjacent cards bleed visually.- The same grid string applies to Skills sources, MCP built-in catalog, MCP installed servers, CLI installed/recommended, and marketplace results — all four MCP/CLI/Skills lists keep the same density on every breakpoint.
The canonical detail-dialog DialogContent className:
<DialogContent className="sm:max-w-2xl max-h-[85vh] flex flex-col gap-0 overflow-hidden">
<DialogHeader className="shrink-0">
<DialogTitle>{item.name}</DialogTitle>
<DialogDescription>{metaLine}</DialogDescription>
</DialogHeader>
<div className="flex-1 min-h-0 overflow-y-auto mt-4 space-y-4">
{/* sections — see body conventions */}
</div>
<DialogFooter className="shrink-0 border-t border-border/50 pt-3 mt-2">
{/* primary actions */}
</DialogFooter>
</DialogContent>sm:max-w-2xlis the unified width across Skills / MCP (built-in + user-installed) / CLI / Marketplace dialogs. Earlier we hadmax-w-md/max-w-lg/max-w-2xlmixed across the four families — that read as inconsistency, not as content-sized variation.max-h-[85vh] flex flex-col gap-0 overflow-hiddenis non-negotiable. Withoutgap-0the default DialogContent gap leaks visible whitespace between header / body / footer; withoutoverflow-hiddenthe dialog itself starts scrolling instead of just the body.- Body is the only scroll region —
flex-1 min-h-0 overflow-y-auto. Header and footer areshrink-0. Pulling header/footer out of the scroll keeps the title + actions anchored when the user scrolls long content (Skills README, CLI use-cases). - Footer separator —
border-t border-border/50 pt-3 mt-2. Visible boundary between content and actions; matches the same border weight used on cards. - Section heading dialect inside the body —
<h5 className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground mb-1.5">. Use· Nafter the heading when the section is a counted list ("工具 · 7"). - Inline list as sub-card — when a section is a list of mono-spaced strings (tool names, server URLs), use the same inset-divider sub-card from above (
rounded-md bg-muted/40+px-3.5 divide-y).
When a detail dialog also offers edit, swap the body in-place — never open a second Dialog. Stacked dialogs ("弹窗叠弹窗") were called out as a UX regression in 2026-05; the same single-dialog-with-mode-state pattern is used by <MarketplaceBrowser> (list ↔ detail) and <McpServerDetailDialog> (detail ↔ edit):
const [mode, setMode] = useState<'detail' | 'edit'>('detail');
<DialogContent className="…">
<DialogHeader className="shrink-0">…name + pills…</DialogHeader>
<div className="flex-1 min-h-0 overflow-y-auto mt-4 …">
{mode === 'detail' ? <DetailBody … /> : <EditForm … />}
</div>
<DialogFooter className="shrink-0 border-t border-border/50 pt-3 mt-2 sm:justify-between">
{mode === 'detail' ? (
<>
<Button variant="ghost" className="text-destructive …" onClick={confirmDelete}>{t('common.delete')}</Button>
<Button onClick={() => setMode('edit')}>{t('common.edit')}</Button>
</>
) : (
<>
<Button variant="outline" onClick={() => setMode('detail')}>{t('common.cancel')}</Button>
<Button onClick={submit}>{t('mcp.saveChanges')}</Button>
</>
)}
</DialogFooter>
</DialogContent>- One Dialog, header stays mounted through the mode switch — the user sees they're still in the same context, no flicker, no focus loss.
- List ↔ detail variant uses a back button at the top of the detail panel instead of
onClose(<MarketplaceBrowser>is the reference). The Dialog wrapper stays open; only the inner panel swaps. - Destructive confirm (delete) goes through
<AlertDialog>— that's the one place stacking is fine, because it's a transient confirm, not navigation. - Form extraction: when the same form is reachable from both an "Add" Dialog and a Detail Dialog edit-mode, extract the form fields into a headless component with imperative
submit()ref, so the two wrappers can place buttons in their own footers without duplicating validation.<McpServerEditorForm>is the reference.
Two distinct dialects, same shape, different intent.
<span className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium",
STATUS_TONE[data.status],
)}>
<span className={cn("size-1.5 rounded-full", DOT_TONE[data.status])} />
{label}
</span>| Status | Background | Dot |
|---|---|---|
available |
bg-status-success-muted text-status-success-foreground |
success-foreground |
needs-config |
bg-status-warning-muted text-status-warning-foreground |
warning-foreground |
error |
bg-status-error-muted text-status-error-foreground |
error-foreground |
unknown |
bg-muted text-muted-foreground |
muted-foreground |
Smaller (text-[10px]), no dot. Used on every row in Settings > Models. Answers "where did this row come from" — purely a lineage marker.
| Source | Tone | When |
|---|---|---|
api |
bg-status-success-muted text-status-success-foreground |
discovered via /v1/models or models.list |
catalog |
bg-muted text-muted-foreground |
shipped from VENDOR_PRESETS |
manual |
bg-primary/10 text-primary |
user hand-entered |
role_mapping |
bg-status-warning-muted text-status-warning-foreground |
implied by model_mapping |
sdk_default |
bg-muted text-muted-foreground |
hard-coded SDK fallback |
A second badge that sits next to the source badge on Models-page rows. Same shape, different question: "why is this row enabled / hidden right now?" Distinct from source (where it came from). Both can render side-by-side because they answer orthogonal questions:
API 同步 · 手动启用= "we got this row from /v1/models, AND the user explicitly turned it on"
Render rule: only show the badge when the state is non-trivially different from the system default. recommended and catalog stay silent (they're the boring default and would just add visual noise). Three states are visible:
| Enable source | Label (zh / en) | Tone | Tooltip |
|---|---|---|---|
manual_enabled |
手动启用 / Manually enabled | bg-primary/10 text-primary |
"你在 Models 页主动启用,刷新不会覆盖" |
manual_hidden |
手动隐藏 / Manually hidden | bg-muted text-muted-foreground |
"你在 Models 页主动隐藏,刷新不会覆盖" |
discovered |
未推荐 / Off-catalog | bg-status-warning-muted text-status-warning-foreground |
"上游有这个模型,但不在推荐目录里 — 默认不在 picker 中显示" |
The two manual states are user-promises: they tell the user "your toggle stuck, refresh is no longer going to flip this." The discovered state is a recommendation-system signal: it tells the user "we found this but didn't think it belonged in the picker; you can turn it on if you want."
The discovered warning tone is intentional — it pairs with the same orange used in the discover-models diff dialog's "will-be-hidden" preview, so the two surfaces feel coherent.
recommended and catalog are never tooltip-explained inline because they're the implicit default; explaining them everywhere would dilute the signal of the three that do display.
The Provider card header (in Settings > Providers) packs identity + status pills + actions. It uses the existing two-row scheme already in ProviderCard.tsx:
- Row 1: icon + name + inline actions (right side)
- Row 2: status pill + compat pill (full inner width, never wraps mid-character)
The Models-page section header went through the same evolution. Single-row was packing 6 chips and made action buttons drift down whenever the row wrapped. Two-row split:
Row 1: [icon] Provider Name 3 / 10 启用 上次同步: N 分钟前 → [刷新] [全部关闭] [全部启用] [角色映射] [+ 添加模型]
Row 2: [Compat pill] [默认: model]
Row-split rules:
- Row 1 = operational: counts that change (enabled/total), freshness (last sync), and the action cluster. Action buttons stay pinned to the right of the title's baseline regardless of how many secondary chips ride along.
- Row 2 = identity: chips that don't change with usage (compat tag, default-model chip). Indented to align with the provider name (
pl-9to clear icon + gap). - Row 2 is omitted entirely when neither chip applies (manual-only providers with no compat info) — don't render an empty row.
X / Y 启用when not searching,X / Y 匹配when searching. Switch the suffix, not the format.- Bulk-toggle and reorder are disabled while searching + tooltip — would otherwise hit the unfiltered list.
- "上次同步" / "上次刷新" uses bucketed relative time (just-now / N-min / N-hour / N-day / absolute date for >30d). Always pair with a
titleattribute carrying the absolute UTC timestamp for users who need precision. - Hide the freshness row entirely when
last_refreshed_atis null (catalog-only providers, never probed) — don't render "从未同步" placeholders inline; the absence IS the signal. - Primary "Add" button on the far right, outline variant. Adding is distinct from the muted bulk-toggles.
Decide by frequency, not by cleanliness.
Visible inline buttons (text or icon+text):
- Edit
- Disconnect
- Refresh models ← was kebab, promoted because discoverability matters for the diff flow
- Primary action: Login (OAuth), Settings link (env-managed), "Set as default" (image families)
Kebab (DotsThree) only:
- Diagnose
- Sync to Claude Code
- Anything < 5% usage
Always: kebab trigger needs aria-label + title.
Used for enabled / hidden / all on the Models page:
<div className="inline-flex items-center rounded-md bg-muted p-0.5">
{options.map(opt => (
<button
onClick={() => setFilter(opt.key)}
className={cn(
"inline-flex items-center gap-1.5 rounded-sm px-2.5 py-1 text-xs transition-colors",
active ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
)}
>
{opt.label}
<span className="text-[10px] tabular-nums">{opt.count}</span>
</button>
))}
</div>- Default to the most useful filter, not "all". Models page defaults to
enabledbecause that's "what's actually exposed to chat". - Counts must use
tabular-numsso digits don't jitter as filters change. - Don't reach for a heavyweight
<Tabs>component if it co-locates with other controls — keep it inline.
<div className="relative flex-1">
<MagnifyingGlass size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none" />
<Input className="pl-9" placeholder="搜索…" />
</div>- Icon
pointer-events-noneso clicks always reach the input. - Search is composable: filters apply first, search filters within the result.
Two separate apply paths in this codebase, with different UX rules:
Used by:
- Add Service success → auto-discover the new provider's models
- Per-provider "刷新" button on Models page section header
- Page-top "刷新全部" batch driver
Why no preview: the apply layer (applyDiscoveryDiff in db.ts) reads
each row's enable_source and refuses to flip rows in
manual_enabled / manual_hidden. New rows land enabled only if the
recommendation list says so; everything else hides. So a silent refresh
can never override a user choice — the protection is in the data
layer, not the UI gate.
A toast (single-provider, or rolling progress for batch) reports the
outcome — recommendedEnabled / discoveredHidden counts; the user
gets a 1-line summary instead of a 3-step dialog.
Used by:
- "按推荐整理" (Tidy by recommended —
alignEnabledWithCatalog) - The advanced diff dialog accessed from
ProviderManager.handleDiscoverModels(legacy, kept for orphan review and forced resets)
Why preview: these flows can intentionally flip many rows or produce permanent deletes (catalog seed pruning); the user needs to see scope before committing.
Implementation in three phases of a dialog:
previewing— spinner + "Computing…"preview-ready— show counts + per-target breakdown + Apply buttonapplying— Apply disabled, "Applying…", close on completion
Rules:
- Preview must be idempotent server-side (no DB writes during dryRun).
- The same code path computes the preview and the apply — never let them diverge.
- Counts are surfaced as a 4-row sub-card (Insert / Enable / Hide / Prune). Per-target deltas use ASCII glyphs (
+ ↑ ↓ −) in monospace for scan-ability. - Skipped targets (no catalog, no upstream support) get a plain text footnote, not their own row.
Pattern for "do this across every applicable target" actions where the per-target version exists somewhere on the page (e.g. "刷新全部" sits at the top of Models, while each section has its own "刷新").
<Button
variant="ghost"
size="sm"
className="gap-1.5 text-muted-foreground hover:text-foreground"
onClick={handleRefreshAll}
disabled={inFlight || syncableCount === 0}
title={syncableCount === 0
? "没有可同步的服务商"
: `挨个刷新 ${syncableCount} 个支持同步的服务商,最后会汇总成功 / 失败 / 无更新`}
>
{inFlight ? <SpinnerGap className="animate-spin" /> : <ArrowsClockwise />}
刷新全部 ({syncableCount})
</Button>Rules:
- Ghost variant, not outline. Outline is reserved for the heavier sweep action sitting next to it ("按推荐整理"). The ghost batch reads as "convenience over the per-target version" rather than "third major action."
- Count in the label (
(N)). It tells the user how much work the click is buying — also doubles as a disabled-when-zero affordance. - Disable when zero rather than hide. Hiding makes the user wonder where it went; disabled-with-tooltip explains why.
- Per-target buttons stay enabled while batch is idle, but mutually gate while either is in flight (no double-fire).
When a batch executes per-target probes (network), prefer sequential + single rolling toast over Promise.all + per-target toasts. Reasons:
- The progress toast actually reads as a progression instead of a blink-and-done.
- Doesn't fan-burst against shared upstreams (some Code Plan endpoints rate-limit on parallel hits).
- One toast lifecycle is easier to mentally track than N stacked toasts.
const toastId = showToast({ type: 'loading', message: t('batch.progress', { done: '0', total: String(N), name: targets[0].name }), duration: 0 });
try {
for (let i = 0; i < targets.length; i++) {
updateToast(toastId, { type: 'loading', message: t('batch.progress', { done: String(i + 1), total: String(N), name: targets[i].name }), duration: 0 });
const result = await runOne(targets[i]); // pure; no toast inside
// ... aggregate counters ...
}
// … post-loop refetch …
updateToast(toastId, { type: failCount > 0 ? 'warning' : 'success', message: summary, duration: failCount > 0 ? 8000 : 6000 });
} catch (err) {
updateToast(toastId, { type: 'warning', message: `批量过程异常: ${err.message}`, duration: 6000 });
} finally {
setInFlight(false); // ALWAYS reset, regardless of throw
}Rules:
try / finallyis non-negotiable — without it, an unexpected throw mid-loop leaves the page-top button stuck in "Refreshing..." forever. The reset MUST happen infinally.catchturns the rolling toast into a warning instead of letting it disappear silently — users need to see something happened.- Pure helper inside the loop — extract a no-toast variant (e.g.
probeAndApplyProvider) so the outer driver owns the toast story; per-target helpers only return typed results. - Final summary lists successes + no-change + failures with up to 3 failure names inline (
+M moresuffix when more). Warning tone if any failed, success tone otherwise.
When a partial state change should reflect on screen but the page lists many sections (Models page, project list, etc.), don't reuse a global fetchAll() that flips loading=true — that unmounts the entire list and loses scroll. Instead, refetch only the affected slice in place:
// Soft per-id refetch — updates one bucket of state, leaves the rest untouched
const refetchProviderBundle = useCallback(async (providerId: string) => {
try {
const r = await fetch(`/api/providers/${providerId}/models?all=1`);
if (r.ok) {
const d = await r.json();
setBundles(prev => ({ ...prev, [providerId]: d.models || [] }));
}
} catch { /* ignore — toast already covers failure */ }
}, []);Use it after a single-target action completes, or after a batch via Promise.all(succeededIds.map(refetchProviderBundle)). The point: only the section whose row count actually changed re-renders.
When an action genuinely cannot succeed for the current state of a target, disable + tooltip the reason rather than letting the user click and read a failure toast.
const sync = isSyncableProvider(provider);
return (
<Button
disabled={!sync.ok}
title={!sync.ok ? sync.reason : "重新从上游拉取模型列表(不会覆盖你手动启用/隐藏的行)"}
>
刷新
</Button>
);Rules:
- Only disable when 100% certain it won't work (e.g. OAuth-only provider, no /v1/models endpoint exists). When a probe might 401 / 404 but the user could fix it (set a key, reconnect), let the click happen — accurate failure beats accurate-sounding pre-filter.
- Reason as the tooltip body, not a separate help icon. The user is already hovering the disabled control.
- Filter out disabled targets from batch operations via the same predicate. The batch button label should show the count of eligible targets, not all targets.
<div className="rounded-lg border border-border/50 bg-card p-10 flex flex-col items-center text-center gap-3">
<div className="text-sm font-medium">{title}</div>
<div className="text-xs text-muted-foreground max-w-md">{body}</div>
<Button variant="default" size="sm" className="gap-1.5 mt-1">
<Plus size={14} weight="bold" />
{primaryAction}
</Button>
</div>- One sentence title (
text-sm font-medium). - One paragraph body (
text-xs text-muted-foreground), capped atmax-w-md. - One primary action — solid (default) variant. Empty state is the moment the page exists for; this is the right place for the loud button.
p-10for generous breathing room.
Two flavors via the shared DialogContent component:
- Centered modal (default) — confirms, small forms, info dialogs.
- Fullscreen takeover (
fullscreenprop) — for Add Service, Connect Provider, Edit Provider. Forms get the user's full attention; the underlying surface is replaced withbg-background, the form content lives in amin-h-full flex items-center justify-centerwrapper so it stays vertically centered.
Both flavors render a circular close button top-right (built in to DialogContent).
Three named tiers. Pick by content type, not by gut feeling:
| Tier | className | Use for |
|---|---|---|
| md | sm:max-w-md |
Single-purpose confirms, tiny forms (≤2 fields), feature-announcement / update dialogs. The width that says "one decision." |
| lg | sm:max-w-lg |
Medium forms (3–6 fields), connection wizards step bodies, install / progress dialogs. |
| 2xl (canonical detail) | sm:max-w-2xl max-h-[85vh] flex flex-col gap-0 overflow-hidden |
All "click-card → detail" dialogs (Skills, MCP, CLI tools, Marketplace, server detail/edit). Full-readme / structured detail / two-mode (detail↔edit) flows live here. |
Pixel widths (sm:max-w-[550px] etc.) are not allowed. If the tier doesn't fit, the content is fighting the dialog; rethink either the content or the tier before reaching for a custom width.
The 2xl tier carries three rules together — body is the only scroll region, header/footer are pinned:
- Header:
<DialogHeader className="shrink-0"> - Body:
<div className="flex-1 min-h-0 overflow-y-auto mt-4 …"> - Footer:
<DialogFooter className="shrink-0 border-t border-border/50 pt-3 mt-2">
Don't put overflow-y-auto on the DialogContent itself — that scrolls the whole dialog including the title, which loses the user's place. Reserve raw overflow-y-auto for tier md/lg dialogs that are short enough not to need a sticky header.
When a feature has both an enabled config toggle AND a separate running runtime state (Bridge, future webhooks, scheduled jobs), the surface MUST distinguish the two with two banners, never one:
{isEnabled && !isRunning && (
<StatusBanner variant="warning">
<Warning size={14} className="shrink-0" />
{t("…enabledNotRunningHint")} {/* "Enabled, but service is not running yet" */}
</StatusBanner>
)}
{isEnabled && isRunning && (
<StatusBanner variant="info" className="bg-primary/10 text-primary">
<span className="size-2 rounded-full bg-primary inline-block" />
{t("…activeHint")} {/* "Service is running. External channels can send tasks" */}
</StatusBanner>
)}- The 2026-05-05 fix in BridgeSection is the cautionary tale: a single banner that fires on
isEnabledtold users their phone could send tasks when the service wasn't actually running. Don't conflate the two states ever. isEnabled && !isRunningis warning tone (yellow) — it's a "you're not done yet" signal, not an error.isEnabled && isRunningusesbg-primary/10 text-primary— it's a positive confirmation, not a status-success (which we reserve for "verified healthy" not "currently active").- Active-state copy must mention what runtime ability the user gains ("外部渠道可以发送任务"), not just "active" / "已激活" — "active" is a config word, "running" is a runtime word.
Symmetric pair of buttons in the status row:
- Start —
<Button size="sm">(default variant). The user is opting in. - Stop —
<Button variant="outline" size="sm">. The user is opting out — outline keeps it visible without competing for attention. - Both show
<SpinnerGap size={14} className="animate-spin mr-1.5" />while the action is in flight; label switches to the gerund i18n key (bridge.starting/bridge.stopping). - Disable the button while in-flight, don't hide it. Letting it disappear creates layout shift and looks broken.
Reference: BridgeSection.tsx:267-300.
<AlertDialog> is for any confirm-then-act flow. The action button's color encodes severity:
bg-destructive— the action is permanent and irreversible (delete a server, remove a tool). Reaches the user as red.bg-status-warning hover:bg-status-warning/80 text-white— the action is reversible-but-risky (enable auto-approve, run a sweeping refresh). Reaches the user as amber.- Default (no override) — the action is benign-but-needs-confirmation (regenerate skill from template, install). Standard primary tone.
Footer always pairs <AlertDialogCancel> (left) with <AlertDialogAction> (right, with the colored class). Never single-button — the cancel must always exist.
Reference: McpServerDetailDialog.tsx:268-285 (destructive); GeneralSection.tsx:234-238 (warning).
Two named widths, one rule: container width follows content density.
max-w-4xl mx-auto space-y-6— catalogue / management pages: Providers (cards grid), Models (table), Overview (dashboard cards). Wider so cards or table columns breathe.max-w-3xl mx-auto space-y-6— config pages: Bridge channels, Telegram / Feishu / Discord / QQ / WeChat credentials, Appearance, About. Narrower so 1-column form rhythm feels intentional, not floaty.
mx-auto is non-negotiable — without it the page anchors left on wide screens and reads as "this column is the whole page" instead of "this card sits in a column."
FieldRow is the canonical row for a single label + control. Use it whenever you'd write:
<div className="flex items-center justify-between">
<div>
<Label>…</Label>
<p className="text-xs text-muted-foreground">…</p>
</div>
<Switch ... />
</div>Use raw <Label htmlFor={...}> + <Input> (stacked, label above) only when:
- The control is a multi-line input (Textarea) where horizontal layout doesn't work.
- The label sits inline with the input on the same baseline (date-range filters:
gallery/page.tsx:185-203). - A single card holds a form-style cluster of inputs that need a tighter visual relationship than
FieldRowallows (e.g. Bot Token + Webhook URL stacked under one heading).
Never roll your own flex justify-between + Switch row — that's the BridgeSection regression that 2026-05-05 fixed by migrating 5 channel toggles back to FieldRow.
Two acceptable patterns:
- Inline spinner (preferred when content area < 50% of viewport):
<SpinnerGap size={20} className="animate-spin text-muted-foreground" />centered in the slot. Use this for cards / sub-cards / dialog bodies. - Dashed-border placeholder card (when the card itself is loading):
rounded-lg border border-dashed border-border/50 bg-card/50 p-10 text-centerwith a one-linetext-muted-foregroundcopy. Reference:OverviewSection.tsx,HealthSection.tsxloading shells.
Don't use raw animate-pulse divs. Don't switch from "no card chrome" (loading) to "full card chrome" (loaded) — the layout shift looks broken; pick one structure for both states.
Two patterns by context. The 2026-05 audit found 6 places each rolling their own; converge on:
- Inline empty (replaces a list) —
<div className="rounded-lg border border-border/50 bg-card p-10 flex flex-col items-center text-center gap-3">with icon (size 32,opacity-40 text-muted-foreground) + heading + description + optional primary action. Use when the list lives inside a Tab / Section that always renders. - Full-page empty — same chrome but
py-12instead ofp-10, no card border (parent is the whole route). Use only when the page itself is empty (/gallerywith no items).
The bordered card variant is the default — only drop the border for full-route empties.
The two-column responsive rule for catalogue grids is grid grid-cols-1 md:grid-cols-2 gap-4. Both md:grid-cols-2 lg:grid-cols-3 AND lg:grid-cols-2 (no md breakpoint) are wrong:
lg:grid-cols-3→ too dense; 3-col card descriptions get line-clamped to oblivion. Anchor implementations stay 2-col.lg:grid-cols-2(skipping md) → the typical Settings panel width is 768–900px and never hitslg(1024px+), so the grid stays single-column where it should already be split. The 2026-05-05 audit caught this inRuntimePanel.tsx; engine cards never went two-up because the breakpoint was wrong.
Engine pickers, model lists, server cards, marketplace cards — all md:grid-cols-2. If you need three columns, content is too dense; split into two grids or reduce per-card content.
Same chrome as outer card (rounded-lg border border-border/50 p-5), heading is text-sm font-medium (no uppercase tracking — the body of the chart already provides visual structure). Stat tiles below the chart use rounded-md bg-muted/30 px-3 py-2 (lighter than the canonical sub-card bg-muted/40 so they read as quick reference, not a separate panel).
Reference: OverviewHeatmap.tsx (heatmap + 4 streak stats), UsageStatsSection.tsx (daily-tokens bar chart).
Single source of truth for "where does this model belong": src/lib/runtime-compat.ts. Provider-layer compat is computed once via getProviderCompat() and consumed by:
| Consumer | What it does with compat |
|---|---|
| Provider Card | Renders a second pill in the header next to status pill |
| Models page | Shows compat badge on every row + Runtime filter dropdown gates whole sections |
/api/providers/models |
Returns compat per group so chat picker can filter / badge |
| provider-resolver | (Phase 2) skip models whose compat doesn't match active runtime |
| State | When | Pill tone |
|---|---|---|
claude_code_ready |
anthropic-official / bedrock / vertex preset, env-detected Claude Code |
success |
claude_code_experimental |
Anthropic-protocol brands & relays (anthropic-thirdparty, kimi, glm, moonshot, minimax, volcengine, xiaomi-mimo, bailian, deepseek, ollama, litellm) | warning |
codepilot_only |
OpenRouter / OpenAI-compat / Google chat — non-Anthropic protocol | primary/10 |
media_only |
image-image protocols | muted |
unknown |
Custom URL with no preset match — UI says "需验证", never "不可用" | muted |
A bag of capability flags (multiple can apply). Used for finer-grained gating:
chat— usable as chat / coding modeltool_capable— known to support tool calls (defaults true unless catalog says otherwise)thinking_capable— supports reasoning / effort levelsclaude_code_compatible— surfaceable when current runtime is Claude Codecodepilot_runtime_compatible— surfaceable when current runtime is CodePilot Runtimemedia— image / video / embedding only; never enters chat picker
Computed via getModelCompat({ modelId, providerCompat, capabilities }). Claude-alias rows (sonnet / opus / haiku / claude-*) auto-get claude_code_compatible even on codepilot_only providers — relays often expose Anthropic models too.
- Hidden (
enabled=0inprovider_models) wins over everything. Catalog fallback / role default / env injection all checkdbHiddenIdsfirst. - Runtime filter then narrows by Provider compat.
unknownstays visible across runtimes — copy is "需验证", not "incompatible". - Media is never in chat surfaces, regardless of filter.
Use compatLabel(compat, isZh) from runtime-compat.ts everywhere. Don't hard-code Chinese strings in components — a future copy change must touch one file.
The Models page is the source of truth for "which models reach the chat picker / runtime". Providers can advertise hundreds of models; users decide which they actually want.
Hard rules baked into the implementation:
- Hiding a model in Models page must suppress it in
/api/providers/models(the picker feed) and inprovider-resolver.ts(the runtime). The catalog fallback respects user-set hidden ids. - The section-level "刷新" button on the Models page silently auto-applies (probe → conservative apply → toast). The data layer's
enable_sourceguard makes this safe:manual_enabled/manual_hiddenrows are never flipped by refresh, regardless of the apply path. - The page-top "刷新全部 (N)" button runs the same flow sequentially across every syncable provider, with a rolling progress toast and a final summary listing successes / failures / no-change.
- The "按推荐整理" button (
alignEnabledWithCatalog) stays preview-first: it's a sweeping reset that intentionally flips many rows + can prune deletes, so the user needs to see scope before committing. - The legacy diff dialog on Provider Card kebab → "刷新模型" stays preview-first too, kept for orphan review and forced resets. Two refresh entry points by intent — section "刷新" for routine maintenance, kebab for advanced inspection.
- User-edited rows (
user_edited=1) and rows withenable_source IN ('manual_enabled','manual_hidden')survive every refresh / align — display_name, capabilities, and especiallyenabled=0are preserved.
The "Models" row on each Provider card shows enabled / total, where:
- enabled = rows the chat picker actually surfaces.
- total =
total_countfrom/api/providers/models— i.e. allprovider_modelsrows for this provider, including hidden ones. Falls back to catalog size when the table is empty.
Don't display only the enabled count — users hide things and need to remember they did. Don't display only total — they need to know how many are actually exposed. The "X / Y" form is non-negotiable.
✅ Do
- Use
border border-border/50for display cards. Soft, not heavy. - Inset dividers via
px-N + divide-ywrapper. Neverdivide-ydirectly on a rounded card. - Mirror the page-shell radius hierarchy: outer
rounded-lg, nestedrounded-md, micro chipsrounded-full. - Show counts as
enabled / totalwhen both are interesting. Don't pick one. aria-labelevery icon-only button.- Use
tabular-numson numeric counts that change. - Auto-apply silently when the data layer protects against override (refresh paths under
enable_sourceguard); show a preview dialog only when the operation can intentionally flip many user choices ("按推荐整理") or perform deletes.
❌ Don't
- Don't put a preview dialog in front of every write — single-provider refresh + batch refresh-all auto-apply on purpose; gating them behind a dialog regresses the UX. Reserve previews for the Tidy / advanced-diff paths where the user is asking for a sweeping change.
- Don't add an apply path that ignores
enable_source IN ('manual_enabled','manual_hidden')— that's the invariant that makes silent refresh safe in the first place. - Don't bury the most-used action in a kebab. If a user needs it weekly, surface it.
- Don't display the same list as both "all models" and "what the picker shows" — they diverge once the user starts hiding things, and the picker view is the lie.
- Don't toggle
loading=trueon a soft refresh — it remounts the list and loses scroll position. - Don't dispatch a global
provider-changedfrom inside the page that listens for it; it's a feedback loop with a flicker. - Don't use
border-border(full-strength) for cards — that's reserved for inputs where contrast aids hit-testing. - Don't assume
total_countequals catalog size — for providers with API-discovered rows, total includes hidden ones. - Don't auto-enable models the user has hidden. Refresh apply must respect
user_edited=1ANDenable_source IN ('manual_enabled','manual_hidden')— both are required, the legacy flag protects pre-Phase-B rows.
| Pattern | File |
|---|---|
| Settings shell + nav | src/components/settings/SettingsLayout.tsx |
| Floating card shell (4-panel) | src/components/layout/card-primitives.tsx (CardFrame / CardSurface / ResizeGutter); 接入点 AppShell.tsx / PanelZone.tsx;约束 card-primitives.test.ts |
| Composer shell | src/components/chat/MessageInput.tsx (--platform-surface-bar hood + footer tools) + MessageInputParts.tsx (胶囊行) |
| macOS platform shell profile | src/app/globals.css (darwin --platform-surface-* 块) + electron/main.ts (hiddenInset + vibrancy) |
| Page-level container width (Settings sub-pages) | src/components/settings/{OverviewSection,GeneralSection,AppearanceSection,ProviderManager,ModelsSection,RuntimePanel,UsageStatsSection,AssistantWorkspaceSection,AboutSection}.tsx |
| Status-dashboard cards | OverviewSection.tsx — GettingStartedBar (top checklist, auto-hides at 4/4) + 6 OverviewCard in lg:grid-cols-2 (Runtime / Providers / Models / Assistant Workspace / Update & About / Setup & Diagnostics; warning-tone cards pick up status-warning-muted accent) + OverviewHeatmap.tsx (365-day grid + 30/90/365D pills, reuses /api/usage/stats) |
| Outer card | ProviderCard.tsx (rounded-lg bg-card border border-border/50 p-5) |
| Inset divider sub-card | ProviderCard.tsx info section (rounded-md bg-muted/40 + px-3.5 divide-y divide-border/50) |
| Catalogue card (clickable, opens detail) | src/components/skills/SkillsManager.tsx (SkillCard), src/components/plugins/BuiltInMcpSection.tsx (BuiltInMcpCard), src/components/plugins/McpServerList.tsx (user-installed card), src/components/cli-tools/CliToolCard.tsx |
| Catalogue 2-col grid | All four files above use grid grid-cols-1 md:grid-cols-2 gap-4 |
| Click-card → detail dialog (canonical) | src/components/skills/SkillDetailDialog.tsx, src/components/plugins/BuiltInMcpSection.tsx (BuiltInMcpDetailDialog), src/components/plugins/McpServerDetailDialog.tsx, src/components/cli-tools/CliToolDetailDialog.tsx, CliToolExtraDetailDialog.tsx — all use sm:max-w-2xl max-h-[85vh] flex flex-col gap-0 overflow-hidden |
| Two-mode dialog (detail ↔ edit, same dialog) | src/components/plugins/McpServerDetailDialog.tsx (mode swap + shared form via McpServerEditorForm.tsx) |
| List ↔ detail dialog (same dialog, back button) | src/components/skills/MarketplaceBrowser.tsx + src/components/skills/MarketplaceSkillDetail.tsx (inline panel, no nested Dialog) |
| Headless form for shared edit | src/components/plugins/McpServerEditorForm.tsx (imperative submit() ref; consumed by McpServerEditor Dialog and McpServerDetailDialog edit-mode) |
SettingsCard + FieldRow patterns |
src/components/patterns/SettingsCard.tsx (p-5, optional title/description) + src/components/patterns/FieldRow.tsx (label + control row, optional separator) |
| Sub-card list of toggles inside card | src/components/bridge/BridgeSection.tsx ChannelToggleRow — channels card uses rounded-md bg-muted/40 + px-3.5 divide-y divide-border/50 for 5 channel rows + auto-start row |
| Inset-divider list inside card | src/components/settings/WorkspaceTabPanels.tsx (FilesTabPanel + TaxonomyTabPanel) |
| Service enable/disable + start/stop | src/components/bridge/BridgeSection.tsx:218-300 (two-banner enabledNotRunning / activeHint pair + Start/Stop button pair) |
| Settings page width tiers | max-w-4xl for catalogue/dashboard pages (OverviewSection.tsx, ProviderManager.tsx, ModelsSection.tsx); max-w-3xl mx-auto for config sub-pages (bridge/*Section.tsx, AppearanceSection.tsx, AboutSection.tsx) |
| Status pill canonical (rounded-full + dot) | src/components/settings/ProviderDoctorDialog.tsx StatusBadge (post 2026-05 fix); src/components/bridge/BridgeSection.tsx:251 (Bridge Connected/Disconnected); WorkspaceTabPanels.tsx (file exists/missing pills) |
| Destructive AlertDialog | src/components/plugins/McpServerDetailDialog.tsx:268-285 (bg-destructive action) |
| Two-banner enabled-vs-running | src/components/bridge/BridgeSection.tsx:218-234 |
| Chart card | src/components/settings/OverviewHeatmap.tsx (heatmap + 4 streak/active stats), src/components/settings/UsageStatsSection.tsx (recharts bar) |
| Sub-card row with relative-time + tooltip | ProviderCard.tsx ("Last refresh" row uses formatRelativeTime + title for absolute UTC) |
| Section 0 stacked card | ProviderManager.tsx 「服务设置」block |
| Status pill with dot | ProviderCard.tsx header |
| Source badge (data origin) | ModelsSection.tsx (SOURCE_TONE) |
| Enable-source badge (intent) | ModelsSection.tsx (ENABLE_SOURCE_TONE / ENABLE_SOURCE_LABEL_* / ENABLE_SOURCE_TOOLTIP_*) |
| Two-row section header (Models page) | ModelsSection.tsx (per-provider section header — row 1 ops, row 2 identity chips) |
| Filter segmented control | ModelsSection.tsx (Models page header) |
| Search input | ModelsSection.tsx |
| Bulk-action header | ModelsSection.tsx (per-provider header) |
| Page-top batch action button (ghost) | ModelsSection.tsx ("刷新全部 (N)" next to "按推荐整理") |
| Sequential batch + rolling toast | ModelsSection.tsx (handleRefreshAll) + src/lib/auto-discover-models.ts (probeAndApplyProvider) |
| Soft refetch (preserve scroll) | ModelsSection.tsx (refetchProviderBundle) |
| Disable-with-explanatory-tooltip | ModelsSection.tsx (isSyncableProvider gate on per-section "刷新") |
| Confirm-then-apply (diff) | ProviderManager.tsx (legacy preview dialog) + ModelsSection.tsx (align dialog) |
| Conservative auto-apply (no preview) | src/lib/auto-discover-models.ts (runAutoDiscoverForProvider) — used by Add Service success + section "刷新" |
| Apply-side manual override guard | src/lib/db.ts (applyDiscoveryDiff + alignEnabledWithCatalog — both check enable_source IN ('manual_*') AND user_edited=1) |
| Empty state | ProviderManager.tsx (no providers connected) |
| Fullscreen dialog | src/components/ui/dialog.tsx (fullscreen prop) + PresetConnectDialog.tsx, ProviderForm.tsx |
| Visible inline kebab demotion | ProviderCard.tsx (Refresh promoted out, Diagnose stays in) |
| Runtime compat matrix | src/lib/runtime-compat.ts (getProviderCompat, getModelCompat, compatLabel, compatTone) |
| Provider compat pill | ProviderCard.tsx header (second pill via data.compat) |
| Per-row compat badge | ModelsSection.tsx (next to source badge in row label) |
| Runtime filter dropdown | ModelsSection.tsx (alongside enabled/hidden tabs + search) |