feat(desktop): 「纸·墨·印」全量 UI 重设计

按设计交付稿重构桌面端整体视觉,只换表现层,功能、数据契约与
交互逻辑零改动。

主题与地基:
- 6 套主题(纯白/纸墨/经典暖色/青瓷/墨夜/墨夜蓝),源色块 + 单一
  语义映射层两层结构;老设置 light→warm-classic 自动迁移
- 状态色全部成对并过 AA;新增 --color-on-brand-soft、
  --color-search-highlight 对、--color-border-strong(1.4.11 控件边界)
- 衬线标题自托管 Noto Serif SC 拉丁分片,中文回退系统衬线
- 圆角/阴影/动效阶梯按稿定稿;新组件 BrandSeal 印章

屏幕:外壳(52px 标签条陶土下划线、印章侧栏、选中会话浮起卡)、
零态、活跃会话、composer 全部弹层、技能市场、定时任务、设置全
分区、Trace、终端、⌘K,及设计稿未覆盖界面的统一收敛。

Review 修复(两轮 6 主题全量走查 + 盲区补扫挖出):
- 未分层 CSS 压死 utilities 两处(图标 24px、focus ring 被吃)
- font-[var(--font-mono)] 无效写法 35 处(mono 从未生效)
- tertiary 文字与控件边框对比度全线达标化,contrast 守门扩容
- /N alpha 修饰符清零(Safari 15 WebView 整条丢弃)
- 触屏 44px 主目标口径统一;hover 门控控件 H5 常显
- 错误态修复:定时任务静默失败、Trace/会话列表错误呈现
- 宠物窗口 ink-blue 暗色覆盖补齐

测试:264 文件 / 3162 用例全绿(新增六主题对比度、跨文件弹层
一致性、触摸目标、错误态等守门);tsc 零报错;构建通过。
This commit is contained in:
程序员阿江(Relakkes)
2026-07-26 22:54:45 +08:00
parent 47d47d1881
commit c712f52858
163 changed files with 5522 additions and 3024 deletions
+97
View File
@@ -0,0 +1,97 @@
# 「纸 · 墨 · 印」重设计规范
本文是桌面端整体视觉重构的执行标准。配套阅读 [`desktop/src/components/AGENTS.md`](../src/components/AGENTS.md)(组件与 token 硬约束,冲突时以那份为准)。
## 唯一边界:只换皮,不动功能
- 允许改:`className`、内联展示样式、纯展示用的 DOM 结构、i18n 文案里的视觉措辞。
- **不允许改**:`stores/`、`api/`、`hooks/` 里的任何逻辑,事件处理、状态流转、数据契约、组件 props 语义。
- 设计稿里有、原项目没有的**功能**(例如零态的三个建议胶囊),**不新增**。
- 原项目有、设计稿没画的界面,**不删**,按同一套规则收敛。
判断口径:如果一处改动会让某个 `.test.tsx` 里的行为断言(点击、请求、状态)失败,那它就越界了;只有快照式的样式断言才应该跟着改。
## 设计主张
纸感暖底分层 + 墨色主按钮 + 衬线标题 + 陶土印章。层次靠**底色分层与 1px 细边框**建立,不靠重阴影。
## Token(已定稿,不要改 `theme/globals.css`)
六套主题(`white` 纯白 / `paper` 纸墨 / `warm-classic` 经典暖色 / `celadon` 青瓷 / `dark` 墨夜 / `ink-blue` 墨夜蓝)只换一组 `--cc-*` 源变量,`:root` 语义层把它们映射成 `--color-*`。**组件一律只用 `--color-*` / `--radius-*` / `--shadow-*` / `--z-*`,永远不要直接用 `--cc-*`。**
| 角色 | token |
|---|---|
| 页面底 | `--color-surface` / `--color-background` |
| 侧栏与次级面板底 | `--color-surface-container-low` |
| 悬停 / 选中 | `--color-surface-hover` / `--color-surface-selected` |
| 卡片内衬 | `--color-surface-container` |
| 常规边框 / 强边框 | `--color-border` / `--color-outline` |
| 主 / 次 / 弱文字 | `--color-text-primary` / `--color-text-secondary` / `--color-text-tertiary` |
| 陶土主色 / 深 / 浅底 / 描边 | `--color-brand` / `--color-brand-hover` / `--color-brand-soft` / `--color-primary-fixed-dim` |
| **陶土浅底上的文字** | `--color-on-brand-soft`(**不是** `--color-brand`,见下) |
| 状态色 | `--color-{success,warning,error,info}` + `-container` + `--color-on-*-container` |
| 主按钮墨色 | `--color-btn-primary-bg` / `--color-btn-primary-fg` |
**状态色成对使用。** `--color-<tone>` 是标识色(描边、图标、下划线);`--color-on-<tone>-container` 才是 `-container` 底上的**文字**色。直接把 `--color-brand` 当 `--color-brand-soft` 上的文字,在两套墨色主题下只有 4.3:1,低于 AA。`contrast.test.ts` 会红。
### 圆角阶梯
`--radius-sm` 6(小徽章)/ `--radius-md` 10(按钮、输入)/ `--radius-lg` 13(卡片内件)/ `--radius-xl` 17(卡片)/ `--radius-2xl` 20(composer)/ `--radius-3xl` 24(模态)/ `--radius-full` 胶囊。
写 `rounded-[var(--radius-md)]`,**不要**写 `rounded-md`(Tailwind 的 `rounded-lg` 是 8px,`--radius-lg` 是 13px,同名不同值)。
### 阴影三级
`--shadow-card`(卡片静止)/ `--shadow-composer`(composer、卡片 hover 抬起)/ `--shadow-overlay`(浮层、下拉、模态)。
### 字体
- 标题走 `style={{ fontFamily: 'var(--font-headline)' }}`:所有 h1/h2/h3、页面大标题、统计大数字、弹层里的大号数值(上下文百分比、推理档位名)。中文会落到系统衬线,这是预期。
- 正文默认 `--font-body`,14px / 1.5。
- 代码、路径、快捷键、ID、耗时、token 数走 `--font-mono`(Tailwind `font-mono`)。
- 字号阶梯:11 / 12 / 12.5 / 13 / 13.5 / 14 / 14.5 / 15 / 16.5 / 21 / 24–28(衬线页标题)/ 32(详情页标题)。
## 组件规则
优先用 `components/ui/` 里的原语,别手写 `<button>`。表在 `components/AGENTS.md` 第一节。
已按设计稿改好、**直接复用**的:
- `Button` — `primary` 已是墨色实心 + hover 转陶土 + 上浮 1px + `active:scale(.97)`;禁用态是不透明的 `--s1`/`--t3`。
- `Card` — 新增 `shadow`(`none`/`card`/`composer`)与 `lift`(hover 抬起 2px)两个 prop,以及 `radius="2xl"`。**`lift` 与 `shadow` 不要同时传**,两个 `shadow-[…]` 不会叠加。
- `IconButton` / `Badge` / `Modal`(圆角已是 24)/ `SegmentedControl` / `Switch` / `Progress` — 都已走 token,跟随主题。
- `composite/BrandSeal` — 印章「哈」,`sm`24 / `md`32 / `lg`38 / `xl`80。
焦点态统一:`focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface)]`。
### 动效
- 按钮 `transition-[background-color,color,border-color,box-shadow,transform] duration-150 ease-out`。
- 卡片 hover 抬起用 `Card` 的 `lift`。
- 屏幕入场用 `.animate-screen-pop`。
- 已有 `.animate-spin`(.9s)、`.animate-pulse-dot`(1.6s)、`.animate-overlay-in*`。
- 任何位移动效都要配 `motion-reduce:` 兜底。
## 常见形态
- **页头**:衬线 24–28px 标题 + 次级说明行 + 右侧墨色主按钮。
- **统计卡**:`Card` + 衬线大数字(21–26px)+ 12px `--color-text-tertiary` 标签。
- **列表行**:状态点 + 主标题 + 12.5px 次级信息 + 右侧 mono 指标;hover 出 `--color-surface-hover`。
- **chip / 胶囊**:`rounded-full`,1px `--color-border`,hover 转 `--color-outline`;激活态 `--color-brand-soft` 底 + `--color-on-brand-soft` 字 + `--color-primary-fixed-dim` 描边。
- **警示条**:`--color-{warning,error}-container` 底 + `--color-on-*-container` 字 + 同色描边。
- **磨砂浮层**:`glass-panel` 类(已含 blur 18px + `--shadow-overlay`)。
- **代码块**:`--color-code-bg` 底 + `font-mono` 13px/1.7 + 头部带 Copy。
## 硬约束(会让 CI 红)
- 不用 Tailwind 原色(`bg-amber-50`、`text-red-400`…)—— 它们不跟随 `data-theme`。`paletteEscapes.test.ts` 拦截。
- 不用 `/N` alpha 修饰符(`bg-[var(--x)]/10`)—— Safari 15 WebView 解析不了。需要浅底就用现成的 `-soft` / `-container` token。
- 不写裸 `zIndex: 9999` 或 `z-[10000]`,层级只能来自 `--z-*`。
- 不写硬编码 hex。
- 所有用户可见文案(含 `aria-label`、`title`)走 `useTranslation()`,五种语言(`zh` / `zh-TW` / `en` / `jp` / `kr`)都要加。
- 图标按钮必须有名字(用 `IconButton`,`label` 必填)。
- 会被浮层锚定的组件必须 `forwardRef`。
## 参考原型
`/Users/nanmi/Downloads/design_handoff_cc_haha_redesign/` 下的 `CC Haha.dc.html` 是高保真交互原型,README.md 是像素级规格。原型用浏览器直接打开可交互。
Binary file not shown.
Binary file not shown.
+44 -8
View File
@@ -207,7 +207,7 @@ describe('Settings > General tab', () => {
useSettingsStore.setState({
locale: 'en',
theme: 'light',
theme: 'warm-classic',
permissionMode: 'default',
autoModeOptInAccepted: false,
thinkingEnabled: true,
@@ -387,19 +387,43 @@ describe('Settings > General tab', () => {
expect(screen.getByLabelText('Skip WebFetch domain preflight')).toBeInTheDocument()
})
it('offers the pure white appearance theme', () => {
it('offers all six palettes, paper grounds before ink ones', () => {
render(<Settings />)
fireEvent.click(screen.getByText('General'))
const pureWhite = screen.getByRole('button', { name: 'Pure White' })
const warmClassic = screen.getByRole('button', { name: 'Warm Classic' })
const dark = screen.getByRole('button', { name: 'Dark' })
// The picker order is load-bearing: the four paper grounds come first, then
// the two ink ones, so the list reads light-to-dark rather than shuffled.
const order = ['Pure White', 'Paper', 'Warm Classic', 'Celadon', 'Ink Night', 'Ink Blue']
.map((name) => screen.getByRole('button', { name }))
for (const [index, chip] of order.slice(0, -1).entries()) {
const next = order[index + 1]!
expect(
(chip.compareDocumentPosition(next) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0,
`${order[index + 1]} should follow ${order[index]}`,
).toBe(true)
}
expect((pureWhite.compareDocumentPosition(warmClassic) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0).toBe(true)
expect((warmClassic.compareDocumentPosition(dark) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0).toBe(true)
fireEvent.click(screen.getByRole('button', { name: 'Pure White' }))
expect(useSettingsStore.getState().setTheme).toHaveBeenCalledWith('white')
fireEvent.click(screen.getByRole('button', { name: 'Ink Blue' }))
expect(useSettingsStore.getState().setTheme).toHaveBeenCalledWith('ink-blue')
})
it('gives the settings rail the handoff width and a rounded selected item', () => {
useUIStore.setState({ activeSettingsTab: 'general', pendingSettingsTab: null })
render(<Settings />)
const activeItem = screen.getByRole('button', { name: 'General' })
// Selection is a rounded ground inside the rail, not a full-bleed band:
// the rail is padded, so a square highlight would touch the divider.
expect(activeItem.className).toContain('rounded-[var(--radius-md)]')
expect(activeItem.className).toContain('bg-[var(--color-surface-hover)]')
expect(activeItem).toHaveAttribute('aria-current', 'page')
const rail = activeItem.parentElement?.parentElement
expect(rail?.className).toContain('w-[260px]')
})
it('marks the pure white appearance theme as selected', () => {
@@ -1576,6 +1600,18 @@ describe('Settings > Providers tab', () => {
providerStoreState.hasLoadedProviders = true
})
it('outlines the default provider in terracotta rather than the focus color', () => {
providerStoreState.activeId = 'provider-1'
render(<Settings />)
const card = screen.getByTestId('provider-provider-1')
// 1.5px so the default row reads as chosen at a glance without the heavier
// ring the focus border gave it, which collided with the real focus ring.
expect(card.className).toContain('border-[1.5px]')
expect(card.className).toContain('border-[var(--color-primary-fixed-dim)]')
expect(card.className).not.toContain('border-[var(--color-border-focus)]')
})
it('does not query official OAuth status before providers finish loading', () => {
providerStoreState.providers = []
providerStoreState.activeId = null
+22 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { renderToStaticMarkup } from 'react-dom/server'
import '@testing-library/jest-dom'
@@ -87,6 +87,7 @@ import { useSessionStore } from '../stores/sessionStore'
import { useProviderStore } from '../stores/providerStore'
import { useSessionRuntimeStore } from '../stores/sessionRuntimeStore'
import { useTabStore } from '../stores/tabStore'
import { useTaskStore } from '../stores/taskStore'
beforeEach(() => {
useSettingsStore.setState({ locale: 'en' })
@@ -1246,6 +1247,26 @@ describe('Content-only pages render without errors', () => {
await screen.findByText('Scheduled tasks')
expect(container.innerHTML).toContain('Scheduled tasks')
})
it('ScheduledTasks reports a failed load instead of showing the empty state', async () => {
const fetchTasks = vi.fn().mockResolvedValue(undefined)
useTaskStore.setState({ tasks: [], isLoading: false, error: 'tasks unreachable', fetchTasks })
// `fetchTasks` resolves and flips `initialized` after mount; awaiting the
// render keeps that state update inside act().
await act(async () => { render(<ScheduledTasks />) })
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent('tasks unreachable')
// The empty state invites creating a task; a failed load must not.
expect(screen.queryByText('No scheduled tasks yet.')).not.toBeInTheDocument()
fetchTasks.mockClear()
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
expect(fetchTasks).toHaveBeenCalled()
act(() => { useTaskStore.setState({ error: null }) })
})
})
describe('Chat attachments', () => {
+2 -1
View File
@@ -41,6 +41,7 @@
| 复制按钮 | `CopyButton` | `ui/CopyButton.tsx` |
| 目录选择 | `DirectoryPicker` | `composite/DirectoryPicker.tsx` |
| 「用 X 打开」菜单 | `OpenWithMenu` | `composite/OpenWithMenu.tsx` |
| 品牌印章「哈」 | `BrandSeal` | `composite/BrandSeal.tsx` |
### 两个 hook
@@ -139,7 +140,7 @@ style={{ zIndex: 'var(--z-dialog)' }}
2. 如果是通用需求(同样的形态在多处出现),**给组件加一个 variant/tone/size**,连同测试一起。
3. 改不了或拿不准,**保持原样并记下来** —— 一个诚实的"跳过 + 原因"比一个视觉走样的替换有价值得多。
`IconButton` 的 `secondary` tone、`2xs`–`2xl` 六档尺寸、`bordered`、`solid`、`hoverTone="danger"`、`pressed`、`surface="sidebar"`,`Button` 的 `base`(h-8) 与 `inverse` 变体,`Badge` 的 `wrap`/`title`/rest 透传,`SearchField` 的 `clearLabel` —— 全部来自这个流程。多轮独立的替换工作各自撞到同一批缺口,然后一次补齐。
`IconButton` 的 `secondary` tone、`2xs`–`2xl` 六档尺寸、`bordered`、`solid`、`hoverTone="danger"`、`pressed`、`surface="sidebar"`、`surface="terminal"`(墨色终端标题栏,纸主题下 `--color-text-tertiary` 在其上不可见),`Button` 的 `base`(h-8)、`inverse` 与 `tonal-outline`(陶土描边 hover 反色)变体,`Card` 的 `shadow`/`lift`/`container` 档与 rest 透传,`Badge` 的 `wrap`/`title`/rest 透传,`SearchField` 的 `clearLabel` 与 `xl`(44px) 档 —— 全部来自这个流程。多轮独立的替换工作各自撞到同一批缺口,然后一次补齐。
几个存在理由值得记住:
@@ -102,13 +102,13 @@ export function AgentMascot({ seed, status }: { seed: string; status: ActivitySt
data-agent-mascot-motion={spec.motion}
data-agent-mascot-tone={spec.tone}
data-agent-mascot-src={imageSrc}
className={`relative inline-flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-xl border bg-[var(--color-surface)] shadow-[0_1px_3px_rgba(15,23,42,0.14),inset_0_1px_0_rgba(255,255,255,0.76)] ${ringClassName(spec.tone)}`}
className={`relative inline-flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-[var(--radius-md)] border bg-[var(--color-surface-container)] ${ringClassName(spec.tone)}`}
aria-hidden="true"
>
{isActive ? (
<span
data-testid="agent-mascot-motion-ring"
className="absolute -inset-0.5 rounded-[14px] border border-transparent border-t-[var(--color-brand)] opacity-80 motion-safe:animate-spin motion-reduce:animate-none"
className="absolute -inset-0.5 rounded-[var(--radius-lg)] border border-transparent border-t-[var(--color-brand)] opacity-80 motion-safe:animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : null}
@@ -21,10 +21,12 @@ export function SessionActivityButton({
icon={<ListChecks size={17} strokeWidth={1.9} />}
label={resolvedLabel}
size="md"
tone={isOpen ? 'default' : 'muted'}
// `default` sets no resting background, so this adds one without colliding
// with a tone class.
className={isOpen ? 'bg-[var(--color-surface-hover)]' : undefined}
// The design's active toolbar toggle is a terracotta wash with a
// terracotta glyph, which `tone="brand"` + `filled` already are. The
// `className` fill this replaces raced the tone's own `bg-[…]`, and which
// won came down to stylesheet order.
tone={isOpen ? 'brand' : 'muted'}
filled={isOpen}
aria-expanded={isOpen}
aria-pressed={isOpen}
onClick={() => toggle(sessionId)}
@@ -46,6 +46,7 @@ vi.mock('../../i18n', () => ({
'session.activity.task.inProgress': 'Task in progress',
'session.activity.task.pending': 'Task pending',
'session.activity.tasks.earlier': 'Earlier tasks',
'session.activity.tasksProgress': 'Task progress {completed}/{total}',
'session.activity.tasks.earlierSummary': 'Earlier turns: {completed}/{total} completed',
'session.activity.status.pending': 'Pending',
'session.activity.status.inProgress': 'In progress',
@@ -162,12 +163,73 @@ describe('SessionActivityPanel', () => {
expect(screen.getByLabelText('Task completed')).toBeInTheDocument()
expect(screen.getByLabelText('Task in progress')).toBeInTheDocument()
expect(screen.getByLabelText('Task pending')).toBeInTheDocument()
expect(screen.getByLabelText('Task in progress').querySelector('svg')).toHaveClass('motion-safe:animate-spin')
// The in-progress marker is the design's terracotta ring — a bordered
// circle, not an icon — so the animation classes sit on the marker itself.
// It still stops rather than slows under reduced motion, which is why this
// panel does not use `Spinner`.
expect(screen.getByLabelText('Task in progress')).toHaveClass('motion-safe:animate-spin')
expect(screen.getByLabelText('Task in progress')).toHaveClass('motion-reduce:animate-none')
expect(screen.getByLabelText('Task in progress')).toHaveClass('rounded-full')
expect(screen.getByLabelText('Task in progress').querySelector('svg')).toBeNull()
expect(screen.getByText('Active task').closest('button,div')).toHaveClass('py-2.5')
expect(screen.getByText('Finished task')).toHaveClass('line-through')
expect(screen.queryByText('Completed')).not.toBeInTheDocument()
expect(screen.queryByText('Pending')).not.toBeInTheDocument()
})
it('summarizes task completion as a labelled mini progress rail and a ratio', () => {
render(
<SessionActivityPanel
model={model({
sections: {
...model().sections,
tasks: {
id: 'tasks',
title: 'Tasks',
emptyLabel: 'No tasks',
rows: [
{ id: 'a', section: 'tasks', label: 'First', status: 'completed', openable: false },
{ id: 'b', section: 'tasks', label: 'Second', status: 'completed', openable: false },
{ id: 'c', section: 'tasks', label: 'Third', status: 'in_progress', openable: false },
{ id: 'd', section: 'tasks', label: 'Fourth', status: 'pending', openable: false },
],
},
},
})}
open
onClose={vi.fn()}
onOpenSubagent={vi.fn()}
/>,
)
// Named for what it measures, not the section it sits in: borrowing the
// section title made screen readers announce "Tasks, 50%", which reads as
// the section being half-done rather than 2 of 4 tasks complete.
const progress = screen.getByRole('progressbar', { name: 'Task progress 2/4' })
expect(progress).toHaveAttribute('aria-valuenow', '50')
expect(screen.getByText('2/4')).toBeInTheDocument()
})
it('leaves sections other than tasks without a progress rail', () => {
render(
<SessionActivityPanel
model={model({
sections: {
...model().sections,
tasks: { id: 'tasks', title: 'Tasks', emptyLabel: 'No tasks', rows: [] },
},
})}
open
onClose={vi.fn()}
onOpenSubagent={vi.fn()}
/>,
)
expect(screen.getByText('SubAgents')).toBeInTheDocument()
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
})
it('renders earlier task history as a localized compact checklist row', () => {
render(
<SessionActivityPanel
@@ -547,14 +609,29 @@ describe('SessionActivityPanel', () => {
</>,
)
expect(screen.getByTestId('session-activity-panel')).toHaveAttribute('data-placement', 'rail')
expect(screen.getByTestId('session-activity-panel')).toHaveClass('my-4')
expect(screen.getByTestId('session-activity-panel')).toHaveClass('mr-3')
expect(screen.getByTestId('session-activity-panel')).toHaveClass('w-[336px]')
expect(screen.getByTestId('session-activity-panel')).toHaveClass('rounded-[22px]')
expect(screen.getByTestId('session-activity-panel')).toHaveClass('self-start')
expect(screen.getByTestId('session-activity-panel')).toHaveClass('max-h-[min(620px,calc(100vh-72px))]')
expect(screen.getByTestId('session-activity-panel')).not.toHaveClass('h-[calc(100%-24px)]')
const panel = screen.getByTestId('session-activity-panel')
expect(panel).toHaveAttribute('data-placement', 'rail')
// Out of flow, not an in-flow flex sibling. This is what lets the content
// column animate its own padding to make way: a width flex computed for it
// could not be transitioned. Anything that puts this panel back in the
// layout takes that transition away with it.
expect(panel).toHaveClass('absolute')
expect(panel).toHaveClass('top-4')
expect(panel).toHaveClass('right-5')
expect(panel).not.toHaveClass('shrink-0')
expect(panel).not.toHaveClass('self-start')
// 340px, 20px from the right edge and `--radius-xl` are the handoff's
// floating-panel numbers; the frosted fill, hairline and overlay shadow all
// come from `glass-panel` rather than the three hardcoded slate shadows
// this used to carry.
expect(panel).toHaveClass('w-[340px]')
expect(panel).toHaveClass('rounded-[var(--radius-xl)]')
expect(panel).toHaveClass('glass-panel')
// Height is capped against the containing block now that the panel is
// absolute, rather than against the viewport as the in-flow rail was.
expect(panel).toHaveClass('max-h-[min(620px,calc(100%-80px))]')
expect(panel).not.toHaveClass('h-[calc(100%-24px)]')
fireEvent.pointerDown(screen.getByRole('button', { name: 'Outside' }))
expect(onClose).not.toHaveBeenCalled()
@@ -1,8 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Check, ChevronRight, Circle, FileText, LoaderCircle, Square, Terminal, Users, X } from 'lucide-react'
import { Badge } from '@/components/ui/Badge'
import { Badge, StatusDot, type Tone } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { IconButton } from '@/components/ui/IconButton'
import { Progress } from '@/components/ui/Progress'
import { useDismissable } from '@/hooks/useDismissable'
import { AgentMascot } from './AgentMascot'
import { getVisibleActivitySections, type ActivityRow, type ActivitySectionId, type SessionActivityModel } from './sessionActivityModel'
@@ -157,7 +158,7 @@ function TaskStatusMarker({ status, t }: { status: ActivityRow['status']; t: Tra
return (
<span
aria-label={t('session.activity.task.completed')}
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md border border-[var(--color-success)] bg-[var(--color-success)] text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.24)]"
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-[var(--color-success)] text-[var(--color-surface)]"
>
<Check size={13} strokeWidth={3} aria-hidden="true" />
</span>
@@ -168,7 +169,7 @@ function TaskStatusMarker({ status, t }: { status: ActivityRow['status']; t: Tra
return (
<span
aria-label={t('session.activity.status.stopped')}
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md border border-[var(--color-text-tertiary)]/50 bg-[var(--color-surface)] text-[var(--color-text-tertiary)]"
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full border border-[var(--color-outline)] text-[var(--color-text-tertiary)]"
>
<X size={12} strokeWidth={2.4} aria-hidden="true" />
</span>
@@ -176,24 +177,22 @@ function TaskStatusMarker({ status, t }: { status: ActivityRow['status']; t: Tra
}
if (status === 'in_progress' || status === 'running') {
// Not `Spinner`: this panel's markers stop under reduced motion, while
// `Spinner` deliberately slows instead. `SessionActivityPanel.test.tsx`
// asserts both the `motion-reduce:animate-none` and the absence of a bare
// `.animate-spin` inside a row.
return (
<span
aria-label={t('session.activity.task.inProgress')}
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md border border-[var(--color-brand)] bg-[var(--color-surface)] text-[var(--color-brand)]"
>
{/* Not `Spinner`: this panel's markers stop under reduced motion, while
`Spinner` deliberately slows instead. `SessionActivityPanel.test.tsx`
asserts both the `motion-reduce:animate-none` and the absence of a
bare `.animate-spin` inside a row. */}
<LoaderCircle size={13} strokeWidth={2.4} aria-hidden="true" className="motion-safe:animate-spin motion-reduce:animate-none" />
</span>
className="inline-flex h-5 w-5 shrink-0 rounded-full border-[2.5px] border-[var(--color-primary-fixed-dim)] border-t-[var(--color-brand)] motion-safe:animate-spin motion-reduce:animate-none"
/>
)
}
return (
<span
aria-label={t('session.activity.task.pending')}
className="inline-flex h-5 w-5 shrink-0 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)]"
className="inline-flex h-5 w-5 shrink-0 rounded-full border-[1.8px] border-[var(--color-outline)]"
/>
)
}
@@ -214,17 +213,18 @@ function getRowIcon(row: ActivityRow) {
}
}
function getStatusTone(status: ActivityRow['status']) {
if (status === 'running' || status === 'in_progress') {
return 'bg-[var(--color-brand)]'
}
if (status === 'completed' || status === 'idle') {
return 'bg-[var(--color-success)]'
}
if (status === 'failed' || status === 'error' || status === 'stopped') {
return 'bg-[var(--color-error)]'
}
return 'bg-[var(--color-text-tertiary)]'
function getStatusTone(status: ActivityRow['status']): Tone {
if (status === 'running' || status === 'in_progress') return 'brand'
if (status === 'completed' || status === 'idle') return 'success'
if (status === 'failed' || status === 'error' || status === 'stopped') return 'danger'
return 'neutral'
}
/** Visible rows only, so the ratio always matches what the section shows. */
function getTaskProgress(rows: ActivityRow[]): { completed: number; total: number; percent: number } | null {
if (rows.length === 0) return null
const completed = rows.filter((row) => row.status === 'completed').length
return { completed, total: rows.length, percent: Math.round((completed / rows.length) * 100) }
}
function ActivityRowIcon({ row, sessionId }: { row: ActivityRow; sessionId: string }) {
@@ -235,7 +235,7 @@ function ActivityRowIcon({ row, sessionId }: { row: ActivityRow; sessionId: stri
const Icon = getRowIcon(row)
return (
<span className="inline-flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-lg text-[var(--color-text-tertiary)]">
<span className="inline-flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-[var(--radius-md)] text-[var(--color-text-tertiary)]">
<Icon size={15} strokeWidth={2} aria-hidden="true" />
</span>
)
@@ -253,13 +253,11 @@ function ActivityStatusIndicator({
const isRunning = animated && (status === 'running' || status === 'in_progress')
return (
<span className="inline-flex shrink-0 items-center gap-1 text-[10px] font-medium text-[var(--color-text-tertiary)]">
<span className="relative inline-flex h-1.5 w-1.5" aria-hidden="true">
{isRunning ? (
<span className={`absolute inline-flex h-full w-full rounded-full opacity-35 motion-safe:animate-ping motion-reduce:animate-none ${getStatusTone(status)}`} />
) : null}
<span className={`relative inline-flex h-1.5 w-1.5 rounded-full ${getStatusTone(status)}`} />
</span>
<span className="inline-flex shrink-0 items-center gap-1.5 text-[11px] font-medium text-[var(--color-text-tertiary)]">
{/* `StatusDot`'s breathing dot rather than the expanding ping this used to
draw: the design language has one live-status motion (1.6s pulse) and
fifteen other dots in the app already run it. */}
<StatusDot tone={getStatusTone(status)} pulse={isRunning} />
{label}
</span>
)
@@ -341,14 +339,14 @@ function ActivityRowView({
)}
<span className="min-w-0 flex-1 truncate text-left">
<span
className={`block truncate text-[12px] font-semibold leading-4 ${isTask && row.status === 'completed' ? 'text-[var(--color-text-tertiary)] line-through decoration-[var(--color-text-tertiary)]/60' : 'text-[var(--color-text-primary)]'}`}
className={`block truncate font-semibold leading-5 ${isTask ? 'text-[14px]' : 'text-[13px]'} ${isTask && row.status === 'completed' ? 'text-[var(--color-text-secondary)] line-through decoration-[var(--color-text-tertiary)]' : 'text-[var(--color-text-primary)]'}`}
title={label}
>
{label}
</span>
{detail ? (
<span
className="block truncate text-[10px] leading-4 text-[var(--color-text-tertiary)]"
className={`mt-0.5 block truncate leading-4 text-[var(--color-text-tertiary)] ${isTask ? 'text-[12.5px]' : 'text-[12px]'}`}
title={detail}
>
{detail}
@@ -368,7 +366,7 @@ function ActivityRowView({
</>
)
const interactiveRowClassName =
'flex min-w-0 items-center gap-2.5 rounded-lg px-2.5 py-2.5 text-left transition-[background-color,transform] duration-150 ease-out hover:bg-[var(--color-surface-hover)] active:translate-y-px focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]'
'flex min-w-0 items-center gap-3 rounded-[var(--radius-md)] px-2.5 py-2.5 text-left transition-[background-color,transform] duration-150 ease-out hover:bg-[var(--color-surface-hover)] active:translate-y-px motion-reduce:active:translate-y-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]'
const stopButton = row.section === 'backgroundTasks' && onStopBackgroundTask ? (
<BackgroundTaskStopButton
row={row}
@@ -440,7 +438,7 @@ function ActivityRowView({
if (stopButton) {
return (
<div className="flex w-full items-center gap-1">
<div className="flex min-w-0 flex-1 items-center gap-2.5 rounded-lg px-2.5 py-2.5">
<div className="flex min-w-0 flex-1 items-center gap-3 rounded-[var(--radius-md)] px-2.5 py-2.5">
{content}
</div>
{stopButton}
@@ -449,7 +447,7 @@ function ActivityRowView({
}
return (
<div className="flex items-center gap-2.5 rounded-lg px-2.5 py-2.5">
<div className="flex items-center gap-3 rounded-[var(--radius-md)] px-2.5 py-2.5">
{content}
</div>
)
@@ -485,17 +483,17 @@ function BackgroundTaskDetail({ row }: { row: ActivityRow }) {
if (details.length === 0) return null
return (
<div className="mx-2.5 mb-1.5 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-2.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.54)]">
<div className="mb-1.5 text-[10px] font-semibold text-[var(--color-text-tertiary)]">
<div className="mx-2.5 mb-1.5 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-3">
<div className="mb-2 text-[11px] font-semibold text-[var(--color-text-tertiary)]">
{t('session.activity.details.title')}
</div>
<dl className="space-y-1.5">
<dl className="space-y-2">
{details.map((detail) => (
<div key={detail.label} className="min-w-0">
<dt className="text-[10px] font-semibold text-[var(--color-text-tertiary)]">
<dt className="text-[11px] font-semibold text-[var(--color-text-tertiary)]">
{detail.label}
</dt>
<dd className="max-h-28 overflow-auto whitespace-pre-wrap break-words text-[10px] leading-relaxed text-[var(--color-text-secondary)]">
<dd className="max-h-28 overflow-auto whitespace-pre-wrap break-words text-[12px] leading-relaxed text-[var(--color-text-secondary)]">
{detail.value}
</dd>
</div>
@@ -561,9 +559,32 @@ export function SessionActivityPanel({
}, [model.sections.backgroundTasks.rows, open, selectedBackgroundTaskId])
if (!open) return null
// Both placements are out-of-flow overlays pinned to the top right of the
// session area. `rail` used to be an in-flow flex sibling, which is why the
// handoff's "content makes way over .2s" could not be built: the content
// column's width was whatever flex computed, and a flex-derived width is not
// animatable. Out of flow, the column owns its own padding and can transition
// it. Whoever renders this decides where it lands — the offsets resolve
// against the nearest positioned ancestor, which in `ActiveSession` is the
// session-area wrapper the chat column and this panel both sit in.
//
// `glass-panel` carries the frosted fill, hairline and `--shadow-overlay` the
// design gives every floating surface; the hand-rolled shadow it replaces was
// three hardcoded slate rgba layers plus a white gloss, none of which follow
// `data-theme`.
//
// The level is pinned to the `z-40` this replaced: the panel has to clear
// `MessageList`'s z-20 scroll button and stay under its fixed z-50 pill.
// `--z-scrim` is that level on the scale; the name is about where 40 sits,
// not about this being a scrim.
const shellClassName = 'glass-panel absolute top-4 z-[var(--z-scrim)] flex flex-col overflow-hidden rounded-[var(--radius-xl)] animate-overlay-in'
const className = placement === 'rail'
? 'my-4 ml-3 mr-3 flex max-h-[min(620px,calc(100vh-72px))] w-[336px] shrink-0 self-start flex-col overflow-hidden rounded-[22px] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-[0_24px_72px_-48px_rgba(15,23,42,0.54),0_10px_26px_-22px_rgba(15,23,42,0.32),inset_0_1px_0_rgba(255,255,255,0.82)]'
: 'absolute right-4 top-4 z-40 flex max-h-[calc(100%-80px)] w-[min(336px,calc(100%-32px))] flex-col overflow-hidden rounded-[22px] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-[0_24px_72px_-48px_rgba(15,23,42,0.54),0_10px_26px_-22px_rgba(15,23,42,0.32),inset_0_1px_0_rgba(255,255,255,0.82)]'
// 20px from the right edge is the handoff's number, and it is what makes
// the content column's 352px of padding leave the 8px gap it draws.
? `${shellClassName} right-5 max-h-[min(620px,calc(100%-80px))] w-[340px]`
// Phone width: inset 16px on both sides, so the panel narrows instead of
// running off the screen.
: `${shellClassName} right-4 max-h-[calc(100%-80px)] w-[min(340px,calc(100%-32px))]`
return (
<div
@@ -574,8 +595,10 @@ export function SessionActivityPanel({
data-placement={placement}
className={className}
>
<div className="flex items-center justify-between px-4 pb-1.5 pt-3.5">
<h2 className="text-[12px] font-semibold text-[var(--color-text-secondary)]">{t('session.activity.title')}</h2>
<div className="flex items-center gap-2 px-4 pb-2 pt-3.5">
<h2 className="min-w-0 flex-1 truncate text-[15px] font-semibold text-[var(--color-text-primary)]">
{t('session.activity.title')}
</h2>
<IconButton
icon={<X size={14} strokeWidth={2.2} aria-hidden="true" />}
label={t('session.activity.close')}
@@ -591,6 +614,7 @@ export function SessionActivityPanel({
>
{visibleSections.map((section, index) => {
const sectionTitle = getSectionTitle(section.id, t)
const taskProgress = section.id === 'tasks' ? getTaskProgress(section.rows) : null
return (
<section
@@ -598,15 +622,36 @@ export function SessionActivityPanel({
aria-label={sectionTitle}
className={index > 0 ? 'border-t border-[var(--color-border)] pt-3' : undefined}
>
<div className="mb-1.5 flex items-center justify-between gap-2 px-0.5">
<div className="flex min-w-0 items-center gap-1.5">
<h3 className="text-[11px] font-semibold text-[var(--color-text-tertiary)]">
<div className="mb-2 flex items-center justify-between gap-2 px-2.5">
<div className="flex min-w-0 items-center gap-2">
<h3 className="text-[13.5px] font-semibold text-[var(--color-text-secondary)]">
{sectionTitle}
</h3>
{section.rows.length > 0 ? (
<Badge tone="neutral" size="xs">{section.rows.length}</Badge>
<Badge tone="neutral" size="sm" pill={false}>{section.rows.length}</Badge>
) : null}
</div>
{taskProgress ? (
<span className="flex shrink-0 items-center gap-2 text-[12px] tabular-nums text-[var(--color-text-tertiary)]">
{/* `Progress` is `w-full`; the wrapper is what makes it the
52px rail the design calls for, since the two width
utilities would otherwise resolve by stylesheet order. */}
<span className="inline-flex w-[52px] shrink-0">
{/* Named for what it measures, not the section: reusing the
section title made screen readers announce "任务, 50%". */}
<Progress
size="xs"
tone="success"
value={taskProgress.percent}
label={t('session.activity.tasksProgress', {
completed: taskProgress.completed,
total: taskProgress.total,
})}
/>
</span>
{taskProgress.completed}/{taskProgress.total}
</span>
) : null}
{section.id === 'backgroundTasks' && finishedBackgroundTaskKeys.length > 0 && onClearFinishedBackgroundTasks ? (
<Button
variant="ghost"
@@ -41,7 +41,7 @@ export function BrowserAddressBar({ url, canGoBack, canGoForward, loading = fals
/>
<form className="min-w-0 flex-1" onSubmit={(e) => { e.preventDefault(); onNavigate(normalizeBrowserAddress(draft)) }}>
<input
className="w-full rounded-[var(--radius-md)] bg-[var(--color-surface)] px-2 py-1 text-xs text-[var(--color-text-primary)]"
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1 font-mono text-xs text-[var(--color-text-primary)] outline-none transition-[border-color,box-shadow] duration-150 ease-out placeholder:font-[var(--font-body)] placeholder:text-[var(--color-text-tertiary)] hover:border-[var(--color-outline)] focus-visible:border-[var(--color-border-focus)] focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder={t('browser.addressPlaceholder')}
@@ -216,7 +216,7 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) {
data-testid="browser-zoom-controls"
role="group"
aria-label={t('browser.zoomControls')}
className="inline-flex h-10 shrink-0 items-center gap-1 rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-1.5 shadow-lg"
className="inline-flex h-10 shrink-0 items-center gap-1 rounded-full border border-[var(--color-border)] bg-[var(--color-surface)] px-1.5 shadow-[var(--shadow-overlay)]"
>
<IconButton
icon={<Minus size={14} />}
@@ -227,7 +227,7 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) {
disabled={!canZoomOut}
onClick={() => setPreviewZoom(previewZoom - BROWSER_ZOOM_STEP)}
/>
<span className="min-w-11 select-none text-center text-xs font-medium tabular-nums text-[var(--color-text-secondary)]">
<span className="min-w-11 select-none text-center font-mono text-xs font-medium tabular-nums text-[var(--color-text-secondary)]">
{zoomPercent}%
</span>
<IconButton
@@ -318,7 +318,7 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) {
</div>
<div
data-testid="browser-preview-floating-controls"
className="pointer-events-none absolute inset-x-0 bottom-0 z-10 flex h-12 items-end justify-end px-3 pb-2"
className="pointer-events-none absolute inset-x-0 bottom-0 z-[var(--z-raised)] flex h-12 items-end justify-end px-3 pb-2"
>
<div className="pointer-events-auto">
{zoomControls}
@@ -208,7 +208,7 @@ export function AskUserQuestion({ sessionId, toolUseId, input, result }: Props)
return (
<div className={`mb-4 rounded-[var(--radius-lg)] border overflow-hidden ${
submitted
? 'border-[var(--color-outline-variant)]/40 bg-[var(--color-surface-container-low)] opacity-70'
? 'border-[var(--color-border)] bg-[var(--color-surface-container-low)] opacity-70'
: 'border-[var(--color-secondary)] bg-[var(--color-surface-container-lowest)]'
}`}>
{/* Header */}
@@ -217,7 +217,7 @@ export function AskUserQuestion({ sessionId, toolUseId, input, result }: Props)
? 'bg-[var(--color-surface-container-low)]'
: 'bg-[var(--color-surface-container)]'
}`}>
<div className="flex items-center justify-center w-8 h-8 rounded-[var(--radius-md)] bg-[var(--color-secondary)]/10">
<div className="flex items-center justify-center w-8 h-8 rounded-[var(--radius-md)] bg-[var(--color-secondary-container)]">
<span className="material-symbols-outlined text-[18px] text-[var(--color-secondary)]">
help
</span>
@@ -236,7 +236,7 @@ export function AskUserQuestion({ sessionId, toolUseId, input, result }: Props)
{/* Question tabs — horizontal tab bar (only show when multiple questions) */}
{questions.length > 1 && (
<div className="flex px-4 border-b border-[var(--color-outline-variant)]/20 bg-[var(--color-surface-container-low)] overflow-x-auto">
<div className="flex px-4 border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)] overflow-x-auto">
{questions.map((q, i) => {
const isActive = safeActiveTab === i
const isAnswered = Boolean(freeTexts[i]?.trim()) || (selections[i]?.length ?? 0) > 0
@@ -283,8 +283,8 @@ export function AskUserQuestion({ sessionId, toolUseId, input, result }: Props)
disabled={submitted}
className={`w-full text-left px-4 py-3 rounded-[var(--radius-md)] border transition-all duration-150 cursor-pointer ${
isSelected
? 'border-[var(--color-secondary)] bg-[var(--color-secondary)]/8 ring-1 ring-[var(--color-secondary)]/30'
: 'border-[var(--color-outline-variant)]/40 bg-[var(--color-surface)] hover:border-[var(--color-outline-variant)] hover:bg-[var(--color-surface-container-low)]'
? 'border-[var(--color-secondary)] bg-[var(--color-secondary-container)]'
: 'border-[var(--color-border)] bg-[var(--color-surface)] hover:border-[var(--color-outline)] hover:bg-[var(--color-surface-container-low)]'
} ${submitted ? 'cursor-default' : ''}`}
>
<div className="flex items-start gap-3">
@@ -342,7 +342,7 @@ export function AskUserQuestion({ sessionId, toolUseId, input, result }: Props)
placeholder={t('question.typePlaceholder')}
rows={3}
wrap="soft"
className="max-h-48 min-h-[84px] w-full resize-y rounded-[var(--radius-md)] border border-[var(--color-outline-variant)]/40 bg-[var(--color-surface)] px-3 py-2 text-sm leading-relaxed text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:border-[var(--color-secondary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-secondary)]/30"
className="max-h-48 min-h-[84px] w-full resize-y rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-sm leading-relaxed text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:border-[var(--color-border-focus)] focus:outline-none focus:shadow-[var(--shadow-focus-ring)]"
/>
</div>
)}
@@ -360,7 +360,7 @@ export function AskUserQuestion({ sessionId, toolUseId, input, result }: Props)
{/* Submit button */}
{!submitted && (
<div className="flex items-center gap-2 px-4 py-3 border-t border-[var(--color-outline-variant)]/20 bg-[var(--color-surface-container-low)]">
<div className="flex items-center gap-2 px-4 py-3 border-t border-[var(--color-border)] bg-[var(--color-surface-container-low)]">
<Button
variant="primary"
size="sm"
@@ -74,10 +74,10 @@ export const AssistantMessage = memo(function AssistantMessage({ content, isStre
className={`group flex min-w-0 flex-col items-start ${
documentLayout
? 'w-full max-w-full'
: 'max-w-[88%] sm:max-w-[80%] lg:max-w-[72%]'
: 'max-w-[88%] sm:max-w-[80%] lg:max-w-[720px]'
}`}
>
<div className={`rounded-[20px] rounded-tl-[8px] border border-[var(--color-border)]/60 bg-[var(--color-surface)] px-4 py-3 text-sm text-[var(--color-text-primary)] shadow-sm ${
<div className={`rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] px-5 py-4 text-[14.5px] text-[var(--color-text-primary)] shadow-[var(--shadow-card)] ${
documentLayout ? 'w-full' : 'max-w-full'
}`}>
<MarkdownRenderer
@@ -108,7 +108,7 @@ export const AssistantMessage = memo(function AssistantMessage({ content, isStre
<MessageActionBar
copyText={isStreaming ? undefined : content}
copyLabel="Copy reply"
copyLabel={t('chat.copyReply')}
branchAction={branchAction}
align="start"
timestamp={timestamp}
@@ -87,7 +87,7 @@ export function AssistantOutputTargetCard({ target, sessionId, workDir }: Props)
}, [openWith, sessionId, t, target.href, workDir])
return (
<section className="flex items-start gap-3 rounded-[var(--radius-lg)] border border-[var(--color-border)]/70 bg-[var(--color-surface-container-low)] px-3 py-2.5 shadow-sm">
<section className="flex items-start gap-3 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2.5 shadow-[var(--shadow-card)]">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[var(--radius-md)] bg-[var(--color-surface)] text-[var(--color-text-secondary)]">
{isLocalhost ? (
<Globe size={17} strokeWidth={2.1} aria-hidden="true" />
@@ -265,7 +265,9 @@ describe('AttachmentGallery', () => {
const tooltip = view.getByRole('tooltip')
expect(noteChip).toHaveAttribute('aria-describedby', tooltip.id)
expect(tooltip).toHaveTextContent('修改内容')
// Was the literal '修改内容': the heading was hard-coded Chinese and so
// rendered untranslated under every locale. This suite runs under `en`.
expect(tooltip).toHaveTextContent('Requested changes')
expect(tooltip).toHaveTextContent('这个标题更轻一点')
fireEvent.mouseLeave(noteChip)
@@ -158,8 +158,8 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove }
onClick={() => setActiveImageIndex(images.findIndex((image) => image.src === src))}
className={
isComposer
? 'overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)]'
: 'overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-left shadow-sm transition-transform hover:scale-[1.01]'
? 'overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)]'
: 'overflow-hidden rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-left shadow-[var(--shadow-card)] transition-transform hover:scale-[1.01] motion-reduce:transition-none'
}
>
<img
@@ -183,7 +183,7 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove }
content={
<>
<span className="block text-[11px] font-medium uppercase tracking-wide opacity-70">
修改内容
{t('attachments.selectionNoteTitle')}
</span>
<span className="mt-1 block whitespace-pre-wrap break-words">
{selectionNote}
@@ -198,9 +198,9 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove }
className={[
'inline-flex h-7 max-w-[260px] items-center gap-1.5 rounded-full border',
'border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-2.5',
'text-[12px] font-medium leading-none text-[var(--color-text-primary)] shadow-[0_1px_2px_rgba(0,0,0,0.04)]',
'transition-colors hover:border-[var(--color-brand)]/45 hover:bg-[var(--color-surface-container)]',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] focus-visible:ring-offset-2',
'text-[12px] font-medium leading-none text-[var(--color-text-primary)] shadow-[var(--shadow-card)]',
'transition-colors hover:border-[var(--color-primary-fixed-dim)] hover:bg-[var(--color-surface-container)]',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-2',
].join(' ')}
>
<span className="material-symbols-outlined text-[15px] text-[var(--color-text-tertiary)]">
@@ -214,7 +214,7 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove }
<button
type="button"
onClick={() => onRemove(attachment.id!)}
className="absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full bg-[var(--color-error)] text-[10px] text-white opacity-0 transition-opacity group-hover:opacity-100"
className="absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full bg-[var(--color-error)] text-[10px] text-[var(--color-on-error)] opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
aria-label={t('attachments.remove', { name: attachment.name })}
>
×
@@ -243,7 +243,7 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove }
<div
key={attachment.id || `${attachment.name}-${index}`}
data-testid="diff-comment-card"
className="group/diff-comment flex max-w-[min(420px,100%)] min-w-[240px] items-start gap-2 rounded-[8px] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-2.5 py-2 text-left shadow-[0_1px_2px_rgba(0,0,0,0.04)]"
className="group/diff-comment flex max-w-[min(420px,100%)] min-w-[240px] items-start gap-2 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-2.5 py-2 text-left shadow-[var(--shadow-card)]"
>
<MessageSquare aria-hidden="true" size={15} className="mt-0.5 shrink-0 text-[var(--color-text-tertiary)]" />
<span className="min-w-0 flex-1">
@@ -256,7 +256,7 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove }
</span>
)}
{quotePreview && (
<span className="mt-0.5 block truncate font-[var(--font-mono)] text-[11px] leading-4 text-[var(--color-text-tertiary)]">
<span className="mt-0.5 block truncate font-mono text-[11px] leading-4 text-[var(--color-text-tertiary)]">
{quotePreview}
</span>
)}
@@ -295,7 +295,7 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove }
<>
<span
aria-hidden="true"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-[9px] bg-[var(--color-surface)] shadow-[inset_0_0_0_1px_var(--color-border)]"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-[var(--radius-md)] bg-[var(--color-surface)] shadow-[inset_0_0_0_1px_var(--color-border)]"
style={{ color: fileIconAccent(fileIcon) }}
>
<span className="material-symbols-outlined text-[19px]">{fileIcon}</span>
@@ -308,7 +308,7 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove }
{typeLabel}
</span>
{hasQuotePreview && (
<span className="mt-0.5 block max-w-[320px] truncate font-[var(--font-mono)] text-[11px] leading-4 text-[var(--color-text-tertiary)]">
<span className="mt-0.5 block max-w-[320px] truncate font-mono text-[11px] leading-4 text-[var(--color-text-tertiary)]">
{quotePreview}
</span>
)}
@@ -321,9 +321,9 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove }
key={attachment.id || `${attachment.name}-${index}`}
data-file-extension={typeInfo.ext || undefined}
className={[
'group/file inline-flex max-w-full min-w-[220px] items-stretch overflow-hidden rounded-[12px] border border-[var(--color-border)]',
'bg-[var(--color-surface-container-low)] text-[var(--color-text-secondary)] shadow-[0_1px_2px_rgba(0,0,0,0.04)]',
'transition-colors hover:border-[var(--color-brand)]/35 hover:bg-[var(--color-surface-container)]',
'group/file inline-flex max-w-full min-w-[220px] items-stretch overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)]',
'bg-[var(--color-surface-container-low)] text-[var(--color-text-secondary)] shadow-[var(--shadow-card)]',
'transition-colors hover:border-[var(--color-outline)] hover:bg-[var(--color-surface-container)]',
].join(' ')}
>
<button
@@ -331,7 +331,7 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove }
onClick={() => openLocalAttachment(attachment)}
aria-label={t('attachments.open', { name: attachment.name })}
title={attachment.path}
className="flex min-h-12 min-w-0 flex-1 items-center gap-2.5 px-2.5 py-1.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-brand)]/40"
className="flex min-h-12 min-w-0 flex-1 items-center gap-2.5 px-2.5 py-1.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-border-focus)]"
>
{fileVisual}
</button>
@@ -340,7 +340,7 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove }
onClick={(event) => openAttachmentWith(event, attachment)}
aria-label={t('openWith.title')}
title={t('openWith.title')}
className="flex w-9 shrink-0 items-center justify-center border-l border-[var(--color-border)] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-brand)]/40"
className="flex w-9 shrink-0 items-center justify-center border-l border-[var(--color-border)] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-border-focus)]"
>
<ChevronDown size={14} strokeWidth={2} aria-hidden="true" />
</button>
@@ -353,8 +353,8 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove }
data-file-extension={typeInfo.ext || undefined}
className={[
'group/file inline-flex max-w-full min-w-0 items-center gap-2.5 border border-[var(--color-border)]',
'rounded-[12px] bg-[var(--color-surface-container-low)] px-2.5 py-1.5 text-[var(--color-text-secondary)]',
'shadow-[0_1px_2px_rgba(0,0,0,0.04)]',
'rounded-[var(--radius-lg)] bg-[var(--color-surface-container-low)] px-2.5 py-1.5 text-[var(--color-text-secondary)]',
'shadow-[var(--shadow-card)]',
].join(' ')}
>
{fileVisual}
@@ -75,7 +75,7 @@ export function BackgroundTasksBar({
<>
{runningCount > 0 || visibleFinishedCount > 0 ? (
<div className={['shrink-0', compact ? 'px-4' : 'px-8'].join(' ')}>
<div className={compact ? 'w-full py-2' : 'mx-auto w-full max-w-[860px] py-2'}>
<div className={compact ? 'w-full py-2' : 'mx-auto w-full max-w-[900px] py-2'}>
<Button
variant="tonal"
size="base"
@@ -761,7 +761,9 @@ describe('ChatInput file mentions', () => {
render(<ChatInput variant="hero" />)
const panel = screen.getByTestId('chat-input-panel')
expect(panel).toHaveClass('rounded-xl')
// 20px composer corner + the composer step of the shadow scale, the same
// shell EmptySession renders (docs/redesign-paper-ink-seal.md §2).
expect(panel).toHaveClass('rounded-[var(--radius-2xl)]', 'glass-panel--composer')
expect(panel).not.toHaveClass('rounded-b-none')
expect(await screen.findByRole('button', { name: /Select branch: main/ })).toBeInTheDocument()
@@ -1453,7 +1455,12 @@ describe('ChatInput file mentions', () => {
expect(screen.queryByText('Run')).not.toBeInTheDocument()
expect(screen.getByTestId('chat-input-shell')).toHaveClass('px-3')
expect(screen.getByTestId('chat-input-shell').className).toContain('safe-area-inset-bottom')
expect(screen.getByTestId('chat-input-panel')).toHaveClass('rounded-2xl')
// `glass-panel--composer` carries the composer step of the shadow scale.
// The phone branch used to swap it for a `shadow-[…]` utility, which loses
// to `.glass-panel`'s own `box-shadow` on stylesheet order — so the phone
// composer silently rendered the floating-overlay shadow instead.
expect(screen.getByTestId('chat-input-panel')).toHaveClass('glass-panel--composer')
expect(screen.getByTestId('chat-input-panel')).toHaveClass('rounded-[var(--radius-2xl)]')
expect(screen.getByTestId('chat-input-panel')).not.toHaveClass('rounded-b-none')
expect(screen.getByTestId('chat-input-toolbar-leading')).toHaveClass('shrink-0', 'gap-1')
expect(screen.getByTestId('chat-input-toolbar-trailing')).toHaveClass('min-w-0', 'flex-1', 'justify-end', 'gap-1')
+53 -36
View File
@@ -943,7 +943,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
isHeroComposer
? `bg-[var(--color-surface)] ${isMobileComposer ? 'px-4 pb-3' : 'px-8 pb-4'}`
: compact
? `border-t border-[var(--color-border)]/70 bg-[var(--color-surface)] ${isMobileComposer ? 'px-3 pb-[calc(env(safe-area-inset-bottom)+10px)] pt-2' : 'px-3 py-3'}`
? `border-t border-[var(--color-border)] bg-[var(--color-surface)] ${isMobileComposer ? 'px-3 pb-[calc(env(safe-area-inset-bottom)+10px)] pt-2' : 'px-3 py-3'}`
: `bg-[var(--color-surface)] ${isMobileComposer ? 'px-3 pb-[calc(env(safe-area-inset-bottom)+10px)] pt-2' : 'px-4 py-4'}`
}
>
@@ -953,17 +953,23 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
? 'mx-auto flex w-full max-w-3xl flex-col'
: compact
? 'mx-auto max-w-full'
: `${isMobileComposer ? 'mx-0 max-w-none' : 'mx-auto max-w-[860px]'}`
// 900px matches the transcript column above it; at 860 the
// composer sat 20px narrower on each side than the messages.
: `${isMobileComposer ? 'mx-0 max-w-none' : 'mx-auto max-w-[900px]'}`
}
>
<div
ref={panelRef}
data-testid="chat-input-panel"
// `glass-panel--composer` is the middle step of the shadow scale, the
// one the handoff gives the composer; `--radius-2xl` (20px) is the
// composer corner. Both match EmptySession's shell so the same
// control does not render two different panels.
className={isHeroComposer
? `glass-panel relative flex flex-col gap-3 overflow-visible ${embedLaunchControlsInHero ? 'rounded-xl' : 'rounded-t-xl rounded-b-none'} p-4 transition-colors ${isDragActive ? 'composer-drop-target-active' : ''}`
? `glass-panel glass-panel--composer relative flex flex-col gap-3 overflow-visible ${embedLaunchControlsInHero ? 'rounded-[var(--radius-2xl)]' : 'rounded-t-[var(--radius-2xl)] rounded-b-none'} p-4 transition-colors ${isDragActive ? 'composer-drop-target-active' : ''}`
: compact
? `glass-panel relative overflow-visible p-3 transition-colors ${isMobileComposer ? 'rounded-2xl shadow-[0_-12px_36px_rgba(54,35,28,0.12)]' : 'rounded-xl'} ${isDragActive ? 'composer-drop-target-active' : ''}`
: `glass-panel relative overflow-visible transition-colors ${isMobileComposer ? 'rounded-2xl p-3 shadow-[0_-12px_36px_rgba(54,35,28,0.12)]' : 'rounded-xl p-4'} ${isDragActive ? 'composer-drop-target-active' : ''}`}
? `glass-panel glass-panel--composer relative overflow-visible rounded-[var(--radius-2xl)] p-3 transition-colors ${isDragActive ? 'composer-drop-target-active' : ''}`
: `glass-panel glass-panel--composer relative overflow-visible rounded-[var(--radius-2xl)] transition-colors ${isMobileComposer ? 'p-3' : 'p-4'} ${isDragActive ? 'composer-drop-target-active' : ''}`}
{...dragHandlers}
>
{isDragActive && (
@@ -1039,7 +1045,10 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
{!isMemberSession && slashMenuOpen && filteredCommands.length > 0 && (
<div
ref={slashMenuRef}
className="absolute bottom-full left-0 right-0 z-50 mb-2 overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)]"
// `--radius-xl`, the card step: this is the same menu the
// zero-state composer renders, and it is the corner the context,
// effort, branch and worktree panels above this row already use.
className="absolute bottom-full left-0 right-0 z-[var(--z-dropdown)] mb-2 overflow-hidden rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-overlay)]"
>
<div className="max-h-[300px] overflow-y-auto py-1">
{filteredCommands.map((command, index) => (
@@ -1084,12 +1093,13 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
)}
{!isMemberSession && activeTabId && queuedUserMessages.length > 0 && (
// Dashed outline cards stacked above the input, per §4 of the
// handoff. They used to be a full-bleed filled strip pinned to the
// panel's top edge, which read as part of the composer chrome
// rather than as messages waiting their turn.
<div
data-testid="pending-user-message-list"
className={[
'overflow-hidden border-b border-[var(--color-border-separator)]',
isHeroComposer ? '-mx-4 -mt-4' : useCompactControls ? '-mx-3 -mt-3' : '-mx-4 -mt-4',
].join(' ')}
className={`flex flex-col gap-1.5 ${isHeroComposer ? '' : 'mb-2'}`}
>
{queuedUserMessages.map((message) => {
const isEditing = editingQueuedMessageId === message.id
@@ -1098,13 +1108,18 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
key={message.id}
data-testid="pending-user-message"
className={[
'flex min-w-0 items-center gap-2 px-3 py-2 text-xs',
'border-t border-[var(--color-border-separator)] first:border-t-0',
'bg-[var(--color-surface-container-lowest)]/70 text-[var(--color-text-secondary)]',
'flex min-w-0 items-center gap-2.5 rounded-[var(--radius-lg)] px-3.5 py-2',
// `--color-outline` rather than `--color-border`: a dashed
// line at the lighter weight all but disappears.
'border border-dashed border-[var(--color-outline)]',
'text-[13.5px] text-[var(--color-text-secondary)]',
].join(' ')}
>
<span className="material-symbols-outlined shrink-0 text-[16px] text-[var(--color-text-tertiary)]" aria-hidden="true">
subdirectory_arrow_right
{/* The handoff labels the row in words rather than with a
glyph; the arrow icon here was a stand-in from before
the string existed. */}
<span className="shrink-0 text-xs text-[var(--color-text-tertiary)]">
{t('chat.pendingMessageQueuedLabel')}
</span>
{isEditing ? (
<>
@@ -1122,7 +1137,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
}
}}
aria-label={t('chat.pendingMessageEditInput')}
className="min-w-0 flex-1 rounded-[6px] border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1 text-xs text-[var(--color-text-primary)] outline-none focus:border-[var(--color-border-focus)]"
className="min-w-0 flex-1 rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1 text-xs text-[var(--color-text-primary)] outline-none focus:border-[var(--color-border-focus)]"
autoFocus
/>
<Button
@@ -1148,8 +1163,9 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
<span className="min-w-0 flex-1 truncate font-medium" title={message.displayContent}>
{message.displayContent}
</span>
{/* The accent action of the three, per the handoff. */}
<Button
variant="ghost"
variant="link"
size="sm"
onClick={() => sendQueuedUserMessage(activeTabId, message.id)}
aria-label={t('chat.pendingMessageGuideNow')}
@@ -1249,23 +1265,23 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
onClick={() => setPlusMenuOpen((value) => !value)}
aria-label={t('chat.composerTools')}
aria-expanded={plusMenuOpen}
className={`text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] ${isMobileComposer ? 'inline-flex h-11 w-11 items-center justify-center rounded-xl' : 'rounded-[var(--radius-md)] p-1.5'}`}
className={`inline-flex items-center justify-center rounded-[var(--radius-md)] text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${isMobileComposer ? 'h-11 w-11' : 'h-8 w-8'}`}
>
<span className="material-symbols-outlined text-[18px]">add</span>
</button>
{plusMenuOpen && (
<div className={`absolute bottom-full left-0 z-50 mb-2 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-1 shadow-[var(--shadow-dropdown)] ${isMobileComposer ? 'w-[min(240px,calc(100vw-32px))]' : 'w-[240px]'}`}>
<div className={`absolute bottom-full left-0 z-[var(--z-dropdown)] mb-2 rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] p-1.5 shadow-[var(--shadow-overlay)] ${isMobileComposer ? 'w-[min(240px,calc(100vw-32px))]' : 'w-[240px]'}`}>
<button
onClick={openAttachmentPicker}
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-[var(--color-surface-hover)]"
className="flex w-full items-center gap-3 rounded-[var(--radius-md)] px-3 py-2.5 text-left transition-colors hover:bg-[var(--color-surface-hover)]"
>
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-secondary)]">attach_file</span>
<span className="text-sm text-[var(--color-text-primary)]">{addFilesLabel}</span>
</button>
<button
onClick={insertSlashCommand}
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-[var(--color-surface-hover)]"
className="flex w-full items-center gap-3 rounded-[var(--radius-md)] px-3 py-2.5 text-left transition-colors hover:bg-[var(--color-surface-hover)]"
>
<span className="w-[24px] text-center text-[18px] font-bold text-[var(--color-text-secondary)]">/</span>
<span className="text-sm text-[var(--color-text-primary)]">{slashCommandsLabel}</span>
@@ -1303,7 +1319,13 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
fluid={isMobileComposer}
/>
)}
<button
{/* Same component and same icon placement as EmptySession's run
button. The two rendered mirror images of each other until it
was spotted in a walkthrough — the arrow led here and trailed
there, on what is the same button to the user. */}
<Button
variant={!isMemberSession && isActive ? 'danger' : 'primary'}
size="base"
onClick={!isMemberSession && isActive ? () => stopGeneration(activeTabId!) : handleSubmit}
disabled={!isMemberSession && isActive ? false : !canSubmit}
aria-label={!isMemberSession && isActive ? t('common.stop') : isMemberSession ? t('common.send') : t('common.run')}
@@ -1316,23 +1338,18 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
: t('common.run')
: undefined
}
className={`flex shrink-0 items-center justify-center gap-1 rounded-lg text-xs font-semibold transition-all hover:brightness-105 disabled:opacity-30 ${
iconOnlyAction ? `${isMobileComposer ? 'h-11 w-11 rounded-xl px-0 py-0' : 'h-8 w-8 px-0 py-0'}` : 'w-[112px] px-3 py-1.5'
} ${
!isMemberSession && isActive
? 'bg-[var(--color-error-container)] text-[var(--color-on-error-container)]'
: 'bg-[image:var(--gradient-btn-primary)] text-[var(--color-btn-primary-fg)] shadow-[var(--shadow-button-primary)]'
className={`shrink-0 ${
iconOnlyAction ? (isMobileComposer ? 'h-11 w-11' : 'w-8') : 'w-[112px]'
}`}
icon={(
<span className="material-symbols-outlined text-[14px]">
{!isMemberSession && isActive ? 'stop' : 'arrow_forward'}
</span>
)}
iconPosition="end"
>
{/* Icon trails the label, matching the same control on
EmptySession. The two rendered mirror images of each other
until this was spotted in a walkthrough — the arrow led here
and trailed there, on what is the same button to the user. */}
{!iconOnlyAction && (!isMemberSession && isActive ? t('common.stop') : isMemberSession ? t('common.send') : t('common.run'))}
<span className="material-symbols-outlined text-[14px]">
{!isMemberSession && isActive ? 'stop' : 'arrow_forward'}
</span>
</button>
</Button>
</div>
</div>
+7 -7
View File
@@ -66,7 +66,7 @@ const warmShikiTheme = {
}
const CODE_AREA_PADDING = '0.5rem 12px'
const CODE_LINE_HEIGHT = 1.3
const CODE_LINE_HEIGHT = 1.7
type ShikiHighlighterProps = {
language: string
@@ -151,7 +151,7 @@ function PrismCodeContent({
margin: 0,
padding: CODE_AREA_PADDING,
fontFamily: 'var(--font-mono)',
fontSize: '12px',
fontSize: '13px',
lineHeight: String(CODE_LINE_HEIGHT),
whiteSpace: wrapLongLines ? 'pre-wrap' : 'pre',
wordBreak: wrapLongLines ? 'break-word' : 'normal',
@@ -268,7 +268,7 @@ function CodeArea({
style={{
margin: 0,
fontFamily: 'var(--font-mono)',
fontSize: '12px',
fontSize: '13px',
lineHeight: String(CODE_LINE_HEIGHT),
whiteSpace: wrapLongLines ? 'pre-wrap' : 'pre',
wordBreak: wrapLongLines ? 'break-word' : 'normal',
@@ -296,16 +296,16 @@ export function CodeViewer({ code, language, maxLines = 20, showLineNumbers = fa
const showExpandToggle = allLines.length > maxLines
return (
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-outline-variant)]/50 bg-[var(--color-surface-container-low)]">
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-code-bg)]">
{/* Header */}
<div className="flex items-center justify-between border-b border-[var(--color-outline-variant)]/40 bg-[var(--color-surface-container)] px-3 py-1.5 text-[11px] text-[var(--color-text-tertiary)]">
<div className="flex items-center justify-between border-b border-[var(--color-border)] bg-[var(--color-surface-container)] px-3 py-1.5 text-[11px] text-[var(--color-text-tertiary)]">
<div className="flex items-center gap-3">
<span className="font-semibold uppercase tracking-[0.14em]">{languageLabel}</span>
<span>{lineCountLabel}</span>
</div>
<CopyButton
text={code}
className="rounded-md border border-[var(--color-outline-variant)]/40 bg-[var(--color-surface-container-lowest)] px-2 py-1 text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-container-high)] hover:text-[var(--color-text-primary)]"
className="rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-2 py-1 text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-container-high)] hover:text-[var(--color-text-primary)]"
/>
</div>
@@ -321,7 +321,7 @@ export function CodeViewer({ code, language, maxLines = 20, showLineNumbers = fa
{showExpandToggle && (
<button
onClick={() => setExpanded((value) => !value)}
className="w-full border-t border-[var(--color-outline-variant)]/40 bg-[var(--color-surface-container)] py-1.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-container-high)] hover:text-[var(--color-text-primary)]"
className="w-full border-t border-[var(--color-border)] bg-[var(--color-surface-container)] py-1.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-container-high)] hover:text-[var(--color-text-primary)]"
>
{expanded
? t('codeViewer.collapse')
@@ -8,11 +8,14 @@ export function ComposerDropOverlay({ title, description, testId }: ComposerDrop
return (
<div
data-testid={testId}
className="composer-drop-overlay pointer-events-none absolute inset-0 z-40 flex items-center justify-center rounded-[inherit] border border-[var(--color-brand)]/45 bg-[var(--color-surface-container-lowest)]/88 p-4 backdrop-blur-[2px]"
// Opaque tokens throughout: `--color-brand/45` and `/88` compile to a
// color function the Safari 15 WebView drops, which left the drop target
// with no edge and a fully transparent scrim there.
className="composer-drop-overlay pointer-events-none absolute inset-0 z-[var(--z-scrim)] flex items-center justify-center rounded-[inherit] border border-[var(--color-primary-fixed-dim)] bg-[var(--color-surface-glass)] p-4 backdrop-blur-[2px]"
aria-hidden="true"
>
<div className="flex max-w-[280px] items-center gap-3 rounded-[10px] border border-[var(--color-brand)]/30 bg-[var(--color-surface-container-low)] px-4 py-3 text-left shadow-[var(--shadow-dropdown)]">
<span className="material-symbols-outlined flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[var(--color-brand)]/12 text-[20px] text-[var(--color-brand)]">
<div className="flex max-w-[280px] items-center gap-3 rounded-[var(--radius-md)] border border-[var(--color-primary-fixed-dim)] bg-[var(--color-surface-container-low)] px-4 py-3 text-left shadow-[var(--shadow-overlay)]">
<span className="material-symbols-outlined flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[var(--color-brand-soft)] text-[20px] text-[var(--color-brand)]">
upload_file
</span>
<span className="min-w-0">
@@ -2,12 +2,25 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom'
const { sessionsApiMock } = vi.hoisted(() => ({
const { sessionsApiMock, runtimeMocks } = vi.hoisted(() => ({
sessionsApiMock: {
getInspection: vi.fn(),
},
runtimeMocks: {
isMobileViewport: false,
isDesktopRuntime: false,
},
}))
vi.mock('../../hooks/useMobileViewport', () => ({
useMobileViewport: () => runtimeMocks.isMobileViewport,
}))
vi.mock('../../lib/desktopRuntime', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../lib/desktopRuntime')>()
return { ...actual, isDesktopRuntime: () => runtimeMocks.isDesktopRuntime }
})
vi.mock('../../api/sessions', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../api/sessions')>()
return {
@@ -358,3 +371,45 @@ describe('ContextUsageIndicator request behavior', () => {
}
})
})
describe('ContextUsageIndicator touch target', () => {
afterEach(() => {
cleanup()
runtimeMocks.isMobileViewport = false
runtimeMocks.isDesktopRuntime = false
})
// `compact` is also true on the desktop composer, which narrows for the right
// panel rather than for touch — so it cannot be the signal that grows this
// trigger. On the phone shell it sits between two 44px buttons.
it('keeps the desktop trigger at 32px even when the composer is compact', () => {
sessionsApiMock.getInspection.mockResolvedValue(baseInspection)
render(<ContextUsageIndicator sessionId="session-1" chatState="idle" messageCount={1} compact />)
const trigger = screen.getByTestId('context-usage-indicator')
expect(trigger).toHaveClass('h-8')
expect(trigger).not.toHaveClass('h-11')
})
it('grows the trigger to the 44px touch target on the browser H5 shell', () => {
sessionsApiMock.getInspection.mockResolvedValue(baseInspection)
runtimeMocks.isMobileViewport = true
render(<ContextUsageIndicator sessionId="session-1" chatState="idle" messageCount={1} compact />)
const trigger = screen.getByTestId('context-usage-indicator')
expect(trigger).toHaveClass('h-11')
expect(trigger).not.toHaveClass('h-8')
})
it('leaves the desktop shell alone on a narrow Electron window', () => {
sessionsApiMock.getInspection.mockResolvedValue(baseInspection)
runtimeMocks.isMobileViewport = true
runtimeMocks.isDesktopRuntime = true
render(<ContextUsageIndicator sessionId="session-1" chatState="idle" messageCount={1} compact />)
expect(screen.getByTestId('context-usage-indicator')).toHaveClass('h-8')
})
})
@@ -2,6 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { sessionsApi, type SessionContextSnapshot } from '../../api/sessions'
import { useTranslation } from '../../i18n'
import type { ChatState } from '../../types/chat'
import { useMobileViewport } from '../../hooks/useMobileViewport'
import { isDesktopRuntime } from '../../lib/desktopRuntime'
import { MobileBottomSheet } from '@/components/ui/MobileBottomSheet'
type Props = {
@@ -83,6 +85,10 @@ export function ContextUsageIndicator({
refreshNonce = 0,
}: Props) {
const t = useTranslation()
// `compact` also fires for the desktop composer, which narrows for the right
// panel rather than for touch, so the phone touch target keys off the
// viewport instead — see the trigger's height below.
const isMobileBrowser = useMobileViewport() && !isDesktopRuntime()
const [context, setContext] = useState<SessionContextSnapshot | null>(null)
const [contextSource, setContextSource] = useState<'live' | 'estimate' | null>(null)
const [loading, setLoading] = useState(() => shouldFetchContext(sessionId, draft))
@@ -267,9 +273,9 @@ export function ContextUsageIndicator({
}}
title={t('contextIndicator.title')}
data-testid="context-usage-indicator"
className={`flex h-8 shrink-0 items-center gap-1.5 rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container)] text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-container-high)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-container-lowest)] ${
compact ? 'px-2' : 'px-2.5'
}`}
className={`flex shrink-0 items-center gap-[7px] rounded-full border border-[var(--color-border)] bg-transparent text-[var(--color-text-secondary)] transition-[background-color,color,border-color] duration-150 ease-out hover:border-[var(--color-outline)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-container-lowest)] ${
isMobileBrowser ? 'h-11' : 'h-8'
} ${compact ? 'px-2' : 'px-3'}`}
>
<span className="relative grid h-[18px] w-[18px] shrink-0 place-items-center rounded-full">
{loading && !displayContext ? (
@@ -292,58 +298,66 @@ export function ContextUsageIndicator({
</span>
</button>
<div className={`pointer-events-none absolute bottom-full right-0 z-40 mb-2 w-[320px] max-w-[calc(100vw-2rem)] translate-y-1 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] p-4 text-left opacity-0 shadow-[var(--shadow-dropdown)] transition-all duration-150 group-hover/context:translate-y-0 group-hover/context:opacity-100 group-focus-within/context:translate-y-0 group-focus-within/context:opacity-100 ${
<div className={`pointer-events-none absolute bottom-full right-0 z-[var(--z-popover)] mb-2 w-[340px] max-w-[calc(100vw-2rem)] translate-y-1 rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-[22px] py-5 text-left opacity-0 shadow-[var(--shadow-overlay)] transition-all duration-150 group-hover/context:translate-y-0 group-hover/context:opacity-100 group-focus-within/context:translate-y-0 group-focus-within/context:opacity-100 ${
compact ? 'hidden' : ''
}`}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-[var(--color-text-tertiary)]">
<div className="text-xs font-semibold tracking-[0.08em] text-[var(--color-text-tertiary)]">
{t('contextIndicator.title')}
</div>
<div className="mt-1 truncate text-sm font-semibold text-[var(--color-text-primary)]">
<div className="mt-1 truncate text-base font-bold text-[var(--color-text-primary)]">
{displayModel ?? t('contextIndicator.modelUnknown')}
</div>
</div>
<div className="shrink-0 font-mono text-xl font-semibold text-[var(--color-text-primary)]">
{/* The headline serif carries the one large number on the panel —
the same treatment the handoff gives every hero statistic. */}
<div
className="shrink-0 text-[27px] font-bold leading-none text-[var(--color-text-primary)]"
style={{ fontFamily: 'var(--font-headline)' }}
>
{displayContext ? formatPercent(percentage) : '--'}
</div>
</div>
{displayContext ? (
<>
<div className="mt-4 grid grid-cols-2 gap-3 font-mono text-xs">
<div className="mt-4 grid grid-cols-2 gap-2">
<div>
<div className="text-[var(--color-text-tertiary)]">{t('contextIndicator.used')}</div>
<div className="mt-1 text-[var(--color-text-primary)]">{formatNumber(usedTokens)}</div>
<div className="text-[12.5px] text-[var(--color-text-tertiary)]">{t('contextIndicator.used')}</div>
<div className="mt-[3px] font-mono text-sm font-medium text-[var(--color-text-primary)]">{formatNumber(usedTokens)}</div>
</div>
<div>
<div className="text-[var(--color-text-tertiary)]">{t('contextIndicator.free')}</div>
<div className="mt-1 text-[var(--color-text-primary)]">{formatNumber(freeTokens)}</div>
<div className="text-[12.5px] text-[var(--color-text-tertiary)]">{t('contextIndicator.free')}</div>
<div className="mt-[3px] font-mono text-sm font-medium text-[var(--color-text-primary)]">{formatNumber(freeTokens)}</div>
</div>
<div className="col-span-2">
<div className="text-[var(--color-text-tertiary)]">{t('contextIndicator.window')}</div>
<div className="mt-1 text-[var(--color-text-primary)]">{maxTokens > 0 ? formatNumber(maxTokens) : '--'}</div>
<div className="col-span-2 mt-1">
<div className="text-[12.5px] text-[var(--color-text-tertiary)]">{t('contextIndicator.window')}</div>
<div className="mt-[3px] font-mono text-sm font-medium text-[var(--color-text-primary)]">{maxTokens > 0 ? formatNumber(maxTokens) : '--'}</div>
</div>
</div>
{details.length > 0 && (
<div className="mt-4 space-y-2">
<div className="mt-[18px] flex flex-col gap-3">
{details.map((category) => {
const percent = maxTokens > 0 ? Math.max(0.5, Math.min(100, (category.tokens / maxTokens) * 100)) : 0
return (
<div key={category.name}>
<div className="flex items-center justify-between gap-3 text-xs">
<span className="min-w-0 truncate text-[var(--color-text-secondary)]">{category.name}</span>
<span className="shrink-0 font-mono text-[var(--color-text-tertiary)]">{formatNumber(category.tokens)}</span>
<div className="flex items-baseline justify-between gap-3">
<span className="min-w-0 truncate text-[13.5px] text-[var(--color-text-primary)]">{category.name}</span>
<span className="shrink-0 font-mono text-[13px] text-[var(--color-text-secondary)]">{formatNumber(category.tokens)}</span>
</div>
<div className="mt-1 h-1 overflow-hidden rounded-full bg-[var(--color-surface-container)]">
<div className="h-full rounded-full" style={{ width: `${percent}%`, backgroundColor: category.color }} />
{/* One terracotta scale for every row. The per-category
colors that used to fill these bars came from the API
payload and read as six unrelated statuses. */}
<div className="mt-[7px] h-[3px] overflow-hidden rounded-full bg-[var(--color-surface-hover)]">
<div className="h-full rounded-full bg-[var(--color-brand)]" style={{ width: `${percent}%` }} />
</div>
</div>
)
})}
</div>
)}
<div className="mt-4 text-[11px] text-[var(--color-text-tertiary)]">
<div className="mt-4 text-xs text-[var(--color-text-tertiary)]">
{formatUpdatedAt(updatedAt, t)}
{contextSource === 'estimate' && (
<span className="ml-2 inline-flex rounded-full border border-[var(--color-border)] px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em]">
@@ -378,7 +392,10 @@ export function ContextUsageIndicator({
contentClassName="p-4"
>
<div className="flex items-end justify-between gap-4">
<div className="font-mono text-4xl font-semibold text-[var(--color-text-primary)]">
<div
className="text-4xl font-bold leading-none text-[var(--color-text-primary)]"
style={{ fontFamily: 'var(--font-headline)' }}
>
{displayContext ? formatPercent(percentage) : '--'}
</div>
{contextSource === 'estimate' && (
@@ -391,15 +408,15 @@ export function ContextUsageIndicator({
{displayContext ? (
<div className="mt-5">
<div className="grid grid-cols-3 gap-2 font-mono text-xs">
<div className="rounded-xl bg-[var(--color-surface-container)] p-3">
<div className="rounded-[var(--radius-lg)] bg-[var(--color-surface-container)] p-3">
<div className="text-[var(--color-text-tertiary)]">{t('contextIndicator.used')}</div>
<div className="mt-1 text-[var(--color-text-primary)]">{formatNumber(usedTokens)}</div>
</div>
<div className="rounded-xl bg-[var(--color-surface-container)] p-3">
<div className="rounded-[var(--radius-lg)] bg-[var(--color-surface-container)] p-3">
<div className="text-[var(--color-text-tertiary)]">{t('contextIndicator.free')}</div>
<div className="mt-1 text-[var(--color-text-primary)]">{formatNumber(freeTokens)}</div>
</div>
<div className="rounded-xl bg-[var(--color-surface-container)] p-3">
<div className="rounded-[var(--radius-lg)] bg-[var(--color-surface-container)] p-3">
<div className="text-[var(--color-text-tertiary)]">{t('contextIndicator.window')}</div>
<div className="mt-1 text-[var(--color-text-primary)]">{maxTokens > 0 ? formatNumber(maxTokens) : '--'}</div>
</div>
@@ -414,8 +431,8 @@ export function ContextUsageIndicator({
<span className="min-w-0 truncate text-[var(--color-text-secondary)]">{category.name}</span>
<span className="shrink-0 font-mono text-[var(--color-text-tertiary)]">{formatNumber(category.tokens)}</span>
</div>
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-[var(--color-surface-container)]">
<div className="h-full rounded-full" style={{ width: `${percent}%`, backgroundColor: category.color }} />
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-[var(--color-surface-hover)]">
<div className="h-full rounded-full bg-[var(--color-brand)]" style={{ width: `${percent}%` }} />
</div>
</div>
)
@@ -427,7 +444,7 @@ export function ContextUsageIndicator({
</div>
</div>
) : (
<div className="mt-5 rounded-xl bg-[var(--color-surface-container)] p-4 text-sm leading-6 text-[var(--color-text-secondary)]">
<div className="mt-5 rounded-[var(--radius-lg)] bg-[var(--color-surface-container)] p-4 text-sm leading-6 text-[var(--color-text-secondary)]">
{isPendingContext
? t('contextIndicator.pendingDetail')
: loading
@@ -194,7 +194,7 @@ export function ConversationNavigator({
setPreviewItemId(null)
}}
onClick={() => onNavigate(item)}
className={`group flex h-4 items-center rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35 ${modeStyles.button}`}
className={`group flex h-4 items-center rounded-[var(--radius-sm)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${modeStyles.button}`}
>
<span
aria-hidden="true"
@@ -221,7 +221,7 @@ export function ConversationNavigator({
id="conversation-navigation-preview"
data-testid="conversation-navigation-preview"
role="tooltip"
className="fixed z-50 w-[min(320px,calc(100vw-88px))] -translate-y-1/2 rounded-xl border border-[var(--color-border)]/80 bg-[var(--color-surface-container-lowest)] px-3.5 py-3 text-left shadow-[var(--shadow-dropdown)]"
className="fixed z-[var(--z-tooltip)] w-[min(320px,calc(100vw-88px))] -translate-y-1/2 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3.5 py-3 text-left shadow-[var(--shadow-overlay)]"
style={{ left: previewPosition.left, top: previewPosition.top }}
>
<div className="mb-1.5 text-[11px] font-semibold uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
@@ -129,7 +129,7 @@ export function CurrentTurnChangeCard({
return (
<section
className="mx-auto mb-5 w-full max-w-[860px] overflow-hidden rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-sm"
className="mx-auto mb-5 w-full max-w-[900px] overflow-hidden rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-[var(--shadow-card)]"
aria-label={cardLabel}
>
<div className="flex items-center justify-between gap-3 border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3">
@@ -138,10 +138,10 @@ export function CurrentTurnChangeCard({
<span className="text-sm font-semibold text-[var(--color-text-primary)]">
{t('chat.turnChangesTitle', { count: files.length })}
</span>
<span className="font-mono text-sm font-semibold text-[var(--color-success)]">
<span className="rounded-[var(--radius-sm)] bg-[var(--color-diff-added-gutter)] px-2 py-0.5 font-mono text-[12px] font-semibold text-[var(--color-diff-added-text)]">
+{checkpoint.code.insertions}
</span>
<span className="font-mono text-sm font-semibold text-[var(--color-error)]">
<span className="rounded-[var(--radius-sm)] bg-[var(--color-diff-removed-gutter)] px-2 py-0.5 font-mono text-[12px] font-semibold text-[var(--color-diff-removed-text)]">
-{checkpoint.code.deletions}
</span>
</div>
@@ -177,7 +177,7 @@ export function CurrentTurnChangeCard({
onClick={(event) => openChangedFile(event, fileEntry)}
aria-label={t('chat.turnChangesOpenInWorkspaceAria', { path: fileEntry.displayPath })}
title={fileEntry.displayPath}
className="flex min-h-[52px] min-w-0 flex-1 items-center gap-3 rounded-[var(--radius-md)] px-4 text-left transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-brand)]/35"
className="flex min-h-[52px] min-w-0 flex-1 items-center gap-3 rounded-[var(--radius-md)] px-4 text-left transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-border-focus)]"
>
<span className="material-symbols-outlined shrink-0 text-[22px] text-[var(--color-text-tertiary)]">{typeInfo.icon}</span>
<span className="min-w-0 flex-1">
@@ -208,7 +208,7 @@ export function CurrentTurnChangeCard({
<button
type="button"
onClick={() => setShowAllFiles((current) => !current)}
className="flex w-full items-center justify-center gap-1 border-t border-[var(--color-border)] px-4 py-2 text-xs text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-brand)]/35"
className="flex w-full items-center justify-center gap-1 border-t border-[var(--color-border)] px-4 py-2 text-xs text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-border-focus)]"
>
{showAllFiles ? (
<>
@@ -225,7 +225,7 @@ export function CurrentTurnChangeCard({
)}
{error && (
<div className="border-t border-[var(--color-error)]/20 bg-[var(--color-error-container)]/18 px-4 py-3 text-xs text-[var(--color-error)]">
<div className="border-t border-[var(--color-error)] bg-[var(--color-error-container)] px-4 py-3 text-xs text-[var(--color-on-error-container)]">
{error}
</div>
)}
+22 -20
View File
@@ -1,6 +1,7 @@
import ReactDiffViewer, { DiffMethod } from 'react-diff-viewer-continued'
import { Highlight, type PrismTheme } from 'prism-react-renderer'
import { CopyButton } from '@/components/ui/CopyButton'
import { useTranslation } from '../../i18n'
import { useUIStore } from '../../stores/uiStore'
type Props = {
@@ -94,17 +95,17 @@ const diffStyles = {
},
diffContainer: {
borderRadius: '0',
fontSize: '12px',
lineHeight: '1.45',
fontSize: '13px',
lineHeight: '1.7',
fontFamily: 'var(--font-mono)',
},
line: {
padding: '1px 0',
padding: '0',
},
gutter: {
padding: '1px 8px',
minWidth: '40px',
fontSize: '11px',
padding: '0 10px',
minWidth: '44px',
fontSize: '12px',
},
wordDiff: {
padding: '1px 2px',
@@ -113,6 +114,7 @@ const diffStyles = {
}
export function DiffViewer({ filePath, oldString, newString }: Props) {
const t = useTranslation()
const theme = useUIStore((state) => state.theme)
const language = inferLanguage(filePath)
@@ -122,25 +124,25 @@ export function DiffViewer({ filePath, oldString, newString }: Props) {
const deletions = oldLines.filter((l, i) => l !== (newLines[i] ?? null)).length
return (
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-outline-variant)]/50 bg-[var(--color-surface-container-low)]">
{/* Header */}
<div className="flex items-center justify-between border-b border-[var(--color-outline-variant)]/40 bg-[var(--color-surface-container)] px-3 py-1.5">
<div className="min-w-0">
<div className="truncate font-[var(--font-mono)] text-[11px] text-[var(--color-text-tertiary)]">
{filePath}
</div>
<div className="mt-1 flex items-center gap-2 text-[10px] uppercase tracking-[0.14em]">
<span className="rounded-full bg-[var(--color-diff-added-bg)] px-2 py-0.5 text-[var(--color-diff-added-text)]">+{additions}</span>
<span className="rounded-full bg-[var(--color-diff-removed-bg)] px-2 py-0.5 text-[var(--color-diff-removed-text)]">-{deletions}</span>
</div>
</div>
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-code-bg)]">
{/* Header — mono path on the left, Copy path chip on the right */}
<div className="flex items-center gap-2.5 px-3.5 py-2.5">
<span className="min-w-0 flex-1 truncate font-mono text-[12.5px] text-[var(--color-text-secondary)]">
{filePath}
</span>
<CopyButton
text={`--- ${filePath}\n+++ ${filePath}`}
label="Copy path"
className="rounded-md border border-[var(--color-outline-variant)]/40 bg-[var(--color-surface-container-lowest)] px-2 py-1 text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-container-high)] hover:text-[var(--color-text-primary)]"
label={t('chat.copyPath')}
className="shrink-0 rounded-[8px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3 py-1 text-[12.5px] text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
/>
</div>
{/* Change counts */}
<div className="flex gap-2 px-3.5 pb-2 font-mono text-[12px] font-semibold">
<span className="rounded-[var(--radius-sm)] bg-[var(--color-diff-added-gutter)] px-2 py-0.5 text-[var(--color-diff-added-text)]">+{additions}</span>
<span className="rounded-[var(--radius-sm)] bg-[var(--color-diff-removed-gutter)] px-2 py-0.5 text-[var(--color-diff-removed-text)]">-{deletions}</span>
</div>
{/* Diff area */}
<div className="max-h-[400px] overflow-auto">
<ReactDiffViewer
@@ -216,7 +216,7 @@ export const FileSearchMenu = forwardRef<FileSearchMenuHandle, Props>(({ cwd, fi
<button
type="button"
onClick={() => selectEntry(entry)}
className={`flex min-w-0 flex-1 items-center rounded-lg px-2.5 text-left transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/40 ${
className={`flex min-w-0 flex-1 items-center rounded-[var(--radius-md)] px-2.5 text-left transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${
isSearchMode ? 'gap-2.5 py-2' : 'gap-3 py-2'
}`}
role="option"
@@ -228,7 +228,7 @@ export const FileSearchMenu = forwardRef<FileSearchMenuHandle, Props>(({ cwd, fi
<span className="min-w-0 flex-1">
{isSearchMode ? (
<span
className="block truncate font-[var(--font-mono)] text-sm text-[var(--color-text-primary)]"
className="block truncate font-mono text-sm text-[var(--color-text-primary)]"
title={displayPath}
>
{displayPath}
@@ -236,14 +236,14 @@ export const FileSearchMenu = forwardRef<FileSearchMenuHandle, Props>(({ cwd, fi
) : (
<>
<span className="block truncate text-sm font-medium text-[var(--color-text-primary)]">{entry.name}</span>
<span className="block truncate font-[var(--font-mono)] text-[11px] text-[var(--color-text-tertiary)]">
<span className="block truncate font-mono text-[11px] text-[var(--color-text-tertiary)]">
{parentPath || (entry.isDirectory ? t('fileSearch.directory') : t('fileSearch.currentDirectory'))}
</span>
</>
)}
</span>
{!isSearchMode ? (
<span className="shrink-0 rounded-md border border-[var(--color-border)] px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-[0.02em] text-[var(--color-text-tertiary)]">
<span className="shrink-0 rounded-[var(--radius-sm)] border border-[var(--color-border)] px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-[0.02em] text-[var(--color-text-tertiary)]">
{entry.isDirectory ? t('fileSearch.folderTag') : t('fileSearch.fileTag')}
</span>
) : null}
@@ -268,7 +268,7 @@ export const FileSearchMenu = forwardRef<FileSearchMenuHandle, Props>(({ cwd, fi
return (
<div
id="file-search-menu"
className={`absolute bottom-full mb-2 z-50 w-full overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)] ${
className={`absolute bottom-full mb-2 z-[var(--z-dropdown)] w-full overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-overlay)] ${
compact ? 'left-0 right-0 min-w-0 max-w-[calc(100vw-32px)]' : 'left-0 min-w-[480px]'
}`}
onMouseDown={(e) => e.stopPropagation()}
@@ -110,7 +110,7 @@ export function InlineImageGallery({ text, sessionId, workDir }: Props) {
key={img.src}
type="button"
onClick={() => setActiveIndex(i)}
className="group/image relative overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-left shadow-sm transition-all hover:shadow-md hover:border-[var(--color-brand)]/40"
className="group/image relative overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-left shadow-[var(--shadow-card)] transition-[border-color,box-shadow] duration-150 hover:shadow-[var(--shadow-composer)] hover:border-[var(--color-primary-fixed-dim)]"
>
<img
src={img.src}
@@ -19,9 +19,9 @@ export function InlineTaskSummary({ tasks }: { tasks: TaskSummaryItem[] }) {
const total = tasks.length
return (
<div className="mb-3 rounded-[var(--radius-lg)] border border-[var(--color-outline-variant)]/40 bg-[var(--color-surface-container-lowest)] overflow-hidden">
<div className="mb-3 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] overflow-hidden">
<div className="flex items-center gap-3 px-4 py-2 bg-[var(--color-surface-container)]">
<div className="flex items-center justify-center w-5 h-5 rounded-[var(--radius-md)] bg-[var(--color-success)]/10">
<div className="flex items-center justify-center w-5 h-5 rounded-[var(--radius-md)] bg-[var(--color-success-container)]">
<span className="material-symbols-outlined text-[13px] text-[var(--color-success)]" style={{ fontVariationSettings: "'FILL' 1" }}>
task_alt
</span>
@@ -67,7 +67,7 @@ function PanelShell({
}) {
const t = useTranslation()
return (
<div className="absolute bottom-full left-0 right-0 z-50 mb-3 overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)]">
<div className="absolute bottom-full left-0 right-0 z-[var(--z-dropdown)] mb-3 overflow-hidden rounded-[var(--radius-2xl)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-overlay)]">
<div className="flex items-start justify-between gap-4 border-b border-[var(--color-border)] px-5 py-4">
<div>
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">{title}</h3>
@@ -664,7 +664,7 @@ function SessionInspectorShell({
}) {
return (
<div
className="absolute bottom-full left-0 right-0 z-50 mb-4 overflow-hidden rounded-[10px] border border-[var(--color-inspector-border)] bg-[var(--color-inspector-surface)] text-[var(--color-inspector-text)] shadow-[var(--shadow-inspector)]"
className="absolute bottom-full left-0 right-0 z-[var(--z-dropdown)] mb-4 overflow-hidden rounded-[var(--radius-md)] border border-[var(--color-inspector-border)] bg-[var(--color-inspector-surface)] text-[var(--color-inspector-text)] shadow-[var(--shadow-inspector)]"
>
<div className="grid min-h-[64px] grid-cols-[1fr_auto_1fr] items-center border-b border-[var(--color-inspector-border)] bg-[var(--color-inspector-surface)] px-6">
<div className="font-mono text-[16px] font-semibold uppercase text-[var(--color-inspector-accent)]">{t('slash.inspector.title')}</div>
@@ -880,7 +880,7 @@ function McpPanel({ cwd, onClose }: { cwd?: string; onClose: () => void }) {
<div className="text-sm font-semibold text-[var(--color-text-primary)]">{scopeLabel(scope, t)}</div>
<div className="text-xs text-[var(--color-text-tertiary)]">{grouped.get(scope)?.length ?? 0}</div>
</div>
<div className="overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)]">
<div className="overflow-hidden rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)]">
{grouped.get(scope)?.map((server) => (
<button
type="button"
@@ -954,7 +954,7 @@ function SkillsPanel({ cwd, onClose }: { cwd?: string; onClose: () => void }) {
) : skills.length === 0 ? (
<EmptyState title={t('slash.skills.emptyTitle')} description={t('slash.skills.emptyBody')} />
) : (
<div className="overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)]">
<div className="overflow-hidden rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)]">
{skills.map((skill) => (
<button
type="button"
@@ -1047,7 +1047,7 @@ function HelpPanel({
return (
<section key={group.titleKey}>
<div className="mb-2 text-sm font-semibold text-[var(--color-text-primary)]">{t(group.titleKey)}</div>
<div className="overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)]">
<div className="overflow-hidden rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)]">
{entries.map(renderCommand)}
</div>
</section>
@@ -1057,7 +1057,7 @@ function HelpPanel({
{otherCommands.length > 0 && (
<section>
<div className="mb-2 text-sm font-semibold text-[var(--color-text-primary)]">{t('slash.help.group.more')}</div>
<div className="overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)]">
<div className="overflow-hidden rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)]">
{otherCommands.map(renderCommand)}
</div>
{hiddenOtherCommandCount > 0 && (
+17 -14
View File
@@ -7,7 +7,7 @@ import { Modal } from '@/components/ui/Modal'
import { CopyButton } from '@/components/ui/CopyButton'
import { useUIStore } from '../../stores/uiStore'
import { useTranslation } from '../../i18n'
import type { ThemeMode } from '../../types/settings'
import { isDarkTheme, type ThemeMode } from '../../types/settings'
type Props = {
code: string
@@ -200,14 +200,17 @@ function resolveThemeColor(token: string, fallback: string) {
}
function getMermaidThemeColors(theme: ThemeMode): MermaidThemeColors {
const isDark = theme === 'dark'
const isDark = isDarkTheme(theme)
// The fallbacks only apply when the probe cannot resolve a token (jsdom, or
// a diagram rendered before first paint); the live values come from
// `data-theme`, so all six palettes are covered by the six token reads.
return {
textColor: resolveThemeColor('--color-text-primary', isDark ? '#E5E2E1' : '#1B1C1A'),
mutedTextColor: resolveThemeColor('--color-text-secondary', isDark ? '#B7AAA5' : '#61514B'),
surfaceColor: resolveThemeColor('--color-surface-container-lowest', isDark ? '#0E0E0E' : '#FFFFFF'),
nodeColor: resolveThemeColor('--color-surface-container-low', isDark ? '#1C1B1B' : '#F4EFEA'),
accentColor: resolveThemeColor('--color-primary', isDark ? '#FFB59F' : '#8F482F'),
lineColor: resolveThemeColor('--color-outline', isDark ? '#BFAEAA' : '#667485'),
textColor: resolveThemeColor('--color-text-primary', isDark ? '#EDE6D6' : '#1F1C17'),
mutedTextColor: resolveThemeColor('--color-text-secondary', isDark ? '#A79B83' : '#5D5850'),
surfaceColor: resolveThemeColor('--color-surface-container-lowest', isDark ? '#201D17' : '#FFFFFF'),
nodeColor: resolveThemeColor('--color-surface-container-low', isDark ? '#1A1813' : '#F6F6F3'),
accentColor: resolveThemeColor('--color-primary', isDark ? '#D07B52' : '#96442B'),
lineColor: resolveThemeColor('--color-outline', isDark ? '#4C4433' : '#D8D5CC'),
isDark,
}
}
@@ -656,12 +659,12 @@ export function MermaidRenderer({ code }: Props) {
if (error) {
return (
<div className="my-4 overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-error)]/30">
<div className="flex items-center gap-2 border-b border-[var(--color-error)]/20 bg-[var(--color-error-container)] px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.14em] text-[var(--color-error)]">
<div className="my-4 overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-error-soft-hover)]">
<div className="flex items-center gap-2 border-b border-[var(--color-error-soft-hover)] bg-[var(--color-error-container)] px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.14em] text-[var(--color-on-error-container)]">
<span className="material-symbols-outlined text-[14px]">error</span>
Mermaid Error
</div>
<div className="bg-[var(--color-error-container)]/30 px-3 py-2 font-[var(--font-mono)] text-[11px] text-[var(--color-error)]">
<div className="bg-[var(--color-error-soft)] px-3 py-2 font-mono text-[11px] text-[var(--color-on-error-container)]">
{error}
</div>
</div>
@@ -670,7 +673,7 @@ export function MermaidRenderer({ code }: Props) {
if (!svg) {
return (
<div className="my-4 flex items-center justify-center rounded-[var(--radius-lg)] border border-[var(--color-border)]/50 bg-[var(--color-surface-container-low)] py-8">
<div className="my-4 flex items-center justify-center rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] py-8">
<div className="flex items-center gap-2 text-[11px] text-[var(--color-text-tertiary)]">
<span className="material-symbols-outlined animate-spin text-[16px]">progress_activity</span>
Rendering diagram...
@@ -681,9 +684,9 @@ export function MermaidRenderer({ code }: Props) {
return (
<>
<div className="my-4 overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-outline-variant)]/50 bg-[var(--color-surface-container-low)]">
<div className="my-4 overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)]">
{/* Header */}
<div className="flex items-center justify-between border-b border-[var(--color-outline-variant)]/40 bg-[var(--color-surface-container)] px-3 py-1.5 text-[11px] text-[var(--color-text-tertiary)]">
<div className="flex items-center justify-between border-b border-[var(--color-border)] bg-[var(--color-surface-container)] px-3 py-1.5 text-[11px] text-[var(--color-text-tertiary)]">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[14px]">account_tree</span>
<span className="font-semibold uppercase tracking-[0.14em]">Mermaid</span>
+21 -19
View File
@@ -166,7 +166,7 @@ function ChatSelectionMenu({
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={onAdd}
className="fixed z-50 inline-flex h-11 items-center gap-2 rounded-full border border-[var(--color-border)]/70 bg-[var(--color-surface-container-lowest)] px-5 text-[15px] font-semibold text-[var(--color-text-primary)] shadow-[0_10px_28px_rgba(15,23,42,0.14),0_2px_8px_rgba(15,23,42,0.08)] transition-colors hover:bg-[var(--color-surface)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
className="fixed z-[var(--z-popover)] inline-flex h-11 items-center gap-2 rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-5 text-[15px] font-semibold text-[var(--color-text-primary)] shadow-[var(--shadow-overlay)] transition-colors hover:bg-[var(--color-surface)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
style={{ left: selection.x, top: selection.y }}
>
<MessageCircle size={21} strokeWidth={2.15} className="shrink-0 text-[var(--color-text-primary)]" aria-hidden="true" />
@@ -214,7 +214,7 @@ function CompactStatusDivider({ message, state }: { message?: CompactSummaryEven
aria-expanded={hasDetails ? expanded : undefined}
onClick={() => hasDetails && setExpanded((value) => !value)}
disabled={!hasDetails}
className="group inline-flex min-h-8 max-w-[min(78vw,520px)] items-center gap-2 rounded-md px-2.5 py-1 text-[13px] font-semibold text-[var(--color-text-secondary)] transition-colors hover:text-[var(--color-text-primary)] disabled:cursor-default disabled:hover:text-[var(--color-text-secondary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/30"
className="group inline-flex min-h-8 max-w-[min(78vw,520px)] items-center gap-2 rounded-[var(--radius-md)] px-2.5 py-1 text-[13px] font-semibold text-[var(--color-text-secondary)] transition-colors hover:text-[var(--color-text-primary)] disabled:cursor-default disabled:hover:text-[var(--color-text-secondary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
>
{state === 'compacting' ? (
<LoaderCircle size={16} strokeWidth={2.1} className="shrink-0 animate-spin text-[var(--color-text-tertiary)]" aria-hidden="true" />
@@ -228,7 +228,7 @@ function CompactStatusDivider({ message, state }: { message?: CompactSummaryEven
<div className="h-px flex-1 bg-[var(--color-border)]" aria-hidden="true" />
</div>
{hasDetails && expanded && (
<div className="mx-auto mt-1.5 w-full max-w-[620px] rounded-md border border-[var(--color-border)]/65 bg-[var(--color-surface-container-lowest)] px-3 py-2">
<div className="mx-auto mt-1.5 w-full max-w-[620px] rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3 py-2">
{meta.length > 0 && (
<div className="mb-1.5 flex flex-wrap gap-x-2 gap-y-1 text-[11px] font-medium text-[var(--color-text-tertiary)]">
{meta.map((item) => <span key={item}>{item}</span>)}
@@ -260,12 +260,12 @@ function GoalEventCard({ message }: { message: GoalEvent }) {
<div className="mb-2">
<div
data-testid="goal-event-card"
className="overflow-hidden rounded-lg border border-[var(--color-memory-border)] bg-[var(--color-memory-surface)]"
className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-memory-border)] bg-[var(--color-memory-surface)]"
>
<button
type="button"
onClick={() => setExpanded((value) => !value)}
className="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-[var(--color-surface-hover)]/50"
className="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-[var(--color-surface-hover)]"
>
{expanded ? (
<ChevronDown size={15} className="shrink-0 text-[var(--color-text-tertiary)]" aria-hidden="true" />
@@ -285,14 +285,14 @@ function GoalEventCard({ message }: { message: GoalEvent }) {
</button>
{expanded ? (
<div className="border-t border-[var(--color-border)]/55 px-3 py-2.5">
<div className="border-t border-[var(--color-border)] px-3 py-2.5">
<div className="space-y-1.5">
{message.objective ? (
<div className="line-clamp-2 rounded-md px-2 py-1 text-[12px] leading-5 text-[var(--color-text-secondary)]">
<div className="line-clamp-2 rounded-[var(--radius-md)] px-2 py-1 text-[12px] leading-5 text-[var(--color-text-secondary)]">
{t('chat.goalEvent.objective', { value: message.objective })}
</div>
) : message.message ? (
<div className="whitespace-pre-wrap rounded-md px-2 py-1 text-[12px] leading-5 text-[var(--color-text-secondary)]">
<div className="whitespace-pre-wrap rounded-[var(--radius-md)] px-2 py-1 text-[12px] leading-5 text-[var(--color-text-secondary)]">
{message.message}
</div>
) : null}
@@ -324,7 +324,7 @@ function GoalContinuationDivider({ message }: { message: GoalEvent }) {
<section data-testid="goal-continuation-divider" className="my-4 w-full px-1">
<div className="flex w-full items-center gap-3">
<div className="h-px flex-1 bg-[var(--color-border)]" aria-hidden="true" />
<div className="inline-flex min-h-8 max-w-[min(78vw,620px)] items-center gap-2 rounded-md px-2.5 py-1 text-[13px] font-medium text-[var(--color-text-secondary)]">
<div className="inline-flex min-h-8 max-w-[min(78vw,620px)] items-center gap-2 rounded-[var(--radius-md)] px-2.5 py-1 text-[13px] font-medium text-[var(--color-text-secondary)]">
<Target size={16} strokeWidth={2.1} className="shrink-0 text-[var(--color-memory-accent)]" aria-hidden="true" />
<span className="shrink-0 font-semibold text-[var(--color-text-primary)]">
{t('chat.goalEvent.continuing')}
@@ -356,7 +356,7 @@ function BackgroundTaskEventCard({ message }: { message: BackgroundTaskEvent })
<div
data-testid="background-task-event-card"
data-status={task.status}
className="flex min-w-0 items-start gap-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2"
className="flex min-w-0 items-start gap-2 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2"
>
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center">
{isRunning ? (
@@ -890,9 +890,9 @@ function MemoryEventCard({ message }: { message: MemoryEvent }) {
return (
<div className="mb-3 flex justify-center px-3">
<div className="w-full max-w-2xl rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3.5 py-3 text-xs shadow-sm">
<div className="w-full max-w-2xl rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3.5 py-3 text-xs shadow-[var(--shadow-card)]">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-brand)]">
<div className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-brand)]">
<BookMarked size={15} aria-hidden="true" />
</div>
<div className="min-w-0 flex-1">
@@ -917,13 +917,13 @@ function MemoryEventCard({ message }: { message: MemoryEvent }) {
<span
key={file.path}
title={file.path}
className="max-w-full truncate rounded-sm border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1 font-mono text-[10px] text-[var(--color-text-secondary)]"
className="max-w-full truncate rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1 font-mono text-[10px] text-[var(--color-text-secondary)]"
>
{memoryFileLabel(file.path)}
</span>
))}
{hiddenCount > 0 ? (
<span className="rounded-sm border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1 font-mono text-[10px] text-[var(--color-text-tertiary)]">
<span className="rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1 font-mono text-[10px] text-[var(--color-text-tertiary)]">
{t('chat.memoryMoreFiles', { count: hiddenCount })}
</span>
) : null}
@@ -2628,7 +2628,7 @@ export function MessageList({ sessionId, compact = false, mobileLayout = false }
>
<div
ref={scrollContentRef}
className={compact ? 'mx-auto max-w-full' : 'mx-auto max-w-[860px]'}
className={compact ? 'mx-auto max-w-full' : 'mx-auto max-w-[900px]'}
>
{virtualTranscriptWindow.enabled ? (
<VirtualSpacer height={virtualTranscriptWindow.beforeHeight} position="top" />
@@ -2684,7 +2684,7 @@ export function MessageList({ sessionId, compact = false, mobileLayout = false }
)}
{!isLoadingTurnChangeCards && visibleTurnChangeCards.length === 0 && turnChangeLoadError && (
<div className="mx-auto mb-5 w-full max-w-[860px] rounded-[var(--radius-lg)] border border-[var(--color-error)]/25 bg-[var(--color-error-container)]/18 px-4 py-3 text-xs text-[var(--color-error)]">
<div className="mx-auto mb-5 w-full max-w-[900px] rounded-[var(--radius-lg)] border border-[var(--color-error)] bg-[var(--color-error-container)] px-4 py-3 text-xs text-[var(--color-on-error-container)]">
{turnChangeLoadError}
</div>
)}
@@ -2709,7 +2709,9 @@ export function MessageList({ sessionId, compact = false, mobileLayout = false }
onClick={handleJumpToLatest}
title={t('chat.jumpToLatest')}
aria-label={t('chat.jumpToLatest')}
className="absolute bottom-4 right-5 z-20 rounded-full shadow-[var(--shadow-dropdown)]"
// `glass-panel` is unlayered CSS, so it wins over the variant's
// layered background/border utilities without a tailwind-merge.
className="glass-panel absolute bottom-4 right-5 z-20 rounded-full text-[13.5px] font-medium hover:-translate-y-px motion-reduce:hover:translate-y-0"
icon={<ArrowDown size={15} aria-hidden="true" />}
>
{t('chat.jumpToLatest')}
@@ -2863,10 +2865,10 @@ export const MessageBlock = memo(function MessageBlock({
message.message.trim() !== '' &&
message.message !== displayMessage
return (
<div className="mb-3 px-4 py-2.5 rounded-lg border border-[var(--color-error)]/20 bg-[var(--color-error-container)]/28 text-sm text-[var(--color-error)]">
<div className="mb-3 px-4 py-2.5 rounded-[var(--radius-lg)] border border-[var(--color-error)] bg-[var(--color-error-container)] text-sm text-[var(--color-on-error-container)]">
<strong>{t('common.error')}:</strong> {displayMessage}
{showRawDetail && (
<div className="mt-1 whitespace-pre-wrap text-xs text-[var(--color-on-error-container)]/85">
<div className="mt-1 whitespace-pre-wrap text-xs text-[var(--color-on-error-container)]">
{message.message}
</div>
)}
@@ -109,7 +109,7 @@ function renderPermissionPreview(toolName: string, input: unknown) {
if (toolName === 'Bash' && typeof obj.command === 'string') {
return (
<div className="overflow-x-auto rounded-[var(--radius-md)] bg-[var(--color-terminal-bg)] px-3 py-2.5">
<pre className="font-[var(--font-mono)] text-[11px] leading-[1.3] text-[var(--color-terminal-fg)] whitespace-pre-wrap break-words">
<pre className="font-mono text-[11px] leading-[1.3] text-[var(--color-terminal-fg)] whitespace-pre-wrap break-words">
<span className="text-[var(--color-terminal-accent)] select-none">$ </span>{obj.command}
</pre>
</div>
@@ -157,7 +157,7 @@ export function PermissionDialog({ sessionId, requestId, toolName, input, descri
className={`mb-4 overflow-hidden rounded-[var(--radius-lg)] border ${
isPending
? 'border-[var(--color-warning)] bg-[var(--color-surface-container-lowest)]'
: 'border-[var(--color-outline-variant)]/40 bg-[var(--color-surface-container-low)] opacity-70'
: 'border-[var(--color-border)] bg-[var(--color-surface-container-low)] opacity-70'
}`}
>
{/* Header */}
@@ -205,11 +205,11 @@ export function PermissionDialog({ sessionId, requestId, toolName, input, descri
</div>
{/* Tool details */}
<div className="border-t border-[var(--color-outline-variant)]/20 px-4 py-3">
<div className="border-t border-[var(--color-border)] px-4 py-3">
{preview ? (
<div className="space-y-2">
{details.primary && toolName !== 'Bash' ? (
<div className="flex items-center gap-2 rounded-[var(--radius-md)] bg-[var(--color-surface-container)] px-3 py-2 text-xs font-[var(--font-mono)] text-[var(--color-text-secondary)]">
<div className="flex items-center gap-2 rounded-[var(--radius-md)] bg-[var(--color-surface-container)] px-3 py-2 text-xs font-mono text-[var(--color-text-secondary)]">
<span aria-hidden="true" className="material-symbols-outlined text-[14px] text-[var(--color-outline)] flex-shrink-0">
folder_open
</span>
@@ -220,7 +220,7 @@ export function PermissionDialog({ sessionId, requestId, toolName, input, descri
</div>
) : details.primary ? (
<div className="mb-2">
<div className="flex items-center gap-2 rounded-[var(--radius-md)] bg-[var(--color-surface-container)] px-3 py-2 text-xs font-[var(--font-mono)] text-[var(--color-text-secondary)]">
<div className="flex items-center gap-2 rounded-[var(--radius-md)] bg-[var(--color-surface-container)] px-3 py-2 text-xs font-mono text-[var(--color-text-secondary)]">
<span aria-hidden="true" className="material-symbols-outlined text-[14px] text-[var(--color-outline)] flex-shrink-0">
{toolName === 'Glob' || toolName === 'Grep' ? 'search' : 'folder_open'}
</span>
@@ -251,7 +251,7 @@ export function PermissionDialog({ sessionId, requestId, toolName, input, descri
)}
{allowRawToggle && showRaw && (
<pre className="mt-2 max-h-[220px] overflow-y-auto overflow-x-auto rounded-[var(--radius-md)] bg-[var(--color-terminal-bg)] px-3 py-2.5 font-[var(--font-mono)] text-[11px] leading-[1.3] text-[var(--color-terminal-fg)] whitespace-pre-wrap break-words">
<pre className="mt-2 max-h-[220px] overflow-y-auto overflow-x-auto rounded-[var(--radius-md)] bg-[var(--color-terminal-bg)] px-3 py-2.5 font-mono text-[11px] leading-[1.3] text-[var(--color-terminal-fg)] whitespace-pre-wrap break-words">
{rawInput}
</pre>
)}
@@ -259,7 +259,7 @@ export function PermissionDialog({ sessionId, requestId, toolName, input, descri
{/* Action buttons */}
{isPending && (
<div className="flex items-center gap-2 border-t border-[var(--color-outline-variant)]/20 bg-[var(--color-surface-container-low)] px-4 py-3">
<div className="flex items-center gap-2 border-t border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3">
<Button
variant="primary"
size="sm"
@@ -323,15 +323,15 @@ function ExitPlanModePermissionDialog({
return (
<div className={`mb-4 overflow-hidden rounded-[var(--radius-lg)] border ${
isPending
? 'border-[var(--color-brand)]/60 bg-[var(--color-surface-container-lowest)]'
: 'border-[var(--color-outline-variant)]/40 bg-[var(--color-surface-container-low)] opacity-70'
? 'border-[var(--color-primary-fixed-dim)] bg-[var(--color-surface-container-lowest)]'
: 'border-[var(--color-border)] bg-[var(--color-surface-container-low)] opacity-70'
}`}>
<div className={`flex items-center gap-3 px-4 py-3 ${
isPending
? 'bg-[var(--color-surface-container)]'
: 'bg-[var(--color-surface-container-low)]'
}`}>
<div className="flex h-8 w-8 items-center justify-center rounded-[var(--radius-md)] bg-[var(--color-brand)]/15">
<div className="flex h-8 w-8 items-center justify-center rounded-[var(--radius-md)] bg-[var(--color-brand-soft)]">
<span className="material-symbols-outlined text-[18px] text-[var(--color-brand)]">architecture</span>
</div>
<div className="min-w-0 flex-1">
@@ -359,7 +359,7 @@ function ExitPlanModePermissionDialog({
</div>
</div>
<div className="space-y-3 border-t border-[var(--color-outline-variant)]/20 px-4 py-3">
<div className="space-y-3 border-t border-[var(--color-border)] px-4 py-3">
<PlanPreviewCard
title={t('permission.planPreviewTitle')}
plan={preview.plan}
@@ -374,13 +374,13 @@ function ExitPlanModePermissionDialog({
onChange={(event) => setFeedback(event.target.value)}
placeholder={t('permission.planFeedbackPlaceholder')}
rows={3}
className="min-h-[72px] w-full resize-y rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-sm text-[var(--color-text-primary)] outline-none transition-colors placeholder:text-[var(--color-text-tertiary)] focus:border-[var(--color-brand)]/60 focus:ring-2 focus:ring-[var(--color-brand)]/15"
className="min-h-[72px] w-full resize-y rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-sm text-[var(--color-text-primary)] outline-none transition-colors placeholder:text-[var(--color-text-tertiary)] focus:border-[var(--color-border-focus)] focus:shadow-[var(--shadow-focus-ring)]"
/>
) : null}
</div>
{isPending ? (
<div className="flex items-center gap-2 border-t border-[var(--color-outline-variant)]/20 bg-[var(--color-surface-container-low)] px-4 py-3">
<div className="flex items-center gap-2 border-t border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3">
<Button
variant="primary"
size="sm"
@@ -45,14 +45,14 @@ export function PlanPreviewCard({
return (
<div data-testid="plan-preview-card" className="overflow-hidden rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)]">
<div className="flex items-start gap-2 border-b border-[var(--color-border)]/65 bg-[var(--color-surface-container-low)] px-3 py-2.5">
<div className="flex items-start gap-2 border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2.5">
<FileText size={15} strokeWidth={2.1} className="mt-0.5 shrink-0 text-[var(--color-brand)]" aria-hidden="true" />
<div className="min-w-0 flex-1">
<div className="text-[12px] font-semibold text-[var(--color-text-primary)]">
{title}
</div>
{filePath ? (
<div className="mt-0.5 truncate font-[var(--font-mono)] text-[11px] text-[var(--color-text-tertiary)]">
<div className="mt-0.5 truncate font-mono text-[11px] text-[var(--color-text-tertiary)]">
{filePath}
</div>
) : null}
@@ -68,7 +68,7 @@ export function PlanPreviewCard({
</div>
{allowedPrompts.length > 0 && requestedPermissionsTitle ? (
<div className="border-t border-[var(--color-border)]/65 bg-[var(--color-surface-container-low)] px-3 py-2.5">
<div className="border-t border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2.5">
<div className="mb-1.5 flex items-center gap-2 text-[11px] font-semibold uppercase text-[var(--color-outline)]">
<ShieldCheck size={13} strokeWidth={2.1} aria-hidden="true" />
{requestedPermissionsTitle}
@@ -77,9 +77,9 @@ export function PlanPreviewCard({
{allowedPrompts.map((prompt, index) => (
<div
key={`${prompt.tool}-${prompt.prompt}-${index}`}
className="rounded-md border border-[var(--color-border)]/70 bg-[var(--color-surface)] px-2.5 py-1.5 text-[11px] text-[var(--color-text-secondary)]"
className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-1.5 text-[11px] text-[var(--color-text-secondary)]"
>
<span className="font-[var(--font-mono)] font-semibold text-[var(--color-text-primary)]">
<span className="font-mono font-semibold text-[var(--color-text-primary)]">
{prompt.tool}
</span>
<span className="text-[var(--color-text-tertiary)]"> · </span>
@@ -35,10 +35,26 @@ type Props = {
const BRANCH_MENU_HEIGHT = 360
const BRANCH_MENU_WIDTH = 390
const WORKTREE_MENU_HEIGHT = 126
const WORKTREE_MENU_WIDTH = 226
const WORKTREE_MENU_HEIGHT = 230
const WORKTREE_MENU_WIDTH = 400
const VIEWPORT_GUTTER = 12
/**
* Decorative dot for the worktree cards. The cards are `role="option"` and
* already carry `aria-selected`, so this is hidden from the accessibility tree
* rather than announced a second time.
*/
function WorktreeRadio({ selected }: { selected: boolean }) {
return (
<span
aria-hidden="true"
className="mt-px flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-full border-[1.5px] border-[var(--color-outline)]"
>
{selected && <span className="h-[9px] w-[9px] rounded-full bg-[var(--color-brand)]" />}
</span>
)
}
function stateMessage(context: RepositoryContextResult | null, error: string | null) {
if (error) return error
if (!context) return null
@@ -81,6 +97,11 @@ export function RepositoryLaunchControls({
const searchInputId = useId()
const listboxId = useId()
const worktreeListboxId = useId()
// The cards show a title and a sentence of description. Pointing the
// accessible name at the title span keeps the option announced as
// "Isolated worktree" instead of the whole paragraph (components/AGENTS.md §6).
const worktreeCurrentLabelId = useId()
const worktreeIsolatedLabelId = useId()
const updateMenuPos = useCallback(() => {
if (!branchButtonRef.current) return
@@ -280,11 +301,16 @@ export function RepositoryLaunchControls({
}, [isLaunchReady, onLaunchReadyChange])
const worktreeLabel = useWorktree ? t('repoLaunch.worktreeIsolated') : t('repoLaunch.worktreeCurrent')
const workbarButtonClassName = 'group inline-flex h-9 min-w-0 items-center gap-1.5 rounded-[7px] border border-transparent px-2.5 text-[13px] font-medium leading-none text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-container-lowest)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35 disabled:cursor-not-allowed disabled:opacity-50'
// The worktree cards name the branch their changes would land on.
const branchLabel = selectedBranch?.name ?? context?.currentBranch ?? t('repoLaunch.noBranch')
// Outlined pill, per the handoff's launch row: 1px border at rest that firms
// up to `--color-outline` on hover. The focus ring uses the opaque focus
// token rather than `--color-brand/35`, whose `/N` modifier Safari 15 drops.
const workbarButtonClassName = 'group inline-flex h-9 min-w-0 items-center gap-2 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3.5 text-[13.5px] font-medium leading-none text-[var(--color-text-primary)] transition-[background-color,color,border-color] duration-150 ease-out hover:border-[var(--color-outline)] hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface)] disabled:cursor-not-allowed disabled:opacity-50'
const branchMenuClassName = isMobileBrowser
? 'max-h-[72dvh] overflow-hidden rounded-t-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[0_-18px_48px_rgba(54,35,28,0.2)]'
: 'w-[390px] overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)]'
: 'w-[390px] overflow-hidden rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-overlay)]'
const branchMenuStyle = isMobileBrowser
? {
position: 'fixed' as const,
@@ -303,7 +329,19 @@ export function RepositoryLaunchControls({
}
const worktreeMenuClassName = isMobileBrowser
? 'overflow-hidden rounded-t-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-2 shadow-[0_-18px_48px_rgba(54,35,28,0.2)]'
: 'w-[226px] overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-1 shadow-[var(--shadow-dropdown)]'
// Literal, not interpolated from WORKTREE_MENU_WIDTH: Tailwind extracts
// class names statically and never emits a rule for a computed one.
: 'w-[400px] overflow-hidden rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] p-2.5 shadow-[var(--shadow-overlay)]'
// The two modes are a single choice, so they read as radio cards rather than
// a menu list: 1.5px edge that turns terracotta on the selected one.
const worktreeCardClassName = (selected: boolean) => [
'flex w-full items-start gap-3 rounded-[var(--radius-lg)] border-[1.5px] px-[15px] py-3 text-left',
'transition-[background-color,border-color] duration-150 ease-out',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-border-focus)]',
selected
? 'border-[var(--color-primary-fixed-dim)] bg-[var(--color-brand-soft)]'
: 'border-[var(--color-border)] bg-[var(--color-surface)] hover:border-[var(--color-outline)] hover:bg-[var(--color-surface-hover)]',
].join(' ')
const worktreeMenuStyle = isMobileBrowser
? {
position: 'fixed' as const,
@@ -328,7 +366,7 @@ export function RepositoryLaunchControls({
? 'min-h-[52px] flex-wrap rounded-none bg-[var(--color-surface-container-lowest)] px-3 py-2 shadow-none'
: isComposerPlacement
? 'min-h-[44px] flex-nowrap bg-transparent px-4 py-2'
: 'min-h-[48px] flex-nowrap rounded-b-xl bg-[var(--color-surface-container-low)] px-4 py-2 shadow-[inset_0_1px_0_rgba(255,255,255,0.45)]'
: 'min-h-[48px] flex-nowrap rounded-b-[var(--radius-xl)] bg-[var(--color-surface-container-low)] px-4 py-2'
}`}>
<DirectoryPicker value={workDir} onChange={onWorkDirChange} variant="workbar" isGitProject={isGitReady} />
@@ -355,13 +393,13 @@ export function RepositoryLaunchControls({
setWorktreeMenuOpen(false)
setBranchFilter('')
}}
className={`${workbarButtonClassName} ${isMobileBrowser ? 'max-w-[160px] shrink-0 bg-[var(--color-surface-container)]' : 'max-w-[260px] shrink'}`}
className={`${workbarButtonClassName} ${isMobileBrowser ? 'max-w-[160px] shrink-0' : 'max-w-[260px] shrink'}`}
>
<GitBranch size={17} className="shrink-0 text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-secondary)]" />
<span className="min-w-0 flex-1 truncate text-[var(--color-text-primary)]">
<GitBranch size={15} className="shrink-0 text-[var(--color-text-secondary)]" />
<span className="min-w-0 flex-1 truncate">
{selectedBranch?.name || t('repoLaunch.noBranch')}
</span>
<ChevronDown size={16} className="shrink-0 text-[var(--color-text-tertiary)]" />
<ChevronDown size={13} className="shrink-0 text-[var(--color-text-tertiary)]" />
</button>
<button
@@ -377,17 +415,17 @@ export function RepositoryLaunchControls({
setWorktreeMenuOpen((prev) => !prev)
setBranchMenuOpen(false)
}}
className={`${workbarButtonClassName} shrink-0 ${isMobileBrowser ? 'bg-[var(--color-surface-container)]' : ''} ${
className={`${workbarButtonClassName} shrink-0 ${
useWorktree
? 'bg-[var(--color-surface-container-lowest)] text-[var(--color-text-primary)]'
? 'border-[var(--color-primary-fixed-dim)] bg-[var(--color-brand-soft)] font-semibold text-[var(--color-on-brand-soft)] hover:border-[var(--color-primary-fixed-dim)] hover:bg-[var(--color-brand-soft-hover)]'
: ''
}`}
>
<GitFork size={17} className="shrink-0 text-[var(--color-text-tertiary)]" />
<GitFork size={15} className={`shrink-0 ${useWorktree ? 'text-[var(--color-on-brand-soft)]' : 'text-[var(--color-text-secondary)]'}`} />
<span className="min-w-0 truncate">
{worktreeLabel}
</span>
<ChevronDown size={16} className="shrink-0 text-[var(--color-text-tertiary)]" />
<ChevronDown size={13} className={`shrink-0 ${useWorktree ? 'text-[var(--color-on-brand-soft)]' : 'text-[var(--color-text-tertiary)]'}`} />
</button>
</>
)}
@@ -420,7 +458,7 @@ export function RepositoryLaunchControls({
closeLabel={t('tabs.close')}
panelRef={menuRef}
headerExtra={(
<div className="flex items-center gap-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2">
<div className="flex items-center gap-2 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2">
<Search size={15} className="shrink-0 text-[var(--color-text-tertiary)]" />
<input
id={searchInputId}
@@ -453,7 +491,7 @@ export function RepositoryLaunchControls({
aria-selected={isSelected}
onMouseEnter={() => setSelectedIndex(index)}
onClick={() => selectBranch(candidate)}
className={`flex min-h-[56px] w-full items-center gap-3 px-4 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-brand)]/35 ${
className={`flex min-h-[56px] w-full items-center gap-3 px-4 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-border-focus)] ${
index === selectedIndex || isSelected ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'
}`}
>
@@ -486,10 +524,10 @@ export function RepositoryLaunchControls({
style={branchMenuStyle}
>
<div className="border-b border-[var(--color-border)] p-3">
<label htmlFor={searchInputId} className="mb-2 block text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)]">
<label htmlFor={searchInputId} className="mb-2 block text-[10px] font-bold uppercase tracking-widest text-[var(--color-text-tertiary)]">
{t('repoLaunch.selectBranch')}
</label>
<div className="flex items-center gap-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2">
<div className="flex items-center gap-2 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2">
<Search size={15} className="shrink-0 text-[var(--color-text-tertiary)]" />
<input
id={searchInputId}
@@ -522,7 +560,7 @@ export function RepositoryLaunchControls({
aria-selected={isSelected}
onMouseEnter={() => setSelectedIndex(index)}
onClick={() => selectBranch(candidate)}
className={`flex w-full items-center gap-3 px-4 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-brand)]/35 ${
className={`flex w-full items-center gap-3 px-4 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-border-focus)] ${
index === selectedIndex || isSelected ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'
}`}
>
@@ -562,41 +600,43 @@ export function RepositoryLaunchControls({
panelRef={worktreeMenuRef}
contentClassName="py-2"
>
<div id={worktreeListboxId} role="listbox" aria-label={t('repoLaunch.selectWorktree')}>
<div id={worktreeListboxId} role="listbox" aria-label={t('repoLaunch.selectWorktree')} className="flex flex-col gap-2 px-4 py-2">
<button
type="button"
role="option"
aria-selected={!useWorktree}
aria-labelledby={worktreeCurrentLabelId}
onClick={() => selectWorktreeMode(false)}
className={`flex min-h-[52px] w-full items-center gap-2.5 px-4 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-brand)]/35 disabled:cursor-not-allowed disabled:opacity-45 ${
!useWorktree ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'
}`}
className={`${worktreeCardClassName(!useWorktree)} min-h-[52px] disabled:cursor-not-allowed disabled:opacity-45`}
>
<GitFork size={16} className="shrink-0 text-[var(--color-text-tertiary)]" />
<WorktreeRadio selected={!useWorktree} />
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-medium text-[var(--color-text-primary)]">
<span id={worktreeCurrentLabelId} className="block text-[14.5px] font-bold text-[var(--color-text-primary)]">
{t('repoLaunch.worktreeCurrent')}
</span>
<span className="mt-[3px] block text-[12.5px] leading-[1.6] text-[var(--color-text-secondary)]">
{t('repoLaunch.worktreeCurrentDesc', { branch: branchLabel })}
</span>
</span>
{!useWorktree && <Check size={16} className="shrink-0 text-[var(--color-brand)]" />}
</button>
<button
type="button"
role="option"
aria-selected={useWorktree}
aria-labelledby={worktreeIsolatedLabelId}
onClick={() => selectWorktreeMode(true)}
className={`flex min-h-[52px] w-full items-center gap-2.5 px-4 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-brand)]/35 ${
useWorktree ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'
}`}
className={`${worktreeCardClassName(useWorktree)} min-h-[52px]`}
>
<GitFork size={16} className="shrink-0 text-[var(--color-text-tertiary)]" />
<WorktreeRadio selected={useWorktree} />
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-medium text-[var(--color-text-primary)]">
<span id={worktreeIsolatedLabelId} className="block text-[14.5px] font-bold text-[var(--color-text-primary)]">
{t('repoLaunch.worktreeIsolated')}
</span>
<span className="mt-[3px] block text-[12.5px] leading-[1.6] text-[var(--color-text-secondary)]">
{t('repoLaunch.worktreeIsolatedDesc', { branch: branchLabel })}
</span>
</span>
{useWorktree && <Check size={16} className="shrink-0 text-[var(--color-brand)]" />}
</button>
</div>
</MobileBottomSheet>
@@ -606,41 +646,43 @@ export function RepositoryLaunchControls({
className={worktreeMenuClassName}
style={worktreeMenuStyle}
>
<div id={worktreeListboxId} role="listbox" aria-label={t('repoLaunch.selectWorktree')}>
<div id={worktreeListboxId} role="listbox" aria-label={t('repoLaunch.selectWorktree')} className="flex flex-col gap-2">
<button
type="button"
role="option"
aria-selected={!useWorktree}
aria-labelledby={worktreeCurrentLabelId}
onClick={() => selectWorktreeMode(false)}
className={`flex w-full items-center gap-2.5 px-3 py-2.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-brand)]/35 disabled:cursor-not-allowed disabled:opacity-45 ${
!useWorktree ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'
}`}
className={`${worktreeCardClassName(!useWorktree)} disabled:cursor-not-allowed disabled:opacity-45`}
>
<GitFork size={16} className="shrink-0 text-[var(--color-text-tertiary)]" />
<WorktreeRadio selected={!useWorktree} />
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-medium text-[var(--color-text-primary)]">
<span id={worktreeCurrentLabelId} className="block text-[14.5px] font-bold text-[var(--color-text-primary)]">
{t('repoLaunch.worktreeCurrent')}
</span>
<span className="mt-[3px] block text-[12.5px] leading-[1.6] text-[var(--color-text-secondary)]">
{t('repoLaunch.worktreeCurrentDesc', { branch: branchLabel })}
</span>
</span>
{!useWorktree && <Check size={16} className="shrink-0 text-[var(--color-brand)]" />}
</button>
<button
type="button"
role="option"
aria-selected={useWorktree}
aria-labelledby={worktreeIsolatedLabelId}
onClick={() => selectWorktreeMode(true)}
className={`flex w-full items-center gap-2.5 px-3 py-2.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-brand)]/35 ${
useWorktree ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'
}`}
className={worktreeCardClassName(useWorktree)}
>
<GitFork size={16} className="shrink-0 text-[var(--color-text-tertiary)]" />
<WorktreeRadio selected={useWorktree} />
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-medium text-[var(--color-text-primary)]">
<span id={worktreeIsolatedLabelId} className="block text-[14.5px] font-bold text-[var(--color-text-primary)]">
{t('repoLaunch.worktreeIsolated')}
</span>
<span className="mt-[3px] block text-[12.5px] leading-[1.6] text-[var(--color-text-secondary)]">
{t('repoLaunch.worktreeIsolatedDesc', { branch: branchLabel })}
</span>
</span>
{useWorktree && <Check size={16} className="shrink-0 text-[var(--color-brand)]" />}
</button>
</div>
</div>,
@@ -45,7 +45,7 @@ export function SessionTaskBar() {
return (
<div className="shrink-0 px-8">
<div className="mx-auto max-w-[860px] rounded-[var(--radius-lg)] border border-[var(--color-outline-variant)]/40 bg-[var(--color-surface-container-lowest)] overflow-hidden mb-2">
<div className="mx-auto max-w-[900px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] overflow-hidden mb-2">
{/* Header — always visible, clickable to toggle */}
<div className="flex items-center gap-2 bg-[var(--color-surface-container)] px-2 py-1.5">
<button
@@ -53,7 +53,7 @@ export function SessionTaskBar() {
onClick={toggleExpanded}
className="flex min-w-0 flex-1 items-center gap-3 rounded-[var(--radius-md)] px-2 py-1 hover:bg-[var(--color-surface-container-low)] transition-colors"
>
<div className="flex items-center justify-center w-6 h-6 rounded-[var(--radius-md)] bg-[var(--color-secondary)]/10">
<div className="flex items-center justify-center w-6 h-6 rounded-[var(--radius-md)] bg-[var(--color-secondary-container)]">
<span
className="material-symbols-outlined text-[14px] text-[var(--color-secondary)]"
>
@@ -102,7 +102,7 @@ export function SessionTaskBar() {
{/* Expanded task list */}
{expanded && (
<div className="px-4 pb-2 pt-1 flex flex-col gap-0.5 max-h-[240px] overflow-y-auto border-t border-[var(--color-outline-variant)]/20">
<div className="px-4 pb-2 pt-1 flex flex-col gap-0.5 max-h-[240px] overflow-y-auto border-t border-[var(--color-border)]">
{tasks.map((task) => (
<TaskItem key={task.id} task={task} />
))}
@@ -66,7 +66,7 @@ export function StreamingIndicator() {
data-testid="api-retry-indicator"
role="status"
aria-live="polite"
className="mb-2 flex w-full max-w-[min(720px,100%)] flex-wrap items-center gap-2 rounded-[var(--radius-md)] border border-[var(--color-warning)] bg-[var(--color-warning-container)] px-3 py-2 text-xs text-[var(--color-on-warning-container)] shadow-sm"
className="mb-2 flex w-full max-w-[min(720px,100%)] flex-wrap items-center gap-2 rounded-[var(--radius-md)] border border-[var(--color-warning)] bg-[var(--color-warning-container)] px-3 py-2 text-xs text-[var(--color-on-warning-container)] shadow-[var(--shadow-card)]"
>
<RefreshCw size={14} strokeWidth={2.2} className="shrink-0 animate-spin text-[var(--color-warning)]" aria-hidden="true" />
<span className="font-medium">{t('chat.retry.title')}</span>
@@ -104,17 +104,17 @@ export function StreamingIndicator() {
data-testid="streaming-fallback-indicator"
role="status"
aria-live="polite"
className="mb-2 flex w-fit items-center gap-2 rounded-full border border-[var(--color-border)]/40 bg-[var(--color-surface-container-low)] px-3 py-1"
className="mb-2 flex w-fit items-center gap-[9px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-4 py-2 text-[13.5px] text-[var(--color-text-secondary)]"
>
<RefreshCw size={12} strokeWidth={2.2} className="shrink-0 animate-spin text-[var(--color-text-secondary)]" aria-hidden="true" />
<span className="text-xs font-medium text-[var(--color-text-secondary)]">
<RefreshCw size={13} strokeWidth={2.2} className="shrink-0 animate-spin text-[var(--color-text-secondary)]" aria-hidden="true" />
<span className="font-medium text-[var(--color-text-primary)]">
{t('chat.fallback.title')}
</span>
<span className="text-[10px] text-[var(--color-text-tertiary)]">
<span className="text-[12.5px] text-[var(--color-text-tertiary)]">
{t('chat.fallback.detail')}
</span>
{elapsedSeconds > 0 && (
<span className="text-[10px] text-[var(--color-text-tertiary)]">
<span className="text-[12.5px] text-[var(--color-text-tertiary)]">
{formatElapsed(elapsedSeconds)}
</span>
)}
@@ -136,18 +136,19 @@ export function StreamingIndicator() {
}
return (
<div className="mb-2 flex w-fit items-center gap-2 rounded-full border border-[var(--color-border)]/40 bg-[var(--color-surface-container-low)] px-3 py-1">
<span className="text-[var(--color-brand)] animate-shimmer text-xs">✦</span>
<span className="text-xs font-medium text-[var(--color-text-secondary)]">{verb}...</span>
<div className="mb-2 flex w-fit items-center gap-[9px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-4 py-2 text-[13.5px] text-[var(--color-text-secondary)]">
<span className="animate-pulse-dot text-[var(--color-brand)]" aria-hidden="true">✦</span>
<span className="font-medium text-[var(--color-text-primary)]">{verb}...</span>
{elapsedSeconds > 0 && (
<span className="text-[10px] text-[var(--color-text-tertiary)]">
{formatElapsed(elapsedSeconds)}
</span>
<span>{formatElapsed(elapsedSeconds)}</span>
)}
{streamingTokens > 0 && (
<span className="text-[10px] text-[var(--color-text-tertiary)]">
· ↓ {t('common.tokens', { count: formatTokenCount(streamingTokens) })}
</span>
<>
<span aria-hidden="true">·</span>
<span className="font-mono text-[12.5px]">
↓ {t('common.tokens', { count: formatTokenCount(streamingTokens) })}
</span>
</>
)}
</div>
)
@@ -12,16 +12,16 @@ type Props = {
*/
export function TerminalChrome({ title, children, className = '' }: Props) {
return (
<div className={`overflow-hidden rounded-2xl border border-[var(--color-outline-variant)]/20 bg-[var(--color-surface-dim)] ${className}`}>
<div className={`overflow-hidden rounded-[var(--radius-xl)] border border-[var(--color-terminal-border)] bg-[var(--color-terminal-bg)] ${className}`}>
{/* Title bar with traffic lights */}
<div className="flex items-center gap-2 border-b border-[var(--color-terminal-border)] bg-[var(--color-terminal-header)] px-3 py-2">
<div className="flex items-center gap-2.5 border-b border-[var(--color-terminal-border)] bg-[var(--color-terminal-header)] px-4 py-[11px]">
<div className="flex gap-1.5">
<div className="w-2.5 h-2.5 rounded-full bg-[var(--color-terminal-danger)]" />
<div className="w-2.5 h-2.5 rounded-full bg-[var(--color-terminal-warning)]" />
<div className="w-2.5 h-2.5 rounded-full bg-[var(--color-terminal-accent)]" />
<div className="h-2.5 w-2.5 rounded-[var(--radius-full)] bg-[var(--color-terminal-danger)]" />
<div className="h-2.5 w-2.5 rounded-[var(--radius-full)] bg-[var(--color-terminal-warning)]" />
<div className="h-2.5 w-2.5 rounded-[var(--radius-full)] bg-[var(--color-terminal-accent)]" />
</div>
{title && (
<span className="ml-2 truncate font-[var(--font-mono)] text-[10px] text-[var(--color-terminal-muted)]">
<span className="ml-1.5 truncate font-mono text-[12.5px] font-medium text-[var(--color-terminal-muted)]">
{title}
</span>
)}
@@ -22,9 +22,9 @@ export function ThinkingBlock({ content, isActive = false }: { content: string;
type="button"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
className="flex w-full items-center gap-1.5 rounded-md px-1 py-0.5 text-left text-[12px] text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-secondary)]"
className="flex w-full items-center gap-[7px] rounded-[var(--radius-sm)] px-1 py-0.5 text-left text-[13.5px] text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)]"
>
<span className="text-[10px] text-[var(--color-outline)]">
<span className="text-[10px]">
{expanded ? '\u25BE' : '\u25B8'}
</span>
<span className="shrink-0 font-medium italic">
@@ -36,7 +36,7 @@ export function ThinkingBlock({ content, isActive = false }: { content: string;
<div
ref={contentRef}
data-thinking-content="expanded"
className="relative mt-1 max-h-[300px] overflow-y-auto rounded-lg border border-[var(--color-border)]/40 bg-[var(--color-surface-container-lowest)] p-2.5 text-[11px] text-[var(--color-text-secondary)]"
className="relative mt-1 max-h-[300px] overflow-y-auto rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] p-2.5 text-[11px] text-[var(--color-text-secondary)]"
>
<MarkdownRenderer
content={displayContent}
+47 -37
View File
@@ -114,7 +114,7 @@ export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, resu
}
return (
<div className={`overflow-hidden rounded-lg border border-[var(--color-border)]/50 bg-[var(--color-surface-container-lowest)] ${
<div className={`overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] ${
compact ? 'mb-0' : 'mb-2'
}`}>
<button
@@ -124,18 +124,28 @@ export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, resu
setExpanded((value) => !value)
}
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-[var(--color-surface-hover)]/50"
className={`flex w-full items-center text-left transition-colors hover:bg-[var(--color-surface-hover)] ${
compact ? 'gap-[11px] px-3.5 py-2.5' : 'gap-3 px-4 py-3'
}`}
>
<span className="material-symbols-outlined text-[14px] text-[var(--color-outline)]">{icon}</span>
<span className="text-[11px] font-semibold text-[var(--color-text-secondary)]">
{compact ? (
<span className="material-symbols-outlined shrink-0 text-[16px] text-[var(--color-text-secondary)]">{icon}</span>
) : (
/* The ink square is the design's tool badge: solid `--t1` with the page
ground as its glyph color, which is exactly the primary-button pair. */
<span className="flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-[8px] bg-[var(--color-btn-primary-bg)] text-[var(--color-btn-primary-fg)]">
<span className="material-symbols-outlined text-[16px]">{icon}</span>
</span>
)}
<span className={`shrink-0 font-bold text-[var(--color-text-primary)] ${compact ? 'text-[13px]' : 'text-[14px]'}`}>
{toolName}
</span>
{filePath ? (
<span className="min-w-0 flex-1 truncate font-[var(--font-mono)] text-[11px] text-[var(--color-text-tertiary)]">
<span className={`min-w-0 flex-1 truncate font-mono text-[var(--color-text-secondary)] ${compact ? 'text-[12.5px]' : 'text-[13px]'}`}>
{filePath.split('/').pop()}
</span>
) : summary ? (
<span className="min-w-0 flex-1 truncate font-[var(--font-mono)] text-[11px] text-[var(--color-text-tertiary)]">
<span className={`min-w-0 flex-1 truncate font-mono text-[var(--color-text-secondary)] ${compact ? 'text-[12.5px]' : 'text-[13px]'}`}>
{summary}
</span>
) : (
@@ -143,52 +153,52 @@ export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, resu
)}
{pendingSummary ? (
<span
className="inline-flex min-w-0 max-w-[58%] shrink-0 items-center gap-1 text-[10px] text-[var(--color-outline)]"
className="inline-flex min-w-0 max-w-[58%] shrink-0 items-center gap-1 text-[12.5px] text-[var(--color-text-tertiary)]"
title={liveStatsSummary ? `${pendingSummary} · ${liveStatsSummary}` : pendingSummary}
>
<LoaderCircle size={12} strokeWidth={2.4} className="animate-spin" aria-hidden="true" />
<LoaderCircle size={13} strokeWidth={2.4} className="animate-spin" aria-hidden="true" />
<span className="truncate">{pendingSummary}</span>
{liveStatsSummary ? (
<>
<span className="shrink-0 text-[var(--color-text-tertiary)]">·</span>
<span className="shrink-0 font-[var(--font-mono)] tabular-nums text-[var(--color-text-tertiary)]">
<span className="shrink-0">·</span>
<span className="shrink-0 font-mono tabular-nums">
{liveStatsSummary}
</span>
</>
) : null}
</span>
) : stoppedSummary ? (
<span className="inline-flex shrink-0 items-center gap-1 text-[10px] text-[var(--color-outline)]">
<CircleStop size={12} strokeWidth={2.25} aria-hidden="true" />
<span className="inline-flex shrink-0 items-center gap-1 text-[12.5px] text-[var(--color-text-tertiary)]">
<CircleStop size={13} strokeWidth={2.25} aria-hidden="true" />
{stoppedSummary}
</span>
) : result && outputSummary ? (
<span
className={`shrink-0 text-[10px] ${
className={`min-w-0 shrink truncate text-[12.5px] ${
result.isError
? 'text-[var(--color-error)]'
: 'text-[var(--color-outline)]'
: 'text-[var(--color-text-tertiary)]'
}`}
>
{outputSummary}
</span>
) : liveStatsSummary ? (
<span className="shrink-0 font-[var(--font-mono)] text-[10px] tabular-nums text-[var(--color-outline)]">
<span className="shrink-0 font-mono text-[12.5px] tabular-nums text-[var(--color-text-tertiary)]">
{liveStatsSummary}
</span>
) : null}
{result?.isError && (
<span className="material-symbols-outlined shrink-0 text-[14px] text-[var(--color-error)]">error</span>
<span className="material-symbols-outlined shrink-0 text-[15px] text-[var(--color-error)]">error</span>
)}
{expandable && (
<span className="material-symbols-outlined text-[14px] text-[var(--color-outline)]">
{expanded ? 'expand_less' : 'expand_more'}
<span className="shrink-0 text-[11px] leading-none text-[var(--color-text-tertiary)]" aria-hidden="true">
{expanded ? '▴' : '▾'}
</span>
)}
</button>
{expandable && expanded && (
<div className="space-y-2.5 border-t border-[var(--color-border)]/60 px-3 py-3">
<div className="space-y-2.5 border-t border-[var(--color-border)] px-4 py-3.5">
{preview}
{details}
</div>
@@ -210,7 +220,7 @@ function EnterPlanModeToolCallBlock({
const errorText = result?.isError ? extractTextContent(result.content) : null
return (
<div className={`overflow-hidden rounded-lg border border-[var(--color-brand)]/30 bg-[var(--color-surface-container-lowest)] ${
<div className={`overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-primary-fixed-dim)] bg-[var(--color-surface-container-lowest)] ${
compact ? 'mb-0' : 'mb-2'
}`}>
<div className="flex w-full items-center gap-2 px-3 py-2 text-left">
@@ -230,7 +240,7 @@ function EnterPlanModeToolCallBlock({
</div>
{result?.isError && errorText ? (
<div className="border-t border-[var(--color-border)]/60 px-3 py-3">
<div className="border-t border-[var(--color-border)] px-3 py-3">
{renderResultOutput(result, errorText, t)}
</div>
) : null}
@@ -269,20 +279,20 @@ function PlanToolCallBlock({
const hasRawResult = Boolean(result && extractTextContent(result.content))
return (
<div className={`overflow-hidden rounded-lg border border-[var(--color-brand)]/35 bg-[var(--color-surface-container-lowest)] ${
<div className={`overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-primary-fixed-dim)] bg-[var(--color-surface-container-lowest)] ${
compact ? 'mb-0' : 'mb-2'
}`}>
<button
type="button"
onClick={onToggle}
className="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-[var(--color-surface-hover)]/50"
className="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-[var(--color-surface-hover)]"
>
<span className="material-symbols-outlined text-[14px] text-[var(--color-brand)]">architecture</span>
<span className="min-w-0 flex-1 truncate text-[12px] font-semibold text-[var(--color-text-primary)]">
{title}
</span>
{preview.filePath ? (
<span className="hidden max-w-[40%] truncate font-[var(--font-mono)] text-[11px] text-[var(--color-text-tertiary)] sm:inline">
<span className="hidden max-w-[40%] truncate font-mono text-[11px] text-[var(--color-text-tertiary)] sm:inline">
{preview.filePath}
</span>
) : null}
@@ -298,7 +308,7 @@ function PlanToolCallBlock({
</button>
{expanded ? (
<div className="space-y-2.5 border-t border-[var(--color-border)]/60 px-3 py-3">
<div className="space-y-2.5 border-t border-[var(--color-border)] px-3 py-3">
{showPlanPreview ? (
<PlanPreviewCard
title={t('permission.planPreviewTitle')}
@@ -350,7 +360,7 @@ function renderPreview(
return (
<>
<TerminalChrome title={typeof obj.description === 'string' ? obj.description : filePath}>
<div className="px-3 py-2.5 font-[var(--font-mono)] text-[11px] leading-[1.3] text-[var(--color-terminal-fg)]">
<div className="px-3 py-2.5 font-mono text-[11px] leading-[1.3] text-[var(--color-terminal-fg)]">
<span className="text-[var(--color-terminal-accent)]">$</span> {obj.command}
</div>
</TerminalChrome>
@@ -389,20 +399,20 @@ function renderResultOutput(
return (
<>
<InlineImageGallery text={text} />
<div className={`overflow-hidden rounded-lg border ${
<div className={`overflow-hidden rounded-[var(--radius-lg)] border ${
result.isError
? 'border-[var(--color-error)]/20 bg-[var(--color-error-container)]/60'
? 'border-[var(--color-error)] bg-[var(--color-error-container)]'
: 'border-[var(--color-border)] bg-[var(--color-surface)]'
}`}>
<div className="flex items-center justify-between border-b border-[var(--color-border)]/60 px-3 py-2 text-[10px] uppercase tracking-[0.18em] text-[var(--color-outline)]">
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-3 py-2 text-[10px] uppercase tracking-[0.18em] text-[var(--color-outline)]">
<span>{result.isError ? t?.('tool.errorOutput') ?? 'Error Output' : t?.('tool.toolOutput') ?? 'Tool Output'}</span>
<CopyButton
text={text}
className="rounded-md border border-[var(--color-border)] px-2 py-1 text-[10px] normal-case tracking-normal text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)]"
className="rounded-[var(--radius-sm)] border border-[var(--color-border)] px-2 py-1 text-[11px] normal-case tracking-normal text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)]"
/>
</div>
{result.isError ? (
<pre className="max-h-[420px] overflow-auto whitespace-pre-wrap break-words bg-[var(--color-code-bg)] px-3 py-2 font-[var(--font-mono)] text-[12px] leading-[1.45] text-[var(--color-error)]">
<pre className="max-h-[420px] overflow-auto whitespace-pre-wrap break-words bg-[var(--color-code-bg)] px-3 py-2 font-mono text-[12px] leading-[1.45] text-[var(--color-error)]">
{text}
</pre>
) : (
@@ -435,12 +445,12 @@ function renderDetails(
const text = JSON.stringify(obj, null, 2)
return (
<div className="overflow-hidden rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)]">
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)]">
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-3 py-2 text-[10px] uppercase tracking-[0.18em] text-[var(--color-outline)]">
<span>{t?.('tool.toolInput') ?? 'Tool Input'}</span>
<CopyButton
text={text}
className="rounded-md border border-[var(--color-border)] px-2 py-1 text-[10px] normal-case tracking-normal text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)]"
className="rounded-[var(--radius-sm)] border border-[var(--color-border)] px-2 py-1 text-[11px] normal-case tracking-normal text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)]"
/>
</div>
<CodeViewer code={text} language="json" maxLines={18} />
@@ -630,14 +640,14 @@ function renderWriterPreview(
}, t)
return (
<div className="overflow-hidden rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)]">
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)]">
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-3 py-2 text-[10px] uppercase tracking-[0.18em] text-[var(--color-outline)]">
<span>{t?.('tool.writerPreview') ?? 'Writer'}</span>
<span className="font-[var(--font-mono)] normal-case tracking-normal tabular-nums">
<span className="font-mono normal-case tracking-normal tabular-nums">
{statsSummary}
</span>
</div>
<pre className="max-h-[420px] overflow-auto whitespace-pre-wrap break-words bg-[var(--color-code-bg)] px-3 py-2 font-[var(--font-mono)] text-[12px] leading-[1.45] text-[var(--color-code-fg)]">
<pre className="max-h-[420px] overflow-auto whitespace-pre-wrap break-words bg-[var(--color-code-bg)] px-3 py-2 font-mono text-[12px] leading-[1.45] text-[var(--color-code-fg)]">
{visibleContent}
</pre>
</div>
@@ -651,7 +661,7 @@ function renderPartialInput(
const formattedInput = formatPartialJsonInput(partialInput)
return (
<div className="overflow-hidden rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)]">
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)]">
<div className="border-b border-[var(--color-border)] px-3 py-2 text-[10px] uppercase tracking-[0.18em] text-[var(--color-outline)]">
{t?.('tool.partialInput') ?? 'Partial input'}
</div>
+32 -32
View File
@@ -1,5 +1,5 @@
import { memo, useCallback, useState } from 'react'
import { BookMarked, ChevronDown, ChevronRight, Settings } from 'lucide-react'
import { BookMarked, ChevronDown, ChevronRight, CircleCheck, Settings } from 'lucide-react'
import { ToolCallBlock } from './ToolCallBlock'
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
import { Badge, StatusDot, type Tone } from '@/components/ui/Badge'
@@ -248,12 +248,12 @@ function MemoryToolActivityGroup({
<div className="mb-2">
<div
data-testid="memory-tool-activity-card"
className="overflow-hidden rounded-lg border border-[var(--color-memory-border)] bg-[var(--color-memory-surface)]"
className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-memory-border)] bg-[var(--color-memory-surface)]"
>
<button
type="button"
onClick={toggleExpanded}
className="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-[var(--color-surface-hover)]/50"
className="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-[var(--color-surface-hover)]"
>
{expanded ? (
<ChevronDown size={15} className="shrink-0 text-[var(--color-text-tertiary)]" aria-hidden="true" />
@@ -270,7 +270,7 @@ function MemoryToolActivityGroup({
</button>
{expanded ? (
<div className="border-t border-[var(--color-border)]/55 px-3 py-2.5">
<div className="border-t border-[var(--color-border)] px-3 py-2.5">
<div className="space-y-1.5">
{visibleFiles.map((file) => (
<button
@@ -278,9 +278,9 @@ function MemoryToolActivityGroup({
type="button"
title={file.path}
onClick={() => openMemorySettings(file.path)}
className="group flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-[var(--color-surface-hover)] focus:outline-none focus-visible:shadow-[var(--shadow-focus-ring)]"
className="group flex w-full items-start gap-2 rounded-[var(--radius-md)] px-2 py-1.5 text-left transition-colors hover:bg-[var(--color-surface-hover)] focus:outline-none focus-visible:shadow-[var(--shadow-focus-ring)]"
>
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-sm border border-[var(--color-memory-border)] bg-[var(--color-memory-icon-bg)] text-[var(--color-text-tertiary)] group-hover:text-[var(--color-memory-accent)]">
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-[var(--radius-sm)] border border-[var(--color-memory-border)] bg-[var(--color-memory-icon-bg)] text-[var(--color-text-tertiary)] group-hover:text-[var(--color-memory-accent)]">
<Settings size={12} aria-hidden="true" />
</span>
<span className="min-w-0 flex-1">
@@ -368,16 +368,16 @@ function AgentToolGroup({
const anyStopped = statuses.some((status) => status === 'stopped')
return (
<div className="mb-2">
<div className="mb-2 overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]">
<button
type="button"
onClick={toggleExpanded}
className="flex w-full items-center gap-2 rounded-lg border border-[var(--color-border)]/40 bg-[var(--color-surface-container-low)] px-3 py-1.5 text-left transition-colors hover:bg-[var(--color-surface-container-high)]"
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-[var(--color-surface-hover)]"
>
<span className="material-symbols-outlined text-[14px] text-[var(--color-outline)]">
{expanded ? 'expand_less' : 'expand_more'}
<span className="shrink-0 text-[11px] leading-none text-[var(--color-text-tertiary)]" aria-hidden="true">
{expanded ? '▾' : '▸'}
</span>
<span className="flex-1 truncate text-[12px] text-[var(--color-text-secondary)]">
<span className="flex-1 truncate text-[14px] font-semibold text-[var(--color-text-primary)]">
{toolCalls.length === 1 ? t('toolGroup.agentOne') : t('toolGroup.agentMany', { count: toolCalls.length })}
</span>
{isAnyRunning && (
@@ -386,28 +386,28 @@ function AgentToolGroup({
</Badge>
)}
{!isAnyRunning && errorPresent && (
<span className="material-symbols-outlined text-[14px] text-[var(--color-error)]">error</span>
<span className="material-symbols-outlined shrink-0 text-[17px] text-[var(--color-error)]">error</span>
)}
{!isAnyRunning && !errorPresent && allComplete && (
<span className="material-symbols-outlined text-[14px] text-[var(--color-success)]">check_circle</span>
<CircleCheck size={19} strokeWidth={1.6} className="shrink-0 text-[var(--color-success)]" aria-hidden="true" />
)}
{!isAnyRunning && !errorPresent && !allComplete && !anyStopped && (
<span className="material-symbols-outlined text-[14px] text-[var(--color-outline)]">pending</span>
<span className="material-symbols-outlined shrink-0 text-[17px] text-[var(--color-text-tertiary)]">pending</span>
)}
{!isAnyRunning && !errorPresent && !allComplete && anyStopped && (
<span className="material-symbols-outlined text-[14px] text-[var(--color-outline)]">stop_circle</span>
<span className="material-symbols-outlined shrink-0 text-[17px] text-[var(--color-text-tertiary)]">stop_circle</span>
)}
</button>
{expanded && (
<div className="relative mt-3 pl-5">
<div className="absolute bottom-6 left-[11px] top-4 w-px rounded-full bg-[var(--color-border)]/45" />
<div className="relative border-t border-[var(--color-border)] py-3 pl-5 pr-3.5">
<div className="absolute bottom-6 left-[11px] top-4 w-px rounded-full bg-[var(--color-border)]" />
<div className="space-y-2">
{toolCalls.map((toolCall) => (
<div key={toolCall.id} className="relative pl-7">
<div className="absolute left-0 top-1/2 -translate-y-1/2">
<div className="absolute left-[11px] top-1/2 h-px w-4 -translate-y-1/2 bg-[var(--color-border)]/45" />
<div className="absolute left-[8px] top-1/2 h-2.5 w-2.5 -translate-y-1/2 rounded-full border border-[var(--color-border)]/65 bg-[var(--color-surface-container-lowest)] shadow-[0_0_0_2px_var(--color-surface)]" />
<div className="absolute left-[11px] top-1/2 h-px w-4 -translate-y-1/2 bg-[var(--color-border)]" />
<div className="absolute left-[8px] top-1/2 h-2.5 w-2.5 -translate-y-1/2 rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[0_0_0_2px_var(--color-surface)]" />
</div>
<AgentCallCard
sessionId={sessionId}
@@ -437,29 +437,29 @@ function ToolCallGroupMulti({ toolCalls, resultMap, childToolCallsByParent, isSt
const isRunning = !!isStreaming || hasUnresolvedTools
return (
<div className="mb-2">
<div className="mb-2 overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]">
<button
type="button"
onClick={toggleExpanded}
className="flex w-full items-center gap-2 rounded-lg border border-[var(--color-border)]/40 bg-[var(--color-surface-container-low)] px-3 py-1.5 text-left transition-colors hover:bg-[var(--color-surface-container-high)]"
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-[var(--color-surface-hover)]"
>
<span className="material-symbols-outlined text-[14px] text-[var(--color-outline)]">
{expanded ? 'expand_less' : 'expand_more'}
<span className="shrink-0 text-[11px] leading-none text-[var(--color-text-tertiary)]" aria-hidden="true">
{expanded ? '▾' : '▸'}
</span>
<span className="flex-1 truncate text-[12px] text-[var(--color-text-secondary)]">
<span className="flex-1 truncate text-[14px] font-semibold text-[var(--color-text-primary)]">
{summary}
</span>
{!isRunning && !errorPresent && (
<span className="material-symbols-outlined text-[14px] text-[var(--color-success)]">check_circle</span>
<CircleCheck size={19} strokeWidth={1.6} className="shrink-0 text-[var(--color-success)]" aria-hidden="true" />
)}
{!isRunning && errorPresent && (
<span className="material-symbols-outlined text-[14px] text-[var(--color-error)]">error</span>
<span className="material-symbols-outlined shrink-0 text-[17px] text-[var(--color-error)]">error</span>
)}
{isRunning && <StatusDot tone="brand" pulse />}
</button>
{expanded && (
<div className="mt-1.5 space-y-1">
<div className="flex flex-col gap-2.5 border-t border-[var(--color-border)] px-3.5 py-2.5">
{toolCalls.map((tc) => {
return (
<ToolCallTree
@@ -535,8 +535,8 @@ function AgentCallCard({
const canOpenRun = showOpenRun && !!sessionId && !!toolCall.toolUseId
return (
<div className="overflow-hidden rounded-lg border border-[var(--color-border)]/50 bg-[var(--color-surface-container-lowest)]">
<div className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-[var(--color-surface-hover)]/50">
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]">
<div className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-[var(--color-surface-hover)]">
<span className="material-symbols-outlined text-[18px] text-[var(--color-outline)]">smart_toy</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
@@ -616,9 +616,9 @@ function AgentCallCard({
</div>
{expanded && (
<div className="border-t border-[var(--color-border)]/60 px-3 py-3">
<div className="border-t border-[var(--color-border)] px-3 py-3">
{errorText && (
<div className="mb-3 rounded-lg border border-[var(--color-error)]/20 bg-[var(--color-error-container)]/60 px-3 py-2 text-[11px] text-[var(--color-error)]">
<div className="mb-3 rounded-[var(--radius-lg)] border border-[var(--color-error)] bg-[var(--color-error-container)] px-3 py-2 text-[11px] text-[var(--color-on-error-container)]">
{errorText}
</div>
)}
@@ -685,7 +685,7 @@ function ToolCallTree({
partialInput={toolCall.partialInput}
/>
{childToolCalls.length > 0 && (
<div className={compact ? 'ml-4 border-l border-[var(--color-border)]/60 pl-3' : 'mb-2 ml-16 border-l border-[var(--color-border)]/60 pl-3'}>
<div className={compact ? 'ml-4 border-l border-[var(--color-border)] pl-3' : 'mb-2 ml-16 border-l border-[var(--color-border)] pl-3'}>
<div className="space-y-1">
{childToolCalls.map((childToolCall) => (
<ToolCallTree
@@ -27,10 +27,10 @@ export const ToolResultBlock = memo(function ToolResultBlock({ content, isError,
const hasMore = text.length > 200
return (
<div className={`mb-2 overflow-hidden rounded-xl border ${
<div className={`mb-2 overflow-hidden rounded-[var(--radius-lg)] border ${
isError
? 'border-[var(--color-error)]/20'
: 'border-[var(--color-outline-variant)]/20'
? 'border-[var(--color-error)]'
: 'border-[var(--color-border)]'
}`}>
{/* Status header */}
<button
@@ -38,8 +38,8 @@ export const ToolResultBlock = memo(function ToolResultBlock({ content, isError,
onClick={() => setExpanded((value) => !value)}
className={`flex w-full items-center justify-between px-3 py-2 text-left text-[10px] font-bold uppercase tracking-wider ${
isError
? 'bg-[var(--color-error-container)] text-[var(--color-error)]'
: 'bg-[var(--color-surface-container-high)] text-[var(--color-outline)]'
? 'bg-[var(--color-error-container)] text-[var(--color-on-error-container)]'
: 'bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)]'
}`}
>
<span className="flex items-center gap-1.5">
@@ -50,7 +50,7 @@ export const ToolResultBlock = memo(function ToolResultBlock({ content, isError,
</span>
<span className={`px-2 py-0.5 rounded-full text-[9px] ${
isError
? 'bg-[var(--color-error)]/10'
? 'bg-[var(--color-error-container)] text-[var(--color-on-error-container)]'
: 'bg-[var(--color-diff-added-bg)] text-[var(--color-diff-added-text)]'
}`}>
{isError ? t('tool.error') : t('tool.success')}
@@ -63,7 +63,7 @@ export const ToolResultBlock = memo(function ToolResultBlock({ content, isError,
{/* Content */}
{expanded ? (
isError ? (
<div className="bg-[var(--color-error-container)]/50 px-3 py-2.5 font-[var(--font-mono)] text-[11px] leading-[1.5] whitespace-pre-wrap break-words text-[var(--color-error)]">
<div className="bg-[var(--color-error-container)] px-3 py-2.5 font-mono text-[11px] leading-[1.5] whitespace-pre-wrap break-words text-[var(--color-on-error-container)]">
{text}
</div>
) : (
@@ -74,7 +74,7 @@ export const ToolResultBlock = memo(function ToolResultBlock({ content, isError,
/>
)
) : (
<div className="bg-[var(--color-surface-container-lowest)] px-3 py-2 font-[var(--font-mono)] text-[10px] leading-[1.35] text-[var(--color-text-tertiary)]">
<div className="bg-[var(--color-surface-container-lowest)] px-3 py-2 font-mono text-[10px] leading-[1.35] text-[var(--color-text-tertiary)]">
{preview}
{hasMore ? '…' : ''}
</div>
@@ -83,7 +83,7 @@ export const ToolResultBlock = memo(function ToolResultBlock({ content, isError,
{hasMore && (
<button
onClick={() => setExpanded((value) => !value)}
className="w-full py-1 text-[10px] font-medium text-[var(--color-text-accent)] hover:underline bg-[var(--color-surface-container-low)] border-t border-[var(--color-outline-variant)]/10"
className="w-full py-1 text-[10px] font-medium text-[var(--color-text-accent)] hover:underline bg-[var(--color-surface-container-low)] border-t border-[var(--color-border)]"
>
{expanded ? t('tool.showLess') : t('tool.showMore', { count: text.length - 200 })}
</button>
@@ -1,8 +1,13 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it } from 'vitest'
import { UserMessage } from './UserMessage'
import { useSettingsStore } from '../../stores/settingsStore'
describe('UserMessage', () => {
afterEach(() => {
useSettingsStore.setState({ locale: 'en' })
})
it('keeps long URLs inside the message bubble', () => {
const longUrl = `https://cn.bing.com/search?q=${'encoded'.repeat(60)}`
@@ -18,4 +23,16 @@ describe('UserMessage', () => {
expect(bubble.style.overflowWrap).toBe('anywhere')
expect(bubble.style.wordBreak).toBe('break-word')
})
// The copy label was a hardcoded "Copy prompt" literal, so it stayed English
// under every locale. English is also what `chat.copyPrompt` resolves to, so
// only a non-English locale can tell the wiring from the old literal.
it('translates the copy action label instead of hardcoding English', () => {
useSettingsStore.setState({ locale: 'zh' })
render(<UserMessage content="把这条复制走" />)
expect(screen.getByRole('button', { name: '复制提示词' })).toBeTruthy()
expect(screen.queryByRole('button', { name: 'Copy prompt' })).toBeNull()
})
})
+5 -4
View File
@@ -1,5 +1,6 @@
import { memo } from 'react'
import type { UIAttachment } from '../../types/chat'
import { useTranslation } from '../../i18n'
import { AttachmentGallery } from './AttachmentGallery'
import { MessageActionBar, type MessageBranchAction } from './MessageActionBar'
@@ -11,13 +12,14 @@ type Props = {
}
export const UserMessage = memo(function UserMessage({ content, attachments, branchAction, timestamp }: Props) {
const t = useTranslation()
const hasText = content.trim().length > 0
return (
<div className="mb-5 flex justify-end">
<div
data-message-shell="user"
className="group flex min-w-0 max-w-[82%] flex-col items-end sm:max-w-[78%] lg:max-w-[72%]"
className="group flex min-w-0 max-w-[82%] flex-col items-end sm:max-w-[78%] lg:max-w-[640px]"
>
<div className="flex max-w-full flex-col items-end gap-2">
{attachments && attachments.length > 0 && (
@@ -26,9 +28,8 @@ export const UserMessage = memo(function UserMessage({ content, attachments, bra
{hasText && (
<div
className="min-w-0 max-w-full bg-[var(--color-surface-user-msg)] px-4 py-3 text-sm leading-relaxed text-[var(--color-text-primary)] whitespace-pre-wrap break-words"
className="min-w-0 max-w-full rounded-[var(--radius-lg)] bg-[var(--color-surface-user-msg)] px-[18px] py-[13px] text-[14.5px] leading-relaxed text-[var(--color-text-primary)] whitespace-pre-wrap break-words"
style={{
borderRadius: '18px 4px 18px 18px',
overflowWrap: 'anywhere',
wordBreak: 'break-word',
}}
@@ -41,7 +42,7 @@ export const UserMessage = memo(function UserMessage({ content, attachments, bra
{hasText && (
<MessageActionBar
copyText={content}
copyLabel="Copy prompt"
copyLabel={t('chat.copyPrompt')}
branchAction={branchAction}
align="end"
timestamp={timestamp}
@@ -0,0 +1,64 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
/**
* The zero-state composer (`pages/EmptySession`) and the session composer
* (`components/chat/ChatInput`) are two implementations of one control. The
* user sees the same slash menu and the same plus menu in both, so the two
* copies drifting is invisible in either file on its own — the slash menu
* shipped at `--radius-lg` in one and `--radius-xl` in the other, and only a
* walkthrough that opened both in the same session caught it.
*
* The panels above the composer row that the handoff specced directly (the
* context, effort, branch and worktree popovers) all sit at the card step, so
* that is the corner the whole composer stack is held to here.
*/
const OVERLAY_RADIUS = 'rounded-[var(--radius-xl)]'
function source(relativePath: string): string {
return readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), 'utf8')
}
const COMPOSERS = {
ChatInput: source('./ChatInput.tsx'),
EmptySession: source('../../pages/EmptySession.tsx'),
PermissionModeSelector: source('../controls/PermissionModeSelector.tsx'),
ModelSelector: source('../controls/ModelSelector.tsx'),
}
/** Every className string that also carries a floating-panel background. */
function overlayPanelClassNames(code: string): string[] {
return code
.split('\n')
.filter((line) => line.includes('bg-[var(--color-surface-container-lowest)]'))
.filter((line) => /\brounded-\[var\(--radius-/.test(line))
}
describe('composer overlay chrome', () => {
for (const [name, code] of Object.entries(COMPOSERS)) {
it(`keeps every floating panel in ${name} at the card corner`, () => {
const panels = overlayPanelClassNames(code)
expect(panels.length).toBeGreaterThan(0)
for (const panel of panels) {
expect(panel, `${name} renders a panel at a corner other than ${OVERLAY_RADIUS}`)
.toContain(OVERLAY_RADIUS)
}
})
}
it('renders the same slash-menu chrome in both composers', () => {
const slashMenu = (code: string) => {
const line = code.split('\n').find((candidate) => candidate.includes('absolute bottom-full left-0 right-0'))
expect(line).toBeDefined()
return line!
}
for (const token of [OVERLAY_RADIUS, 'border-[var(--color-border)]', 'bg-[var(--color-surface-container-lowest)]']) {
expect(slashMenu(COMPOSERS.ChatInput)).toContain(token)
expect(slashMenu(COMPOSERS.EmptySession)).toContain(token)
}
})
})
@@ -0,0 +1,44 @@
import { render } from '@testing-library/react'
import '@testing-library/jest-dom'
import { describe, expect, it } from 'vitest'
import { BrandSeal } from './BrandSeal'
describe('BrandSeal', () => {
it('is decorative and hidden from assistive tech', () => {
// The product name always sits beside the seal (sidebar) or under it
// (empty state); announcing the glyph as well reads the brand twice.
const { container } = render(<BrandSeal />)
expect(container.firstElementChild).toHaveAttribute('aria-hidden', 'true')
})
it('renders the 哈 glyph in the headline serif at every size', () => {
for (const size of ['sm', 'md', 'lg', 'xl'] as const) {
const { container, unmount } = render(<BrandSeal size={size} />)
const glyph = container.querySelector('span span:last-child') as HTMLElement
expect(glyph.textContent).toBe('哈')
expect(glyph.style.fontFamily).toContain('var(--font-headline)')
unmount()
}
})
it('fills with the brand token so all six palettes recolor it', () => {
// This is why the seal replaced the raster app icon: a bitmap kept its own
// colors under every theme while the chrome around it moved.
const { container } = render(<BrandSeal />)
expect(container.firstElementChild?.className).toContain('bg-[var(--color-brand)]')
})
it('engraves the inner rule only at the empty-state size', () => {
// At 38px and below the 1.5px inset ring closes up into a smudge; the
// handoff only draws it on the 80px seal.
const { container: xl } = render(<BrandSeal size="xl" />)
expect(xl.querySelector('.border-\\[1\\.5px\\]')).not.toBeNull()
for (const size of ['sm', 'md', 'lg'] as const) {
const { container, unmount } = render(<BrandSeal size={size} />)
expect(container.querySelector('.border-\\[1\\.5px\\]')).toBeNull()
unmount()
}
})
})
@@ -0,0 +1,68 @@
import { cx } from '@/lib/cx'
/**
* The 「哈」 seal — the "印" of 「纸 · 墨 · 印」.
*
* A terracotta rounded square holding one serif glyph. It replaces the raster
* app icon inside the app chrome: the icon is a fixed bitmap, so it kept its
* own colors under all six palettes while everything around it moved, and it
* had no size below 32px that stayed legible.
*
* The glyph is Chinese, and only the latin cut of Noto Serif SC is
* self-hosted, so it renders in the platform serif named by `--font-headline`
* (Songti SC on macOS, Source Han Serif / SimSun elsewhere). That is the same
* fallback the handoff prototype relies on.
*/
export type BrandSealSize = 'sm' | 'md' | 'lg' | 'xl'
/**
* The three sizes the handoff calls for — sidebar 32, collapsed rail 38, empty
* state 80 — plus a 24px one for dense chrome. The inner rule only appears at
* `xl`: at 38px and below it closes up into a smudge.
*/
const SIZES: Record<BrandSealSize, {
box: string
radius: string
glyph: string
shadow: string
rule: boolean
}> = {
sm: { box: 'h-6 w-6', radius: 'rounded-[7px]', glyph: 'text-[12px]', shadow: 'shadow-[0_1px_2px_rgba(64,30,12,0.28)]', rule: false },
md: { box: 'h-8 w-8', radius: 'rounded-[9px]', glyph: 'text-[16px]', shadow: 'shadow-[0_1px_3px_rgba(64,30,12,0.3)]', rule: false },
lg: { box: 'h-[38px] w-[38px]', radius: 'rounded-[11px]', glyph: 'text-[18px]', shadow: 'shadow-[0_1px_3px_rgba(64,30,12,0.3)]', rule: false },
xl: { box: 'h-20 w-20', radius: 'rounded-[22px]', glyph: 'text-[42px]', shadow: 'shadow-[0_10px_28px_rgba(100,45,20,0.3)]', rule: true },
}
export type BrandSealProps = {
size?: BrandSealSize
className?: string
}
export function BrandSeal({ size = 'md', className }: BrandSealProps) {
const spec = SIZES[size]
return (
<span
// Decorative: the product name sits next to it in the sidebar and above
// it on the empty state, so announcing "哈" as well is duplicate noise.
aria-hidden="true"
className={cx(
'relative inline-flex flex-shrink-0 items-center justify-center bg-[var(--color-brand)]',
spec.box,
spec.radius,
spec.shadow,
className,
)}
>
{spec.rule && (
<span className="pointer-events-none absolute inset-[5px] rounded-[17px] border-[1.5px] border-[var(--color-brand-seal-rule)]" />
)}
<span
className={cx('font-black leading-none text-[var(--color-on-primary)]', spec.glyph)}
style={{ fontFamily: 'var(--font-headline)' }}
>
哈
</span>
</span>
)
}
@@ -83,7 +83,11 @@ describe('DirectoryPicker', () => {
const trigger = screen.getByRole('button')
expect(trigger).toHaveTextContent('project')
expect(trigger.className).toContain('rounded-[7px]')
// The workbar trigger shares the launch row's outlined-pill recipe (see
// RepositoryLaunchControls.workbarButtonClassName): the directory, branch
// and worktree chips sit on one row and must read as one family.
expect(trigger.className).toContain('rounded-[var(--radius-lg)]')
expect(trigger.className).toContain('border-[var(--color-border)]')
expect(trigger.className).not.toContain('rounded-full')
})
@@ -165,13 +165,13 @@ export function DirectoryPicker({ value, onChange, variant = 'chip', isGitProjec
const selectedLabel = selectedProject?.repoName || selectedProject?.projectName || projectNameFromPath(value)
const showGitIcon = selectedProject?.isGit || isGitProject
const triggerClassName = isWorkbar
? 'inline-flex h-9 max-w-full min-w-0 items-center gap-1.5 rounded-[7px] border border-transparent px-2.5 text-[13px] font-medium leading-none text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-container-lowest)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35'
? 'max-w-full ' + (isMobileBrowser ? 'min-h-11 ' : '') + 'group inline-flex h-9 min-w-0 items-center gap-2 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3.5 text-[13.5px] font-medium leading-none text-[var(--color-text-primary)] transition-[background-color,color,border-color] duration-150 ease-out hover:border-[var(--color-outline)] hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface)] disabled:cursor-not-allowed disabled:opacity-50'
: 'flex items-center gap-2 px-3 py-1.5 bg-[var(--color-surface-container-low)] hover:bg-[var(--color-surface-hover)] rounded-full text-xs transition-colors border border-[var(--color-border)]'
const emptyTriggerClassName = isWorkbar
? 'flex h-9 min-w-0 items-center gap-1.5 rounded-[7px] border border-transparent px-2.5 text-[13px] font-medium leading-none text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-container-lowest)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35'
? (isMobileBrowser ? 'min-h-11 ' : '') + 'group inline-flex h-9 min-w-0 items-center gap-2 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3.5 text-[13.5px] font-medium leading-none text-[var(--color-text-primary)] transition-[background-color,color,border-color] duration-150 ease-out hover:border-[var(--color-outline)] hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface)] disabled:cursor-not-allowed disabled:opacity-50'
: 'flex items-center gap-2 text-xs text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] transition-colors'
const dropdownClassName = 'overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)]'
const dropdownClassName = 'overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)]'
const dropdownStyle = {
position: 'fixed' as const,
left: dropdownPos?.left,
@@ -208,18 +208,18 @@ export function DirectoryPicker({ value, onChange, variant = 'chip', isGitProjec
}`}
>
{project.isGit ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-secondary)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="flex-shrink-0">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-secondary)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="w-5 flex-shrink-0">
<circle cx="18" cy="18" r="3" /><circle cx="6" cy="6" r="3" />
<path d="M13 6h3a2 2 0 0 1 2 2v7" /><line x1="6" y1="9" x2="6" y2="21" />
</svg>
) : (
<span className="material-symbols-outlined flex-shrink-0 text-[20px] text-[var(--color-text-secondary)]">folder</span>
<span className="material-symbols-outlined w-5 flex-shrink-0 text-center text-[20px] text-[var(--color-text-secondary)]">folder</span>
)}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-[var(--color-text-primary)]">
{project.repoName || project.projectName}
</div>
<div className="truncate font-[var(--font-mono)] text-[11px] text-[var(--color-text-tertiary)]">
<div className="truncate font-mono text-[11px] text-[var(--color-text-tertiary)]">
{project.realPath}
</div>
</div>
@@ -297,7 +297,7 @@ export function DirectoryPicker({ value, onChange, variant = 'chip', isGitProjec
</div>
<div className="flex items-center justify-between border-t border-[var(--color-border)] px-3 py-2">
<span className="truncate font-[var(--font-mono)] text-[10px] text-[var(--color-text-tertiary)]">{browsePath}</span>
<span className="truncate font-mono text-[10px] text-[var(--color-text-tertiary)]">{browsePath}</span>
<Button size="base" onClick={() => handleSelect(browsePath)}>
{t('dirPicker.useThisFolder')}
</Button>
@@ -20,10 +20,14 @@ export function AutoModeOptInDialog({ open, loading = false, onClose, onConfirm
loading={loading}
body={(
<div className="space-y-3">
<div className="flex items-start gap-3 rounded-lg border border-[var(--color-warning)]/30 bg-[var(--color-warning)]/10 px-3 py-3">
{/* Warning tones are paired: `--color-warning` is the marker (icon,
border) and `--color-on-warning-container` is the only readable
foreground on the container fill. The `/N` alpha this replaced is
also dropped outright by the Safari 15 WebView. */}
<div className="flex items-start gap-3 rounded-[var(--radius-lg)] border border-[var(--color-warning)] bg-[var(--color-warning-container)] px-3 py-3">
<span className="material-symbols-outlined mt-0.5 text-[20px] text-[var(--color-warning)]">warning</span>
<div className="space-y-2 text-sm leading-6 text-[var(--color-text-secondary)]">
<p className="font-medium text-[var(--color-text-primary)]">{t('permMode.enableAutoBody')}</p>
<div className="space-y-2 text-sm leading-6 text-[var(--color-on-warning-container)]">
<p className="font-semibold">{t('permMode.enableAutoBody')}</p>
<p>{t('permMode.enableAutoDetail')}</p>
</div>
</div>
@@ -2,6 +2,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom'
const { runtimeMocks } = vi.hoisted(() => ({
runtimeMocks: { isMobileViewport: false, isDesktopRuntime: false },
}))
vi.mock('../../hooks/useMobileViewport', () => ({
useMobileViewport: () => runtimeMocks.isMobileViewport,
}))
vi.mock('../../lib/desktopRuntime', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../lib/desktopRuntime')>()
return { ...actual, isDesktopRuntime: () => runtimeMocks.isDesktopRuntime }
})
import { ModelSelector } from './ModelSelector'
import { useChatStore } from '../../stores/chatStore'
import { useHahaOAuthStore } from '../../stores/hahaOAuthStore'
@@ -297,6 +310,59 @@ describe('ModelSelector', () => {
expect(flashOption?.className).toContain('border-[var(--color-model-option-selected-border)]')
})
it('closes the focus ring on both halves of the segmented control', () => {
useSettingsStore.setState({
locale: 'en',
availableModels: MODELS,
currentModel: MODELS[0],
activeProviderName: 'Provider A',
effortLevel: 'max',
})
useSessionRuntimeStore.getState().setSelection('session-ring', {
providerId: null,
modelId: 'alpha',
effortLevel: 'max',
})
const { container } = render(<ModelSelector runtimeKey="session-ring" />)
const [modelHalf, effortHalf] = [...container.querySelectorAll('button')]
// The ring traces `border-radius`. Each half is rounded on one side only,
// so without this the focused half drew a box that was round down one edge
// and square down the other.
expect(modelHalf).toHaveClass('rounded-l-[var(--radius-md)]', 'focus-visible:rounded-[var(--radius-md)]')
expect(effortHalf).toHaveClass('rounded-r-[var(--radius-md)]', 'focus-visible:rounded-[var(--radius-md)]')
})
// On the phone composer this control sits between two 44px buttons and opens
// a bottom sheet. `compact` cannot drive the height: the desktop composer
// also sets it, and there it narrows for the right panel, not for touch.
it.each([
['browser H5', { isMobileViewport: true, isDesktopRuntime: false }, true],
['desktop compact composer', { isMobileViewport: false, isDesktopRuntime: false }, false],
['narrow Electron window', { isMobileViewport: true, isDesktopRuntime: true }, false],
])('stretches both halves to the 44px touch target only on %s', (_name, runtime, expected) => {
Object.assign(runtimeMocks, runtime)
useSettingsStore.setState({
locale: 'en',
availableModels: MODELS,
currentModel: MODELS[0],
activeProviderName: 'Provider A',
effortLevel: 'max',
})
useSessionRuntimeStore.getState().setSelection('session-touch', {
providerId: null,
modelId: 'alpha',
effortLevel: 'max',
})
const { container } = render(<ModelSelector runtimeKey="session-touch" compact />)
const segmented = container.querySelector('[data-testid="model-selector-shell"] > div')
expect(segmented).toHaveClass('items-stretch')
expect(segmented?.classList.contains('min-h-11')).toBe(expected)
})
it('keeps runtime effort scoped to the selected session', async () => {
const setSessionRuntime = vi.fn()
useSettingsStore.setState({
@@ -416,9 +416,9 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
const dropdownContent = (
<>
<div className={`overflow-y-auto ${isMobileBrowser ? 'p-1' : 'p-3'}`} style={{ maxHeight: isMobileBrowser ? undefined : dropdownPosition?.maxHeight }}>
<div className={`overflow-y-auto ${isMobileBrowser ? 'p-1' : 'p-1.5'}`} style={{ maxHeight: isMobileBrowser ? undefined : dropdownPosition?.maxHeight }}>
{!isMobileBrowser && (
<div className="mb-2 px-1 text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)]">
<div className="mb-1 px-3 pt-1.5 text-[10px] font-bold uppercase tracking-widest text-[var(--color-text-tertiary)]">
{t('model.configuration')}
</div>
)}
@@ -427,8 +427,8 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
<div className="space-y-3">
{providerChoices.map((choice) => (
<div key={choice.providerId ?? 'official'} className="space-y-1.5">
<div className="flex items-center justify-between px-2 pt-1">
<span className="truncate text-[11px] font-semibold tracking-[0.01em] text-[var(--color-text-secondary)]">
<div className="flex items-center justify-between gap-2 px-3 pt-1">
<span className="truncate text-xs font-semibold text-[var(--color-text-tertiary)]">
{choice.providerName}
</span>
{choice.isDefault && (
@@ -463,33 +463,31 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
})
}}
className={`
w-full rounded-lg border px-3 text-left transition-colors
${isMobileBrowser ? 'min-h-[56px] py-3' : 'py-2.5'}
w-full rounded-[var(--radius-md)] border px-3 text-left transition-colors
${isMobileBrowser ? 'min-h-[56px] py-3' : 'py-2'}
${isSelected
? 'border-[var(--color-model-option-selected-border)] bg-[var(--color-model-option-selected-bg)]'
: 'border-transparent hover:bg-[var(--color-surface-hover)]'
}
`}
>
<div className="flex items-start gap-3">
<div className={`mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full border-2 ${
isSelected ? 'border-[var(--color-brand)]' : 'border-[var(--color-outline)]'
}`}>
{isSelected && (
<div className="h-2 w-2 rounded-full bg-[var(--color-brand)]" />
)}
</div>
<div className="flex items-center gap-3">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-[var(--color-text-primary)]">
{/* Model ids are identifiers, so they sit in the
mono face alongside paths and token counts. */}
<div className="truncate font-mono text-[13px] font-medium text-[var(--color-text-primary)]">
{model.name}
</div>
{model.description && (
<div className="mt-0.5 truncate pr-[6px] text-[10px] text-[var(--color-text-tertiary)]">
<div className="mt-0.5 truncate pr-[6px] text-[11px] text-[var(--color-text-tertiary)]">
{model.description}
</div>
)}
</div>
{isSelected && (
<span className="material-symbols-outlined flex-shrink-0 text-[16px] text-[var(--color-brand)]">check</span>
)}
</div>
</button>
)
@@ -514,8 +512,8 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
setOpen(false)
}}
className={`
w-full rounded-lg px-3 text-left transition-colors
${isMobileBrowser ? 'min-h-[56px] py-3' : 'py-2.5'}
w-full rounded-[var(--radius-md)] px-3 text-left transition-colors
${isMobileBrowser ? 'min-h-[56px] py-3' : 'py-2'}
${isSelected
? 'border border-[var(--color-model-option-selected-border)] bg-[var(--color-model-option-selected-bg)]'
: 'hover:bg-[var(--color-surface-hover)]'
@@ -523,22 +521,18 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
`}
>
<div className="flex items-center gap-3">
<div className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full border-2 ${
isSelected ? 'border-[var(--color-brand)]' : 'border-[var(--color-outline)]'
}`}>
{isSelected && (
<div className="h-2 w-2 rounded-full bg-[var(--color-brand)]" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-[var(--color-text-primary)]">{model.name}</div>
<div className="truncate font-mono text-[13px] font-medium text-[var(--color-text-primary)]">{model.name}</div>
{model.description && (
<div className="mt-0.5 truncate text-[10px] text-[var(--color-text-tertiary)]">
<div className="mt-0.5 truncate text-[11px] text-[var(--color-text-tertiary)]">
{model.description}
</div>
)}
</div>
{isSelected && (
<span className="material-symbols-outlined flex-shrink-0 text-[16px] text-[var(--color-brand)]">check</span>
)}
</div>
</button>
)
@@ -568,7 +562,7 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
<div
ref={dropdownRef}
data-testid="model-selector-dropdown"
className="fixed z-[80] rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)]"
className="fixed z-[var(--z-dropdown)] rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-overlay)]"
style={{
top: dropdownPosition.top,
bottom: dropdownPosition.bottom,
@@ -587,7 +581,14 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
data-testid="model-selector-shell"
className={`relative min-w-0 ${fluid ? 'flex-1' : 'shrink-0'}`}
>
<div ref={ref} className={`flex min-w-0 items-stretch rounded-full bg-[var(--color-surface-container-low)] transition-colors hover:bg-[var(--color-surface-hover)] ${fluid ? 'w-full' : ''} ${disabled ? 'opacity-50' : ''}`}>
{/* No fill at rest: on the composer row the model name is type, not a
control chip — the handoff reserves filled pills for the permission
and context chips and gives this one a hover ground only. */}
{/* On the phone composer this sits between two 44px buttons and opens a
bottom sheet, so both halves stretch to the same 44px touch target
`PermissionModeSelector` uses; `compact` alone would also shrink the
desktop composer, which narrows for the right panel, not for touch. */}
<div ref={ref} className={`flex min-w-0 items-stretch rounded-[var(--radius-md)] transition-colors hover:bg-[var(--color-surface-hover)] ${isMobileBrowser ? 'min-h-11' : ''} ${fluid ? 'w-full' : ''} ${disabled ? 'opacity-50' : ''}`}>
<button
onClick={() => {
if (disabled) return
@@ -597,11 +598,15 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
disabled={disabled}
aria-label={buttonProviderLabel ? `${buttonModelLabel}, ${buttonProviderLabel}` : undefined}
title={buttonProviderLabel ? `${buttonProviderLabel} · ${buttonModelLabel}` : undefined}
className={`flex min-w-0 items-center gap-2 rounded-l-full text-xs font-medium text-[var(--color-text-secondary)] outline-none transition-colors focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] disabled:cursor-not-allowed ${
compact ? `${fluid ? 'flex-1' : ''} max-w-[112px] py-1.5 pl-2.5 pr-1` : 'max-w-[220px] py-1.5 pl-3 pr-1'
// `focus-visible:rounded-*` restores the other pair of corners while
// focused. The ring traces `border-radius`, so on the half-rounded
// halves of this segmented control it otherwise drew a box that was
// rounded down one side and square down the other.
className={`flex min-w-0 items-center gap-2 rounded-l-[var(--radius-md)] text-xs font-medium text-[var(--color-text-secondary)] outline-none transition-colors focus-visible:rounded-[var(--radius-md)] focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] disabled:cursor-not-allowed ${
compact ? `${fluid ? 'flex-1' : ''} max-w-[112px] py-1.5 pl-2.5 pr-1` : 'max-w-[220px] py-2 pl-2.5 pr-1'
}`}
>
<span className={`${compact ? 'text-xs' : 'text-sm'} min-w-0 flex-1 truncate font-semibold text-[var(--color-text-primary)]`}>
<span className={`${compact ? 'text-xs' : 'text-[15px]'} min-w-0 flex-1 truncate font-semibold text-[var(--color-text-primary)]`}>
{buttonModelLabel}
</span>
{!canEditRuntimeEffort && !compact && buttonProviderLabel && (
@@ -609,7 +614,7 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
{buttonProviderLabel}
</span>
)}
<span className="material-symbols-outlined flex-shrink-0 text-[12px]">expand_more</span>
<span className="material-symbols-outlined flex-shrink-0 text-[12px] text-[var(--color-text-tertiary)]">expand_more</span>
</button>
{canEditRuntimeEffort && selectedRuntimeEffort && runtimeEffortOptions.length > 0 && (
@@ -624,7 +629,7 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
setOpen(false)
setEffortOpen(!effortOpen)
}}
className={`rounded-r-full pr-3 text-[var(--color-text-tertiary)] outline-none transition-colors hover:text-[var(--color-text-secondary)] focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] disabled:cursor-not-allowed ${compact ? 'pl-1 text-[10px]' : 'pl-1.5 text-xs'}`}
className={`rounded-r-[var(--radius-md)] pr-2.5 text-[var(--color-text-secondary)] outline-none transition-colors hover:text-[var(--color-text-primary)] focus-visible:rounded-[var(--radius-md)] focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] disabled:cursor-not-allowed ${compact ? 'pl-1 text-[10px]' : 'pl-1.5 text-[13.5px]'}`}
>
{effortLabels[selectedRuntimeEffort]}
</button>
@@ -478,6 +478,22 @@ describe('PermissionModeSelector', () => {
expect(triggerIcon).toHaveClass('text-[12px]')
})
it('keeps every row on one text column despite the reduced Auto glyph', () => {
render(<PermissionModeSelector value="default" onChange={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Ask permissions' }))
const icons = screen
.getAllByRole('menuitem')
.map((item) => item.querySelector('.material-symbols-outlined'))
expect(icons).toHaveLength(5)
// The box, not the glyph, sets where the title starts. Without it the
// 18px Auto glyph pulled its own title 2px left of the other four.
for (const icon of icons) {
expect(icon).toHaveClass('w-5', 'shrink-0', 'text-center')
}
})
it('does not change mode when first-use Auto confirmation is cancelled', () => {
const onChange = vi.fn()
useSettingsStore.setState({ autoModeOptInAccepted: false } as never)
@@ -10,6 +10,7 @@ import { useTranslation } from '../../i18n'
import type { PermissionMode } from '../../types/settings'
import { useMobileViewport } from '../../hooks/useMobileViewport'
import { isDesktopRuntime } from '../../lib/desktopRuntime'
import { Badge } from '@/components/ui/Badge'
import { MobileBottomSheet } from '@/components/ui/MobileBottomSheet'
import { ActionDialog } from '@/components/ui/ActionDialog'
import { AutoModeOptInDialog } from './AutoModeOptInDialog'
@@ -67,6 +68,8 @@ export function PermissionModeSelector({ workDir: workDirProp, compact = false,
description: string
icon: string
color?: string
/** Display-only flag on the row; carries no behaviour. */
badge?: { tone: 'warning' | 'danger'; label: string }
}> = [
{
value: 'default',
@@ -86,6 +89,7 @@ export function PermissionModeSelector({ workDir: workDirProp, compact = false,
description: t('permMode.autoModeDesc'),
icon: 'autoplay',
color: 'text-[var(--color-brand)]',
badge: { tone: 'warning', label: t('permMode.badge.optIn') },
},
{
value: 'plan',
@@ -100,6 +104,7 @@ export function PermissionModeSelector({ workDir: workDirProp, compact = false,
description: t('permMode.bypassDesc'),
icon: 'gavel',
color: 'text-[var(--color-error)]',
badge: { tone: 'danger', label: t('permMode.badge.risky') },
},
]
@@ -121,9 +126,9 @@ export function PermissionModeSelector({ workDir: workDirProp, compact = false,
const workDir = workDirProp || activeSession?.workDir || '~'
const compactButtonClass = compact
? isMobile
? 'h-11 w-11 justify-center rounded-xl p-0'
: 'h-8 w-8 justify-center rounded-full p-0'
: 'gap-1.5 rounded-full px-2.5 py-1.5 text-xs'
? 'h-11 w-11 justify-center rounded-[var(--radius-md)] p-0 border border-[var(--color-border)] bg-[var(--color-surface)]'
: 'h-8 w-8 justify-center rounded-full p-0 bg-[var(--color-surface-container-low)]'
: 'h-8 gap-2 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 text-[13px]'
const menuPlacementClass = menuPlacement === 'bottom'
? 'top-full mt-2'
: 'bottom-full mb-2'
@@ -202,17 +207,30 @@ export function PermissionModeSelector({ workDir: workDirProp, compact = false,
interactionTabIdRef.current = null
}}
className={`
flex w-full items-start gap-3 px-4 py-3 text-left transition-colors
flex w-full items-start gap-3 rounded-[var(--radius-md)] px-3 py-2.5 text-left transition-colors
hover:bg-[var(--color-surface-hover)]
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-border-focus)]
${item.value === currentMode ? 'bg-[var(--color-surface-selected)]' : ''}
`}
>
<span className={`material-symbols-outlined mt-0.5 ${item.value === 'auto' ? 'text-[18px]' : 'text-[20px]'} ${item.color || 'text-[var(--color-text-secondary)]'}`}>
{/* Fixed 20px box: the Auto glyph is drawn 2px smaller to match the
others optically, and without a box that difference shifted its
whole title/description column 2px left of the other four rows. */}
<span className={`material-symbols-outlined mt-0.5 w-5 shrink-0 text-center ${item.value === 'auto' ? 'text-[18px]' : 'text-[20px]'} ${item.color || 'text-[var(--color-text-secondary)]'}`}>
{item.icon}
</span>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-[var(--color-text-primary)]">{item.label}</div>
<div className="mt-0.5 text-xs text-[var(--color-text-tertiary)]">{item.description}</div>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-[var(--color-text-primary)]">{item.label}</span>
{item.badge && (
// `pill={false}` gives the handoff's 6px corner; the pill shape
// is reserved for status chips elsewhere.
<Badge tone={item.badge.tone} size="xs" pill={false}>
{item.badge.label}
</Badge>
)}
</div>
<div className="mt-0.5 text-[12.5px] leading-snug text-[var(--color-text-tertiary)]">{item.description}</div>
</div>
{item.value === currentMode && (
<span className="material-symbols-outlined mt-0.5 text-[16px] text-[var(--color-brand)]" style={{ fontVariationSettings: "'FILL' 1" }}>
@@ -244,17 +262,17 @@ export function PermissionModeSelector({ workDir: workDirProp, compact = false,
aria-expanded={open}
aria-controls={open ? menuId : undefined}
title={isTurnActive ? t('permMode.disabledDuringTurn') : (compact ? MODE_LABELS[currentMode] : undefined)}
className={`flex items-center bg-[var(--color-surface-container-low)] font-medium text-[var(--color-text-secondary)] transition-colors ${
isTurnActive ? 'opacity-50 cursor-not-allowed' : 'hover:bg-[var(--color-surface-hover)]'
className={`flex items-center font-medium text-[var(--color-text-primary)] transition-[background-color,color,border-color] duration-150 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface)] ${
isTurnActive ? 'opacity-50 cursor-not-allowed' : 'hover:border-[var(--color-outline)] hover:bg-[var(--color-surface-hover)]'
} ${compactButtonClass}`}
>
<span className={`material-symbols-outlined ${currentMode === 'auto' ? 'text-[12px]' : 'text-[14px]'}`}>
<span className={`material-symbols-outlined text-[var(--color-text-secondary)] ${currentMode === 'auto' ? 'text-[12px]' : 'text-[14px]'}`}>
{MODE_ICONS[currentMode]}
</span>
{!compact && (
<>
<span>{MODE_LABELS[currentMode]}</span>
<span className="material-symbols-outlined text-[12px]">expand_more</span>
<span className="material-symbols-outlined text-[12px] text-[var(--color-text-tertiary)]">expand_more</span>
</>
)}
</button>
@@ -274,8 +292,8 @@ export function PermissionModeSelector({ workDir: workDirProp, compact = false,
</div>
</MobileBottomSheet>
) : (
<div id={menuId} ref={menuRef} role="menu" className={`absolute left-0 ${menuPlacementClass} w-[320px] rounded-xl bg-[var(--color-surface-container-lowest)] border border-[var(--color-border)] shadow-[var(--shadow-dropdown)] z-50 py-2`}>
<div className="px-4 py-2 text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)]">
<div id={menuId} ref={menuRef} role="menu" className={`absolute left-0 ${menuPlacementClass} z-[var(--z-dropdown)] w-[360px] rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] p-1.5 shadow-[var(--shadow-overlay)]`}>
<div className="px-3 pb-1 pt-2 text-[10px] font-bold uppercase tracking-widest text-[var(--color-text-tertiary)]">
{t('permMode.executionPermissions')}
</div>
{permissionItems}
@@ -300,9 +318,9 @@ export function PermissionModeSelector({ workDir: workDirProp, compact = false,
className="text-xs leading-relaxed text-[var(--color-text-secondary)]"
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(t('permMode.enableBypassBody')) }}
/>
<div className="flex items-center gap-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container)] px-3 py-2" title={workDir}>
<div className="flex items-center gap-2 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container)] px-3 py-2" title={workDir}>
<span className="material-symbols-outlined shrink-0 text-[16px] text-[var(--color-text-tertiary)]">folder</span>
<code className="truncate text-xs font-[var(--font-mono)] text-[var(--color-text-primary)]">{workDir}</code>
<code className="truncate font-mono text-xs text-[var(--color-text-primary)]">{workDir}</code>
</div>
<ul className="space-y-1.5 text-xs text-[var(--color-text-secondary)]">
<li className="flex items-start gap-2">
@@ -40,20 +40,40 @@ function renderPopover(overrides: Partial<React.ComponentProps<typeof ReasoningE
}
describe('ReasoningEffortPopover', () => {
// Sizes come from §3 of docs/redesign-paper-ink-seal.md: a 300px panel with
// 19/22px padding, the serif level name over a 14px-tall track, and a 24px
// knob. They replace the earlier 240px panel and 24px track.
it('keeps the effort visual compact without non-functional icon controls', () => {
renderPopover()
const popover = screen.getByTestId('reasoning-effort-popover')
expect(popover).toHaveStyle({ width: '240px' })
expect(popover).toHaveClass('px-3.5', 'pb-3.5', 'pt-3')
expect(popover).toHaveStyle({ width: '300px' })
expect(popover).toHaveClass('px-[22px]', 'pb-[19px]', 'pt-[19px]')
expect(popover.querySelectorAll('svg')).toHaveLength(0)
expect(screen.getByTestId('reasoning-effort-header')).toHaveClass('mb-2.5', 'justify-between')
expect(screen.getByTestId('reasoning-effort-label')).toHaveClass('text-sm')
expect(screen.getByTestId('reasoning-effort-context-label')).toHaveClass('text-[10px]')
expect(screen.getByTestId('reasoning-effort-header')).toHaveClass('mb-[15px]', 'justify-between')
expect(screen.getByTestId('reasoning-effort-label')).toHaveClass('text-[19px]')
expect(screen.getByTestId('reasoning-effort-label')).toHaveStyle({ fontFamily: 'var(--font-headline)' })
expect(screen.getByTestId('reasoning-effort-context-label')).toHaveClass('text-[12.5px]')
expect(screen.getByTestId('reasoning-effort-context-label')).toHaveTextContent('推理强度')
expect(screen.getByRole('slider', { name: '推理强度' })).toHaveClass('h-9')
expect(screen.getByTestId('reasoning-effort-track')).toHaveClass('h-6')
expect(screen.getByTestId('reasoning-effort-thumb')).toHaveClass('h-8', 'w-8')
expect(screen.getByRole('slider', { name: '推理强度' })).toHaveClass('h-[26px]')
expect(screen.getByTestId('reasoning-effort-track')).toHaveClass('h-[14px]')
expect(screen.getByTestId('reasoning-effort-thumb')).toHaveClass('h-6', 'w-6')
})
it('paints the knob and stops from tokens so they survive the ink themes', () => {
renderPopover()
// `bg-white` on the knob and `white/45` on the stops were invisible on the
// paper themes and dropped outright by the Safari 15 WebView, which cannot
// parse the `/N` alpha modifier.
expect(screen.getByTestId('reasoning-effort-thumb')).toHaveClass('bg-[var(--color-surface)]')
// On dark and ink-blue `--color-surface` is the same value as the panel
// behind it, so the fill alone left the knob with no edge to read against.
expect(screen.getByTestId('reasoning-effort-thumb')).toHaveClass('border', 'border-[var(--color-outline)]')
for (const stop of screen.getAllByTestId('reasoning-effort-stop')) {
expect(stop.className).not.toMatch(/white/)
expect(stop.className).not.toMatch(/\/\d+/)
}
})
it('renders every model-supported stop and exposes the selected localized value', () => {
@@ -67,7 +87,9 @@ describe('ReasoningEffortPopover', () => {
expect(screen.getAllByTestId('reasoning-effort-stop')).toHaveLength(5)
expect(screen.getByText('极高')).toBeInTheDocument()
expect(screen.getByTestId('reasoning-effort-fill')).toHaveClass('bg-[var(--color-brand)]')
expect(slider).toHaveClass('focus-visible:ring-[var(--color-brand)]')
// `--color-border-focus` is the app-wide focus token (terracotta in all six
// palettes); the raw brand color here predated it.
expect(slider).toHaveClass('focus-visible:ring-[var(--color-border-focus)]')
})
it('selects a discrete stop from the track', () => {
@@ -22,7 +22,7 @@ type PopoverPosition = {
width: number
}
const POPOVER_WIDTH = 240
const POPOVER_WIDTH = 300
const VIEWPORT_MARGIN = 16
const POPOVER_GAP = 10
@@ -113,22 +113,23 @@ export function ReasoningEffortPopover({
<div
ref={popoverRef}
data-testid="reasoning-effort-popover"
className="fixed z-[90] rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3.5 pb-3.5 pt-3 shadow-[0_12px_30px_rgba(15,23,42,0.13)]"
className="fixed z-[var(--z-popover)] rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-[22px] pb-[19px] pt-[19px] shadow-[var(--shadow-overlay)]"
style={{ bottom: position.bottom, left: position.left, width: position.width }}
>
<div
data-testid="reasoning-effort-header"
className="mb-2.5 flex items-baseline justify-between gap-3"
className="mb-[15px] flex items-baseline justify-between gap-3"
>
<div
data-testid="reasoning-effort-label"
className="text-sm font-semibold text-[var(--color-text-secondary)]"
className="text-[19px] font-bold leading-none text-[var(--color-text-primary)]"
style={{ fontFamily: 'var(--font-headline)' }}
>
{labels[value]}
</div>
<div
data-testid="reasoning-effort-context-label"
className="text-[10px] font-medium tracking-wide text-[var(--color-text-tertiary)]"
className="text-[12.5px] text-[var(--color-text-tertiary)]"
>
{label}
</div>
@@ -143,7 +144,7 @@ export function ReasoningEffortPopover({
aria-valuemax={maxIndex}
aria-valuenow={selectedIndex}
aria-valuetext={labels[value]}
className="group relative flex h-9 touch-none cursor-pointer items-center outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] focus-visible:ring-offset-3 focus-visible:ring-offset-[var(--color-surface-container-lowest)]"
className="group relative flex h-[26px] touch-none cursor-pointer items-center outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-4 focus-visible:ring-offset-[var(--color-surface-container-lowest)]"
onClick={(event) => selectFromClientX(event.clientX)}
onPointerDown={(event) => {
draggingRef.current = true
@@ -188,21 +189,25 @@ export function ReasoningEffortPopover({
>
<div
data-testid="reasoning-effort-track"
className="absolute inset-x-0 h-6 overflow-hidden rounded-full bg-[var(--color-surface-container-high)] shadow-[inset_0_0_0_1px_var(--color-border)]"
className="absolute inset-x-0 h-[14px] overflow-hidden rounded-full bg-[var(--color-surface-hover)]"
>
<div
data-testid="reasoning-effort-fill"
className="h-full rounded-full bg-[var(--color-brand)] transition-[width] duration-200 motion-reduce:transition-none"
className="h-full rounded-full bg-[var(--color-brand)] transition-[width] duration-[180ms] motion-reduce:transition-none"
style={{ width: `${fillPercent}%` }}
/>
</div>
<div className="absolute inset-x-0 flex items-center justify-between px-[18px]">
<div className="absolute inset-x-0 flex items-center justify-between px-[11px]">
{options.map((option, index) => (
<span
key={option}
data-testid="reasoning-effort-stop"
className={`h-1.5 w-1.5 rounded-full ${index <= selectedIndex ? 'bg-white/45' : 'bg-[var(--color-outline)]/55'}`}
// Foreground token rather than `white/45`: the `/N` modifier
// compiles to a color function Safari 15's WebView drops, and the
// unfilled half of the track is a light surface under every
// palette, where white dots are invisible.
className={`h-[7px] w-[7px] rounded-full ${index <= selectedIndex ? 'bg-[var(--color-on-primary)]' : 'bg-[var(--color-outline)]'}`}
/>
))}
</div>
@@ -210,7 +215,10 @@ export function ReasoningEffortPopover({
<div
aria-hidden="true"
data-testid="reasoning-effort-thumb"
className="absolute top-1/2 h-8 w-8 -translate-x-1/2 -translate-y-1/2 rounded-full border border-[var(--color-border)] bg-white shadow-[0_2px_7px_rgba(15,23,42,0.13)] transition-[left] duration-200 motion-reduce:transition-none"
// The hairline is what makes the knob readable on the ink themes,
// where `--color-surface` resolves to the same value as the popover
// it sits on and `--shadow-card` has nothing lighter to cast against.
className="absolute top-1/2 h-6 w-6 -translate-x-1/2 -translate-y-1/2 rounded-full border border-[var(--color-outline)] bg-[var(--color-surface)] shadow-[var(--shadow-card)] transition-[left] duration-[180ms] motion-reduce:transition-none"
style={{ left: `${fillPercent}%` }}
/>
</div>
@@ -120,7 +120,12 @@ export function DoctorPanel({ compact = false }: DoctorPanelProps) {
<Card as="section" radius="md" padding={compact ? 'sm' : 'md'}>
<div className={`flex ${compact ? 'flex-col gap-3' : 'items-start justify-between gap-4'}`}>
<div className="min-w-0">
<div className="text-sm font-medium text-[var(--color-text-primary)]">{t('settings.diagnostics.doctorTitle')}</div>
<div
className="text-[14.5px] font-semibold text-[var(--color-text-primary)]"
style={{ fontFamily: 'var(--font-headline)' }}
>
{t('settings.diagnostics.doctorTitle')}
</div>
<p className="mt-1 text-xs text-[var(--color-text-tertiary)]">
{t('settings.diagnostics.doctorDescription')}
</p>
@@ -562,7 +562,10 @@ describe('AppShell boot flow', () => {
expect(header).toHaveTextContent('Analyze recent commits')
expect(header).toHaveTextContent('session.active')
expect(header).toHaveTextContent('session.messages')
expect(screen.getByTestId('mobile-sidebar-toggle')).toHaveClass('h-10', 'w-10')
// 44px — the hamburger is the primary mobile navigation target, and the
// platform minimum for primary touch targets is 44, not the 40 it shipped
// at (IconButton's own size doc had the two tiers reversed).
expect(screen.getByTestId('mobile-sidebar-toggle')).toHaveClass('h-11', 'w-11')
})
it('keeps browser H5 mobile on chat tabs when settings was restored as active', async () => {
+2 -2
View File
@@ -285,7 +285,7 @@ export function AppShell() {
<button
type="button"
data-testid="sidebar-backdrop"
className="app-shell-backdrop fixed inset-0 z-40 border-0 p-0"
className="app-shell-backdrop fixed inset-0 z-[var(--z-scrim)] border-0 p-0"
aria-label={t('sidebar.collapse')}
onClick={() => setEffectiveSidebarOpen(false)}
/>
@@ -317,7 +317,7 @@ export function AppShell() {
icon={effectiveSidebarOpen ? 'close' : 'menu'}
label={effectiveSidebarOpen ? t('sidebar.collapse') : t('sidebar.expand')}
onClick={toggleEffectiveSidebar}
size="xl"
size="2xl"
aria-controls="sidebar-shell"
aria-expanded={effectiveSidebarOpen}
/>
@@ -2,7 +2,10 @@ import type { FormEvent } from 'react'
import { useState } from 'react'
import { saveAndVerifyH5Connection } from '../../lib/desktopRuntime'
import { Button } from '@/components/ui/Button'
import { Card } from '@/components/ui/Card'
import { Input } from '@/components/ui/Input'
import { BrandSeal } from '@/components/composite/BrandSeal'
import { useTranslation } from '../../i18n'
type H5ConnectionViewProps = {
initialServerUrl?: string | null
@@ -15,6 +18,7 @@ export function H5ConnectionView({
error: initialError,
onConnected,
}: H5ConnectionViewProps) {
const t = useTranslation()
const [serverUrl, setServerUrl] = useState(initialServerUrl ?? '')
const [token, setToken] = useState('')
const [error, setError] = useState(initialError ?? '')
@@ -30,7 +34,7 @@ export function H5ConnectionView({
onConnected()
} catch (submitError) {
setError(
submitError instanceof Error ? submitError.message : 'Unable to connect to the H5 server.',
submitError instanceof Error ? submitError.message : t('h5Connect.failed'),
)
} finally {
setSubmitting(false)
@@ -38,20 +42,31 @@ export function H5ConnectionView({
}
return (
<div className="h-screen flex items-center justify-center bg-[var(--color-surface)] px-6">
<section className="w-full max-w-md rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-6 shadow-[var(--shadow-md)]">
<div className="mb-5">
<h1 className="text-lg font-semibold text-[var(--color-text-primary)]">
Connect to H5 Access
<div className="animate-screen-pop h-screen flex items-center justify-center bg-[var(--color-surface)] px-6">
<Card
as="section"
radius="xl"
surface="low"
padding="lg"
shadow="card"
className="w-full max-w-md"
>
<div className="mb-5 flex flex-col items-center gap-3 text-center">
<BrandSeal size="lg" />
<h1
className="text-[21px] font-semibold tracking-tight text-[var(--color-text-primary)]"
style={{ fontFamily: 'var(--font-headline)' }}
>
{t('h5Connect.title')}
</h1>
<p className="mt-2 text-sm text-[var(--color-text-secondary)]">
Enter the server URL and H5 access token from the desktop app.
<p className="text-sm leading-6 text-[var(--color-text-secondary)]">
{t('h5Connect.subtitle')}
</p>
</div>
<form className="space-y-4" onSubmit={handleSubmit}>
<Input
label="Server URL"
label={t('h5Connect.serverUrl')}
placeholder="https://chat.example.com"
value={serverUrl}
onChange={(event) => setServerUrl(event.target.value)}
@@ -59,7 +74,7 @@ export function H5ConnectionView({
required
/>
<Input
label="H5 Token"
label={t('h5Connect.token')}
type="password"
placeholder="h5_..."
value={token}
@@ -69,16 +84,19 @@ export function H5ConnectionView({
/>
{error ? (
<div className="rounded-lg border border-[var(--color-error)]/30 bg-[var(--color-error)]/8 px-3 py-2 text-sm text-[var(--color-error)]">
<div
role="alert"
className="rounded-[var(--radius-md)] border border-[var(--color-error)] bg-[var(--color-error-container)] px-3 py-2 text-sm text-[var(--color-on-error-container)]"
>
{error}
</div>
) : null}
<Button type="submit" size="lg" className="w-full" loading={submitting}>
Connect
{t('h5Connect.submit')}
</Button>
</form>
</section>
</Card>
</div>
)
}
@@ -78,7 +78,7 @@ export function OpenProjectMenu({ path }: Props) {
}
void handleOpenTarget(primaryTarget.id)
}}
className={`inline-flex h-8 items-center justify-center gap-1 rounded-[10px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] text-[var(--color-text-tertiary)] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${
className={`inline-flex h-8 items-center justify-center gap-1 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] text-[var(--color-text-tertiary)] transition-[background-color,color,border-color,box-shadow] duration-150 ease-out hover:border-[var(--color-outline)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface)] ${
hasMenu
? 'min-w-[2.75rem] px-2 hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]'
: 'w-8 hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]'
@@ -92,7 +92,7 @@ export function OpenProjectMenu({ path }: Props) {
<div
ref={menuRef}
role="menu"
className="fixed z-50 min-w-[220px] overflow-hidden rounded-[12px] border border-[var(--color-border)] bg-[var(--color-surface)] py-1 shadow-[var(--shadow-dropdown)]"
className="glass-panel fixed z-[var(--z-dropdown)] min-w-[220px] overflow-hidden rounded-[var(--radius-lg)] py-1"
style={{ top: rect.bottom + 6, right: Math.max(12, window.innerWidth - rect.right) }}
>
{targets.map((target) => (
@@ -1344,6 +1344,39 @@ describe('Sidebar', () => {
expect(addToast).not.toHaveBeenCalled()
})
it('announces a session list failure and offers a retry', () => {
useSessionStore.setState({ sessions: [], isLoading: false, error: 'upstream exploded' })
render(<Sidebar />)
const alert = screen.getByRole('alert')
expect(alert).toHaveTextContent('Session list failed')
expect(alert).toHaveTextContent('upstream exploded')
fetchSessions.mockClear()
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
expect(fetchSessions).toHaveBeenCalled()
})
it('does not claim there are no sessions while the list is failing', () => {
useSessionStore.setState({ sessions: [], isLoading: false, error: 'upstream exploded' })
render(<Sidebar />)
// Showing "no sessions" next to the failure reads as "the list is empty",
// which is a different fact from "we could not load the list".
expect(screen.queryByText('No sessions')).not.toBeInTheDocument()
})
it('says there are no sessions once the list loads empty', () => {
useSessionStore.setState({ sessions: [], isLoading: false, error: null })
render(<Sidebar />)
expect(screen.getByText('No sessions')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('keeps the initial loading state during an empty first build', () => {
useSessionStore.setState({
sessions: [],
@@ -1619,4 +1652,58 @@ describe('Sidebar', () => {
value: originalVisibility,
})
})
// The whole drawer is touch-only: it has no hover, and nothing can be focused
// through `pointer-events: none`. Every control gated on `group-hover` was
// therefore either dead or an invisible tap target, and the 53 tests above
// never saw it because they all render the desktop sidebar.
describe('touch drawer controls', () => {
const renderWithProject = (isMobile: boolean) => {
useSessionStore.setState({
sessions: [makeSession('alpha-1', 'Alpha newest', '/workspace/alpha', new Date('2026-05-15T10:00:00.000Z').toISOString())],
})
return render(<Sidebar isMobile={isMobile} />)
}
it('keeps the project row actions hover-gated on desktop', () => {
renderWithProject(false)
const actions = screen.getByRole('button', { name: 'Project actions for alpha' })
expect(actions).toHaveClass('h-7', 'w-7')
expect(actions.parentElement).toHaveClass('pointer-events-none', 'opacity-0')
})
it('leaves the project row actions tappable at 44px in the drawer', () => {
renderWithProject(true)
const actions = screen.getByRole('button', { name: 'Project actions for alpha' })
const create = screen.getByRole('button', { name: 'New session in alpha' })
expect(actions).toHaveClass('h-11', 'w-11')
expect(create).toHaveClass('h-11', 'w-11')
// Both live in one row, so they need a gap wide enough not to catch a
// thumb aimed at the other.
expect(actions.parentElement).toHaveClass('opacity-100', 'gap-1.5')
expect(actions.parentElement).not.toHaveClass('pointer-events-none')
})
it('stops rendering the projects header actions as an invisible tap target', () => {
renderWithProject(true)
// These kept `pointer-events` while sitting at `opacity: 0` — visually
// absent on a phone, yet still firing on tap.
const menu = screen.getByRole('button', { name: 'Project menu' })
expect(menu).toHaveClass('h-11', 'w-11')
expect(menu.parentElement).toHaveClass('opacity-100')
expect(menu.parentElement).not.toHaveClass('opacity-0')
})
it('raises the search row and overflow toggle to the touch minimum', () => {
renderWithProject(true)
expect(screen.getByRole('button', { name: 'Refresh sessions' })).toHaveClass('h-11', 'w-11')
expect(screen.getByRole('button', { name: 'Batch manage' })).toHaveClass('h-11', 'w-11')
// Same flex row as the two above; at h-9 it left the row ragged.
expect(screen.getAllByRole('button', { name: 'Search chats' })[0]).toHaveClass('h-11')
})
})
})
+68 -45
View File
@@ -3,8 +3,11 @@ import { Check, ChevronDown, Clock, Folder, FolderOpen, FolderPlus, GitBranch, M
import { useSessionStore } from '../../stores/sessionStore'
import { useUIStore } from '../../stores/uiStore'
import { useTranslation, type TranslationKey } from '../../i18n'
import { BrandSeal } from '@/components/composite/BrandSeal'
import { Button } from '@/components/ui/Button'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
import { EmptyState } from '@/components/ui/EmptyState'
import { ErrorState } from '@/components/ui/ErrorState'
import { IconButton } from '@/components/ui/IconButton'
import { Spinner } from '@/components/ui/Spinner'
import { useDismissable } from '@/hooks/useDismissable'
@@ -16,7 +19,6 @@ import { useChatStore } from '../../stores/chatStore'
import { useOpenTargetStore } from '../../stores/openTargetStore'
import { desktopUiPreferencesApi, type SidebarProjectPreferences } from '../../api/desktopUiPreferences'
import { getDesktopHost } from '../../lib/desktopHost'
import { publicAssetPath } from '../../lib/publicAsset'
import { hasRunningBackgroundTasks } from '../../lib/backgroundTasks'
import { getSessionWorkspaceState } from '../../lib/sessionWorkspace'
@@ -679,12 +681,12 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
>
<div className={`flex ${expanded ? 'items-center justify-between gap-3' : 'flex-col items-center gap-2'}`}>
<div className={`flex min-w-0 items-center ${expanded ? 'gap-2.5' : 'justify-center'}`}>
<img src={publicAssetPath('app-icon.png')} alt="" className="h-8 w-8 flex-shrink-0" />
<BrandSeal size={expanded ? 'md' : 'lg'} />
<span
className={`sidebar-copy ${expanded ? 'sidebar-copy--visible' : 'sidebar-copy--hidden'} text-[13px] font-semibold tracking-tight text-[var(--color-text-primary)]`}
className={`sidebar-copy ${expanded ? 'sidebar-copy--visible' : 'sidebar-copy--hidden'} text-base font-bold tracking-tight text-[var(--color-text-primary)]`}
style={{ fontFamily: 'var(--font-headline)' }}
>
Claude Code <span className="text-[var(--color-primary-container)]">Haha</span>
Claude Code <span className="text-[var(--color-brand)]">Haha</span>
</span>
</div>
<div className={`flex items-center ${expanded ? 'gap-1.5' : 'flex-col gap-2'}`}>
@@ -692,7 +694,7 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
href="https://github.com/NanmiCoder/cc-haha"
target="_blank"
rel="noopener noreferrer"
className={`sidebar-copy ${expanded ? 'sidebar-copy--visible' : 'sidebar-copy--hidden'} inline-flex items-center justify-center rounded-md p-1 text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]`}
className={`sidebar-copy ${expanded ? 'sidebar-copy--visible' : 'sidebar-copy--hidden'} inline-flex items-center justify-center rounded-[var(--radius-sm)] p-1 text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]`}
title="GitHub"
tabIndex={expanded ? undefined : -1}
aria-hidden={!expanded}
@@ -703,7 +705,7 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
<button
type="button"
onClick={closeMobileDrawer}
className="sidebar-toggle-button flex h-11 w-11 items-center justify-center rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-sidebar)]"
className="sidebar-toggle-button flex h-11 w-11 items-center justify-center rounded-[var(--radius-lg)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-sidebar)]"
aria-label={t('sidebar.collapse')}
title={t('sidebar.collapse')}
>
@@ -785,7 +787,7 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
<button
type="button"
onClick={() => openModal('globalSearch')}
className="flex h-9 min-w-0 flex-1 items-center gap-2 rounded-[14px] border border-[var(--color-sidebar-search-border)] bg-[var(--color-sidebar-search-bg)] pl-3 pr-2 text-left text-[13px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-sidebar-item-hover)] focus-visible:border-[var(--color-border-focus)] focus-visible:outline-none"
className={`flex min-w-0 flex-1 items-center gap-2 rounded-[var(--radius-md)] border border-[var(--color-sidebar-search-border)] bg-[var(--color-sidebar-search-bg)] pl-3 pr-2 text-left text-[13px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-sidebar-item-hover)] focus-visible:border-[var(--color-border-focus)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-sidebar)] ${isMobile ? 'h-11' : 'h-9'}`}
aria-label={t('search.global.trigger')}
title={t('search.global.trigger')}
>
@@ -799,7 +801,7 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
icon={<RefreshCw className={`h-4 w-4 ${showRefreshLoading ? 'animate-spin' : ''}`} strokeWidth={1.9} aria-hidden="true" />}
label={t('sidebar.refreshSessions')}
onClick={() => void refreshSessionsNow()}
size="lg"
size={isMobile ? '2xl' : 'lg'}
tone="secondary"
surface="sidebar"
className="border border-[var(--color-sidebar-search-border)] bg-[var(--color-sidebar-search-bg)]"
@@ -808,7 +810,7 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
icon={isBatchMode ? 'close' : 'delete_sweep'}
label={isBatchMode ? t('sidebar.batchExit') : t('sidebar.batchManage')}
onClick={isBatchMode ? handleExitBatchMode : enterBatchMode}
size="lg"
size={isMobile ? '2xl' : 'lg'}
tone={isBatchMode ? 'brand' : 'secondary'}
surface="sidebar"
aria-pressed={isBatchMode}
@@ -895,26 +897,23 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
className="sidebar-scroll-area min-h-0 flex-1 overflow-y-auto px-3 pb-20"
>
{error && (
<div className="mx-1 mt-2 rounded-[var(--radius-md)] border border-[var(--color-error)]/20 bg-[var(--color-error)]/5 px-3 py-2">
<div className="text-xs font-medium text-[var(--color-error)]">{t('sidebar.sessionListFailed')}</div>
<div className="mt-1 text-[11px] text-[var(--color-text-secondary)] break-words">{error}</div>
<Button
variant="link"
size="xs"
onClick={() => fetchSessions()}
className="mt-2"
>
{t('common.retry')}
</Button>
</div>
<ErrorState
className="mx-1 mt-2 break-words"
size="sm"
tone="strong"
title={t('sidebar.sessionListFailed')}
detail={error}
onRetry={() => fetchSessions()}
retryLabel={t('common.retry')}
/>
)}
{showInitialLoading ? (
<div className="px-3 py-4 text-center text-xs text-[var(--color-text-tertiary)]">
{t('common.loading')}
</div>
) : filteredSessions.length === 0 && (
<div className="px-3 py-4 text-center text-xs text-[var(--color-text-tertiary)]">
{t('sidebar.noSessions')}
) : !error && filteredSessions.length === 0 && (
<div className="px-3 py-2">
<EmptyState variant="inline" title={t('sidebar.noSessions')} />
</div>
)}
{orderedProjectGroups.length > 0 && (
@@ -925,6 +924,7 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
onOpenMenu={(event) => openProjectHeaderMenu(event, 'main')}
onOpenCreate={(event) => openProjectHeaderMenu(event, 'create')}
actionsRef={projectHeaderActionsRef}
isMobile={isMobile}
/>
)}
{visibleProjectGroups.map((project) => {
@@ -965,7 +965,7 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
onDragEnd={clearProjectDragState}
onClick={() => toggleProjectCollapsed(project.key)}
data-state={projectCollapsed ? 'closed' : 'open'}
className="flex min-w-0 flex-1 cursor-grab items-center gap-2 rounded-xl px-1.5 py-2 text-left transition-[background,color] active:cursor-grabbing hover:bg-[var(--color-sidebar-item-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
className={`flex min-w-0 flex-1 cursor-grab items-center gap-2 rounded-[var(--radius-md)] px-1.5 text-left transition-[background,color] active:cursor-grabbing hover:bg-[var(--color-sidebar-item-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${isMobile ? 'min-h-11 py-2.5' : 'py-2'}`}
aria-expanded={!projectCollapsed}
aria-label={t(projectCollapsed ? 'sidebar.expandProject' : 'sidebar.collapseProject', { project: project.title })}
title={project.subtitle || project.title}
@@ -1001,7 +1001,7 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
<button
type="button"
onClick={() => toggleGroupSelection(groupIds)}
className={`rounded-md px-1.5 py-1 text-[11px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${
className={`rounded-[var(--radius-sm)] px-1.5 py-1 text-[11px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${
groupSelectedCount > 0
? 'text-[var(--color-brand)] hover:bg-[var(--color-brand-soft)]'
: 'text-[var(--color-text-tertiary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-secondary)]'
@@ -1014,7 +1014,15 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
</button>
)}
{!isBatchMode && (
<div className="pointer-events-none flex items-center gap-0.5 opacity-0 transition-opacity duration-150 group-hover/project:pointer-events-auto group-hover/project:opacity-100 group-focus-within/project:pointer-events-auto group-focus-within/project:opacity-100">
// Desktop reveals these on row hover. The touch drawer
// has neither hover nor a way to focus through
// `pointer-events: none`, so there they stay put — two
// 44px targets with enough gap not to catch each other.
<div className={`flex items-center transition-opacity duration-150 ${
isMobile
? 'gap-1.5 opacity-100'
: 'pointer-events-none gap-0.5 opacity-0 group-hover/project:pointer-events-auto group-hover/project:opacity-100 group-focus-within/project:pointer-events-auto group-focus-within/project:opacity-100'
}`}>
<IconButton
icon={<MoreHorizontal className="h-[17px] w-[17px]" strokeWidth={2} aria-hidden="true" />}
label={t('sidebar.projectActions', { project: project.title })}
@@ -1023,7 +1031,7 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
setContextMenu(null)
setProjectContextMenu({ key: project.key, x: event.clientX, y: event.clientY })
}}
size="sm"
size={isMobile ? '2xl' : 'sm'}
tone="muted"
surface="sidebar"
/>
@@ -1034,7 +1042,7 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
event.stopPropagation()
void createSessionForWorkDir(project.workDir)
}}
size="sm"
size={isMobile ? '2xl' : 'sm'}
tone="muted"
surface="sidebar"
/>
@@ -1082,11 +1090,14 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
}}
onContextMenu={(e) => handleContextMenu(e, session.id)}
className={`
group/session w-full rounded-lg px-2.5 ${isMobile ? 'py-3' : 'py-1.5'} text-left text-[13px] transition-[background,filter,color] duration-200
group/session w-full rounded-[var(--radius-md)] px-2.5 ${isMobile ? 'py-3' : 'py-1.5'} text-left text-[13px] transition-[background,filter,color,box-shadow] duration-200
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--color-surface-sidebar)]
${selectedSessionIds.has(session.id)
? 'sidebar-session-row--selected bg-[var(--color-sidebar-item-active)] text-[var(--color-text-primary)]'
? 'sidebar-session-row--selected bg-[var(--color-sidebar-item-active)] text-[var(--color-text-primary)] shadow-[var(--shadow-card)]'
: session.id === activeTabId
? 'sidebar-session-row--active bg-[var(--color-sidebar-item-active)] text-[var(--color-text-primary)]'
// The handoff marks the open session as a card lifted off the
// sidebar ground: page-white fill plus the resting shadow step.
? 'sidebar-session-row--active bg-[var(--color-sidebar-item-active)] text-[var(--color-text-primary)] shadow-[var(--shadow-card)]'
: 'sidebar-session-row--idle text-[var(--color-text-secondary)] hover:bg-[var(--color-sidebar-item-hover)] hover:text-[var(--color-text-primary)]'
}
`}
@@ -1099,7 +1110,9 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-[5px] border transition-colors ${
selectedSessionIds.has(session.id)
? 'border-[var(--color-brand)] bg-[var(--color-brand)] text-[var(--color-on-primary)]'
: 'border-[var(--color-border)] bg-[var(--color-surface)]'
// Hairline `--color-border` only reaches 1.2:1 here; a control
// boundary needs 3:1 (WCAG 1.4.11).
: 'border-[var(--color-border-strong)] bg-[var(--color-surface)]'
}`}
aria-hidden="true"
>
@@ -1134,7 +1147,7 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
<button
type="button"
onClick={() => toggleProjectSessionExpansion(project.key)}
className="inline-flex items-center justify-start py-1 text-[13px] font-semibold text-[var(--color-text-tertiary)] opacity-75 transition-[color,opacity] hover:text-[var(--color-text-secondary)] hover:opacity-100 focus-visible:rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
className={`inline-flex items-center justify-start text-[13px] font-semibold text-[var(--color-text-tertiary)] opacity-75 transition-[color,opacity] hover:text-[var(--color-text-secondary)] hover:opacity-100 focus-visible:rounded-[var(--radius-sm)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${isMobile ? 'min-h-11 py-2' : 'py-1'}`}
aria-expanded={sessionsExpanded}
>
{sessionsExpanded
@@ -1182,21 +1195,21 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
{contextMenu && (
<div
ref={sessionContextMenuRef}
className="fixed z-50 min-w-[140px] rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] py-1"
style={{ left: contextMenu.x, top: contextMenu.y, boxShadow: 'var(--shadow-dropdown)' }}
className="fixed z-[var(--z-dropdown)] min-w-[180px] overflow-hidden rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-2 shadow-[var(--shadow-dropdown)]"
style={{ left: contextMenu.x, top: contextMenu.y }}
>
<button
onClick={() => {
const session = sessions.find((s) => s.id === contextMenu.id)
handleStartRename(contextMenu.id, session?.title || '')
}}
className="w-full px-3 py-1.5 text-left text-xs text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)]"
className="w-full px-4 py-2 text-left text-[13px] text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)]"
>
{t('common.rename')}
</button>
<button
onClick={() => handleDelete(contextMenu.id)}
className="w-full px-3 py-1.5 text-left text-xs text-[var(--color-error)] transition-colors hover:bg-[var(--color-surface-hover)]"
className="w-full px-4 py-2 text-left text-[13px] text-[var(--color-error)] transition-colors hover:bg-[var(--color-error-container)]"
>
{t('common.delete')}
</button>
@@ -1212,7 +1225,7 @@ export function Sidebar({ isMobile = false, onRequestClose }: SidebarProps) {
<div
ref={projectContextMenuRef}
role="menu"
className="fixed z-50 min-w-[230px] overflow-hidden rounded-[18px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-2 shadow-[var(--shadow-dropdown)]"
className="fixed z-[var(--z-dropdown)] min-w-[230px] overflow-hidden rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-2 shadow-[var(--shadow-dropdown)]"
style={positionProjectMenu(projectContextMenu.x, projectContextMenu.y)}
onClick={(event) => event.stopPropagation()}
>
@@ -1415,6 +1428,7 @@ function ProjectHeaderActions({
onOpenMenu,
onOpenCreate,
actionsRef,
isMobile = false,
}: {
title: string
menuLabel: string
@@ -1423,6 +1437,7 @@ function ProjectHeaderActions({
onOpenCreate: (event: React.MouseEvent) => void
/** Handed to `useDismissable` as the trigger, so opening does not self-close. */
actionsRef: React.RefObject<HTMLDivElement>
isMobile?: boolean
}) {
return (
<div
@@ -1432,15 +1447,22 @@ function ProjectHeaderActions({
<div className="text-[12px] font-semibold tracking-normal text-[var(--color-text-primary)]">
{title}
</div>
{/* Hover-revealed on desktop. A touch drawer has no hover, and these kept
`pointer-events`, so on the phone they were invisible but still
tappable — a blind target. */}
<div
ref={actionsRef}
className="flex items-center gap-1 opacity-0 transition-opacity group-hover/sidebar-projects:opacity-100 focus-within:opacity-100"
className={`flex items-center transition-opacity focus-within:opacity-100 ${
isMobile
? 'gap-1.5 opacity-100'
: 'gap-1 opacity-0 group-hover/sidebar-projects:opacity-100'
}`}
>
<IconButton
icon={<MoreHorizontal className="h-[18px] w-[18px]" strokeWidth={2} aria-hidden="true" />}
label={menuLabel}
onClick={onOpenMenu}
size="md"
size={isMobile ? '2xl' : 'md'}
tone="muted"
surface="sidebar"
/>
@@ -1448,7 +1470,7 @@ function ProjectHeaderActions({
icon={<FolderPlus className="h-[18px] w-[18px]" strokeWidth={1.9} aria-hidden="true" />}
label={createLabel}
onClick={onOpenCreate}
size="md"
size={isMobile ? '2xl' : 'md'}
tone="muted"
surface="sidebar"
/>
@@ -1493,7 +1515,7 @@ const ProjectHeaderMenu = forwardRef<HTMLDivElement, {
}, ref) {
const width = type === 'sort' ? 230 : type === 'create' ? 250 : 270
const style: React.CSSProperties = { left: x, top: y, width, boxShadow: 'var(--shadow-dropdown)' }
const className = 'fixed z-50 overflow-hidden rounded-[18px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-2 shadow-[var(--shadow-dropdown)]'
const className = 'fixed z-[var(--z-dropdown)] overflow-hidden rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-2 shadow-[var(--shadow-dropdown)]'
if (type === 'create') {
return (
@@ -1954,7 +1976,7 @@ function ProjectMenuItem({
onClick={disabled ? undefined : onClick}
className={`flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:bg-[var(--color-surface-hover)] disabled:cursor-default disabled:opacity-45 ${
danger
? 'text-[var(--color-error)] enabled:hover:bg-[var(--color-error)]/10'
? 'text-[var(--color-error)] enabled:hover:bg-[var(--color-error-container)]'
: 'text-[var(--color-text-primary)] enabled:hover:bg-[var(--color-surface-hover)]'
}`}
>
@@ -2035,7 +2057,8 @@ function NavItem({
title={collapsed ? label : undefined}
className={`
flex items-center transition-colors duration-200
${collapsed ? 'h-10 w-10 justify-center rounded-[var(--radius-md)] px-0 py-0' : `w-full gap-2.5 rounded-[12px] px-3 ${touchFriendly ? 'py-3' : 'py-2.5'} text-sm`}
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-sidebar)]
${collapsed ? 'h-10 w-10 justify-center rounded-[var(--radius-md)] px-0 py-0' : `w-full gap-2.5 rounded-[var(--radius-md)] px-3 ${touchFriendly ? 'py-3' : 'py-2.5'} text-[14.5px]`}
${active
? 'bg-[var(--color-sidebar-item-active)] font-medium text-[var(--color-text-primary)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-sidebar-item-hover)] hover:text-[var(--color-text-primary)]'
@@ -2,6 +2,7 @@ import { Copy, RefreshCw } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from '../../i18n'
import { Button } from '@/components/ui/Button'
import { Card } from '@/components/ui/Card'
import { DoctorPanel } from '../doctor/DoctorPanel'
import { copyTextToClipboard } from '@/lib/clipboard'
@@ -44,36 +45,46 @@ export function StartupErrorView({ error }: StartupErrorViewProps) {
}
return (
<div className="h-screen flex items-center justify-center bg-[var(--color-surface)] px-6">
<section className="w-full max-w-3xl rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-6 shadow-[var(--shadow-md)]">
<div className="animate-screen-pop h-screen flex items-center justify-center bg-[var(--color-surface)] px-6">
<Card
as="section"
radius="xl"
surface="low"
padding="lg"
shadow="card"
className="w-full max-w-3xl"
>
<div className="flex flex-col gap-4">
<div>
<h1 className="text-lg font-semibold text-[var(--color-text-primary)]">
<h1
className="text-[21px] font-semibold tracking-tight text-[var(--color-text-primary)]"
style={{ fontFamily: 'var(--font-headline)' }}
>
{t('app.serverFailed')}
</h1>
<p className="mt-2 text-sm text-[var(--color-text-secondary)]">
<p className="mt-2 text-sm leading-6 text-[var(--color-text-secondary)]">
{t('app.serverFailedHint')}
</p>
</div>
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-4">
<div className="text-xs font-medium uppercase text-[var(--color-text-tertiary)]">
<Card radius="lg" surface="none" className="bg-[var(--color-code-bg)]">
<div className="text-[11px] font-semibold uppercase tracking-[0.12em] text-[var(--color-text-tertiary)]">
{t('app.startupError')}
</div>
<pre className="mt-2 max-h-28 overflow-auto whitespace-pre-wrap break-words font-mono text-xs text-[var(--color-error)]">
<pre className="mt-2 max-h-28 overflow-auto whitespace-pre-wrap break-words font-mono text-[13px] leading-[1.7] text-[var(--color-error)]">
{message}
</pre>
</div>
</Card>
{logs ? (
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-4">
<div className="text-xs font-medium uppercase text-[var(--color-text-tertiary)]">
<Card radius="lg" surface="none" className="bg-[var(--color-code-bg)]">
<div className="text-[11px] font-semibold uppercase tracking-[0.12em] text-[var(--color-text-tertiary)]">
{t('app.serverLogs')}
</div>
<pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-words font-mono text-xs leading-relaxed text-[var(--color-text-secondary)]">
<pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-words font-mono text-[13px] leading-[1.7] text-[var(--color-text-secondary)]">
{logs}
</pre>
</div>
</Card>
) : null}
<div className="flex flex-wrap items-center gap-2">
@@ -99,7 +110,7 @@ export function StartupErrorView({ error }: StartupErrorViewProps) {
<DoctorPanel compact />
</div>
</section>
</Card>
</div>
)
}
+5 -5
View File
@@ -17,16 +17,16 @@ export function StatusBar() {
const modelLabel = runtimeSelection?.modelId ?? currentModel?.name ?? null
return (
<div className="h-[var(--statusbar-height)] flex items-center justify-between px-4 border-t border-[var(--color-border)] bg-[var(--color-surface-sidebar)] select-none text-[11px]">
<div className="flex items-center gap-3">
<div className="flex h-[var(--statusbar-height)] select-none items-center justify-between border-t border-[var(--color-border)] bg-[var(--color-surface-sidebar)] px-4 text-[11px]">
<div className="flex min-w-0 items-center gap-3">
{projectName && (
<span className="text-[var(--color-text-secondary)] font-[var(--font-mono)]">{projectName}</span>
<span className="truncate font-mono text-[var(--color-text-secondary)]">{projectName}</span>
)}
</div>
<div className="flex items-center gap-4">
<div className="flex min-w-0 items-center gap-4">
{modelLabel && (
<span className="text-[var(--color-text-tertiary)] font-[var(--font-mono)]">
<span className="truncate font-mono text-[var(--color-text-tertiary)]">
{modelLabel}
</span>
)}
+37 -3
View File
@@ -872,9 +872,43 @@ describe('TabBar', () => {
const tabBar = screen.getByTestId('tab-bar')
const tab = screen.getByText('Untitled Session').closest('.tab-bar-interactive')
expect(tabBar).toHaveClass('min-h-11')
expect(tab).toHaveClass('min-h-11')
expect(screen.getByTestId('tab-bar-drag-gutter')).toHaveClass('min-h-11')
// 52px, from the handoff. The three have to agree: the strip, the tab, and
// the drag gutter are siblings, so a mismatch leaves the shorter one with a
// dead strip of titlebar that the window drag region does not cover.
expect(tabBar).toHaveClass('min-h-[52px]')
expect(tab).toHaveClass('min-h-[52px]')
expect(screen.getByTestId('tab-bar-drag-gutter')).toHaveClass('min-h-[52px]')
})
it('marks the active tab with a rule rather than a filled pill', async () => {
const { TabBar } = await import('./TabBar')
const { useTabStore } = await import('../../stores/tabStore')
const { useChatStore } = await import('../../stores/chatStore')
useTabStore.setState({
tabs: [
{ sessionId: 'tab-1', title: 'Active One', type: 'session', status: 'idle' },
{ sessionId: 'tab-2', title: 'Inactive One', type: 'session', status: 'idle' },
],
activeTabId: 'tab-1',
})
useChatStore.setState({
sessions: {},
disconnectSession: vi.fn(),
} as Partial<ReturnType<typeof useChatStore.getState>>)
await act(async () => {
render(<TabBar />)
})
const active = screen.getByText('Active One').closest('.tab-bar-interactive')
const inactive = screen.getByText('Inactive One').closest('.tab-bar-interactive')
expect(active?.className).toContain('shadow-[inset_0_-3px_0_var(--color-brand)]')
// The active tab shares the strip's ground; giving it its own fill is what
// turned the underline treatment back into a pill.
expect(active?.className).toContain('bg-transparent')
expect(inactive?.className).not.toContain('inset_0_-3px')
})
it('passes the active session workdir into the open-project control', async () => {
+21 -19
View File
@@ -405,11 +405,11 @@ export function TabBar() {
<div
data-testid="tab-bar"
data-desktop-drag-region={isDesktopRuntime ? true : undefined}
className="flex min-h-11 items-stretch bg-[var(--color-surface-container)] select-none border-b border-[var(--color-border)]"
className="flex min-h-[52px] items-stretch bg-[var(--color-surface)] select-none border-b border-[var(--color-border)]"
>
{canScrollLeft && (
<button type="button" onClick={() => scroll('left')} aria-label={t('tabs.scrollLeft')} className="flex h-11 w-7 flex-shrink-0 items-center justify-center text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-border-focus)]">
<button type="button" onClick={() => scroll('left')} aria-label={t('tabs.scrollLeft')} className="flex h-[52px] w-7 flex-shrink-0 items-center justify-center text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-border-focus)]">
<span className="material-symbols-outlined text-[16px]">chevron_left</span>
</button>
)}
@@ -447,7 +447,7 @@ export function TabBar() {
})}
</div>
<div className="flex shrink-0 items-center gap-1 border-l border-[var(--color-border)]/70 px-2">
<div className="flex shrink-0 items-center gap-1 border-l border-[var(--color-border)] px-2">
{showActivityButton && activeTabId && (
<SessionActivityButton sessionId={activeTabId} />
)}
@@ -495,12 +495,12 @@ export function TabBar() {
data-testid="tab-bar-drag-gutter"
data-desktop-drag-region
aria-hidden="true"
className={`min-h-11 flex-shrink-0 ${showWindowControls ? 'w-3' : 'w-4'}`}
className={`min-h-[52px] flex-shrink-0 ${showWindowControls ? 'w-3' : 'w-4'}`}
/>
)}
{canScrollRight && (
<button type="button" onClick={() => scroll('right')} aria-label={t('tabs.scrollRight')} className="flex h-11 w-7 flex-shrink-0 items-center justify-center text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-border-focus)]">
<button type="button" onClick={() => scroll('right')} aria-label={t('tabs.scrollRight')} className="flex h-[52px] w-7 flex-shrink-0 items-center justify-center text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-border-focus)]">
<span className="material-symbols-outlined text-[16px]">chevron_right</span>
</button>
)}
@@ -510,37 +510,37 @@ export function TabBar() {
{contextMenu && (
<div
ref={contextMenuRef}
className="fixed z-50 bg-[var(--color-surface)] border border-[var(--color-border)] rounded-[var(--radius-md)] py-1 min-w-[160px]"
style={{ left: contextMenu.x, top: contextMenu.y, boxShadow: 'var(--shadow-dropdown)' }}
className="fixed z-[var(--z-dropdown)] min-w-[180px] overflow-hidden rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-2 shadow-[var(--shadow-dropdown)]"
style={{ left: contextMenu.x, top: contextMenu.y }}
>
<button
onClick={() => { handleClose(contextMenu.sessionId); setContextMenu(null) }}
className="w-full px-3 py-1.5 text-xs text-left text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]"
className="w-full px-4 py-2 text-left text-[13px] text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)]"
>
{t('tabs.close')}
</button>
<button
onClick={() => handleCloseOthers(contextMenu.sessionId)}
className="w-full px-3 py-1.5 text-xs text-left text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]"
className="w-full px-4 py-2 text-left text-[13px] text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)]"
>
{t('tabs.closeOthers')}
</button>
<button
onClick={() => handleCloseLeft(contextMenu.sessionId)}
className="w-full px-3 py-1.5 text-xs text-left text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]"
className="w-full px-4 py-2 text-left text-[13px] text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)]"
>
{t('tabs.closeLeft')}
</button>
<button
onClick={() => handleCloseRight(contextMenu.sessionId)}
className="w-full px-3 py-1.5 text-xs text-left text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]"
className="w-full px-4 py-2 text-left text-[13px] text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)]"
>
{t('tabs.closeRight')}
</button>
<div className="my-1 border-t border-[var(--color-border)]" />
<div className="my-1.5 border-t border-[var(--color-border)]" />
<button
onClick={handleCloseAll}
className="w-full px-3 py-1.5 text-xs text-left text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)]"
className="w-full px-4 py-2 text-left text-[13px] text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)]"
>
{t('tabs.closeAll')}
</button>
@@ -611,15 +611,17 @@ const TabItem = forwardRef<HTMLDivElement, {
onMouseDown={onMouseDown}
onContextMenu={onContextMenu}
className={`
tab-bar-interactive group relative flex min-h-11 flex-shrink-0 items-center gap-1.5 px-3
${isDragging ? 'z-20 cursor-grabbing' : 'cursor-grab'}
tab-bar-interactive group relative flex min-h-[52px] flex-shrink-0 items-center gap-1.5 px-3
${isDragging ? 'z-[var(--z-sticky)] cursor-grabbing' : 'cursor-grab'}
transition-[background-color,box-shadow,opacity,transform] duration-150 ease-out
${isActive
? 'bg-[var(--color-surface)] shadow-[inset_0_-2px_0_var(--color-brand)]'
// Underline, not a pill: the active tab shares the bar's ground and is
// marked by a 3px terracotta rule plus weight on the label.
? 'bg-transparent shadow-[inset_0_-3px_0_var(--color-brand)]'
: 'bg-transparent hover:bg-[var(--color-surface-hover)]'
}
${isDragging ? 'opacity-95 shadow-[0_10px_24px_rgba(0,0,0,0.18)] ring-1 ring-[var(--color-border)]' : ''}
${isDragOver ? 'before:absolute before:left-0 before:top-[4px] before:bottom-[4px] before:w-[3px] before:bg-[var(--color-brand)] before:rounded-full before:shadow-[0_0_0_1px_rgba(255,255,255,0.25)]' : ''}
${isDragging ? 'opacity-95 shadow-[var(--shadow-overlay)] ring-1 ring-[var(--color-border)]' : ''}
${isDragOver ? 'before:absolute before:left-0 before:top-[4px] before:bottom-[4px] before:w-[3px] before:bg-[var(--color-brand)] before:rounded-full' : ''}
`}
style={{
width: TAB_WIDTH,
@@ -628,7 +630,7 @@ const TabItem = forwardRef<HTMLDivElement, {
}}
>
{tab.type === 'session' && isRunning && (
<StatusDot tone="success" pulse label={runningLabel} className="flex-shrink-0" />
<StatusDot tone="brand" pulse label={runningLabel} className="flex-shrink-0" />
)}
{tab.type === 'session' && tab.status === 'error' && (
<StatusDot tone="danger" className="flex-shrink-0" />
+10 -8
View File
@@ -2,11 +2,13 @@ import { IconButton } from '@/components/ui/IconButton'
import { useUIStore, type Toast as ToastType } from '../../stores/uiStore'
import { useTranslation } from '../../i18n'
// The tone lives in a 3px left rule rather than a tinted body: on the paper
// themes a filled banner would fight the surrounding surface layering.
const typeStyles: Record<ToastType['type'], string> = {
success: 'border-l-4 border-l-[var(--color-success)]',
error: 'border-l-4 border-l-[var(--color-error)]',
warning: 'border-l-4 border-l-[var(--color-warning)]',
info: 'border-l-4 border-l-[var(--color-text-accent)]',
success: 'border-l-[3px] border-l-[var(--color-success)]',
error: 'border-l-[3px] border-l-[var(--color-error)]',
warning: 'border-l-[3px] border-l-[var(--color-warning)]',
info: 'border-l-[3px] border-l-[var(--color-info)]',
}
function ToastItem({ toast }: { toast: ToastType }) {
@@ -20,14 +22,14 @@ function ToastItem({ toast }: { toast: ToastType }) {
aria-live={isUrgent ? 'assertive' : 'polite'}
aria-atomic="true"
className={`
bg-[var(--color-surface)] rounded-[var(--radius-md)] shadow-[var(--shadow-dropdown)]
px-4 py-3 text-sm text-[var(--color-text-primary)]
rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)]
px-4 py-3 text-[13.5px] text-[var(--color-text-primary)] shadow-[var(--shadow-overlay)]
${typeStyles[toast.type]}
animate-overlay-in-right
`}
>
<div className="flex items-center justify-between gap-2">
<span>{toast.message}</span>
<div className="flex items-center justify-between gap-2.5">
<span className="min-w-0 flex-1 leading-[1.5]">{toast.message}</span>
<IconButton
icon="close"
label={t('common.dismissNotification')}
@@ -30,16 +30,16 @@ export function UpdateChecker() {
return (
<div className="fixed bottom-4 left-1/2 z-[var(--z-toast)] w-[min(360px,calc(100vw-2rem))] -translate-x-1/2">
<div className="bg-[var(--color-surface-container-low)] border border-[var(--color-border)] rounded-[var(--radius-lg)] shadow-[var(--shadow-dropdown)] p-3">
<p className="text-sm font-medium text-[var(--color-text-primary)]">
<div className="rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] p-4 shadow-[var(--shadow-overlay)]">
<p className="text-[14px] font-semibold text-[var(--color-text-primary)]">
{t('update.readyTitle')}
</p>
<p className="mt-1 text-xs leading-5 text-[var(--color-text-secondary)]">
<p className="mt-1 text-[12.5px] leading-5 text-[var(--color-text-secondary)]">
{statusText}
</p>
{releaseNotes && (
<div className="mt-2 max-h-28 overflow-y-auto rounded-lg border border-[var(--color-border)]/60 bg-[var(--color-surface)]/70 px-3 py-2">
<div className="mt-2.5 max-h-28 overflow-y-auto rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2">
<MarkdownRenderer
content={releaseNotes}
className="text-xs leading-5 text-[var(--color-text-secondary)] [&_h1]:mb-2 [&_h1]:text-sm [&_h1]:font-semibold [&_h2]:mb-1.5 [&_h2]:text-xs [&_h2]:font-semibold [&_p]:my-1.5 [&_p]:text-xs [&_p]:leading-5 [&_ul]:my-1.5 [&_ol]:my-1.5"
@@ -48,13 +48,13 @@ export function UpdateChecker() {
)}
{error && (
<p className="mt-2 text-xs text-[var(--color-error)]">
<p className="mt-2 text-[12.5px] text-[var(--color-error)]" role="alert">
{t('update.failed', { error })}
</p>
)}
{status === 'downloaded' && (
<div className="mt-3 flex gap-2">
<div className="mt-3.5 flex gap-2">
<Button variant="primary" size="base" onClick={() => void installUpdate()}>
{t('update.installAndRestart')}
</Button>
@@ -8,6 +8,15 @@ const isWindows = typeof navigator !== 'undefined' && /Win/.test(navigator.platf
/** Whether to render custom window controls (Windows + desktop host only) */
export const showWindowControls = isWindows && getDesktopHost().capabilities.windowControls
/**
* These stay hand-rolled buttons rather than `IconButton`: they are OS chrome,
* so they must stay square, full-bleed to the titlebar edge and free of the
* radius/padding every in-app control carries. The focus ring is inset for the
* same reason — an offset ring would be clipped by the window edge.
*/
const WINDOW_CONTROL_CLASS =
'w-[46px] h-full flex items-center justify-center text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-border-focus)]'
export function WindowControls() {
const t = useTranslation()
const [maximized, setMaximized] = useState(false)
@@ -55,7 +64,7 @@ export function WindowControls() {
<button
onClick={() => runWindowAction(() => win.minimize())}
aria-label={t('windowControls.minimize')}
className="w-[46px] h-full flex items-center justify-center text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] transition-colors"
className={WINDOW_CONTROL_CLASS}
>
<svg width="10" height="1" viewBox="0 0 10 1">
<rect width="10" height="1" fill="currentColor" />
@@ -66,7 +75,7 @@ export function WindowControls() {
<button
onClick={() => runWindowAction(() => win.toggleMaximize())}
aria-label={t(maximized ? 'windowControls.restore' : 'windowControls.maximize')}
className="w-[46px] h-full flex items-center justify-center text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] transition-colors"
className={WINDOW_CONTROL_CLASS}
>
{maximized ? (
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1">
@@ -84,7 +93,7 @@ export function WindowControls() {
<button
onClick={() => runWindowAction(() => win.close())}
aria-label={t('windowControls.close')}
className="w-[46px] h-full flex items-center justify-center text-[var(--color-text-secondary)] hover:bg-[var(--color-window-close-hover)] hover:text-white transition-colors"
className={`${WINDOW_CONTROL_CLASS} hover:bg-[var(--color-window-close-hover)] hover:text-[var(--color-on-error)]`}
>
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.2">
<line x1="0" y1="0" x2="10" y2="10" />
@@ -112,7 +112,7 @@ describe('MarkdownRenderer', () => {
render(<MarkdownRenderer content={'```mermaid\ngraph TB\nA-->B'} streaming />)
expect(screen.getByTestId('mermaid-streaming-placeholder')).toHaveTextContent(
'Generating diagram...',
'正在生成图表…',
)
expect(screen.queryByTestId('mermaid-renderer')).not.toBeInTheDocument()
expect(screen.queryByTestId('code-viewer')).not.toBeInTheDocument()
@@ -344,7 +344,7 @@ describe('MarkdownRenderer', () => {
expect(execCommand).toHaveBeenCalledWith('copy')
})
expect(writeText).toHaveBeenCalledWith('npm run verify')
expect(screen.getByRole('button', { name: 'Copied' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '已复制' })).toBeInTheDocument()
} finally {
Object.defineProperty(document, 'execCommand', {
configurable: true,
@@ -7,6 +7,7 @@ import { marked, type Tokens } from 'marked'
import { CodeViewer } from '../chat/CodeViewer'
import { MermaidRenderer } from '../chat/MermaidRenderer'
import { copyTextToClipboard } from '@/lib/clipboard'
import { t } from '../../i18n'
type Props = {
content: string
@@ -78,11 +79,11 @@ function MermaidStreamingPlaceholder() {
return (
<div
data-testid="mermaid-streaming-placeholder"
className="my-4 flex items-center justify-center rounded-[var(--radius-lg)] border border-[var(--color-border)]/50 bg-[var(--color-surface-container-low)] py-8"
className="my-4 flex items-center justify-center rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] py-8"
>
<div className="flex items-center gap-2 text-[11px] text-[var(--color-text-tertiary)]">
<span className="material-symbols-outlined animate-spin text-[16px]">progress_activity</span>
Generating diagram...
{t('chat.generatingDiagram')}
</div>
</div>
)
@@ -410,10 +411,10 @@ export const __markdownParseCacheInternals = {
}
const BASE_PROSE_CLASSES = `markdown-prose prose prose-sm min-w-0 max-w-none break-words [overflow-wrap:anywhere] text-[var(--color-text-primary)]
prose-headings:text-[var(--color-text-primary)] prose-headings:font-semibold
prose-headings:text-[var(--color-text-primary)] prose-headings:font-semibold prose-headings:font-[var(--font-headline)]
prose-p:my-2 prose-p:leading-relaxed
prose-p:break-words prose-p:[overflow-wrap:anywhere]
prose-code:text-[13px] prose-code:text-[var(--color-code-fg)] prose-code:font-[var(--font-mono)] prose-code:bg-[var(--color-code-bg)] prose-code:border prose-code:border-[var(--color-border)] prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded-md prose-code:before:hidden prose-code:after:hidden
prose-code:text-[13px] prose-code:text-[var(--color-code-fg)] prose-code:font-mono prose-code:bg-[var(--color-code-bg)] prose-code:border prose-code:border-[var(--color-border)] prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded-[var(--radius-sm)] prose-code:before:hidden prose-code:after:hidden
prose-pre:!bg-transparent prose-pre:!p-0 prose-pre:!shadow-none
prose-a:text-[var(--color-text-accent)] prose-a:no-underline prose-a:[overflow-wrap:anywhere] hover:prose-a:underline
prose-strong:text-[var(--color-text-primary)]
@@ -426,7 +427,7 @@ const BASE_PROSE_CLASSES = `markdown-prose prose prose-sm min-w-0 max-w-none bre
[&_.md-math-inline]:inline-flex [&_.md-math-inline]:max-w-full [&_.md-math-inline]:overflow-x-auto [&_.md-math-inline]:[vertical-align:-0.08em] [&_.md-math-inline_.katex]:text-[1.02em]
[&_.md-math-display]:my-5 [&_.md-math-display]:flex [&_.md-math-display]:max-w-full [&_.md-math-display]:justify-center [&_.md-math-display]:overflow-x-auto [&_.md-math-display]:px-1 [&_.md-math-display]:py-2 [&_.md-math-display]:[scrollbar-width:thin]
[&_.md-math-display_.katex-display]:m-0 [&_.md-math-display_.katex]:text-[1.14em] [&_.md-math-display_.katex-html]:min-w-max
[&_.md-table-wrap]:my-5 [&_.md-table-wrap]:overflow-x-auto [&_.md-table-wrap]:rounded-xl [&_.md-table-wrap]:border [&_.md-table-wrap]:border-[var(--color-border)] [&_.md-table-wrap]:bg-[var(--color-surface-container-lowest)]`
[&_.md-table-wrap]:my-5 [&_.md-table-wrap]:overflow-x-auto [&_.md-table-wrap]:rounded-[var(--radius-lg)] [&_.md-table-wrap]:border [&_.md-table-wrap]:border-[var(--color-border)] [&_.md-table-wrap]:bg-[var(--color-surface-container-lowest)]`
const DOCUMENT_PROSE_CLASSES = `
prose-p:text-[15px] prose-p:leading-7
@@ -435,10 +436,10 @@ const DOCUMENT_PROSE_CLASSES = `
prose-h2:mt-8 prose-h2:mb-3 prose-h2:border-b prose-h2:border-[var(--color-border)] prose-h2:pb-2 prose-h2:text-xl prose-h2:font-semibold
prose-h3:mt-6 prose-h3:mb-2 prose-h3:text-base prose-h3:font-semibold
prose-h4:mt-5 prose-h4:mb-2 prose-h4:text-sm prose-h4:font-semibold
prose-blockquote:my-4 prose-blockquote:rounded-r-lg prose-blockquote:border-l-4 prose-blockquote:border-[var(--color-outline-variant)] prose-blockquote:bg-[var(--color-surface-container-low)] prose-blockquote:px-4 prose-blockquote:py-2 prose-blockquote:italic
prose-blockquote:my-4 prose-blockquote:rounded-r-[var(--radius-md)] prose-blockquote:border-l-4 prose-blockquote:border-[var(--color-primary-fixed-dim)] prose-blockquote:bg-[var(--color-surface-container-low)] prose-blockquote:px-4 prose-blockquote:py-2 prose-blockquote:italic
prose-hr:my-6 prose-hr:border-[var(--color-border)]
prose-img:rounded-lg prose-img:border prose-img:border-[var(--color-border)]
prose-kbd:rounded prose-kbd:border prose-kbd:border-[var(--color-border)] prose-kbd:bg-[var(--color-surface-container-lowest)] prose-kbd:px-1.5 prose-kbd:py-0.5 prose-kbd:font-[var(--font-mono)] prose-kbd:text-[12px] prose-kbd:font-normal prose-kbd:text-[var(--color-text-secondary)] prose-kbd:shadow-none
prose-img:rounded-[var(--radius-lg)] prose-img:border prose-img:border-[var(--color-border)]
prose-kbd:rounded-[var(--radius-sm)] prose-kbd:border prose-kbd:border-[var(--color-border)] prose-kbd:bg-[var(--color-surface-container-lowest)] prose-kbd:px-1.5 prose-kbd:py-0.5 prose-kbd:font-mono prose-kbd:text-[12px] prose-kbd:font-normal prose-kbd:text-[var(--color-text-secondary)] prose-kbd:shadow-none
prose-ul:pl-5 prose-ul:[&>li]:marker:text-[var(--color-text-tertiary)]
prose-ol:pl-5 prose-ol:[&>li]:marker:text-[var(--color-text-tertiary)]
prose-li:my-1.5
@@ -449,7 +450,7 @@ const COMPACT_PROSE_CLASSES = `
prose-p:my-1 prose-p:text-xs prose-p:leading-5 prose-p:text-[var(--color-text-secondary)]
prose-headings:mt-2 prose-headings:mb-1 prose-headings:leading-snug
prose-h1:text-base prose-h2:text-sm prose-h3:text-xs prose-h4:text-xs
prose-blockquote:my-2 prose-blockquote:border-l-2 prose-blockquote:border-[var(--color-outline-variant)] prose-blockquote:pl-3 prose-blockquote:text-[var(--color-text-secondary)]
prose-blockquote:my-2 prose-blockquote:border-l-2 prose-blockquote:border-[var(--color-primary-fixed-dim)] prose-blockquote:pl-3 prose-blockquote:text-[var(--color-text-secondary)]
prose-code:text-[12px]
prose-ul:my-1 prose-ol:my-1 prose-ul:pl-4 prose-ol:pl-4
prose-li:my-0.5 prose-li:text-xs prose-li:leading-5 prose-li:text-[var(--color-text-secondary)]
@@ -528,7 +529,7 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ content, varian
if (!copied) return
const original = button.textContent
button.textContent = 'Copied'
button.textContent = t('common.copied')
window.setTimeout(() => {
button.textContent = original
}, 1500)
+41 -26
View File
@@ -1,4 +1,17 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Braces,
CircleAlert,
Code,
File,
FileCode2,
FileText,
FolderX,
RefreshCw,
Scissors,
Terminal,
type LucideIcon,
} from 'lucide-react'
import { useTranslation } from '../../i18n'
import { Button } from '@/components/ui/Button'
import { EmptyState } from '@/components/ui/EmptyState'
@@ -23,15 +36,15 @@ export type PreviewFileContent = {
truncated: boolean
}
const LANG_ICONS: Record<string, string> = {
markdown: 'description',
python: 'code',
javascript: 'javascript',
typescript: 'code',
bash: 'terminal',
json: 'data_object',
yaml: 'data_object',
text: 'notes',
const LANG_ICONS: Record<string, LucideIcon> = {
markdown: FileText,
python: Code,
javascript: FileCode2,
typescript: FileCode2,
bash: Terminal,
json: Braces,
yaml: Braces,
text: FileText,
}
function formatSize(bytes: number): string {
@@ -117,7 +130,7 @@ export function FilePreview({
// column rather than shrink to its content.
return (
<EmptyState
icon={<span className="material-symbols-outlined text-[20px]">folder_off</span>}
icon={<FolderX size={20} strokeWidth={1.6} aria-hidden="true" />}
title={t('market.file.noFiles')}
className="min-h-0 flex-1"
/>
@@ -128,30 +141,31 @@ export function FilePreview({
return (
<div
className="grid min-h-0 min-w-0 flex-1 gap-4 lg:h-full lg:grid-cols-[minmax(200px,240px)_minmax(0,1fr)]"
className="grid min-h-0 min-w-0 flex-1 gap-5 lg:h-full lg:grid-cols-[minmax(0,320px)_minmax(0,1fr)]"
data-testid="market-file-preview"
>
<div className="flex max-h-[40vh] min-h-0 flex-col gap-0.5 overflow-y-auto rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] p-1.5 lg:max-h-none">
<div className="flex max-h-[40vh] min-h-0 flex-col gap-0.5 overflow-y-auto rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] p-2 lg:max-h-none">
{files.map((file) => {
const active = file.path === activePath
const Icon = LANG_ICONS[file.language] ?? File
return (
<button
key={file.path}
type="button"
data-testid={`market-file-item-${file.path}`}
onClick={() => void open(file.path)}
className={`flex items-center gap-2 rounded-lg px-2.5 py-2 text-left transition-colors ${
className={`flex items-center gap-2.5 rounded-[var(--radius-lg)] px-3 py-2.5 text-left transition-colors focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus-ring)] ${
active
? 'bg-[var(--color-primary-fixed)] text-[var(--color-brand)]'
? // The darkened pair, not `--color-brand`: raw terracotta on
// its own soft fill misses AA under the two ink themes.
'bg-[var(--color-brand-soft)] text-[var(--color-on-brand-soft)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}`}
>
<span className="material-symbols-outlined flex-shrink-0 text-[16px]" aria-hidden>
{LANG_ICONS[file.language] || 'draft'}
</span>
<Icon className="h-[15px] w-[15px] flex-shrink-0" strokeWidth={1.5} aria-hidden="true" />
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-medium">{file.path}</span>
<span className={`block text-[10px] ${active ? 'opacity-80' : 'text-[var(--color-text-tertiary)]'}`}>
<span className="block truncate text-[13.5px] font-medium">{file.path}</span>
<span className={`mt-px block text-[11.5px] ${active ? 'opacity-80' : 'text-[var(--color-text-tertiary)]'}`}>
{file.language} · {formatSize(file.size)}
</span>
</span>
@@ -160,22 +174,23 @@ export function FilePreview({
})}
</div>
<div className="flex min-h-0 min-w-0 flex-col overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)]">
<div className="flex min-h-0 min-w-0 flex-col overflow-hidden rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)]">
{activeFile && (
<div className="flex flex-shrink-0 flex-wrap items-center gap-x-3 gap-y-1 border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-2.5 text-[11px] text-[var(--color-text-tertiary)]">
<span className="font-mono font-medium text-[var(--color-text-secondary)]">{activeFile.path}</span>
<div className="flex flex-shrink-0 flex-wrap items-center gap-x-2.5 gap-y-1 border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-[18px] py-3 text-xs text-[var(--color-text-tertiary)]">
<span className="font-mono text-[13.5px] font-semibold text-[var(--color-text-primary)]">{activeFile.path}</span>
<span>{activeFile.language}</span>
<span>·</span>
<span>{formatSize(activeFile.size)}</span>
{state.kind === 'loaded' && state.file.truncated && (
<span className="inline-flex items-center gap-1 text-[var(--color-warning)]">
<span className="material-symbols-outlined text-[13px]" aria-hidden>content_cut</span>
<Scissors className="h-3 w-3" strokeWidth={1.6} aria-hidden="true" />
{t('market.file.truncated')}
</span>
)}
</div>
)}
<div className="min-h-0 flex-1 overflow-y-auto p-4 max-lg:max-h-[70vh]">
<div className="min-h-0 flex-1 overflow-y-auto px-[26px] py-[22px] max-lg:max-h-[70vh]">
{state.kind === 'loading' && (
<div className="flex justify-center py-10" data-testid="market-file-loading">
<Spinner size={20} tone="brand" label={t('common.loading')} />
@@ -183,14 +198,14 @@ export function FilePreview({
)}
{state.kind === 'error' && (
<div className="flex flex-col items-center gap-2 py-8 text-center" data-testid="market-file-error">
<span className="material-symbols-outlined text-[28px] text-[var(--color-error)]">error</span>
<CircleAlert className="h-7 w-7 text-[var(--color-error)]" strokeWidth={1.6} aria-hidden="true" />
<p className="text-sm text-[var(--color-text-primary)]">{t('market.file.loadError')}</p>
<p className="max-w-md break-words text-xs text-[var(--color-text-tertiary)]">{state.message}</p>
<Button
variant="secondary"
size="base"
className="mt-1"
icon={<span className="material-symbols-outlined text-[14px]">refresh</span>}
icon={<RefreshCw className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />}
onClick={() => activePath && void open(activePath)}
>
{t('market.retry')}
+8 -6
View File
@@ -29,16 +29,18 @@ const FilterTrigger = forwardRef<HTMLButtonElement, FilterTriggerProps>(function
<button
ref={ref}
type="button"
className={`inline-flex min-h-10 items-center gap-1.5 rounded-lg border px-3 text-xs transition-colors focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus-ring)] active:scale-[0.98] ${
className={`inline-flex min-h-11 items-center gap-2 rounded-[var(--radius-lg)] border px-4 text-sm transition-[background-color,color,border-color,box-shadow,transform] duration-150 ease-out focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus-ring)] active:scale-[0.98] motion-reduce:transition-none ${
active
? 'border-[var(--color-brand)]/35 bg-[var(--color-primary-fixed)] text-[var(--color-brand)]'
: 'border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] text-[var(--color-text-secondary)] hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)]'
? // Foreground is the darkened pair; raw terracotta on its own soft
// fill only reaches 4.3:1 under the two ink themes.
'border-[var(--color-primary-fixed-dim)] bg-[var(--color-brand-soft)] text-[var(--color-on-brand-soft)]'
: 'border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-primary)] hover:border-[var(--color-outline)] hover:bg-[var(--color-surface-hover)]'
}`}
{...props}
>
<span className={active ? 'text-[var(--color-brand)]/75' : 'text-[var(--color-text-tertiary)]'}>{label}</span>
<span className="font-medium">{value}</span>
<ChevronDown className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />
<span className={active ? 'text-[var(--color-on-brand-soft)]' : 'text-[var(--color-text-secondary)]'}>{label}</span>
<span className="font-semibold">{value}</span>
<ChevronDown className="h-3.5 w-3.5 text-[var(--color-text-tertiary)]" strokeWidth={1.8} aria-hidden="true" />
</button>
)
})
@@ -13,7 +13,7 @@ const COLLAPSIBLE_THRESHOLD = 8
function Chip({ children }: { children: React.ReactNode }) {
return (
<span className="inline-flex max-w-full items-center rounded-md bg-[var(--color-surface-container-high)] px-2 py-0.5 text-[11px] leading-5 text-[var(--color-text-secondary)] break-words">
<span className="inline-flex max-w-full items-center rounded-[var(--radius-sm)] bg-[var(--color-surface-container-high)] px-2 py-0.5 text-[11px] leading-5 text-[var(--color-text-secondary)] break-words">
{children}
</span>
)
@@ -33,9 +33,9 @@ function ValueCell({ value }: { value: FrontmatterValue }) {
if (typeof value === 'boolean') {
return (
<span
className={`inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-[11px] leading-5 ${
className={`inline-flex items-center gap-1 rounded-[var(--radius-sm)] px-2 py-0.5 text-[11px] leading-5 ${
value
? 'bg-[var(--color-success-container)]/45 text-[var(--color-success)]'
? 'bg-[var(--color-success-container)] text-[var(--color-on-success-container)]'
: 'bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)]'
}`}
>
@@ -46,7 +46,7 @@ function ValueCell({ value }: { value: FrontmatterValue }) {
if (typeof value === 'string' && value.includes('\n')) {
return (
<pre className="max-h-56 overflow-auto whitespace-pre-wrap break-words rounded-lg bg-[var(--color-surface-container-high)]/60 px-3 py-2 font-[var(--font-mono)] text-[11.5px] leading-5 text-[var(--color-text-secondary)]">
<pre className="max-h-56 overflow-auto whitespace-pre-wrap break-words rounded-[var(--radius-lg)] bg-[var(--color-code-bg)] px-3 py-2 font-mono text-[11.5px] leading-5 text-[var(--color-text-secondary)]">
{value}
</pre>
)
@@ -63,7 +63,7 @@ function CollapseToggle({ open, onToggle }: { open: boolean; onToggle: () => voi
data-testid="skill-frontmatter-toggle"
aria-expanded={open}
onClick={onToggle}
className="-mr-1 inline-flex min-h-6 items-center gap-1 rounded-md px-1.5 text-[11px] font-normal normal-case tracking-normal text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus-ring)]"
className="-mr-1 inline-flex min-h-6 items-center gap-1 rounded-[var(--radius-sm)] px-1.5 text-[11px] font-normal normal-case tracking-normal text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus-ring)]"
>
{open ? t('market.detail.metadataCollapse') : t('market.detail.metadataExpand')}
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${open ? 'rotate-180' : ''}`} strokeWidth={2} aria-hidden="true" />
@@ -86,12 +86,12 @@ function SidebarRows({ entries }: { entries: FrontmatterEntry[] }) {
<div
key={entry.key}
data-testid={`skill-frontmatter-row-${entry.key}`}
className={`min-w-0 border-b border-[var(--color-border)]/65 px-4 py-2.5 last:border-b-0 ${
className={`min-w-0 border-b border-[var(--color-border)] px-[18px] py-2.5 last:border-b-0 ${
stacked ? 'flex flex-col gap-1.5' : 'flex items-start justify-between gap-4'
}`}
>
<dt
className="min-w-0 font-[var(--font-mono)] text-[10.5px] leading-5 text-[var(--color-text-tertiary)] [overflow-wrap:anywhere]"
className="min-w-0 font-mono text-[10.5px] leading-5 text-[var(--color-text-tertiary)] [overflow-wrap:anywhere]"
title={entry.key}
>
{entry.key}
@@ -124,7 +124,7 @@ function GridRows({ entries }: { entries: FrontmatterEntry[] }) {
data-testid={`skill-frontmatter-row-${entry.key}`}
className={`flex min-w-0 flex-col gap-1 ${entry.block ? 'col-span-full' : ''}`}
>
<dt className="truncate font-[var(--font-mono)] text-[10.5px] leading-4 text-[var(--color-text-tertiary)]" title={entry.key}>
<dt className="truncate font-mono text-[10.5px] leading-4 text-[var(--color-text-tertiary)]" title={entry.key}>
{entry.key}
</dt>
<dd className="min-w-0 text-[12.5px] leading-5 text-[var(--color-text-primary)]">
@@ -172,7 +172,7 @@ export function FrontmatterPanel({
<section data-testid="skill-frontmatter-panel" className={className}>
{/* Sticky: the rail scrolls on its own, and a bare list of `slug` /
`xiaping_*` rows is meaningless without the heading in view. */}
<div className="sticky top-0 z-10 flex items-center gap-2 border-b border-[var(--color-border)]/65 bg-[var(--color-surface-container)] px-4 py-2">
<div className="sticky top-0 z-10 flex items-center gap-2 border-b border-[var(--color-border)] bg-[var(--color-surface-container)] px-[18px] py-2">
<h3 className="flex-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
{t('market.detail.metadata')}
</h3>
@@ -1,3 +1,4 @@
import { ShieldAlert, ShieldCheck, ShieldQuestion } from 'lucide-react'
import { useTranslation } from '../../i18n'
import type { NormalizedSkill } from '../../types/market'
import { Button } from '@/components/ui/Button'
@@ -28,6 +29,8 @@ export function InstallConfirmDialog({
if (!skill) return null
const risky = skill.securityStatus === 'flagged' || skill.securityStatus === 'unknown'
const RiskIcon =
skill.securityStatus === 'flagged' ? ShieldAlert : risky ? ShieldQuestion : ShieldCheck
return (
<Modal open={open} onClose={installing ? () => {} : onClose} title={t('market.installConfirm.title')} width={480}>
@@ -36,7 +39,7 @@ export function InstallConfirmDialog({
{t('market.installConfirm.message', { name: skill.name, source: t(`market.source.${skill.source}`) })}
</p>
<div className="flex flex-col gap-2 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3.5 py-3 text-xs">
<div className="flex flex-col gap-2 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3.5 py-3 text-xs">
<div className="flex items-center justify-between gap-2">
<span className="text-[var(--color-text-tertiary)]">{t('market.filter.source')}</span>
<span className="font-medium text-[var(--color-text-primary)]">{t(`market.source.${skill.source}`)}</span>
@@ -59,27 +62,19 @@ export function InstallConfirmDialog({
</div>
</div>
{/* Tone pairs, not alpha: `--color-<tone>-container` with its own
`--color-on-<tone>-container` foreground. The `/40` modifiers this
carried compile to a color function Safari 15 WebViews drop. */}
<div
className={`flex items-start gap-2 rounded-xl px-3.5 py-2.5 text-xs leading-5 ${
className={`flex items-start gap-2 rounded-[var(--radius-lg)] px-3.5 py-2.5 text-xs leading-5 ${
skill.securityStatus === 'flagged'
? 'border border-[var(--color-error)]/40 bg-[var(--color-error-container)]/40 text-[var(--color-text-primary)]'
? 'border border-[var(--color-error)] bg-[var(--color-error-container)] text-[var(--color-on-error-container)]'
: risky
? 'border border-[var(--color-warning)]/40 bg-[var(--color-warning-container)]/40 text-[var(--color-text-primary)]'
: 'border border-[var(--color-success)]/30 bg-[var(--color-success-container)]/40 text-[var(--color-text-primary)]'
? 'border border-[var(--color-warning)] bg-[var(--color-warning-container)] text-[var(--color-on-warning-container)]'
: 'border border-[var(--color-success)] bg-[var(--color-success-container)] text-[var(--color-on-success-container)]'
}`}
>
<span
className={`material-symbols-outlined mt-0.5 text-[16px] ${
skill.securityStatus === 'flagged'
? 'text-[var(--color-error)]'
: risky
? 'text-[var(--color-warning)]'
: 'text-[var(--color-success)]'
}`}
aria-hidden
>
{skill.securityStatus === 'flagged' ? 'gpp_maybe' : risky ? 'shield_question' : 'gpp_good'}
</span>
<RiskIcon className="mt-0.5 h-4 w-4 flex-shrink-0" strokeWidth={1.6} aria-hidden="true" />
<span>{t(RISK_KEYS[skill.securityStatus])}</span>
</div>
@@ -28,11 +28,13 @@ export function MarketDisclaimer() {
<div
role="note"
data-testid="market-disclaimer"
className="flex items-start gap-3 rounded-lg border border-[var(--color-warning)]/20 border-l-2 bg-[var(--color-surface-container-low)] px-3.5 py-2.5"
className="mt-6 flex items-start gap-3 rounded-[var(--radius-lg)] border border-[var(--color-primary-fixed-dim)] bg-[var(--color-brand-soft)] px-[18px] py-3.5"
>
<ShieldAlert className="mt-0.5 h-4 w-4 flex-shrink-0 text-[var(--color-warning)]" strokeWidth={2} aria-hidden="true" />
<p className="min-w-0 flex-1 text-[11px] leading-[18px] text-[var(--color-text-secondary)] sm:text-xs sm:leading-5">
<span className="font-semibold text-[var(--color-text-primary)]">{t('market.disclaimer.title')}</span>{' '}
<ShieldAlert className="mt-0.5 h-[17px] w-[17px] flex-shrink-0 text-[var(--color-brand)]" strokeWidth={1.5} aria-hidden="true" />
{/* Foreground is the darkened pair, never `--color-brand`: terracotta on
its own soft fill measures 4.3:1 under the two ink themes. */}
<p className="min-w-0 flex-1 text-[13px] leading-[1.7] text-[var(--color-on-brand-soft)] sm:text-sm">
<span className="font-semibold">{t('market.disclaimer.title')}</span>{' '}
{t('market.disclaimer.body')}
</p>
<IconButton
+64 -55
View File
@@ -11,6 +11,13 @@ import { MarketDisclaimer } from './MarketDisclaimer'
import { SkillCard } from './SkillCard'
import { SourceStatusBar } from './SourceStatusBar'
/**
* `minmax(340px, 1fr)` verbatim would overflow a phone viewport — the same
* bundle serves the touch H5 shell — so the floor is clamped to the column
* width. On desktop it is the handoff's grid exactly.
*/
const CATALOG_GRID = 'repeat(auto-fill,minmax(min(100%,340px),1fr))'
export function MarketHome({ onRequestInstall }: { onRequestInstall: (id: string) => void }) {
const t = useTranslation()
const {
@@ -40,62 +47,60 @@ export function MarketHome({ onRequestInstall }: { onRequestInstall: (id: string
const hasQuery = query.trim().length > 0
return (
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto bg-[var(--color-surface-container-lowest)]">
<header className="shrink-0 border-b border-[var(--color-border)]/70 bg-[var(--color-surface)]">
<div className="mx-auto flex w-full max-w-[1400px] flex-wrap items-center justify-between gap-5 px-6 py-6 lg:px-8">
<div className="flex min-w-0 items-center gap-3.5">
<span className="flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-[var(--color-brand)] shadow-[0_1px_2px_rgba(27,28,26,0.06)]">
<Store className="h-5 w-5" strokeWidth={1.9} aria-hidden="true" />
</span>
<div className="min-w-0">
<h1 className="text-[22px] font-semibold leading-7 tracking-[-0.025em] text-[var(--color-text-primary)]">
{t('market.title')}
</h1>
<p className="mt-0.5 max-w-2xl text-[13px] leading-5 text-[var(--color-text-secondary)]">
{t('market.subtitle')}
</p>
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto bg-[var(--color-surface)]">
<div className="mx-auto flex w-full max-w-[1280px] flex-col px-6 pb-10 pt-7 lg:px-10">
<header className="flex flex-wrap items-start gap-x-[18px] gap-y-4">
<span className="flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-brand)] shadow-[var(--shadow-card)]">
<Store className="h-[26px] w-[26px]" strokeWidth={1.4} aria-hidden="true" />
</span>
<div className="min-w-0 flex-1">
<h1
style={{ fontFamily: 'var(--font-headline)' }}
className="text-[26px] font-bold leading-9 tracking-[-0.012em] text-[var(--color-text-primary)]"
>
{t('market.title')}
</h1>
<p className="mt-1 max-w-2xl text-[15px] leading-6 text-[var(--color-text-secondary)]">
{t('market.subtitle')}
</p>
</div>
<SourceStatusBar sources={sources} />
</div>
</header>
<SourceStatusBar sources={sources} className="pt-2" />
</header>
<div className="mx-auto flex w-full max-w-[1400px] flex-col gap-4 px-6 py-5 lg:px-8">
<MarketDisclaimer />
<section className="sticky top-0 z-20 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-glass)] p-2.5 shadow-[0_8px_24px_rgba(27,28,26,0.06)] backdrop-blur-xl">
<div className="flex flex-wrap items-center gap-2.5">
<div className="flex min-h-10 min-w-[260px] flex-1 items-center gap-2 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3 transition-colors focus-within:border-[var(--color-border-focus)] focus-within:shadow-[var(--shadow-focus-ring)]">
<Search className="h-4 w-4 flex-shrink-0 text-[var(--color-text-tertiary)]" strokeWidth={2} aria-hidden="true" />
<input
data-testid="market-search-input"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t('market.searchPlaceholder')}
aria-label={t('market.searchPlaceholder')}
className="min-w-0 flex-1 bg-transparent text-[13px] text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]"
<div className="mt-[22px] flex flex-wrap items-center gap-3">
{/* Kept hand-rolled rather than moved onto `SearchField`: the command
bar is a 44px field on the `--radius-lg` step, and the shared
component tops out at h-10 / `--radius-md`. Overriding both from a
className is the class fight components/AGENTS.md §3.6 warns about. */}
<div className="flex min-h-11 min-w-[240px] flex-1 items-center gap-2.5 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)] px-4 transition-colors focus-within:border-[var(--color-border-focus)] focus-within:shadow-[var(--shadow-focus-ring)]">
<Search className="h-[15px] w-[15px] flex-shrink-0 text-[var(--color-text-tertiary)]" strokeWidth={1.6} aria-hidden="true" />
<input
data-testid="market-search-input"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t('market.searchPlaceholder')}
aria-label={t('market.searchPlaceholder')}
className="min-w-0 flex-1 bg-transparent text-[14.5px] text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]"
/>
{query && (
<IconButton
icon={<X className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />}
label={t('market.clearSearch')}
size="sm"
tone="muted"
onClick={() => setQuery('')}
/>
{query && (
<IconButton
icon={<X className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />}
label={t('market.clearSearch')}
size="sm"
tone="muted"
onClick={() => setQuery('')}
/>
)}
</div>
<FilterBar />
)}
</div>
</section>
<FilterBar />
</div>
{!isLoading && items.length > 0 && (
<div className="flex items-center gap-3 px-0.5">
<p className="flex-shrink-0 text-[11px] font-medium tabular-nums text-[var(--color-text-tertiary)]">
{t('market.resultCount', { count: String(items.length) })}
</p>
<div className="h-px flex-1 bg-[var(--color-border)]/60" />
</div>
<p className="mb-4 mt-5 text-sm tabular-nums text-[var(--color-text-secondary)]">
{t('market.resultCount', { count: String(items.length) })}
</p>
)}
{isLoading && <MarketGridSkeleton label={t('market.loading')} />}
@@ -111,7 +116,7 @@ export function MarketHome({ onRequestInstall }: { onRequestInstall: (id: string
<div
role="alert"
data-testid="market-error"
className="flex flex-col items-center gap-3 rounded-[var(--radius-lg)] border border-dashed border-[var(--color-error-soft-hover)] bg-[var(--color-error-soft)] px-6 py-14 text-center"
className="mt-5 flex flex-col items-center gap-3 rounded-[var(--radius-xl)] border border-dashed border-[var(--color-error-soft-hover)] bg-[var(--color-error-soft)] px-6 py-14 text-center"
>
<CloudOff className="h-8 w-8 text-[var(--color-error)]" strokeWidth={1.7} aria-hidden="true" />
<p className="text-sm font-medium text-[var(--color-text-primary)]">{t('market.error.list')}</p>
@@ -128,7 +133,7 @@ export function MarketHome({ onRequestInstall }: { onRequestInstall: (id: string
)}
{!isLoading && !error && items.length === 0 && (
<div data-testid="market-empty">
<div data-testid="market-empty" className="mt-5">
<EmptyState
size="lg"
icon={
@@ -149,7 +154,11 @@ export function MarketHome({ onRequestInstall }: { onRequestInstall: (id: string
{!isLoading && items.length > 0 && (
<>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3" data-testid="market-grid">
<div
className="grid gap-[18px]"
style={{ gridTemplateColumns: CATALOG_GRID }}
data-testid="market-grid"
>
{items.map((skill) => (
<SkillCard
key={skill.id}
@@ -162,7 +171,7 @@ export function MarketHome({ onRequestInstall }: { onRequestInstall: (id: string
</div>
{nextCursor && (
<div className="flex justify-center py-2 pb-5">
<div className="flex justify-center pt-7">
<Button
variant="secondary"
size="lg"
@@ -183,13 +192,13 @@ export function MarketHome({ onRequestInstall }: { onRequestInstall: (id: string
function MarketGridSkeleton({ label }: { label: string }) {
return (
<div data-testid="market-loading">
<div data-testid="market-loading" className="mt-5">
<SkeletonCards
label={label}
count={6}
minHeight="212px"
minHeight="232px"
withAvatar
className="grid-cols-1 md:grid-cols-2 xl:grid-cols-3"
className="[grid-template-columns:repeat(auto-fill,minmax(min(100%,340px),1fr))]"
/>
</div>
)
@@ -79,31 +79,31 @@ export function MarketSkillDetail({
if (isDetailLoading) {
return (
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto bg-[var(--color-surface-container-lowest)]" data-testid="market-detail-loading">
<div className="mx-auto w-full max-w-[1320px] px-6 py-6 lg:px-8">
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto bg-[var(--color-surface)]" data-testid="market-detail-loading">
<div className="mx-auto w-full max-w-[1280px] px-6 py-6 lg:px-11">
<Button
variant="ghost"
size="base"
icon={<ArrowLeft className="h-4 w-4" strokeWidth={2} aria-hidden="true" />}
icon={<ArrowLeft className="h-4 w-4" strokeWidth={1.6} aria-hidden="true" />}
onClick={backToList}
>
{t('market.detail.back')}
</Button>
<SkeletonGroup label={t('market.loading')} className="mt-5">
<div className="flex items-start gap-5 border-b border-[var(--color-border)]/70 pb-6">
<Skeleton shape="block" width="4rem" height="4rem" radius="lg" tone="strong" className="flex-shrink-0" />
<SkeletonGroup label={t('market.loading')} className="mt-[18px]">
<div className="flex items-start gap-6 pb-6">
<Skeleton shape="block" width="92px" height="92px" radius="lg" tone="strong" className="flex-shrink-0" />
<div className="min-w-0 flex-1 pt-1">
<Skeleton height="0.625rem" className="w-24" />
<Skeleton height="1.5rem" tone="strong" className="mt-3 w-64 max-w-full" />
<Skeleton height="0.75rem" className="w-32" />
<Skeleton height="2rem" tone="strong" className="mt-3 w-64 max-w-full" />
<Skeleton height="0.75rem" className="mt-4 w-[min(100%,36rem)]" />
</div>
</div>
<div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,1fr)_280px]">
<div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,1fr)_300px] lg:gap-9">
<div>
<Skeleton height="2.5rem" className="w-52" />
<div className="mt-5 h-72 rounded-xl border border-[var(--color-border)]/60 bg-[var(--color-surface-container-low)]" />
<Skeleton height="2.75rem" className="w-52" />
<div className="mt-[22px] h-72 rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)]" />
</div>
<div className="order-first h-72 rounded-xl border border-[var(--color-border)]/60 bg-[var(--color-surface-container-low)] lg:order-none" />
<div className="order-first h-72 rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] lg:order-none" />
</div>
</SkeletonGroup>
</div>
@@ -180,9 +180,9 @@ export function MarketSkillDetail({
{installError && installError.id === detail.id && (
<div
data-testid="market-install-error"
className="mt-4 flex items-start gap-2 rounded-lg border border-[var(--color-error)]/25 bg-[var(--color-error-container)]/35 px-3.5 py-2.5 text-sm text-[var(--color-text-primary)]"
className="mt-4 flex items-start gap-2 rounded-[var(--radius-lg)] border border-[var(--color-error)] bg-[var(--color-error-container)] px-3.5 py-2.5 text-sm text-[var(--color-on-error-container)]"
>
<CircleAlert className="mt-0.5 h-4 w-4 flex-shrink-0 text-[var(--color-error)]" strokeWidth={2} aria-hidden="true" />
<CircleAlert className="mt-0.5 h-4 w-4 flex-shrink-0" strokeWidth={1.6} aria-hidden="true" />
<span className="break-words">
{installError.kind === 'generic'
? t('market.installError.generic', { message: installError.message })
+10 -5
View File
@@ -38,14 +38,18 @@ export function SkillAvatar({
size?: number
className?: string
}) {
// The handoff draws the tile at 46px/r12 in the grid and 92px/r22 in the
// detail header — one ratio, not one fixed corner.
const radius = Math.round(size * 0.24)
if (skill.iconUrl) {
return (
<img
src={skill.iconUrl}
alt=""
loading="lazy"
style={{ width: size, height: size }}
className={`flex-shrink-0 rounded-[14px] border border-[var(--color-border)] bg-[var(--color-surface-container)] object-cover shadow-[0_1px_2px_rgba(27,28,26,0.08)] ${className}`}
style={{ width: size, height: size, borderRadius: radius }}
className={`flex-shrink-0 border border-[var(--color-border)] bg-[var(--color-surface-container)] object-cover shadow-[var(--shadow-card)] ${className}`}
/>
)
}
@@ -57,11 +61,12 @@ export function SkillAvatar({
style={{
width: size,
height: size,
fontSize: Math.round(size * 0.45),
borderRadius: radius,
fontSize: Math.round(size * 0.38),
background: `linear-gradient(145deg, ${from}, ${to})`,
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.18), 0 3px 10px rgba(27,28,26,0.12)',
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.18), var(--shadow-card)',
}}
className={`inline-flex flex-shrink-0 select-none items-center justify-center rounded-[14px] font-semibold tracking-[-0.04em] text-white ${className}`}
className={`inline-flex flex-shrink-0 select-none items-center justify-center font-bold tracking-[-0.04em] text-white ${className}`}
>
{initialOf(skill.name)}
</span>
+35 -44
View File
@@ -1,6 +1,7 @@
import { ArrowUpRight, Download, Star } from 'lucide-react'
import { Download, Star } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { Button } from '@/components/ui/Button'
import { Card } from '@/components/ui/Card'
import type { NormalizedSkill } from '../../types/market'
import { InstallStateBadge } from './InstallStateBadge'
import { SecurityBadge } from './SecurityBadge'
@@ -30,76 +31,64 @@ export function SkillCard({
const showInstallButton = Boolean(onInstall) && skill.installState === 'installable'
return (
<article
data-testid={`market-skill-card-${skill.id}`}
className="group relative isolate flex min-h-[212px] min-w-0 flex-col overflow-hidden rounded-xl border border-[var(--color-border)]/70 bg-[var(--color-surface-container-low)] p-4 transition-[background-color,border-color,box-shadow] duration-200 hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface)] hover:shadow-[var(--shadow-dropdown)]"
style={{ contentVisibility: 'auto', containIntrinsicSize: '212px' }}
<Card
as="article"
radius="xl"
surface="base"
padding="lg"
interactive
lift
className="group relative isolate flex min-h-[232px] min-w-0 flex-col gap-3"
>
<button
type="button"
aria-label={skill.name}
data-market-skill-open-id={skill.id}
onClick={() => onOpen(skill.id)}
className="absolute inset-0 z-0 rounded-xl focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus-ring)]"
className="absolute inset-0 z-0 rounded-[var(--radius-xl)] focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus-ring)]"
/>
<div className="pointer-events-none absolute inset-x-5 top-0 z-10 h-px bg-gradient-to-r from-transparent via-[var(--color-brand)]/55 to-transparent opacity-0 transition-opacity duration-200 group-hover:opacity-100" />
<div className="pointer-events-none relative z-10 flex items-start gap-3.5">
<div className="pointer-events-none relative z-10 flex items-center gap-3.5">
<SkillAvatar skill={skill} size={46} />
<div className="min-w-0 flex-1">
<div className="flex items-start gap-2">
<h3 className="min-w-0 flex-1 truncate text-[15px] font-semibold leading-5 tracking-[-0.01em] text-[var(--color-text-primary)]">
<div className="flex items-baseline gap-2.5">
<h3 className="min-w-0 flex-1 truncate text-[15.5px] font-bold leading-6 tracking-[-0.01em] text-[var(--color-text-primary)]">
{skill.name}
</h3>
{skill.version && (
<span className="flex-shrink-0 font-mono text-[10px] leading-5 text-[var(--color-text-tertiary)]">
<span className="flex-shrink-0 font-mono text-xs text-[var(--color-text-tertiary)]">
v{skill.version}
</span>
)}
</div>
<div className="mt-1 flex min-w-0 items-center gap-1.5 text-[11px] text-[var(--color-text-tertiary)]">
<span className="flex-shrink-0 font-semibold uppercase tracking-[0.08em] text-[var(--color-text-secondary)]">
{t(`market.source.${skill.source}`)}
</span>
<div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-[11.5px] font-semibold uppercase tracking-[0.06em] text-[var(--color-text-tertiary)]">
<span className="flex-shrink-0">{t(`market.source.${skill.source}`)}</span>
{skill.author.handle && (
<>
<span aria-hidden>·</span>
<span className="truncate">{t('market.card.by', { author: skill.author.displayName || skill.author.handle })}</span>
<span className="truncate font-normal normal-case tracking-normal">
{t('market.card.by', { author: skill.author.displayName || skill.author.handle })}
</span>
</>
)}
</div>
</div>
<ArrowUpRight
className="mt-0.5 h-4 w-4 flex-shrink-0 text-[var(--color-text-tertiary)] opacity-0 transition-[opacity,transform] duration-200 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 group-hover:opacity-100"
strokeWidth={1.8}
aria-hidden="true"
/>
</div>
<p className="pointer-events-none relative z-10 mt-3 line-clamp-2 min-h-[2.75rem] text-[12px] leading-[1.375rem] text-[var(--color-text-secondary)] break-words">
<p className="pointer-events-none relative z-10 line-clamp-2 min-h-[44px] break-words text-[13.5px] leading-[1.65] text-[var(--color-text-secondary)]">
{skill.summary || t('market.detail.noDescription')}
</p>
{skill.tags.length > 0 && (
<div className="pointer-events-none relative z-10 mt-2.5 flex min-h-4 flex-wrap items-center gap-x-2.5 gap-y-1">
<div className="pointer-events-none relative z-10 flex min-h-5 flex-wrap items-center gap-x-2.5 gap-y-1 text-[12.5px] text-[var(--color-text-tertiary)]">
{skill.tags.slice(0, MAX_VISIBLE_TAGS).map((tag) => (
<span
key={tag}
className="text-[10px] text-[var(--color-text-tertiary)]"
>
#{tag}
</span>
<span key={tag}>#{tag}</span>
))}
{extraTags > 0 && (
<span className="text-[10px] text-[var(--color-text-tertiary)]">
{t('market.card.moreTags', { count: String(extraTags) })}
</span>
)}
{extraTags > 0 && <span>{t('market.card.moreTags', { count: String(extraTags) })}</span>}
</div>
)}
<footer className="pointer-events-none relative z-10 mt-auto flex flex-wrap items-center justify-between gap-x-2 gap-y-2 border-t border-[var(--color-border)]/60 pt-3">
<footer className="pointer-events-none relative z-10 mt-auto flex flex-wrap items-center gap-x-2 gap-y-2 border-t border-[var(--color-border)] pt-3.5">
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<SecurityBadge status={skill.securityStatus} />
{/* The quick-install button already communicates "installable" — skip the badge when the button renders. */}
@@ -107,27 +96,29 @@ export function SkillCard({
<InstallStateBadge state={skill.installState} />
)}
</div>
<div className="ml-auto flex flex-shrink-0 items-center gap-2.5 text-[11px] tabular-nums text-[var(--color-text-tertiary)]">
<span className="inline-flex items-center gap-1" title={t('market.detail.downloads')}>
<Download className="h-3.5 w-3.5" strokeWidth={1.8} aria-hidden="true" />
<div className="ml-auto flex flex-shrink-0 items-center gap-2.5 text-[12.5px] tabular-nums text-[var(--color-text-secondary)]">
<span className="inline-flex items-center gap-1.5" title={t('market.detail.downloads')}>
<Download className="h-3 w-3" strokeWidth={1.5} aria-hidden="true" />
{formatCount(skill.stats.downloads)}
</span>
{typeof skill.stats.stars === 'number' && skill.stats.stars > 0 && (
<span className="inline-flex items-center gap-1" title={t('market.detail.stars')}>
<Star className="h-3.5 w-3.5" strokeWidth={1.8} aria-hidden="true" />
<span className="inline-flex items-center gap-1.5" title={t('market.detail.stars')}>
<Star className="h-3 w-3" strokeWidth={1.5} aria-hidden="true" />
{formatCount(skill.stats.stars)}
</span>
)}
{showInstallButton && (
// The card's overlay link sets `pointer-events-none` on this
// footer, so the button has to opt back in and sit above it.
// `tonal` is the soft terracotta fill; the border is the handoff's
// terracotta hairline around it.
<Button
variant="tonal"
size="base"
className="pointer-events-auto relative z-20"
className="pointer-events-auto relative z-20 border border-[var(--color-primary-fixed-dim)]"
loading={installing}
data-market-skill-action-id={skill.id}
icon={<Download className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />}
icon={<Download className="h-3 w-3" strokeWidth={1.6} aria-hidden="true" />}
onClick={() => onInstall?.(skill.id)}
>
{installing ? t('market.install.installing') : t('market.install.action')}
@@ -135,6 +126,6 @@ export function SkillCard({
)}
</div>
</footer>
</article>
</Card>
)
}
@@ -3,6 +3,7 @@ import { ArrowLeft, CircleSlash2, FileText, Folder } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { Card } from '@/components/ui/Card'
import type {
InstallState,
NotInstallableReason,
@@ -70,7 +71,7 @@ export function SkillDetailView(props: SkillDetailViewProps) {
return (
<div
className="min-h-0 flex-1 overflow-y-auto bg-[var(--color-surface-container-lowest)]"
className="min-h-0 flex-1 overflow-y-auto bg-[var(--color-surface)]"
data-testid="skill-detail-view"
>
{/*
@@ -80,36 +81,37 @@ export function SkillDetailView(props: SkillDetailViewProps) {
left the bottom of the page empty. A definite height makes the tab panel
claim the leftover space. Narrow layouts keep the page scrolling.
*/}
<div className="mx-auto flex w-full max-w-[1320px] flex-col px-6 py-6 lg:h-full lg:px-8">
<div className="mx-auto flex w-full max-w-[1280px] flex-col px-6 pb-12 pt-6 lg:h-full lg:px-11">
{/* `size="base"` is h-8, matching the `min-h-8` this button carried
before it became a component. */}
<Button
variant="ghost"
size="base"
className="w-fit"
icon={<ArrowLeft className="h-4 w-4" strokeWidth={2} aria-hidden="true" />}
icon={<ArrowLeft className="h-4 w-4" strokeWidth={1.6} aria-hidden="true" />}
onClick={props.onBack}
>
{props.backLabel}
</Button>
<header className="mt-5 flex-shrink-0 border-b border-[var(--color-border)]/70 pb-6">
<div className="flex min-w-0 items-start gap-4 sm:gap-5">
<SkillAvatar skill={{ name: props.name, iconUrl: props.iconUrl }} size={64} />
<div className="min-w-0 flex-1 pt-0.5">
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-1 text-[10px] font-semibold uppercase tracking-[0.1em] text-[var(--color-text-tertiary)]">
<header className="mt-[18px] flex-shrink-0">
<div className="flex min-w-0 items-start gap-5 sm:gap-6">
<SkillAvatar skill={{ name: props.name, iconUrl: props.iconUrl }} size={92} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 font-mono text-xs font-semibold uppercase tracking-[0.12em] text-[var(--color-text-tertiary)]">
<span>{props.sourceLabel}</span>
{props.version && (
<>
<span aria-hidden>·</span>
<span className="font-mono font-normal normal-case tracking-normal">v{props.version}</span>
<span className="normal-case">v{props.version}</span>
</>
)}
</div>
<h1
ref={headingRef}
tabIndex={-1}
className="mt-1.5 break-words text-2xl font-semibold leading-8 tracking-[-0.03em] text-[var(--color-text-primary)] outline-none sm:text-[28px]"
style={{ fontFamily: 'var(--font-headline)' }}
className="mt-1 break-words text-[26px] font-bold leading-tight tracking-[-0.012em] text-[var(--color-text-primary)] outline-none sm:text-[32px]"
>
{props.name}
</h1>
@@ -118,7 +120,7 @@ export function SkillDetailView(props: SkillDetailViewProps) {
{props.installState && <InstallStateBadge state={props.installState} />}
</div>
{props.summary && (
<p className="mt-3 max-w-3xl text-[13px] leading-6 text-[var(--color-text-secondary)] break-words sm:text-sm">
<p className="mt-3.5 max-w-3xl break-words text-[15px] leading-relaxed text-[var(--color-text-secondary)] sm:text-base">
{props.summary}
</p>
)}
@@ -128,34 +130,31 @@ export function SkillDetailView(props: SkillDetailViewProps) {
{props.installState === 'not-installable' && props.notInstallableReason && (
<div
data-testid="market-not-installable-reason"
className="mt-5 flex items-start gap-2 rounded-lg border border-[var(--color-error)]/25 bg-[var(--color-error-container)]/35 px-3.5 py-2.5 text-sm text-[var(--color-text-primary)]"
className="mt-5 flex items-start gap-2 rounded-[var(--radius-lg)] border border-[var(--color-error)] bg-[var(--color-error-container)] px-3.5 py-2.5 text-sm text-[var(--color-on-error-container)]"
>
<CircleSlash2 className="mt-0.5 h-4 w-4 flex-shrink-0 text-[var(--color-error)]" strokeWidth={2} aria-hidden="true" />
<CircleSlash2 className="mt-0.5 h-4 w-4 flex-shrink-0" strokeWidth={1.6} aria-hidden="true" />
<span>{t(`market.reason.${props.notInstallableReason}`)}</span>
</div>
)}
{props.securityReports && props.securityReports.length > 0 && (
<div
className="mt-5 flex flex-wrap items-center gap-x-2.5 gap-y-2 border-t border-[var(--color-border)]/60 pt-4"
className="mt-6 flex flex-wrap items-center gap-x-3 gap-y-2 border-y border-[var(--color-border)] px-0.5 py-3.5 text-sm"
data-testid="market-security-reports"
>
<span className="mr-1 text-[11px] font-semibold text-[var(--color-text-tertiary)]">
{t('market.detail.securityReport')}
</span>
<span className="text-[var(--color-text-tertiary)]">{t('market.detail.securityReport')}</span>
{props.securityReports.map((report) => (
<span
key={report.vendor}
className="inline-flex items-center gap-1.5 rounded-md bg-[var(--color-surface-container-low)] px-2.5 py-1.5 text-[11px] text-[var(--color-text-secondary)]"
>
<span className="font-medium text-[var(--color-text-primary)]">{report.vendor}</span>
{report.statusText}
<span key={report.vendor} className="inline-flex flex-wrap items-center gap-x-3 gap-y-1.5">
<strong className="font-mono text-[13.5px] font-semibold text-[var(--color-text-primary)]">
{report.vendor}
</strong>
<span className="text-[var(--color-text-secondary)]">{report.statusText}</span>
{report.reportUrl && (
<a
href={report.reportUrl}
target="_blank"
rel="noreferrer"
className="text-[var(--color-brand)] hover:underline"
className="inline-flex items-center rounded-[var(--radius-sm)] bg-[var(--color-brand-soft)] px-3 py-1 text-[13px] font-semibold text-[var(--color-on-brand-soft)] transition-colors hover:bg-[var(--color-brand-soft-hover)] focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus-ring)]"
onClick={(e) => e.stopPropagation()}
>
{t('market.detail.viewReport')}
@@ -169,12 +168,12 @@ export function SkillDetailView(props: SkillDetailViewProps) {
{props.banner}
</header>
<div className="mt-6 grid gap-6 lg:min-h-0 lg:flex-1 lg:grid-cols-[minmax(0,1fr)_300px] lg:items-stretch">
<div className="mt-6 grid gap-6 lg:min-h-0 lg:flex-1 lg:grid-cols-[minmax(0,1fr)_300px] lg:items-stretch lg:gap-9">
<main className="flex min-w-0 flex-col lg:min-h-0">
<div
role="tablist"
aria-label={props.name}
className="flex flex-shrink-0 items-center gap-1 border-b border-[var(--color-border)]"
className="flex flex-shrink-0 items-center gap-[26px] border-b border-[var(--color-border)]"
>
{(['overview', 'files'] as const).map((key) => {
const active = tab === key
@@ -189,16 +188,16 @@ export function SkillDetailView(props: SkillDetailViewProps) {
aria-controls={`skill-detail-${key}-panel`}
data-testid={`skill-detail-tab-${key}`}
onClick={() => setTab(key)}
className={`relative -mb-px inline-flex min-h-10 items-center gap-1.5 border-b-2 px-3.5 text-sm transition-colors focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus-ring)] ${
className={`relative -mb-px inline-flex min-h-11 items-center gap-2 border-b-[3px] px-1 text-[15px] font-semibold transition-colors focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus-ring)] ${
active
? 'border-[var(--color-brand)] font-medium text-[var(--color-text-primary)]'
? 'border-[var(--color-brand)] text-[var(--color-text-primary)]'
: 'border-transparent text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'
}`}
>
<Icon className="h-4 w-4" strokeWidth={1.9} aria-hidden="true" />
<Icon className="h-[15px] w-[15px]" strokeWidth={1.5} aria-hidden="true" />
{t(`market.detail.${key}`)}
{key === 'files' && (
<Badge pill={false} className="leading-4">{props.files.length}</Badge>
<Badge pill={false} size="sm" className="leading-5">{props.files.length}</Badge>
)}
</button>
)
@@ -210,7 +209,7 @@ export function SkillDetailView(props: SkillDetailViewProps) {
id="skill-detail-overview-panel"
role="tabpanel"
aria-labelledby="skill-detail-tab-overview-trigger"
className="mt-5 rounded-xl border border-[var(--color-border)]/70 bg-[var(--color-surface)] px-6 py-6 sm:px-8 sm:py-7 lg:min-h-0 lg:flex-1 lg:overflow-y-auto"
className="mt-[22px] rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] px-6 py-7 sm:px-[34px] sm:py-[30px] lg:min-h-0 lg:flex-1 lg:overflow-y-auto"
data-testid="skill-detail-overview"
>
{overview.body.trim() ? (
@@ -226,7 +225,7 @@ export function SkillDetailView(props: SkillDetailViewProps) {
id="skill-detail-files-panel"
role="tabpanel"
aria-labelledby="skill-detail-tab-files-trigger"
className="mt-5 flex min-h-[22rem] flex-col lg:min-h-0 lg:flex-1"
className="mt-[22px] flex min-h-[22rem] flex-col lg:min-h-0 lg:flex-1"
data-testid="skill-detail-files"
>
<FilePreview files={props.files} loadFile={props.loadFile} />
@@ -238,23 +237,21 @@ export function SkillDetailView(props: SkillDetailViewProps) {
data-testid="skill-detail-sidebar"
className="order-first min-w-0 lg:order-none lg:max-h-full lg:self-start lg:overflow-y-auto"
>
<div className="overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] shadow-[0_1px_2px_rgba(27,28,26,0.05)]">
<Card radius="xl" surface="base" padding="none" className="overflow-hidden">
{props.actions && (
<div
className={`p-3 [&>button]:w-full [&>button]:justify-center ${props.meta.length > 0 ? 'border-b border-[var(--color-border)]' : ''}`}
>
<div className="px-[18px] pt-[18px] [&>button]:w-full [&>button]:justify-center">
{props.actions}
</div>
)}
{props.meta.length > 0 && (
<dl>
<dl className="px-[18px]">
{props.meta.map((item) => (
<div
key={item.label}
className="flex min-w-0 items-start justify-between gap-4 border-b border-[var(--color-border)]/65 px-4 py-3 last:border-b-0"
className="flex min-w-0 items-baseline justify-between gap-4 border-b border-[var(--color-border)] py-[13px] last:border-b-0"
>
<dt className="text-[11px] leading-5 text-[var(--color-text-tertiary)]">{item.label}</dt>
<dd className="max-w-[62%] break-words text-right text-[12px] font-medium leading-5 text-[var(--color-text-primary)]">
<dt className="text-sm leading-5 text-[var(--color-text-secondary)]">{item.label}</dt>
<dd className="max-w-[62%] break-words text-right text-[14.5px] font-bold leading-5 text-[var(--color-text-primary)]">
{item.value}
</dd>
</div>
@@ -272,7 +269,7 @@ export function SkillDetailView(props: SkillDetailViewProps) {
variant="sidebar"
className={props.actions || props.meta.length > 0 ? 'border-t border-[var(--color-border)]' : ''}
/>
</div>
</Card>
</aside>
</div>
</div>
@@ -29,7 +29,7 @@ export function SourceStatusBar({
const t = useTranslation()
return (
<div
className={`flex flex-wrap items-center gap-1 rounded-lg bg-[var(--color-surface-container-low)] p-1 ${className}`}
className={`flex flex-wrap items-center gap-x-[18px] gap-y-1.5 ${className}`}
data-testid="market-source-status"
>
{MARKET_SOURCES.map((source) => {
@@ -44,10 +44,10 @@ export function SourceStatusBar({
key={source}
data-testid={`market-source-status-${source}`}
title={info.error || undefined}
className="inline-flex min-h-7 items-center gap-1.5 rounded-md px-2 text-[11px] text-[var(--color-text-secondary)]"
className="inline-flex items-center gap-[7px] text-[13.5px] text-[var(--color-text-secondary)]"
>
<StatusDot tone={DOT_TONES[info.status]} />
<span className="font-medium text-[var(--color-text-primary)]">{t(`market.source.${source}`)}</span>
<StatusDot tone={DOT_TONES[info.status]} size="md" />
<span className="font-semibold text-[var(--color-text-primary)]">{t(`market.source.${source}`)}</span>
<span className="text-[var(--color-text-tertiary)]">{statusLabel}</span>
</span>
)
+29 -15
View File
@@ -198,14 +198,17 @@ export function PluginDetail() {
</Button>
</div>
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] overflow-hidden">
<section className="rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] shadow-[var(--shadow-card)] overflow-hidden">
<div className="grid gap-4 px-5 py-5 lg:grid-cols-[minmax(0,1.5fr)_minmax(280px,0.9fr)] lg:items-start">
<div className="min-w-0">
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[var(--color-text-tertiary)] mb-2">
{t('settings.plugins.entryEyebrow')}
</div>
<div className="flex flex-wrap items-center gap-2 mb-2">
<h3 className="text-[22px] font-semibold leading-tight text-[var(--color-text-primary)] break-all">
<h3
className="text-[24px] font-semibold leading-tight text-[var(--color-text-primary)] break-all"
style={{ fontFamily: 'var(--font-headline)' }}
>
{selectedPlugin.name}
</h3>
<StatusPill enabled={selectedPlugin.enabled} hasErrors={selectedPlugin.hasErrors} />
@@ -254,7 +257,7 @@ export function PluginDetail() {
</div>
</section>
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] px-5 py-4">
<section className="rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-[var(--shadow-card)] px-5 py-4">
<div className="flex flex-wrap gap-2">
{canMutate && (
selectedPlugin.enabled ? (
@@ -348,9 +351,12 @@ export function PluginDetail() {
/>
)}
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden">
<section className="rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-[var(--shadow-card)] overflow-hidden">
<div className="px-5 py-4 border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)]">
<h4 className="text-sm font-semibold text-[var(--color-text-primary)]">
<h4
className="text-[16.5px] font-semibold leading-tight text-[var(--color-text-primary)]"
style={{ fontFamily: 'var(--font-headline)' }}
>
{t('settings.plugins.capabilitiesTitle')}
</h4>
<p className="text-xs text-[var(--color-text-tertiary)] mt-1">
@@ -465,7 +471,7 @@ export function PluginDetail() {
{otherCapabilityItems.map(({ key, items }) => (
<div
key={key}
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3"
className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3"
>
<div className="flex items-center justify-between gap-2 mb-2">
<div className="text-sm font-semibold text-[var(--color-text-primary)]">
@@ -529,10 +535,15 @@ function CapabilityPreviewSection({
hint?: string
}) {
return (
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] overflow-hidden">
<section className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] overflow-hidden">
<div className="flex items-center justify-between gap-3 border-b border-[var(--color-border)] px-4 py-3">
<div className="text-sm font-semibold text-[var(--color-text-primary)]">{title}</div>
<div className="text-[11px] text-[var(--color-text-tertiary)]">{count}</div>
<div
className="text-[14.5px] font-semibold text-[var(--color-text-primary)]"
style={{ fontFamily: 'var(--font-headline)' }}
>
{title}
</div>
<div className="font-mono text-[11px] tabular-nums text-[var(--color-text-tertiary)]">{count}</div>
</div>
<div className="p-4">
{hint && count > 0 && (
@@ -569,7 +580,7 @@ function SkillPreviewCard({
type="button"
onClick={onClick}
disabled={disabled}
className="group rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3 text-left transition-colors hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] disabled:cursor-default disabled:opacity-70 disabled:hover:border-[var(--color-border)] disabled:hover:bg-[var(--color-surface)]"
className="group rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3 text-left transition-colors hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] disabled:cursor-default disabled:opacity-70 disabled:hover:border-[var(--color-border)] disabled:hover:bg-[var(--color-surface)]"
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 flex-wrap min-w-0">
@@ -595,7 +606,7 @@ function CommandPreviewCard({
description: string
}) {
return (
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3">
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3">
<div className="text-sm font-semibold text-[var(--color-text-primary)] break-all">/{name}</div>
<div className="mt-2 text-xs leading-5 text-[var(--color-text-secondary)] break-words">{description}</div>
</div>
@@ -618,7 +629,7 @@ function AgentPreviewCard({
type="button"
onClick={onClick}
disabled={disabled}
className="group rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3 text-left transition-colors hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] disabled:cursor-default disabled:opacity-70 disabled:hover:border-[var(--color-border)] disabled:hover:bg-[var(--color-surface)]"
className="group rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3 text-left transition-colors hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] disabled:cursor-default disabled:opacity-70 disabled:hover:border-[var(--color-border)] disabled:hover:bg-[var(--color-surface)]"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
@@ -651,7 +662,7 @@ function McpPreviewCard({
type="button"
onClick={onClick}
disabled={disabled}
className="group rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3 text-left transition-colors hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] disabled:cursor-default disabled:opacity-70 disabled:hover:border-[var(--color-border)] disabled:hover:bg-[var(--color-surface)]"
className="group rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3 text-left transition-colors hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] disabled:cursor-default disabled:opacity-70 disabled:hover:border-[var(--color-border)] disabled:hover:bg-[var(--color-surface)]"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
@@ -679,7 +690,7 @@ function HookPreviewCard({
actions: string[]
}) {
return (
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3">
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-[var(--color-text-primary)] break-all">{event}</span>
{/* `wrap` is what unblocked these two: a hook matcher is a regex and an
@@ -721,7 +732,10 @@ function DetailStat({
<span className="material-symbols-outlined text-[14px]">{icon}</span>
<span>{label}</span>
</div>
<div className="mt-2 text-base font-semibold text-[var(--color-text-primary)] break-all">
<div
className="mt-2 text-[21px] font-semibold leading-none text-[var(--color-text-primary)] break-all"
style={{ fontFamily: 'var(--font-headline)' }}
>
{value}
</div>
</Card>
+32 -21
View File
@@ -6,6 +6,7 @@ import { useUIStore } from '../../stores/uiStore'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { Card } from '@/components/ui/Card'
import { Checkbox } from '@/components/ui/Checkbox'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
import { EmptyState } from '@/components/ui/EmptyState'
import { ErrorState } from '@/components/ui/ErrorState'
@@ -188,7 +189,7 @@ export function PluginList() {
return (
<div className="flex flex-col gap-6 min-w-0">
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] overflow-hidden">
<section className="rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] shadow-[var(--shadow-card)] overflow-hidden">
<div className="flex flex-col gap-4 px-5 py-5 min-w-0">
<div className="flex flex-col gap-4 min-w-0 xl:flex-row xl:items-start xl:justify-between">
<div className="min-w-0 max-w-4xl">
@@ -199,7 +200,10 @@ export function PluginList() {
<span className="material-symbols-outlined text-[22px] text-[var(--color-brand)]">
extension
</span>
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">
<h3
className="text-[21px] font-semibold leading-tight text-[var(--color-text-primary)]"
style={{ fontFamily: 'var(--font-headline)' }}
>
{t('settings.plugins.browserTitle')}
</h3>
</div>
@@ -304,9 +308,12 @@ export function PluginList() {
</section>
{marketplaces.length > 0 && (
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden">
<section className="rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-[var(--shadow-card)] overflow-hidden">
<div className="px-5 py-4 border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)]">
<h4 className="text-sm font-semibold text-[var(--color-text-primary)]">
<h4
className="text-[16.5px] font-semibold leading-tight text-[var(--color-text-primary)]"
style={{ fontFamily: 'var(--font-headline)' }}
>
{t('settings.plugins.marketplacesTitle')}
</h4>
<p className="text-xs text-[var(--color-text-tertiary)] mt-1">
@@ -317,7 +324,7 @@ export function PluginList() {
{marketplaces.map((marketplace) => (
<div
key={marketplace.name}
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3"
className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3"
>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-[var(--color-text-primary)]">
@@ -416,47 +423,48 @@ function renderGroup(
return (
<section
key={bucket}
className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden"
className="rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-[var(--shadow-card)] overflow-hidden"
>
<div className="flex items-start justify-between gap-3 px-5 py-4 border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)]">
<div className="min-w-0">
<h4 className="text-sm font-semibold text-[var(--color-text-primary)]">
<h4
className="text-[16.5px] font-semibold leading-tight text-[var(--color-text-primary)]"
style={{ fontFamily: 'var(--font-headline)' }}
>
{t(titleKey)}
</h4>
<p className="text-xs leading-5 text-[var(--color-text-tertiary)] mt-1">
{t('settings.plugins.groupHint', { count: String(items.length) })}
</p>
</div>
<span className="text-xs text-[var(--color-text-tertiary)]">{items.length}</span>
<span className="font-mono text-xs tabular-nums text-[var(--color-text-tertiary)]">{items.length}</span>
</div>
<div className="flex flex-col p-2">
{items.map((plugin) => (
<div
key={plugin.id}
className={`group rounded-xl border px-3 py-3 transition-all hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] ${
className={`group rounded-[var(--radius-lg)] border px-3 py-3 transition-[background-color,border-color] duration-150 ease-out hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] ${
selectedPluginIds.has(plugin.id)
? 'border-[var(--color-brand)]/45 bg-[var(--color-surface-selected)]'
? 'border-[var(--color-primary-fixed-dim)] bg-[var(--color-surface-selected)]'
: 'border-transparent'
}`}
>
<div className="flex items-start gap-3">
{canMutatePlugin(plugin) ? (
<label className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-container-high)]">
<input
type="checkbox"
aria-label={t('settings.plugins.selectPlugin', { name: plugin.name })}
checked={selectedPluginIds.has(plugin.id)}
onChange={(event) => onToggleSelection(plugin.id, event.currentTarget.checked)}
className="h-4 w-4 rounded border-[var(--color-border)] accent-[var(--color-brand)]"
/>
</label>
<Checkbox
label={t('settings.plugins.selectPlugin', { name: plugin.name })}
labelHidden
checked={selectedPluginIds.has(plugin.id)}
onChange={(event) => onToggleSelection(plugin.id, event.currentTarget.checked)}
containerClassName="h-6 w-6 shrink-0"
/>
) : (
<span className="mt-0.5 h-6 w-6 shrink-0" aria-hidden="true" />
)}
<button
type="button"
onClick={() => void fetchPluginDetail(plugin.id, cwd)}
className="flex min-w-0 flex-1 items-start gap-3 rounded-lg text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface)]"
className="flex min-w-0 flex-1 items-start gap-3 rounded-[var(--radius-lg)] text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface)]"
>
<span className="mt-0.5 material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)]">
{plugin.hasErrors ? 'warning' : plugin.enabled ? 'extension' : 'extension_off'}
@@ -528,7 +536,10 @@ function SummaryCard({
{label}
</span>
</div>
<div className="mt-1.5 truncate text-lg font-semibold text-[var(--color-text-primary)]">
<div
className="mt-1.5 truncate text-[21px] font-semibold leading-none text-[var(--color-text-primary)]"
style={{ fontFamily: 'var(--font-headline)' }}
>
{value}
</div>
</Card>
@@ -201,13 +201,17 @@ export function FindInPageModal({ open, onClose }: Props) {
if (!open) return null
return createPortal(
<div className="fixed right-3 top-3 z-50" data-find-bar>
<div className="fixed right-3 top-3 z-[var(--z-dialog)]" data-find-bar>
{/* Highlight colors have to be real CSS declarations (the Custom Highlight
API takes no class). They use the dedicated search-highlight pair —
these wash over arbitrary page content, not a brand-soft panel, so
borrowing the brand chip tokens reads wrong on tinted surfaces. */}
<style>{`
::highlight(${RESULTS_HL}) { background-color: rgba(250, 204, 21, 0.45); color: inherit; }
::highlight(${ACTIVE_HL}) { background-color: rgba(249, 115, 22, 0.9); color: #fff; }
::highlight(${RESULTS_HL}) { background-color: var(--color-search-highlight); color: var(--color-on-search-highlight); }
::highlight(${ACTIVE_HL}) { background-color: var(--color-search-highlight-active); color: var(--color-on-search-highlight-active); }
`}</style>
<div
className="glass-panel flex items-center gap-1 rounded-lg border border-[var(--color-border)] px-2 py-1.5 shadow-lg"
className="glass-panel animate-overlay-in-top flex items-center gap-1 rounded-[var(--radius-lg)] px-2 py-1.5"
role="dialog"
aria-label={t('search.findInPage.title')}
>
@@ -219,9 +223,9 @@ export function FindInPageModal({ open, onClose }: Props) {
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
placeholder={t('search.findInPage.placeholder')}
className="w-52 bg-transparent text-sm text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]"
className="w-52 bg-transparent text-[13.5px] text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]"
/>
<span className="min-w-[48px] shrink-0 px-1 text-center text-[11px] tabular-nums text-[var(--color-text-tertiary)]">
<span className="min-w-[48px] shrink-0 px-1 text-center font-mono text-[12px] tabular-nums text-[var(--color-text-tertiary)]">
{count > 0 ? `${activeIndex + 1} / ${count}` : (debouncedQuery.trim() ? '0' : '')}
</span>
<IconButton
@@ -214,21 +214,21 @@ export function GlobalSearchModal({ open, onClose }: Props) {
if (!open) return null
return createPortal(
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[12vh]">
<div className="fixed inset-0 z-[var(--z-dialog)] flex items-start justify-center pt-[12vh]">
<div
className="absolute inset-0 bg-[var(--color-overlay-scrim)] transition-opacity duration-200"
className="absolute inset-0 bg-[var(--color-modal-scrim)] backdrop-blur-[2px] transition-opacity duration-200"
onClick={onClose}
/>
<div
className="glass-panel relative z-10 flex max-h-[70vh] w-[640px] max-w-[calc(100vw-48px)] flex-col overflow-hidden rounded-[var(--radius-xl)]"
className="animate-overlay-in relative z-10 flex max-h-[70vh] w-[640px] max-w-[calc(100vw-48px)] flex-col overflow-hidden rounded-[var(--radius-2xl)] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-[var(--shadow-overlay)]"
role="dialog"
aria-modal="true"
aria-label={t('search.global.placeholder')}
>
{/* Search input */}
<div className="flex items-center gap-2.5 border-b border-[var(--color-border)] px-4 py-3">
<Search className="h-4 w-4 shrink-0 text-[var(--color-text-tertiary)]" aria-hidden="true" />
<div className="flex items-center gap-3 border-b border-[var(--color-border)] px-[22px] py-[18px]">
<Search className="h-[17px] w-[17px] shrink-0 text-[var(--color-text-tertiary)]" aria-hidden="true" />
<input
ref={inputRef}
type="text"
@@ -239,7 +239,7 @@ export function GlobalSearchModal({ open, onClose }: Props) {
onKeyDown={handleKeyDown}
placeholder={t('search.global.placeholder')}
aria-label={t('search.global.placeholder')}
className="min-w-0 flex-1 bg-transparent text-sm text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]"
className="min-w-0 flex-1 bg-transparent text-[16px] text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]"
/>
{loading && <Spinner size={16} className="text-[var(--color-text-tertiary)]" />}
<IconButton
@@ -253,11 +253,11 @@ export function GlobalSearchModal({ open, onClose }: Props) {
</div>
{/* Results */}
<div ref={listRef} className="min-h-0 flex-1 overflow-y-auto py-1.5" role="listbox">
<div ref={listRef} className="min-h-0 flex-1 overflow-y-auto px-2 py-2.5" role="listbox">
{!isSearching ? (
<>
{rows.length > 0 && (
<div className="px-4 pb-1 pt-1.5 text-[11px] font-medium uppercase tracking-wide text-[var(--color-text-tertiary)]">
<div className="px-4 py-1.5 text-[12px] text-[var(--color-text-tertiary)]">
{t('search.global.recentTitle')}
</div>
)}
@@ -274,15 +274,15 @@ export function GlobalSearchModal({ open, onClose }: Props) {
))}
</>
) : loading && results.length === 0 ? (
<div className="px-4 py-8 text-center text-xs text-[var(--color-text-tertiary)]">
<div className="px-4 py-8 text-center text-[13px] text-[var(--color-text-tertiary)]">
{t('search.global.loading')}
</div>
) : error ? (
<div className="px-4 py-8 text-center text-xs text-[var(--color-error)]">
<div className="px-4 py-8 text-center text-[13px] text-[var(--color-error)]">
{t('search.global.error')}
</div>
) : rows.length === 0 ? (
<div className="px-4 py-8 text-center text-xs text-[var(--color-text-tertiary)]">
<div className="px-4 py-8 text-center text-[13px] text-[var(--color-text-tertiary)]">
{t('search.global.noResults')}
</div>
) : (
@@ -299,7 +299,7 @@ export function GlobalSearchModal({ open, onClose }: Props) {
/>
))}
{truncated && (
<div className="px-4 py-2 text-center text-[11px] text-[var(--color-text-tertiary)]">
<div className="px-4 py-2 text-center text-[12px] text-[var(--color-text-tertiary)]">
{t('search.global.truncated', { count: SEARCH_LIMIT })}
</div>
)}
@@ -308,13 +308,19 @@ export function GlobalSearchModal({ open, onClose }: Props) {
</div>
{/* Footer hints */}
<div className="flex items-center gap-1.5 border-t border-[var(--color-border)] px-4 py-1.5 text-[10px] text-[var(--color-text-tertiary)]">
<kbd className="rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1 py-0.5 font-mono">↑↓</kbd>
<span>{t('fileSearch.navigate')}</span>
<kbd className="ml-2 rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1 py-0.5 font-mono">Enter</kbd>
<span>{t('fileSearch.select')}</span>
<kbd className="ml-2 rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1 py-0.5 font-mono">Esc</kbd>
<span>{t('fileSearch.close')}</span>
<div className="flex items-center gap-3.5 border-t border-[var(--color-border)] px-[22px] py-3 text-[12px] text-[var(--color-text-tertiary)]">
<span className="flex items-center gap-1.5">
<Keycap>↑↓</Keycap>
{t('fileSearch.navigate')}
</span>
<span className="flex items-center gap-1.5">
<Keycap>Enter</Keycap>
{t('fileSearch.select')}
</span>
<span className="flex items-center gap-1.5">
<Keycap>Esc</Keycap>
{t('fileSearch.close')}
</span>
</div>
</div>
</div>,
@@ -322,6 +328,14 @@ export function GlobalSearchModal({ open, onClose }: Props) {
)
}
function Keycap({ children }: { children: ReactNode }) {
return (
<kbd className="rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface-container)] px-1.5 py-px font-mono text-[11px] font-medium text-[var(--color-text-secondary)]">
{children}
</kbd>
)
}
type RowProps = {
row: Row
index: number
@@ -340,19 +354,19 @@ function ResultRow({ row, index, active, onActivate, onOpen, t }: RowProps) {
aria-selected={active}
onMouseEnter={() => onActivate(index)}
onClick={() => onOpen(row)}
className={`flex w-full flex-col gap-0.5 px-4 py-2 text-left transition-colors focus-visible:outline-none ${
className={`flex w-full flex-col gap-0.5 rounded-[var(--radius-md)] px-4 py-2.5 text-left transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none ${
active ? 'bg-[var(--color-surface-hover)]' : ''
}`}
>
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-sm font-medium text-[var(--color-text-primary)]">
<span className="min-w-0 flex-1 truncate text-[14.5px] font-medium text-[var(--color-text-primary)]">
{row.title}
</span>
<span className="shrink-0 text-[10px] tabular-nums text-[var(--color-text-tertiary)]">
<span className="shrink-0 text-[12px] tabular-nums text-[var(--color-text-tertiary)]">
{formatRelativeTime(row.modifiedAt, t)}
</span>
</div>
<div className="flex items-center gap-1.5 text-[11px] text-[var(--color-text-tertiary)]">
<div className="flex items-center gap-1.5 text-[12.5px] text-[var(--color-text-tertiary)]">
<span className="min-w-0 truncate">{projectLabel(row)}</span>
{row.matchCount > 0 && (
<>
@@ -403,7 +417,7 @@ function renderHighlighted(
parts.push(
<mark
key={`${start}-${end}`}
className="rounded-[3px] bg-[var(--color-brand)]/25 px-0.5 text-[var(--color-text-primary)]"
className="rounded-[var(--radius-sm)] bg-[var(--color-brand-soft)] px-0.5 text-[var(--color-on-brand-soft)]"
>
{snippet.slice(start, end)}
</mark>,
@@ -46,6 +46,7 @@ import { Dropdown } from '@/components/ui/Dropdown'
import { Input } from '@/components/ui/Input'
import { Modal } from '@/components/ui/Modal'
import { SearchField } from '@/components/ui/SearchField'
import { SettingsPageHeader } from '@/components/settings/SettingsSection'
const AGENT_COLORS: Record<string, string> = {
red: '#ef4444',
@@ -154,7 +155,7 @@ export function AgentManager() {
<div className="w-full min-w-0">
{mutationWarning && (
<div
className="mb-4 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-[var(--color-warning)]/30 bg-[var(--color-warning-container)] px-4 py-3"
className="mb-4 flex flex-wrap items-center justify-between gap-3 rounded-[var(--radius-xl)] border border-[var(--color-warning)] bg-[var(--color-warning-container)] px-4 py-3"
role="status"
>
<div className="flex min-w-0 items-start gap-2">
@@ -187,19 +188,15 @@ export function AgentManager() {
/>
) : (
<>
<div className="mb-5 flex flex-wrap items-start justify-between gap-4">
<div>
<h2 className="text-xl font-semibold text-[var(--color-text-primary)]">
{t('settings.agents.title')}
</h2>
<p className="mt-1 max-w-3xl text-sm leading-6 text-[var(--color-text-secondary)]">
{t('settings.agents.description')}
</p>
</div>
<Button icon={<Plus size={16} />} onClick={() => setFormState({ mode: 'create' })}>
{t('settings.agents.create')}
</Button>
</div>
<SettingsPageHeader
title={t('settings.agents.title')}
description={t('settings.agents.description')}
action={(
<Button icon={<Plus size={16} />} onClick={() => setFormState({ mode: 'create' })}>
{t('settings.agents.create')}
</Button>
)}
/>
{isLoading && allAgents.length === 0 ? (
<LoadingState label={t('common.loading')} labelHidden size="md" />
@@ -219,23 +216,25 @@ export function AgentManager() {
/>
) : (
<div className="flex min-w-0 flex-col gap-6">
<section className="overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)]">
<div className="grid min-w-0 gap-4 px-5 py-5 xl:grid-cols-[minmax(0,1.6fr)_minmax(320px,1fr)] xl:items-end">
<section className="overflow-hidden rounded-[var(--radius-2xl)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)]">
<div className="grid min-w-0 gap-4 px-5 py-5 xl:grid-cols-[minmax(0,1.15fr)_minmax(400px,1fr)] xl:items-end">
<div className="min-w-0">
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.2em] text-[var(--color-text-tertiary)]">
{t('settings.agents.browserEyebrow')}
</div>
<div className="mb-2 flex items-center gap-3">
<Bot size={22} className="text-[var(--color-brand)]" />
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]" style={{ fontFamily: 'var(--font-headline)' }}>
{t('settings.agents.browserTitle')}
</h3>
</div>
</div>
<div className="grid min-w-0 grid-cols-2 gap-3 sm:grid-cols-3">
{/* Column count follows the track width, not the viewport: `sm:grid-cols-3`
kept forcing three columns into a 320px column and clipped the CJK labels. */}
<div className="grid min-w-0 grid-cols-[repeat(auto-fit,minmax(116px,1fr))] gap-3">
<SummaryCard label={t('settings.agents.summary.totalAgents')} value={String(allAgents.length)} icon={<Bot size={14} />} />
<SummaryCard label={t('settings.agents.summary.activeAgents')} value={String(activeAgents.length)} icon={<Bolt size={14} />} />
<SummaryCard label={t('settings.agents.summary.sources')} value={String(sourceCount)} icon={<Layers size={14} />} className="col-span-2 sm:col-span-1" />
<SummaryCard label={t('settings.agents.summary.sources')} value={String(sourceCount)} icon={<Layers size={14} />} />
</div>
</div>
</section>
@@ -246,7 +245,7 @@ export function AgentManager() {
if (!group?.length) return null
const sourceLabel = t(`settings.agents.source.${source}`)
return (
<section key={source} className="min-w-0 overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)]">
<section key={source} className="min-w-0 overflow-hidden rounded-[var(--radius-2xl)] border border-[var(--color-border)] bg-[var(--color-surface)]">
<div className="border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-5 py-4">
<div className="mb-1 flex flex-wrap items-center gap-2">
<span className={`inline-flex h-7 w-7 items-center justify-center rounded-full ${getAgentSourceAccentClass(source)}`}>
@@ -264,17 +263,24 @@ export function AgentManager() {
<button
key={`${agent.source}-${agent.agentType}-${agent.target ?? agent.baseDir ?? index}`}
onClick={() => selectAgent(agent, 'agents')}
className="group rounded-xl border border-transparent px-3 py-3 text-left transition-all hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]"
className="group rounded-[var(--radius-xl)] border border-transparent px-3 py-3 text-left transition-all hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]"
>
<div className="flex items-start gap-3">
<Bot size={18} className="mt-0.5 shrink-0" style={{ color: getAgentDotColor(agent.color) }} />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="break-all text-sm font-bold text-[var(--color-text-primary)]">{agent.agentType}</span>
<span className="break-all font-mono text-[13px] font-semibold text-[var(--color-text-primary)]">{agent.agentType}</span>
{agent.modelDisplay && <MetaPill>{agent.modelDisplay}</MetaPill>}
{agent.effort !== undefined && <MetaPill>{agent.effort}</MetaPill>}
<MetaPill>{sourceLabel}</MetaPill>
<MetaPill>{agent.isActive ? t('settings.agents.status.active') : t('settings.agents.status.available')}</MetaPill>
<Badge
tone={agent.isActive ? 'success' : 'neutral'}
size="md"
bordered
className="uppercase tracking-[0.12em]"
>
{agent.isActive ? t('settings.agents.status.active') : t('settings.agents.status.available')}
</Badge>
{agent.overriddenBy && (
<MetaPill>{t('settings.agents.overriddenBy', { source: t(`settings.agents.source.${agent.overriddenBy}`) })}</MetaPill>
)}
@@ -288,7 +294,7 @@ export function AgentManager() {
: agent.tools.length === 0
? t('settings.agents.disabledTools')
: t('settings.agents.toolCount', { count: String(agent.tools.length) })}</span>
{(agent.target || agent.baseDir) && <span className="break-all">{agent.target || agent.baseDir}</span>}
{(agent.target || agent.baseDir) && <span className="break-all font-mono">{agent.target || agent.baseDir}</span>}
</div>
</div>
</div>
@@ -360,13 +366,13 @@ function AgentDetailView({
)}
</div>
<section className="overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)]">
<section className="overflow-hidden rounded-[var(--radius-2xl)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)]">
<div className="grid gap-4 px-5 py-5 lg:grid-cols-[minmax(0,1.4fr)_minmax(340px,1fr)] lg:items-start">
<div className="min-w-0">
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.2em] text-[var(--color-text-tertiary)]">{t('settings.agents.entryEyebrow')}</div>
<div className="mb-2 flex flex-wrap items-center gap-2">
<span className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: getAgentDotColor(agent.color) }} />
<h3 className="break-all text-[22px] font-semibold leading-tight text-[var(--color-text-primary)]">{agent.agentType}</h3>
<h3 className="break-all text-[22px] font-semibold leading-tight text-[var(--color-text-primary)]" style={{ fontFamily: 'var(--font-headline)' }}>{agent.agentType}</h3>
<MetaPill>{sourceLabel}</MetaPill>
<MetaPill>{agent.isActive ? t('settings.agents.status.active') : t('settings.agents.status.available')}</MetaPill>
{agent.overriddenBy && (
@@ -397,7 +403,7 @@ function AgentDetailView({
</section>
{agent.tools && agent.tools.length > 0 && (
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] px-5 py-4">
<section className="rounded-[var(--radius-2xl)] border border-[var(--color-border)] bg-[var(--color-surface)] px-5 py-4">
<div className="mb-3 flex items-center gap-2">
<Wrench size={18} className="text-[var(--color-text-tertiary)]" />
<h4 className="text-sm font-semibold text-[var(--color-text-primary)]">{t('settings.agents.tools')}</h4>
@@ -406,7 +412,7 @@ function AgentDetailView({
</section>
)}
<section className="flex min-h-0 min-w-0 flex-1 overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)]">
<section className="flex min-h-0 min-w-0 flex-1 overflow-hidden rounded-[var(--radius-2xl)] border border-[var(--color-border)] bg-[var(--color-surface)]">
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3">
<div className="min-w-0">
@@ -993,9 +999,9 @@ function getAgentSourceAccentClass(source: AgentSource) {
case 'userSettings': return 'bg-[var(--color-primary-fixed)] text-[var(--color-brand)]'
case 'projectSettings': return 'bg-[var(--color-success-container)] text-[var(--color-success)]'
case 'localSettings': return 'bg-[var(--color-info-container)] text-[var(--color-info)]'
case 'policySettings': return 'bg-[var(--color-warning-container)] text-[var(--color-warning)]'
case 'plugin': return 'bg-[var(--color-warning-container)] text-[var(--color-warning)]'
case 'flagSettings': return 'bg-[var(--color-error)]/10 text-[var(--color-error)]'
case 'policySettings': return 'bg-[var(--color-warning-container)] text-[var(--color-on-warning-container)]'
case 'plugin': return 'bg-[var(--color-warning-container)] text-[var(--color-on-warning-container)]'
case 'flagSettings': return 'bg-[var(--color-error-container)] text-[var(--color-on-error-container)]'
case 'built-in': return 'bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)]'
}
}
@@ -1067,7 +1073,7 @@ function MetaPill({ children }: { children: ReactNode }) {
function SummaryCard({ label, value, icon, className = '' }: { label: string; value: string; icon: ReactNode; className?: string }) {
return (
<div className={`min-w-0 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3 ${className}`}>
<div className={`min-w-0 rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3 ${className}`}>
<div className="flex min-w-0 items-center gap-1.5 text-[11px] uppercase tracking-[0.12em] text-[var(--color-text-tertiary)]">{icon}<span className="truncate">{label}</span></div>
<div className="mt-2 truncate text-lg font-semibold text-[var(--color-text-primary)]">{value}</div>
</div>
@@ -1076,7 +1082,7 @@ function SummaryCard({ label, value, icon, className = '' }: { label: string; va
function DetailStat({ label, value, icon }: { label: string; value: string; icon: ReactNode }) {
return (
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3">
<div className="rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3">
<div className="flex items-center gap-2 text-[11px] uppercase tracking-[0.16em] text-[var(--color-text-tertiary)]">{icon}<span>{label}</span></div>
<div className="mt-2 break-all text-base font-semibold text-[var(--color-text-primary)]">{value}</div>
</div>
@@ -0,0 +1,110 @@
import { fireEvent, render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { describe, expect, it, vi } from 'vitest'
import {
SettingsPageHeader,
SettingsPill,
SettingsSection,
SettingsStat,
} from './SettingsSection'
describe('SettingsPageHeader', () => {
it('renders the title as a heading so panes are navigable by landmark', () => {
render(<SettingsPageHeader title="Providers" description="Pick a default" />)
expect(screen.getByRole('heading', { name: 'Providers' })).toBeInTheDocument()
expect(screen.getByText('Pick a default')).toBeInTheDocument()
})
it('sets the serif family on the title', () => {
render(<SettingsPageHeader title="Providers" />)
expect(screen.getByRole('heading', { name: 'Providers' })).toHaveStyle({
fontFamily: 'var(--font-headline)',
})
})
it('omits the description line when none is given', () => {
const { container } = render(<SettingsPageHeader title="Providers" />)
expect(container.querySelector('p')).toBeNull()
})
it('renders the action slot', () => {
render(<SettingsPageHeader title="Providers" action={<button type="button">Add</button>} />)
expect(screen.getByRole('button', { name: 'Add' })).toBeInTheDocument()
})
})
describe('SettingsSection', () => {
it('labels its children with a heading', () => {
render(
<SettingsSection title="Appearance" description="Theme and density">
<span>body</span>
</SettingsSection>,
)
expect(screen.getByRole('heading', { name: 'Appearance' })).toBeInTheDocument()
expect(screen.getByText('body')).toBeInTheDocument()
})
it('renders as a section element so the heading scopes a region', () => {
const { container } = render(<SettingsSection title="Appearance">x</SettingsSection>)
expect(container.firstElementChild?.tagName).toBe('SECTION')
})
})
describe('SettingsPill', () => {
it('reports its selection through aria-pressed', () => {
render(
<>
<SettingsPill selected onClick={() => {}}>Paper</SettingsPill>
<SettingsPill selected={false} onClick={() => {}}>Dark</SettingsPill>
</>,
)
expect(screen.getByRole('button', { name: 'Paper' })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('button', { name: 'Dark' })).toHaveAttribute('aria-pressed', 'false')
})
it('defaults to type="button" so it cannot submit a surrounding form', () => {
render(<SettingsPill selected={false} onClick={() => {}}>Paper</SettingsPill>)
expect(screen.getByRole('button', { name: 'Paper' })).toHaveAttribute('type', 'button')
})
it('fills with ink when selected and tone is ink', () => {
render(<SettingsPill selected onClick={() => {}}>Paper</SettingsPill>)
expect(screen.getByRole('button', { name: 'Paper' }).className)
.toContain('bg-[var(--color-btn-primary-bg)]')
})
it('outlines in terracotta when selected and tone is terracotta', () => {
render(<SettingsPill selected tone="terracotta" onClick={() => {}}>DeepSeek</SettingsPill>)
const className = screen.getByRole('button', { name: 'DeepSeek' }).className
expect(className).toContain('border-[var(--color-primary-fixed-dim)]')
// The soft fill's own accent is too low-contrast on it under the two ink
// palettes; the paired token is the readable one.
expect(className).toContain('text-[var(--color-on-brand-soft)]')
})
it('calls onClick', () => {
const onClick = vi.fn()
render(<SettingsPill selected={false} onClick={onClick}>Paper</SettingsPill>)
fireEvent.click(screen.getByRole('button', { name: 'Paper' }))
expect(onClick).toHaveBeenCalledTimes(1)
})
it('can be disabled', () => {
render(<SettingsPill selected={false} disabled onClick={() => {}}>Paper</SettingsPill>)
expect(screen.getByRole('button', { name: 'Paper' })).toBeDisabled()
})
})
describe('SettingsStat', () => {
it('renders the value in the serif face and keeps the label readable', () => {
render(<SettingsStat label="Sessions" value="128" />)
expect(screen.getByText('128')).toHaveStyle({ fontFamily: 'var(--font-headline)' })
expect(screen.getByText('Sessions')).toBeInTheDocument()
})
it('renders the optional hint line', () => {
render(<SettingsStat label="Sessions" value="128" hint="last 30 days" />)
expect(screen.getByText('last 30 days')).toBeInTheDocument()
})
})
@@ -0,0 +1,152 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react'
import { cx } from '@/lib/cx'
export type SettingsPageHeaderProps = {
title: ReactNode
description?: ReactNode
/** Right-aligned slot, normally the section's primary ink button. */
action?: ReactNode
className?: string
}
/**
* The page-level header of a settings pane: serif title, secondary line, and an
* optional primary action pinned right.
*
* Kept here rather than in `ui/` because the serif size and the 24px gap under
* it are settings-suite decisions, not a generic heading primitive — the market
* and schedule pages use a larger 26–28px title with different chrome.
*/
export function SettingsPageHeader({ title, description, action, className }: SettingsPageHeaderProps) {
return (
<div className={cx('mb-6 flex items-start justify-between gap-4', className)}>
<div className="min-w-0">
<h2
className="text-[24px] font-semibold leading-tight text-[var(--color-text-primary)]"
style={{ fontFamily: 'var(--font-headline)' }}
>
{title}
</h2>
{description ? (
<p className="mt-1.5 text-[13.5px] leading-6 text-[var(--color-text-secondary)]">{description}</p>
) : null}
</div>
{action ? <div className="flex shrink-0 items-center gap-2">{action}</div> : null}
</div>
)
}
export type SettingsSectionProps = {
title: ReactNode
description?: ReactNode
action?: ReactNode
className?: string
children?: ReactNode
}
/**
* One labelled block inside a settings pane.
*
* Replaces the `<h2 class="text-base font-semibold"> + <p class="text-sm"> +
* bare div` triple that was copy-pasted 19 times in `Settings.tsx` alone, each
* copy free to drift in its own margin and text size.
*/
export function SettingsSection({ title, description, action, className, children }: SettingsSectionProps) {
return (
<section className={cx('mb-8', className)}>
<div className="mb-3 flex items-start justify-between gap-3">
<div className="min-w-0">
<h2
className="text-[16.5px] font-semibold leading-tight text-[var(--color-text-primary)]"
style={{ fontFamily: 'var(--font-headline)' }}
>
{title}
</h2>
{description ? (
<p className="mt-1 text-[13px] leading-5 text-[var(--color-text-tertiary)]">{description}</p>
) : null}
</div>
{action ? <div className="flex shrink-0 items-center gap-2">{action}</div> : null}
</div>
{children}
</section>
)
}
export type SettingsPillProps = {
selected: boolean
/**
* Which selected look to wear.
*
* `ink` is the solid ink fill the handoff specifies for the theme and
* language rows — a choice that takes effect immediately and applies to the
* whole app. `terracotta` is the softer accent outline used where the pill
* only pre-fills a form (provider presets), so it does not compete with the
* modal's real primary button.
*/
tone?: 'ink' | 'terracotta'
onClick: () => void
className?: string
children: ReactNode
} & Pick<ButtonHTMLAttributes<HTMLButtonElement>, 'title' | 'disabled'>
const PILL_SELECTED = {
ink: 'bg-[var(--color-btn-primary-bg)] text-[var(--color-btn-primary-fg)] border-transparent shadow-[var(--shadow-button-primary)]',
terracotta:
'bg-[var(--color-brand-soft)] text-[var(--color-on-brand-soft)] border-[var(--color-primary-fixed-dim)]',
} as const
/** A `rounded-full` single-choice chip. */
export function SettingsPill({
selected,
tone = 'ink',
onClick,
className,
children,
...props
}: SettingsPillProps) {
return (
<button
type="button"
{...props}
onClick={onClick}
aria-pressed={selected}
className={cx(
'inline-flex items-center justify-center gap-1.5 rounded-full border px-3.5 py-1.5 text-[12.5px] font-medium',
'transition-[background-color,color,border-color,box-shadow,transform] duration-150 ease-out',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]',
'focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface)]',
selected
? PILL_SELECTED[tone]
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:border-[var(--color-outline)] hover:bg-[var(--color-surface-hover)]',
className,
)}
>
{children}
</button>
)
}
export type SettingsStatProps = {
label: ReactNode
value: ReactNode
hint?: ReactNode
className?: string
}
/** A stat tile: serif number over a small tertiary label. */
export function SettingsStat({ label, value, hint, className }: SettingsStatProps) {
return (
<div className={cx('min-w-0', className)}>
<div
className="text-[25px] font-semibold leading-none text-[var(--color-text-primary)]"
style={{ fontFamily: 'var(--font-headline)' }}
>
{value}
</div>
<div className="mt-2 truncate text-[12px] text-[var(--color-text-tertiary)]">{label}</div>
{hint ? <div className="mt-0.5 truncate text-[11px] text-[var(--color-text-tertiary)]">{hint}</div> : null}
</div>
)
}
@@ -1,4 +1,5 @@
import { useCallback, useMemo, useState } from 'react'
import { Trash2 } from 'lucide-react'
import { useSkillStore } from '../../stores/skillStore'
import { useTranslation } from '../../i18n'
import { useUIStore } from '../../stores/uiStore'
@@ -120,7 +121,7 @@ export function SkillDetail() {
size="lg"
data-testid="local-skill-uninstall-button"
loading={uninstalling}
icon={<span className="material-symbols-outlined text-[18px]" aria-hidden>delete</span>}
icon={<Trash2 className="h-4 w-4" strokeWidth={1.5} aria-hidden="true" />}
onClick={() => setConfirmUninstall(true)}
>
{uninstalling ? t('market.uninstall.uninstalling') : t('market.uninstall.action')}
+83 -61
View File
@@ -1,4 +1,19 @@
import { useEffect, useMemo, useState } from 'react'
import {
ChevronRight,
FileStack,
Folder,
Layers,
Package,
Puzzle,
Search,
SearchX,
Share2,
Sparkles,
User,
X,
type LucideIcon,
} from 'lucide-react'
import { useSkillStore } from '../../stores/skillStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useTranslation } from '../../i18n'
@@ -12,19 +27,20 @@ import type { SkillMeta, SkillSource } from '../../types/skill'
const SOURCE_ORDER: SkillSource[] = ['user', 'project', 'plugin', 'mcp', 'bundled']
const SOURCE_ICONS: Record<SkillSource, string> = {
user: 'person',
project: 'folder',
plugin: 'extension',
mcp: 'hub',
bundled: 'inventory_2',
const SOURCE_ICONS: Record<SkillSource, LucideIcon> = {
user: User,
project: Folder,
plugin: Puzzle,
mcp: Share2,
bundled: Package,
}
/** Fill plus its AA-checked foreground — never the accent on its own tint. */
const SOURCE_ACCENT_CLASSES: Record<SkillSource, string> = {
user: 'bg-[var(--color-primary-fixed)] text-[var(--color-brand)]',
project: 'bg-[var(--color-success-container)] text-[var(--color-success)]',
plugin: 'bg-[var(--color-warning-container)] text-[var(--color-warning)]',
mcp: 'bg-[var(--color-info-container)] text-[var(--color-info)]',
user: 'bg-[var(--color-brand-soft)] text-[var(--color-on-brand-soft)]',
project: 'bg-[var(--color-success-container)] text-[var(--color-on-success-container)]',
plugin: 'bg-[var(--color-warning-container)] text-[var(--color-on-warning-container)]',
mcp: 'bg-[var(--color-info-container)] text-[var(--color-on-info-container)]',
bundled: 'bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)]',
}
@@ -99,7 +115,7 @@ export function SkillList() {
if (skills.length === 0) {
return (
<EmptyState
icon={<span className="material-symbols-outlined text-[20px]">auto_awesome</span>}
icon={<Sparkles size={20} strokeWidth={1.6} aria-hidden="true" />}
title={t('settings.skills.empty')}
description={t('settings.skills.emptyHint')}
/>
@@ -107,32 +123,31 @@ export function SkillList() {
}
return (
<div className="flex flex-col gap-6 min-w-0">
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] overflow-hidden">
<div className="grid gap-4 px-5 py-5 min-w-0 xl:grid-cols-[minmax(0,1.6fr)_minmax(320px,1fr)] xl:items-end">
<div className="flex min-w-0 flex-col gap-6">
<Card radius="xl" surface="low" padding="none" className="overflow-hidden">
<div className="grid min-w-0 gap-5 px-5 py-5 xl:grid-cols-[minmax(0,1.15fr)_minmax(400px,1fr)] xl:items-end">
<div className="min-w-0">
<div className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[var(--color-text-tertiary)] mb-2">
<div className="mb-2 text-[11px] font-semibold uppercase tracking-[0.2em] text-[var(--color-text-tertiary)]">
{t('settings.skills.browserEyebrow')}
</div>
<div className="flex items-center gap-3 mb-2">
<span className="material-symbols-outlined text-[22px] text-[var(--color-brand)]">
auto_awesome
</span>
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">
<div className="mb-2 flex items-center gap-2.5">
<Sparkles className="h-[22px] w-[22px] text-[var(--color-brand)]" strokeWidth={1.5} aria-hidden="true" />
<h3
style={{ fontFamily: 'var(--font-headline)' }}
className="text-[21px] font-bold tracking-[-0.01em] text-[var(--color-text-primary)]"
>
{t('settings.skills.browserTitle')}
</h3>
</div>
<p className="text-sm leading-6 text-[var(--color-text-secondary)] max-w-3xl">
<p className="max-w-3xl text-sm leading-6 text-[var(--color-text-secondary)]">
{t('settings.skills.browserDescription')}
</p>
<div className="mt-4 max-w-2xl">
<label className="sr-only" htmlFor="settings-skill-search">
{t('settings.skills.searchLabel')}
</label>
<div className="flex min-h-11 items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 transition-colors focus-within:border-[var(--color-border-focus)] focus-within:ring-2 focus-within:ring-[var(--color-brand)]/20">
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)]">
search
</span>
<div className="flex min-h-11 items-center gap-2.5 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3.5 transition-colors focus-within:border-[var(--color-border-focus)] focus-within:shadow-[var(--shadow-focus-ring)]">
<Search className="h-[15px] w-[15px] flex-shrink-0 text-[var(--color-text-tertiary)]" strokeWidth={1.6} aria-hidden="true" />
<input
id="settings-skill-search"
value={searchQuery}
@@ -142,7 +157,7 @@ export function SkillList() {
/>
{searchQuery && (
<IconButton
icon="close"
icon={<X className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />}
label={t('settings.skills.clearSearch')}
size="sm"
shape="circle"
@@ -162,11 +177,13 @@ export function SkillList() {
</div>
</div>
<div className="grid grid-cols-2 gap-3 min-w-0 sm:grid-cols-3">
{/* Column count follows the track width, not the viewport: `sm:grid-cols-3` kept
forcing three columns into a 320px sidebar column and clipped the CJK labels. */}
<div className="grid min-w-0 grid-cols-[repeat(auto-fit,minmax(116px,1fr))] gap-3">
<SummaryCard
label={t('settings.skills.summary.totalSkills')}
value={String(filteredSkills.length)}
icon="auto_awesome"
icon={Sparkles}
/>
<SummaryCard
label={t('settings.skills.summary.sources')}
@@ -174,21 +191,20 @@ export function SkillList() {
SOURCE_ORDER.filter((source) => (grouped[source] ?? []).length > 0)
.length,
)}
icon="layers"
icon={Layers}
/>
<SummaryCard
label={t('settings.skills.summary.tokens')}
value={t('settings.skills.tokenEstimateShort', { count: String(totalTokens) })}
icon="notes"
className="col-span-2 sm:col-span-1"
icon={FileStack}
/>
</div>
</div>
</section>
</Card>
{filteredSkills.length === 0 && (
<EmptyState
icon={<span className="material-symbols-outlined text-[20px]">search_off</span>}
icon={<SearchX size={20} strokeWidth={1.6} aria-hidden="true" />}
title={t('settings.skills.noSearchResults')}
description={t('settings.skills.noSearchResultsHint')}
action={{ label: t('settings.skills.clearSearch'), onClick: () => setSearchQuery('') }}
@@ -201,28 +217,31 @@ export function SkillList() {
if (!group?.length) return null
const sourceLabel = t(`settings.skills.source.${source}`)
const SourceIcon = SOURCE_ICONS[source]
const sourceTokenCount = group.reduce(
(sum, skill) => sum + estimateTokens(skill.contentLength),
0,
)
return (
<section
<Card
key={source}
className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden min-w-0"
as="section"
radius="xl"
surface="base"
padding="none"
className="min-w-0 overflow-hidden"
>
<div className="flex items-start justify-between gap-3 px-5 py-4 border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)]">
<div className="flex items-start justify-between gap-3 border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-5 py-4">
<div className="min-w-0">
<div className="flex items-center gap-2 mb-1">
<div className="mb-1 flex items-center gap-2">
<span className={`inline-flex h-7 w-7 items-center justify-center rounded-full ${SOURCE_ACCENT_CLASSES[source]}`}>
<span className="material-symbols-outlined text-[16px]">
{SOURCE_ICONS[source]}
</span>
<SourceIcon className="h-4 w-4" strokeWidth={1.6} aria-hidden="true" />
</span>
<h4 className="text-sm font-semibold text-[var(--color-text-primary)]">
{sourceLabel}
</h4>
<span className="text-xs text-[var(--color-text-tertiary)]">
<span className="font-mono text-xs text-[var(--color-text-tertiary)]">
{group.length}
</span>
</div>
@@ -233,7 +252,7 @@ export function SkillList() {
})}
</p>
</div>
<div className="text-[11px] text-[var(--color-text-tertiary)] whitespace-nowrap">
<div className="whitespace-nowrap font-mono text-[11px] text-[var(--color-text-tertiary)]">
{t('settings.skills.tokenEstimateShort', { count: String(sourceTokenCount) })}
</div>
</div>
@@ -247,15 +266,13 @@ export function SkillList() {
fetchSkillDetail(skill.source, skill.name, currentWorkDir, 'skills')
}
disabled={!skill.hasDirectory}
className="group rounded-xl border border-transparent px-3 py-3 text-left transition-all hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface)] disabled:opacity-60 disabled:cursor-default disabled:hover:bg-transparent disabled:hover:border-transparent"
className="group rounded-[var(--radius-lg)] border border-transparent px-3 py-3 text-left transition-[background-color,border-color,box-shadow] duration-150 ease-out hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface)] disabled:cursor-default disabled:opacity-60 disabled:hover:border-transparent disabled:hover:bg-transparent"
>
<div className="flex items-start gap-3">
<span className="mt-0.5 material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)]">
auto_awesome
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-[var(--color-text-primary)] break-all">
<Sparkles className="mt-0.5 h-[18px] w-[18px] flex-shrink-0 text-[var(--color-text-tertiary)]" strokeWidth={1.5} aria-hidden="true" />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="break-all text-sm font-semibold text-[var(--color-text-primary)]">
{skill.displayName || skill.name}
</span>
{skill.version && <Badge mono>v{skill.version}</Badge>}
@@ -279,24 +296,25 @@ export function SkillList() {
</Badge>
)}
</div>
<p className="mt-1 text-xs leading-5 text-[var(--color-text-secondary)] break-words">
<p className="mt-1 break-words text-xs leading-5 text-[var(--color-text-secondary)]">
{skill.description}
</p>
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-[var(--color-text-tertiary)]">
<span>{sourceLabel}</span>
<span>{t('settings.skills.tokenEstimateShort', { count: String(estimateTokens(skill.contentLength)) })}</span>
<span className="font-mono">{t('settings.skills.tokenEstimateShort', { count: String(estimateTokens(skill.contentLength)) })}</span>
<span>{skill.hasDirectory ? t('settings.skills.ready') : t('settings.skills.unavailable')}</span>
</div>
</div>
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)] opacity-60 transition-transform group-hover:translate-x-0.5 group-hover:opacity-100">
chevron_right
</span>
<ChevronRight
className="h-[18px] w-[18px] flex-shrink-0 text-[var(--color-text-tertiary)] opacity-60 transition-transform duration-150 group-hover:translate-x-0.5 group-hover:opacity-100 motion-reduce:transition-none motion-reduce:group-hover:translate-x-0"
strokeWidth={1.6}
aria-hidden="true"
/>
</div>
</button>
))}
</div>
</section>
</Card>
)
})}
</div>
@@ -307,21 +325,25 @@ export function SkillList() {
function SummaryCard({
label,
value,
icon,
icon: Icon,
className = '',
}: {
label: string
value: string
icon: string
icon: LucideIcon
className?: string
}) {
return (
<Card radius="lg" padding="sm" className={`min-w-0 ${className}`}>
<div className="flex items-center gap-1.5 text-[11px] uppercase tracking-[0.12em] text-[var(--color-text-tertiary)] min-w-0">
<span className="material-symbols-outlined text-[14px] flex-shrink-0">{icon}</span>
<div className="flex min-w-0 items-center gap-1.5 text-[11px] uppercase tracking-[0.12em] text-[var(--color-text-tertiary)]">
<Icon className="h-3.5 w-3.5 flex-shrink-0" strokeWidth={1.6} aria-hidden="true" />
<span className="truncate">{label}</span>
</div>
<div className="mt-2 text-lg font-semibold text-[var(--color-text-primary)] truncate">
{/* Statistic, so it takes the serif face the handoff pins on big numbers. */}
<div
style={{ fontFamily: 'var(--font-headline)' }}
className="mt-2 truncate text-[21px] font-bold text-[var(--color-text-primary)]"
>
{value}
</div>
</Card>
@@ -18,8 +18,10 @@ export function ConfirmPopover({
confirmVariant = 'primary',
}: ConfirmPopoverProps) {
return (
<div className="absolute right-0 top-full mt-1.5 z-50 w-52 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-lg p-3">
<p className="mb-2.5 text-xs text-[var(--color-text-secondary)]">{message}</p>
// `glass-panel` carries the frosted fill and `--shadow-overlay`; the level
// comes from the `--z-*` scale rather than a bare `z-50`.
<div className="glass-panel absolute right-0 top-full z-[var(--z-popover)] mt-1.5 w-52 rounded-[var(--radius-lg)] p-3">
<p className="mb-2.5 text-[13px] leading-[1.5] text-[var(--color-text-secondary)]">{message}</p>
<div className="flex justify-end gap-1.5">
<Button type="button" variant="ghost" size="sm" onClick={onCancel}>
{cancelLabel}
@@ -40,11 +40,18 @@ export function DayOfWeekPicker({ selected, onChange }: Props) {
key={day}
type="button"
onClick={() => toggle(day)}
aria-pressed={isActive}
className={`
w-8 h-8 rounded-full text-xs font-medium transition-colors
h-8 w-8 cursor-pointer rounded-full border text-xs font-medium
transition-[background-color,color,border-color] duration-150 ease-out
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]
focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface)]
${isActive
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)] border border-[var(--color-border-focus)]'
: 'bg-[var(--color-surface)] text-[var(--color-text-tertiary)] border border-[var(--color-border)] hover:bg-[var(--color-surface-hover)]'
// The handoff's active chip: terracotta wash, its paired ink,
// and the accent border. `--color-brand` as the text would be
// 4.3:1 on this fill under the two ink palettes.
? 'border-[var(--color-primary-fixed-dim)] bg-[var(--color-brand-soft)] text-[var(--color-on-brand-soft)]'
: 'border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-tertiary)] hover:border-[var(--color-outline)] hover:bg-[var(--color-surface-hover)]'
}
`}
>
@@ -91,4 +91,41 @@ describe('NewTaskModal', () => {
recurring: true,
}))
})
describe('schedule controls', () => {
function renderModal() {
useSettingsStore.setState({ locale: 'en' })
useAdapterStore.setState({
fetchConfig: vi.fn(async () => {}),
config: {},
} as Partial<ReturnType<typeof useAdapterStore.getState>>)
return render(<NewTaskModal open onClose={vi.fn()} />)
}
it('gives the frequency select a name', () => {
// All seven native selects in the app shipped without a label or an
// `aria-label`, leaving them nameless in the accessibility tree.
renderModal()
expect(screen.getByLabelText('Frequency')).toHaveValue('daily')
})
it('names the time field even though it shares the frequency caption', () => {
// One「频率」heading covers both controls, so the time input carries its
// name on `aria-label` rather than a second visible label.
renderModal()
expect(screen.getByLabelText('Run time')).toHaveValue('09:00')
})
it('announces an invalid custom cron instead of only printing it', () => {
renderModal()
fireEvent.change(screen.getByLabelText('Frequency'), { target: { value: 'customCron' } })
const cronField = screen.getByLabelText('Custom cron expression')
fireEvent.change(cronField, { target: { value: 'not a cron' } })
expect(screen.getByRole('alert')).toHaveTextContent('Invalid cron expression')
expect(cronField).toHaveAttribute('aria-invalid', 'true')
expect(screen.getByRole('button', { name: 'Create task' })).toBeDisabled()
})
})
})
+176 -194
View File
@@ -3,7 +3,11 @@ import { useTaskStore } from '../../stores/taskStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useAdapterStore } from '../../stores/adapterStore'
import { Modal } from '@/components/ui/Modal'
import { Badge } from '@/components/ui/Badge'
import { Card } from '@/components/ui/Card'
import { Checkbox } from '@/components/ui/Checkbox'
import { Input } from '@/components/ui/Input'
import { SelectField } from '@/components/ui/SelectField'
import { Button } from '@/components/ui/Button'
import { PromptEditor } from './PromptEditor'
import { DayOfWeekPicker } from './DayOfWeekPicker'
@@ -152,29 +156,41 @@ export function NewTaskModal({ open, onClose, editTask }: Props) {
}
}
const selectClass = 'w-full h-10 px-3 pr-8 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] text-sm text-[var(--color-text-primary)] outline-none focus:border-[var(--color-border-focus)] appearance-none cursor-pointer'
const cronPreview = frequency === 'customCron' && customCron.trim() && !isValidCron(customCron)
? t('newTask.invalidCron')
: describeCron(cronValue, t)
return (
<Modal
open={open}
onClose={onClose}
// 760px, per the handoff: the prompt editor's embedded toolbar puts a
// permission chip, a folder chip and a model picker on one line, which
// wraps into three rows at the 560px default.
width={760}
title={isEdit ? t('tasks.editTitle') : t('newTask.title')}
footer={
<>
<Button variant="secondary" onClick={onClose}>{t('common.cancel')}</Button>
<Button onClick={handleSubmit} disabled={!canSubmit} loading={isSubmitting}>
{isEdit ? t('tasks.saveChanges') : t('newTask.create')}
</Button>
</>
<div className="flex w-full flex-wrap items-center gap-2.5 border-t border-[var(--color-border)] pt-4">
{/* The human-readable schedule reads as the sentence the buttons are
about to commit to, so it sits with them rather than in the body. */}
<span aria-hidden="true" className="material-symbols-outlined shrink-0 text-[16px] text-[var(--color-text-secondary)]">schedule</span>
<span className="min-w-0 text-[13.5px] text-[var(--color-text-secondary)]">{cronPreview}</span>
<div className="ml-auto flex shrink-0 gap-2.5">
<Button variant="secondary" onClick={onClose}>{t('common.cancel')}</Button>
<Button onClick={handleSubmit} disabled={!canSubmit} loading={isSubmitting}>
{isEdit ? t('tasks.saveChanges') : t('newTask.create')}
</Button>
</div>
</div>
}
>
{/* Info banner */}
<div className="flex items-center gap-2.5 px-3.5 py-2.5 rounded-[var(--radius-md)] bg-[var(--color-surface-container)] mb-5">
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-secondary)]">info</span>
<span className="text-xs text-[var(--color-text-secondary)]">
<Card radius="md" surface="low" padding="none" className="mb-5 flex items-center gap-2.5 px-[15px] py-[11px]">
<span aria-hidden="true" className="material-symbols-outlined shrink-0 text-[16px] text-[var(--color-text-secondary)]">info</span>
<span className="text-[13.5px] text-[var(--color-text-secondary)]">
{t('newTask.localWarning')}
</span>
</div>
</Card>
<div className="flex flex-col gap-4">
<Input
@@ -208,73 +224,69 @@ export function NewTaskModal({ open, onClose, editTask }: Props) {
onUseWorktreeChange={setUseWorktree}
/>
{/* Frequency */}
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-[var(--color-text-primary)]">{t('newTask.frequency')}</label>
<div className="relative">
<select
value={frequency}
onChange={(e) => setFrequency(e.target.value as FrequencyKey)}
className={selectClass}
>
{FREQUENCY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)] absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none">
expand_more
</span>
</div>
{/* Frequency, with the time of day beside it — the handoff pairs
「每天」and「09:00」on one line because they are one sentence.
The selects are `SelectField` rather than bare `<select>`: all seven
native selects in the app shipped nameless, and the hand-rolled
chevron needed `appearance-none` plus an absolutely positioned icon
to reproduce what the platform control draws for free. */}
<div className="flex flex-wrap items-end gap-2.5">
<SelectField<FrequencyKey>
containerClassName="min-w-[220px] flex-1"
label={t('newTask.frequency')}
value={frequency}
onChange={setFrequency}
options={FREQUENCY_OPTIONS}
/>
{/* Time picker — shown for daily, weekdays, specificDays, monthly.
Named by `aria-label` rather than a visible label: the handoff puts
one「频率」heading over both controls, and a second caption here
would break that pairing. Same treatment `SelectField` gives its
own `labelHidden` fields. */}
{showTime && (
<input
type="time"
aria-label={t('newTask.time')}
value={time}
onChange={(e) => setTime(e.target.value)}
className="h-10 w-40 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 font-mono text-sm tabular-nums text-[var(--color-text-primary)] outline-none transition-colors focus:border-[var(--color-border-focus)]"
/>
)}
</div>
{/* Sub-controls based on frequency */}
{frequency === 'everyNMinutes' && (
<div className="relative">
<select
value={minuteInterval}
onChange={(e) => setMinuteInterval(Number(e.target.value))}
className={selectClass}
>
{MINUTE_INTERVALS.map((n) => (
<option key={n} value={n}>{t('newTask.intervalMinutes', { n })}</option>
))}
</select>
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)] absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none">
expand_more
</span>
</div>
<SelectField
label={t('newTask.everyNMinutes')}
labelHidden
value={String(minuteInterval)}
onChange={(value) => setMinuteInterval(Number(value))}
options={MINUTE_INTERVALS.map((n) => ({ value: String(n), label: t('newTask.intervalMinutes', { n }) }))}
/>
)}
{frequency === 'everyNHours' && (
<div className="flex gap-2">
<div className="relative flex-1">
<select
value={hourInterval}
onChange={(e) => setHourInterval(Number(e.target.value))}
className={selectClass}
>
{HOUR_INTERVALS.map((n) => (
<option key={n} value={n}>{t('newTask.intervalHours', { n })}</option>
))}
</select>
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)] absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none">
expand_more
</span>
</div>
<div className="relative flex-1">
<select
value={minuteOffset}
onChange={(e) => setMinuteOffset(Number(e.target.value))}
className={selectClass}
>
{MINUTE_OFFSETS.map((m) => (
<option key={m} value={m}>{t('newTask.atMinute', { m: m.toString().padStart(2, '0') })}</option>
))}
</select>
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)] absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none">
expand_more
</span>
</div>
<div className="flex gap-2.5">
<SelectField
containerClassName="flex-1"
label={t('newTask.everyNHours')}
labelHidden
value={String(hourInterval)}
onChange={(value) => setHourInterval(Number(value))}
options={HOUR_INTERVALS.map((n) => ({ value: String(n), label: t('newTask.intervalHours', { n }) }))}
/>
<SelectField
containerClassName="flex-1"
label={t('newTask.atMinute', { m: '00' })}
labelHidden
value={String(minuteOffset)}
onChange={(value) => setMinuteOffset(Number(value))}
options={MINUTE_OFFSETS.map((m) => ({
value: String(m),
label: t('newTask.atMinute', { m: m.toString().padStart(2, '0') }),
}))}
/>
</div>
)}
@@ -283,141 +295,111 @@ export function NewTaskModal({ open, onClose, editTask }: Props) {
)}
{frequency === 'monthly' && (
<div className="relative">
<select
value={monthDay}
onChange={(e) => setMonthDay(Number(e.target.value))}
className={selectClass}
>
{Array.from({ length: 28 }, (_, i) => i + 1).map((d) => (
<option key={d} value={d}>{t('newTask.onMonthDay', { d })}</option>
))}
</select>
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)] absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none">
expand_more
</span>
</div>
<SelectField
label={t('newTask.monthly')}
labelHidden
value={String(monthDay)}
onChange={(value) => setMonthDay(Number(value))}
options={Array.from({ length: 28 }, (_, i) => i + 1).map((d) => ({
value: String(d),
label: t('newTask.onMonthDay', { d }),
}))}
/>
)}
{frequency === 'customCron' && (
<div className="flex flex-col gap-1">
<input
type="text"
value={customCron}
onChange={(e) => setCustomCron(e.target.value)}
placeholder={t('newTask.cronFormatHint')}
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] text-sm text-[var(--color-text-primary)] font-[var(--font-mono)] outline-none focus:border-[var(--color-border-focus)]"
/>
<span className="text-xs text-[var(--color-text-tertiary)]">{t('newTask.cronFormatHint')}</span>
{customCron.trim() && !isValidCron(customCron) && (
<span className="text-xs text-[var(--color-error)]">{t('newTask.invalidCron')}</span>
)}
</div>
<Input
aria-label={t('newTask.customCron')}
value={customCron}
onChange={(e) => setCustomCron(e.target.value)}
placeholder={t('newTask.cronFormatHint')}
className="font-mono"
hint={t('newTask.cronFormatHint')}
// `error` gives the message `role="alert"` and points the field's
// `aria-describedby` at it; the loose span it replaces was never
// announced.
error={customCron.trim() && !isValidCron(customCron) ? t('newTask.invalidCron') : undefined}
/>
)}
{/* Time picker — shown for daily, weekdays, specificDays, monthly */}
{showTime && (
<div className="flex flex-col gap-1">
<input
type="time"
value={time}
onChange={(e) => setTime(e.target.value)}
className="w-auto h-10 px-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] text-sm text-[var(--color-text-primary)] outline-none focus:border-[var(--color-border-focus)]"
style={{ maxWidth: 120 }}
/>
</div>
)}
{/* Notification */}
<div className="flex flex-col gap-3 rounded-[var(--radius-md)] border border-[var(--color-border)] p-3">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={notifyEnabled}
onChange={(e) => {
setNotifyEnabled(e.target.checked)
if (e.target.checked && notifyChannels.length === 0) {
setNotifyChannels(['desktop'])
}
}}
className="w-4 h-4 rounded border-[var(--color-border)] accent-[var(--color-brand)]"
/>
<div>
<span className="text-sm font-medium text-[var(--color-text-primary)]">{t('newTask.notifyOnComplete')}</span>
<p className="text-xs text-[var(--color-text-tertiary)]">{t('newTask.notifyHint')}</p>
</div>
</label>
{/* Notification. The three hand-rolled checkboxes here were part of the
19 across the app that varied in size, accent token and how the
label was associated; `Checkbox` also carries the disabled styling
the channel rows were spelling out by hand. */}
<Card radius="lg" padding="none" className="flex flex-col gap-3 px-[18px] py-[15px]">
<Checkbox
label={t('newTask.notifyOnComplete')}
description={t('newTask.notifyHint')}
checked={notifyEnabled}
onChange={(e) => {
setNotifyEnabled(e.target.checked)
if (e.target.checked && notifyChannels.length === 0) {
setNotifyChannels(['desktop'])
}
}}
/>
{notifyEnabled && (
<div className="flex flex-col gap-2 pl-7">
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={notifyChannels.includes('desktop')}
onChange={(e) => {
setNotifyChannels((prev) =>
e.target.checked ? [...prev, 'desktop'] : prev.filter((c) => c !== 'desktop'),
)
}}
className="w-3.5 h-3.5 rounded border-[var(--color-border)] accent-[var(--color-brand)]"
/>
<span className="text-sm text-[var(--color-text-primary)]">{t('newTask.notifyDesktop')}</span>
</label>
<label className={`flex items-center gap-2 ${isFeishuConfigured ? 'cursor-pointer' : 'opacity-50 cursor-not-allowed'}`}>
<input
type="checkbox"
checked={notifyChannels.includes('feishu')}
disabled={!isFeishuConfigured}
onChange={(e) => {
setNotifyChannels((prev) =>
e.target.checked ? [...prev, 'feishu'] : prev.filter((c) => c !== 'feishu'),
)
}}
className="w-3.5 h-3.5 rounded border-[var(--color-border)] accent-[var(--color-brand)]"
/>
<span className="text-sm text-[var(--color-text-primary)]">{t('settings.adapters.feishu')}</span>
{!isFeishuConfigured && (
<span className="text-[10px] text-[var(--color-warning)]">{t('newTask.notConfigured')}</span>
)}
</label>
<label className={`flex items-center gap-2 ${isTelegramConfigured ? 'cursor-pointer' : 'opacity-50 cursor-not-allowed'}`}>
<input
type="checkbox"
checked={notifyChannels.includes('telegram')}
disabled={!isTelegramConfigured}
onChange={(e) => {
setNotifyChannels((prev) =>
e.target.checked ? [...prev, 'telegram'] : prev.filter((c) => c !== 'telegram'),
)
}}
className="w-3.5 h-3.5 rounded border-[var(--color-border)] accent-[var(--color-brand)]"
/>
<span className="text-sm text-[var(--color-text-primary)]">{t('settings.adapters.telegram')}</span>
{!isTelegramConfigured && (
<span className="text-[10px] text-[var(--color-warning)]">{t('newTask.notConfigured')}</span>
)}
</label>
<div className="flex flex-col gap-2 pl-6">
<div className="flex flex-wrap items-center gap-4">
<Checkbox
size="sm"
label={t('newTask.notifyDesktop')}
checked={notifyChannels.includes('desktop')}
onChange={(e) => {
setNotifyChannels((prev) =>
e.target.checked ? [...prev, 'desktop'] : prev.filter((c) => c !== 'desktop'),
)
}}
/>
<Checkbox
size="sm"
label={
<span className="inline-flex items-center gap-1.5">
{t('settings.adapters.feishu')}
{!isFeishuConfigured && <Badge tone="warning">{t('newTask.notConfigured')}</Badge>}
</span>
}
checked={notifyChannels.includes('feishu')}
disabled={!isFeishuConfigured}
onChange={(e) => {
setNotifyChannels((prev) =>
e.target.checked ? [...prev, 'feishu'] : prev.filter((c) => c !== 'feishu'),
)
}}
/>
<Checkbox
size="sm"
label={
<span className="inline-flex items-center gap-1.5">
{t('settings.adapters.telegram')}
{!isTelegramConfigured && <Badge tone="warning">{t('newTask.notConfigured')}</Badge>}
</span>
}
checked={notifyChannels.includes('telegram')}
disabled={!isTelegramConfigured}
onChange={(e) => {
setNotifyChannels((prev) =>
e.target.checked ? [...prev, 'telegram'] : prev.filter((c) => c !== 'telegram'),
)
}}
/>
</div>
{notifyChannels.length === 0 && (
<p className="text-xs text-[var(--color-warning)]">
<span className="material-symbols-outlined text-[12px] align-middle mr-1">warning</span>
<Badge
tone="warning"
size="sm"
wrap
bordered
pill={false}
role="alert"
icon={<span aria-hidden="true" className="material-symbols-outlined text-[13px]">warning</span>}
>
{t('newTask.noChannelSelected')}
</p>
</Badge>
)}
</div>
)}
</div>
{/* Cron preview */}
<div className="flex items-center gap-2 px-3 py-2 rounded-[var(--radius-md)] bg-[var(--color-surface-container)] text-xs text-[var(--color-text-secondary)]">
<span className="material-symbols-outlined text-[16px]">schedule</span>
<span>
{frequency === 'customCron' && customCron.trim() && !isValidCron(customCron)
? t('newTask.invalidCron')
: describeCron(cronValue, t)
}
</span>
</div>
</Card>
<p className="text-xs text-[var(--color-text-tertiary)]">
{t('newTask.delayNote')}
@@ -35,25 +35,26 @@ export function PromptEditor({
}: Props) {
const t = useTranslation()
return (
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] focus-within:border-[var(--color-border-focus)] transition-colors overflow-visible">
<div className="overflow-visible rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] transition-colors focus-within:border-[var(--color-border-focus)]">
{/* Prompt textarea */}
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
rows={4}
className="w-full resize-y bg-transparent px-3 py-2.5 text-sm leading-relaxed text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]"
className="w-full resize-y bg-transparent px-4 py-3.5 text-sm leading-[1.8] text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]"
style={{ minHeight: 120 }}
/>
{/* Bottom toolbar */}
<div className="border-t border-[var(--color-border)]/40 px-3 py-2 flex flex-col gap-2 bg-[var(--color-surface-container-low)] rounded-b-[var(--radius-lg)]">
{/* Bottom toolbar. The rule was `--color-border` at `/40`, which Safari 15
WebView drops entirely — the toolbar then floated with no seam. */}
<div className="flex flex-col gap-2 rounded-b-[var(--radius-xl)] border-t border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2.5">
{/* Row 1: Permission + Model selectors */}
<div className="flex items-center justify-between">
<Badge
tone="danger"
size="md"
icon={<span className="material-symbols-outlined text-[14px]">gavel</span>}
icon={<span aria-hidden="true" className="material-symbols-outlined text-[14px]">gavel</span>}
>
{t('newTask.fullPermissions')}
</Badge>
@@ -75,7 +76,7 @@ export function PromptEditor({
which a default badge would push onto one unbreakable line. The
fill also moves off `bg-[var(--color-error)]/8` — Safari 15 WebView
drops that color function, so the strip had no background there. */}
<Badge tone="danger" wrap pill={false} className="w-full" icon={<span className="material-symbols-outlined text-[12px]">warning</span>}>
<Badge tone="danger" size="sm" wrap bordered pill={false} className="w-full" icon={<span aria-hidden="true" className="material-symbols-outlined text-[13px]">warning</span>}>
{t('promptEditor.bypassWarning')}{folderPath ? ` ${t('promptEditor.within')} ${folderPath}` : ` ${t('promptEditor.selectFolder')}`}.
</Badge>
</div>
+32 -8
View File
@@ -1,6 +1,7 @@
import { useState } from 'react'
import type { CronTask } from '../../types/task'
import { TaskRow } from './TaskRow'
import { Card } from '@/components/ui/Card'
import { useTranslation } from '../../i18n'
type Props = {
@@ -15,14 +16,27 @@ export function TaskList({ tasks }: Props) {
return (
<div>
{/* Stats */}
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="grid grid-cols-3 gap-3.5">
<StatCard label={t('tasks.totalTasks')} value={String(tasks.length)} />
<StatCard label={t('tasks.active')} value={String(enabledCount)} />
<StatCard label={t('tasks.disabled')} value={String(tasks.length - enabledCount)} />
</div>
{/* Task rows — accordion: only one logs panel open at a time */}
<div className="flex flex-col">
{/* Task rows — accordion: only one logs panel open at a time.
The separators live on this container rather than on each row so the
last row does not draw a rule against the card's own edge.
The corners are rounded per-row instead of with `overflow-hidden`:
each row anchors a confirm popover and an action menu, and an
`overflow-hidden` ancestor clips absolutely positioned descendants
whatever their own stacking is. Rounding the first row's head and the
last row's tail keeps the row hover fill and the open runs drawer
inside the card's edge without a clipping context. */}
<Card
radius="xl"
padding="none"
className="mt-[22px] divide-y divide-[var(--color-border-separator)] [&>*:first-child>*:first-child]:rounded-t-[var(--radius-xl)] [&>*:last-child>*:last-child]:rounded-b-[var(--radius-xl)]"
>
{tasks.map((task) => (
<TaskRow
key={task.id}
@@ -31,16 +45,26 @@ export function TaskList({ tasks }: Props) {
onToggleLogs={() => setExpandedLogsId(expandedLogsId === task.id ? null : task.id)}
/>
))}
</div>
</Card>
</div>
)
}
/**
* `surface="none"` rather than one of `Card`'s named layers: the handoff puts
* these on `--color-surface-container`, which the component has no name for,
* and an empty surface class cannot fight the one passed below it.
*/
function StatCard({ label, value }: { label: string; value: string }) {
return (
<div className="px-4 py-3 rounded-[var(--radius-lg)] bg-[var(--color-surface-info)]">
<div className="text-2xl font-bold text-[var(--color-text-primary)]">{value}</div>
<div className="text-xs text-[var(--color-text-secondary)]">{label}</div>
</div>
<Card radius="xl" surface="none" padding="none" className="bg-[var(--color-surface-container)] px-[22px] py-[18px]">
<div
className="text-[26px] font-bold leading-none text-[var(--color-text-primary)]"
style={{ fontFamily: 'var(--font-headline)' }}
>
{value}
</div>
<div className="mt-1.5 text-xs text-[var(--color-text-tertiary)]">{label}</div>
</Card>
)
}
@@ -0,0 +1,67 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { TaskRow } from './TaskRow'
import { useSettingsStore } from '../../stores/settingsStore'
import { useTaskStore } from '../../stores/taskStore'
import type { CronTask } from '../../types/task'
const task: CronTask = {
id: 'task-1',
name: 'daily-code-review',
description: 'Review yesterday’s commits',
cron: '*/20 * * * *',
prompt: 'Look at the commits',
enabled: true,
createdAt: Date.parse('2026-07-26T09:00:00.000Z'),
lastFiredAt: '2026-07-26T09:20:00.000Z',
}
function renderRow(props: Partial<Parameters<typeof TaskRow>[0]> = {}) {
useSettingsStore.setState({ locale: 'en' })
return render(
<TaskRow task={task} showLogs={false} onToggleLogs={vi.fn()} {...props} />,
)
}
afterEach(() => {
cleanup()
useSettingsStore.setState(useSettingsStore.getInitialState(), true)
useTaskStore.setState(useTaskStore.getInitialState(), true)
})
describe('TaskRow', () => {
it('exposes the logs toggle as a pressed state rather than a background class', () => {
// The open state used to be a bare `bg-[…]` override, which is invisible to
// assistive tech and could lose to the tone's own hover fill depending on
// stylesheet order.
const { rerender } = renderRow()
expect(screen.getByRole('button', { name: 'Logs' })).toHaveAttribute('aria-pressed', 'false')
rerender(<TaskRow task={task} showLogs onToggleLogs={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Logs' })).toHaveAttribute('aria-pressed', 'true')
})
it('reads the schedule out as one caption line with the raw cron on hover', () => {
renderRow()
expect(screen.getByTitle('*/20 * * * *')).toHaveTextContent('Runs every 20 minutes')
// Created / last run / description used to be two stacked lines; the handoff
// folds them into a single caption separated by middots.
expect(screen.getByText(/Review yesterday/)).toHaveTextContent(/Created: .+ · Last run: .+ · Review/)
})
it('keeps the run button unavailable while the task is disabled', () => {
renderRow({ task: { ...task, enabled: false } })
expect(screen.getByRole('button', { name: 'Run now' })).toBeDisabled()
})
it('names the menu items by their label alone, not by the icon ligature', () => {
// The material-symbols spans render their glyph name as text, so without
// `aria-hidden` every menu item was announced as "edit Edit".
renderRow()
fireEvent.click(screen.getByRole('button', { name: 'More actions' }))
expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument()
})
})

Some files were not shown because too many files have changed in this diff Show More