mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 11:53:10 +08:00
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:
@@ -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.
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user