feat(desktop): redesign the desktop UI on the Porcelain visual system

Rebuild every desktop page on one visual system, 「素 Porcelain」: a quiet
light theme with a tool-call timeline, bubbled user messages and a floating
composer card.

- Tokens: rewrite the white theme on Porcelain values and give all six
  themes one radius scale (4/6/8/12/16/20), one type scale
  (11/12/13/14/15/18/22/26), Inter up to semibold, JetBrains Mono at 400,
  and status colours by meaning (running = info, waiting = warning,
  done = success, failed = error). Terracotta is kept for the mark, the
  send key and selection.
- Icons: draw every icon from lucide and drop the Material Symbols font.
  The generic sparkle glyph is gone: a working turn shows the running
  ring, skills use the same box the @ and / menus use for SKILL.md.
- Chat: tool calls read as a timeline, user turns as bubbles, the composer
  as a floating card; cards, menus, dialogs and permission prompts share
  the same surfaces and shadows.
- Shell: session tabs and workspace tabs use one shared tab chip; the
  sidebar, workspace and settings follow the same grounds.
- Settings: the page frame is fluid up to 1120px for every pane, so
  explorer-style panes (memory, skills) are no longer squeezed into 700px.
- Guard: designSystem.test.ts fails on off-scale font sizes, weights above
  semibold, italic text, Material Symbols, Tailwind radius names and
  sparkle icon imports.
This commit is contained in:
程序员阿江(Relakkes)
2026-10-05 12:46:55 +08:00
parent 9aaa53cbea
commit 98e1a12766
296 changed files with 13982 additions and 10177 deletions
Binary file not shown.
+23 -9
View File
@@ -136,8 +136,10 @@ const MOCK_SKILL_DETAIL = {
skillRoot: '/tmp/skill-docs',
}
// By role, not text: the pane's page title is the nav label verbatim, so once
// the Agents pane is open "Agents" names both the rail entry and the heading.
function switchToAgentsTab() {
fireEvent.click(screen.getByText('Agents'))
fireEvent.click(screen.getByRole('button', { name: 'Agents' }))
}
function switchToSkillsTab() {
@@ -192,7 +194,15 @@ describe('Settings > Agents tab', () => {
it('renders the Agents tab button in sidebar', () => {
render(<Settings />)
expect(screen.getByText('Agents')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Agents' })).toBeInTheDocument()
})
it('titles the pane with the nav label', () => {
render(<Settings />)
switchToAgentsTab()
expect(screen.getByRole('heading', { level: 2, name: 'Agents' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Create Agent' })).toBeInTheDocument()
})
it('shows loading spinner when fetching agents', () => {
@@ -243,12 +253,14 @@ describe('Settings > Agents tab', () => {
render(<Settings />)
switchToAgentsTab()
expect(screen.getByText('Browse installed agents')).toBeInTheDocument()
expect(screen.getByText('Agent Browser')).toBeInTheDocument()
expect(screen.getAllByText('User').length).toBeGreaterThan(0)
expect(screen.getAllByText('Built-in').length).toBeGreaterThan(0)
expect(screen.getAllByText('Project').length).toBeGreaterThan(0)
expect(screen.getAllByText('Plugin').length).toBeGreaterThan(0)
// Summary tiles, then one labelled section per source, in source order.
expect(screen.getByText('Total agents')).toBeInTheDocument()
expect(screen.getByText('Active agents')).toBeInTheDocument()
const sourceHeadings = ['User', 'Project', 'Plugin', 'Built-in']
.map((name) => screen.getByRole('heading', { level: 3, name: new RegExp(`^${name}\\s*\\d+$`) }))
for (const [index, heading] of sourceHeadings.slice(0, -1).entries()) {
expect(heading.compareDocumentPosition(sourceHeadings[index + 1]!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
}
expect(screen.getByText('code-reviewer')).toBeInTheDocument()
expect(screen.getByText('Writes technical documentation')).toBeInTheDocument()
expect(screen.getByText('telegram:pairing')).toBeInTheDocument()
@@ -334,7 +346,9 @@ describe('Settings > Agents tab', () => {
await Promise.resolve()
})
expect(screen.getByText('Installed Plugins')).toBeInTheDocument()
// The Plugins pane is titled with its nav label, which the rail entry also
// carries — the heading is what proves the pane itself opened.
expect(screen.getByRole('heading', { name: 'Plugins' })).toBeInTheDocument()
})
})
@@ -279,8 +279,17 @@ describe('Settings > Diagnostics tab', () => {
expect(screen.getByText('event-1')).toBeInTheDocument()
expect(screen.getByText(/best-effort/i)).toHaveTextContent(/review.*private metadata/i)
const eventRow = screen.getByText('cli_start_failed').closest('.grid')
expect(eventRow).toHaveClass('grid-cols-1', 'md:grid-cols-[120px_92px_1fr]')
// Each event is one list row: type, severity, timestamp, session, summary and
// its copyable ID all stay together, so a long summary cannot push the
// timestamp onto a different event.
const eventRow = screen.getByText('cli_start_failed').closest('[data-testid="diagnostic-event"]') as HTMLElement
expect(eventRow).not.toBeNull()
expect(within(eventRow).getByText('error')).toBeInTheDocument()
expect(within(eventRow).getByText('session-1')).toBeInTheDocument()
expect(within(eventRow).getByText('CLI exited during startup with code 1')).toBeInTheDocument()
expect(within(eventRow).getByText(new Date('2026-05-02T00:00:00.000Z').toLocaleString())).toBeInTheDocument()
expect(within(eventRow).getByRole('button', { name: 'Copy event ID: event-1' })).toBeInTheDocument()
expect(screen.getAllByTestId('diagnostic-event')).toHaveLength(100)
})
it('shows local-index state, counts, storage, update time, and error code without rollout modes', async () => {
@@ -572,7 +581,9 @@ describe('Settings > Diagnostics tab', () => {
await screen.findByText('Log directory')
expect(diagnosticsTab).toHaveAttribute('aria-current', 'page')
expect(diagnosticsTab.querySelector('.material-symbols-outlined')).toHaveAttribute('aria-hidden', 'true')
// The nav icon is decorative: whichever icon element the rail renders first
// must be hidden so the tab is announced by its label alone.
expect(diagnosticsTab.firstElementChild).toHaveAttribute('aria-hidden', 'true')
})
it('exports a diagnostics bundle from the settings page', async () => {
+114 -52
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import '@testing-library/jest-dom'
import { DesktopSettings as Settings } from '../pages/Settings'
@@ -475,16 +475,27 @@ describe('Settings > General tab', () => {
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.className).toContain('rounded-[var(--radius-sm)]')
expect(activeItem.className).toContain('bg-[var(--color-surface-selected)]')
expect(activeItem.className).toContain('h-[30px]')
expect(activeItem).toHaveAttribute('aria-current', 'page')
const rail = activeItem.parentElement?.parentElement
expect(rail?.className).toContain('w-[195px]')
expect(rail?.className).toContain('pl-3')
expect(rail?.className).toContain('pr-1')
expect(activeItem.className).toContain('pl-3')
expect(activeItem.className).toContain('pr-2')
const rail = screen.getByTestId('settings-navigation')
expect(rail).toContainElement(activeItem)
expect(rail.className).toContain('w-[216px]')
expect(rail.className).toContain('px-2.5')
// The rail opens with its own small title, and "About" sits apart below a rule.
expect(within(rail).getByText('Settings')).toBeInTheDocument()
const about = within(rail).getByRole('button', { name: 'About' })
const diagnostics = within(rail).getByRole('button', { name: 'Diagnostics' })
expect((diagnostics.compareDocumentPosition(about) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0).toBe(true)
})
it('titles each pane with its rail label', () => {
useUIStore.setState({ activeSettingsTab: 'general', pendingSettingsTab: null })
render(<Settings />)
expect(screen.getByRole('heading', { level: 2, name: 'General' })).toBeInTheDocument()
})
it('marks the pure white appearance theme as selected', () => {
@@ -539,6 +550,21 @@ describe('Settings > General tab', () => {
expect(screen.getByRole('button', { name: 'Ink Night' })).toHaveAttribute('aria-pressed', 'false')
})
it('keeps the theme swatches at preview size on a wide settings frame', () => {
// The settings frame is fluid up to 1120px; the swatch grid must not grow
// with it, in either picker layout.
render(<Settings />)
fireEvent.click(screen.getByText('General'))
expect(screen.getByRole('button', { name: 'Pure White' }).parentElement).toHaveClass('grid-cols-3', 'max-w-[600px]')
cleanup()
useUIStore.setState({ followSystemTheme: true, lightTheme: 'celadon', darkTheme: 'ink-blue', theme: 'ink-blue', activeSettingsTab: 'general' })
render(<Settings />)
for (const label of ['Pure White', 'Ink Night']) {
expect(screen.getByRole('button', { name: label }).parentElement, label).toHaveClass('grid-cols-4', 'max-w-[600px]')
}
})
it('hides the light-half hint when not following the system', () => {
render(<Settings />)
@@ -572,41 +598,70 @@ describe('Settings > General tab', () => {
expect(screen.getByRole('button', { name: 'Pure White' })).toHaveAttribute('aria-pressed', 'false')
})
it('keeps UI zoom below system notifications because it is a secondary setting', () => {
it('groups General settings into topic sections in a stable order', () => {
render(<Settings />)
fireEvent.click(screen.getByText('General'))
const notificationsHeading = screen.getByRole('heading', { name: 'System Notifications' })
const uiZoomHeading = screen.getByRole('heading', { name: 'UI Zoom' })
const networkHeading = screen.getByRole('heading', { name: 'Network' })
const webFetchHeading = screen.getByRole('heading', { name: 'WebFetch Preflight' })
// The stand-alone toggles are grouped by topic: what the app looks like
// first, then what a new session starts from, then plumbing and data.
const order = [
'Appearance',
'Language',
'Chat appearance',
'UI Zoom',
'Agent preferences',
'System Notifications',
'Network',
'WebSearch',
'Agent trace',
'Session History',
'Data Storage Location',
].map((name) => screen.getByRole('heading', { level: 3, name }))
for (const [index, heading] of order.slice(0, -1).entries()) {
expect(
(heading.compareDocumentPosition(order[index + 1]!) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0,
`${order[index + 1]!.textContent} should follow ${heading.textContent}`,
).toBe(true)
}
expect((notificationsHeading.compareDocumentPosition(uiZoomHeading) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0).toBe(true)
expect((uiZoomHeading.compareDocumentPosition(networkHeading) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0).toBe(true)
expect((networkHeading.compareDocumentPosition(webFetchHeading) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0).toBe(true)
// Each Agent switch lives in the shared Agent preferences card rather
// than under a heading of its own.
const agentSection = screen.getByRole('heading', { name: 'Agent preferences' }).closest('section')!
for (const name of [
'Enable thinking mode',
'Enable Ultracode keyword trigger',
'Enable Agent Teams',
'Enable Auto-dream',
]) {
expect(within(agentSection).getByRole('switch', { name })).toBeInTheDocument()
}
// WebFetch preflight is a network concern and sits in that card.
const networkSection = screen.getByRole('heading', { name: 'Network' }).closest('section')!
expect(within(networkSection).getByRole('switch', { name: 'Skip WebFetch domain preflight' })).toBeInTheDocument()
})
it('lets users choose Ctrl or Command Enter as the chat send shortcut', async () => {
render(<Settings />)
fireEvent.click(screen.getByText('General'))
fireEvent.click(screen.getByRole('button', { name: /Ctrl\/Cmd\+Enter sends/i }))
fireEvent.click(screen.getByRole('radio', { name: /Ctrl\/Cmd\+Enter sends/i }))
await waitFor(() => {
expect(useSettingsStore.getState().setChatSendBehavior).toHaveBeenCalledWith('modifierEnter')
})
expect(screen.getByRole('button', { name: /Ctrl\/Cmd\+Enter sends/i })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('radio', { name: /Ctrl\/Cmd\+Enter sends/i })).toHaveAttribute('aria-checked', 'true')
expect(screen.getByRole('radio', { name: /^Enter sends/i })).toHaveAttribute('aria-checked', 'false')
})
it('saves provider network timeout and manual proxy from General settings', async () => {
render(<Settings />)
fireEvent.click(screen.getByText('General'))
expect(screen.getByRole('button', { name: /Direct connection/i })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('button', { name: /System proxy/i })).toBeInTheDocument()
expect(screen.getByRole('radio', { name: /Direct connection/i })).toHaveAttribute('aria-checked', 'true')
expect(screen.getByRole('radio', { name: /System proxy/i })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /^Manual proxy/i }))
fireEvent.click(screen.getByRole('radio', { name: /^Manual proxy/i }))
const proxyInput = screen.getByLabelText('Proxy URL')
const saveButton = screen.getAllByRole('button', { name: 'Save' })[0]!
@@ -700,7 +755,7 @@ describe('Settings > General tab', () => {
expect(input).toHaveValue(365)
const section = screen.getByRole('heading', { name: 'Session History' })
.parentElement as HTMLElement
.closest('section') as HTMLElement
const saveButton = within(section).getByRole('button', { name: 'Save' })
expect(saveButton).toBeDisabled()
@@ -758,7 +813,7 @@ describe('Settings > General tab', () => {
fireEvent.change(input, { target: { value: '0' } })
const section = screen.getByRole('heading', { name: 'Session History' })
.parentElement as HTMLElement
.closest('section') as HTMLElement
await act(async () => {
fireEvent.click(within(section).getByRole('button', { name: 'Save' }))
})
@@ -792,7 +847,7 @@ describe('Settings > General tab', () => {
})
const section = screen.getByRole('heading', { name: 'Session History' })
.parentElement as HTMLElement
.closest('section') as HTMLElement
await act(async () => {
fireEvent.click(within(section).getByRole('button', { name: 'Save' }))
})
@@ -830,7 +885,7 @@ describe('Settings > General tab', () => {
})
const section = screen.getByRole('heading', { name: 'Session History' })
.parentElement as HTMLElement
.closest('section') as HTMLElement
const saveButton = within(section).getByRole('button', { name: 'Save' })
fireEvent.click(saveButton)
@@ -871,7 +926,7 @@ describe('Settings > General tab', () => {
})
const section = screen.getByRole('heading', { name: 'Session History' })
.parentElement as HTMLElement
.closest('section') as HTMLElement
await act(async () => {
fireEvent.click(within(section).getByRole('button', { name: 'Save' }))
})
@@ -914,7 +969,7 @@ describe('Settings > General tab', () => {
})
const section = screen.getByRole('heading', { name: 'Session History' })
.parentElement as HTMLElement
.closest('section') as HTMLElement
await act(async () => {
fireEvent.click(within(section).getByRole('button', { name: 'Save' }))
})
@@ -1414,7 +1469,7 @@ describe('Settings > General tab', () => {
expect(useSettingsStore.getState().setAutoDreamEnabled).toHaveBeenCalledWith(false)
})
it('keeps General checkbox inputs anchored inside their visible rows', () => {
it('renders every General on/off setting as a named switch beside its visible title', () => {
render(<Settings />)
fireEvent.click(screen.getByText('General'))
@@ -1426,12 +1481,14 @@ describe('Settings > General tab', () => {
'Enable system notifications',
'Skip WebFetch domain preflight',
]) {
const toggle = screen.getByLabelText(label)
const row = toggle.closest('label') as HTMLElement | null
expect(toggle).toHaveClass('settings-checkbox-input')
expect(toggle).not.toHaveClass('sr-only')
// One control per setting, announced as a switch under the same words
// the row shows — the hand-rolled checkbox rows these replaced each
// stretched an invisible input over the whole card.
const toggle = screen.getByRole('switch', { name: label })
expect(screen.getByLabelText(label)).toBe(toggle)
const row = toggle.closest('.min-h-\\[52px\\]') as HTMLElement | null
expect(row).not.toBeNull()
expect(row!).toHaveClass('relative')
expect(within(row!).getByText(label)).toBeVisible()
}
})
@@ -1559,8 +1616,8 @@ describe('Settings > General tab', () => {
fireEvent.click(screen.getByText('General'))
expect(screen.queryByRole('region', { name: 'H5 Access' })).not.toBeInTheDocument()
const generalTab = screen.getByText('General')
const h5Tab = screen.getByText('H5 Access')
const generalTab = screen.getByRole('button', { name: 'General' })
const h5Tab = screen.getByRole('button', { name: 'H5 Access' })
expect((generalTab.compareDocumentPosition(h5Tab) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0).toBe(true)
fireEvent.click(h5Tab)
@@ -2098,12 +2155,12 @@ describe('Settings > General tab', () => {
fireEvent.click(screen.getByText('General'))
fireEvent.click(screen.getByRole('button', { name: 'Tavily' }))
fireEvent.click(screen.getByRole('radio', { name: 'Tavily' }))
fireEvent.change(screen.getByLabelText('Tavily API key'), {
target: { value: 'tvly-test-key' },
})
const webSearchSection = screen.getByRole('heading', { name: 'WebSearch' })
.parentElement as HTMLElement
.closest('section') as HTMLElement
fireEvent.click(within(webSearchSection).getByRole('button', { name: 'Save' }))
expect(useSettingsStore.getState().setWebSearch).toHaveBeenCalledWith({
@@ -2186,11 +2243,15 @@ describe('Settings > Providers tab', () => {
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]')
// A terracotta-mixed hairline plus a soft brand halo, so the default row
// reads as chosen at a glance — not the focus border, which collided with
// the real focus ring.
expect(card.className).toContain('border-[var(--color-primary-fixed-dim)]')
expect(card.className).toContain('shadow-[0_0_0_3px_var(--color-brand-soft)]')
expect(card.className).not.toContain('border-[var(--color-border-focus)]')
// Only the default card carries the accent.
const other = screen.getByTestId('claude-official-provider')
expect(other.className).not.toContain('border-[var(--color-primary-fixed-dim)]')
})
it('does not query official OAuth status before providers finish loading', () => {
@@ -2301,17 +2362,18 @@ describe('Settings > Providers tab', () => {
})
it.each([
['zh', '模型配置', '模型管理', '添加模型', '配置名称'],
['zh-TW', '模型設定', '模型管理', '新增模型', '設定名稱'],
['en', 'Model Settings', 'Model Management', 'Add Model', 'Configuration name'],
['jp', 'モデル設定', 'モデル管理', 'モデルを追加', '設定名'],
['kr', '모델 설정', '모델 관리', '모델 추가', '설정 이름'],
] as const)('uses model terminology throughout settings in %s', (locale, menu, title, add, name) => {
['zh', '模型配置', '添加模型', '配置名称'],
['zh-TW', '模型設定', '新增模型', '設定名稱'],
['en', 'Model Settings', 'Add Model', 'Configuration name'],
['jp', 'モデル設定', 'モデルを追加', '設定名'],
['kr', '모델 설정', '모델 추가', '설정 이름'],
] as const)('uses model terminology throughout settings in %s', (locale, menu, add, name) => {
useSettingsStore.setState({ locale })
render(<Settings />)
expect(screen.getByRole('button', { name: new RegExp(menu) })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: title })).toBeInTheDocument()
// The pane is titled with the rail label itself, so the two never disagree.
expect(screen.getByRole('heading', { level: 2, name: menu })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: new RegExp(add) }))
const dialog = screen.getByRole('dialog')
@@ -3697,10 +3759,10 @@ describe('Settings > About tab', () => {
render(<Settings />)
fireEvent.click(screen.getByRole('button', { name: /Advanced update proxy/i }))
expect(screen.getByRole('button', { name: /System proxy/i })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('radio', { name: /System proxy/i })).toHaveAttribute('aria-checked', 'true')
expect(screen.getByText('This only affects app update checks and downloads.')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /Manual proxy/i }))
fireEvent.click(screen.getByRole('radio', { name: /Manual proxy/i }))
const proxyInput = screen.getByLabelText('Proxy URL')
const saveButton = screen.getByRole('button', { name: 'Save' })
@@ -3731,9 +3793,9 @@ describe('Settings > About tab', () => {
render(<Settings />)
fireEvent.click(screen.getByRole('button', { name: /Advanced update proxy/i }))
expect(screen.getByRole('button', { name: /Manual proxy/i })).toHaveAttribute('aria-pressed', 'true')
expect(screen.getByRole('radio', { name: /Manual proxy/i })).toHaveAttribute('aria-checked', 'true')
fireEvent.click(screen.getByRole('button', { name: /System proxy/i }))
fireEvent.click(screen.getByRole('radio', { name: /System proxy/i }))
const saveButton = screen.getByRole('button', { name: 'Save' })
await act(async () => {
@@ -350,6 +350,52 @@ describe('McpSettings', () => {
expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled()
})
it('switches transport from the segmented control on create and locks it while editing', async () => {
const server = {
name: 'global-user',
scope: 'user',
transport: 'http',
enabled: true,
status: 'connected',
statusLabel: 'Connected',
configLocation: '/tmp/config',
summary: 'https://example.com/mcp',
canEdit: true,
canRemove: true,
canReconnect: true,
canToggle: true,
config: { type: 'http', url: 'https://example.com/mcp', headers: {} },
} as const
useMcpStore.setState({ servers: [server] })
await renderLoadedMcpSettings()
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /add server/i }))
})
const transport = screen.getByRole('radiogroup', { name: 'Transport' })
expect(screen.getByRole('radio', { name: 'STDIO' })).toHaveAttribute('aria-checked', 'true')
expect(screen.getByLabelText(/Command to launch/)).toBeInTheDocument()
fireEvent.click(screen.getByRole('radio', { name: 'Streamable HTTP' }))
expect(screen.getByRole('radio', { name: 'Streamable HTTP' })).toHaveAttribute('aria-checked', 'true')
expect(screen.queryByLabelText(/Command to launch/)).not.toBeInTheDocument()
expect(screen.getByLabelText(/^URL/)).toBeInTheDocument()
expect(transport).not.toHaveTextContent(/expand_more|check/)
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /back/i }))
})
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Open global-user' }))
})
for (const radio of screen.getAllByRole('radio')) expect(radio).toBeDisabled()
expect(screen.getByRole('radio', { name: 'Streamable HTTP' })).toHaveAttribute('aria-checked', 'true')
expect(screen.getByText('If you would like to switch MCP server type, uninstall first.')).toBeInTheDocument()
})
it('keeps same-name project MCP servers distinct by project path', async () => {
useMcpStore.setState({
servers: [
+15 -8
View File
@@ -185,7 +185,8 @@ describe('Content-only pages render without errors', () => {
await Promise.resolve()
})
expect(container.querySelector('[data-composer-editor]')).toBeInTheDocument()
expect(container.innerHTML).toContain('New session')
// The 「素」 new-session hero asks what to work on (with or without a project).
expect(container.innerHTML).toMatch(/What should we work on/)
expect(container.innerHTML).toContain('Ask anything')
})
@@ -274,8 +275,8 @@ describe('Content-only pages render without errors', () => {
},
})
const { container } = render(<ActiveSession />)
// With empty messages, the hero is shown
expect(container.innerHTML).toContain('New session')
// With empty messages, the new-session hero is shown
expect(container.innerHTML).toMatch(/What should we work on/)
// ChatInput renders the ProseMirror composer
const composer = container.querySelector('[data-composer-editor]')
expect(composer).toBeInTheDocument()
@@ -693,7 +694,11 @@ describe('Content-only pages render without errors', () => {
expect(sendMessage).not.toHaveBeenCalled()
expect(await screen.findByText('Session inspector')).toBeInTheDocument()
expect(vi.mocked(sessionsApi.getInspection)).toHaveBeenCalledWith(SESSION_ID, { includeContext: false })
expect(container.innerHTML).toContain('bg-[var(--color-inspector-surface)]')
// The inspector is a token-colored popover card, not the old hex-tinted sheet.
expect(screen.getByText('Session inspector').closest('.absolute')).toHaveClass(
'bg-[var(--color-surface-container-lowest)]',
'shadow-[var(--shadow-dropdown)]',
)
expect(container.innerHTML).not.toContain('bg-[#fbfaf6]')
expect(container.innerHTML).not.toContain('bg-[#f4f2ed]')
expect(container.innerHTML).not.toContain('border-[#d8b3a8]')
@@ -835,7 +840,9 @@ describe('Content-only pages render without errors', () => {
const indicator = await screen.findByLabelText('Context usage loading')
expect(indicator).toHaveTextContent('--')
expect(indicator).toHaveClass('h-8')
// Same 28px box as the loaded indicator, so the toolbar does not shift when
// the first context reading arrives.
expect(indicator).toHaveClass('h-7')
resetPageStores()
})
@@ -1329,12 +1336,12 @@ describe('AppShell layout renders chrome', () => {
})
describe('Design system compliance', () => {
it('Pages use Material Symbols Outlined icons', () => {
it('Pages draw their icons from lucide, never Material Symbols ligatures', () => {
const pages = [EmptySession]
for (const Page of pages) {
const { container, unmount } = render(<Page />)
const icons = container.querySelectorAll('.material-symbols-outlined')
expect(icons.length).toBeGreaterThan(0)
expect(container.querySelectorAll('svg.lucide').length).toBeGreaterThan(0)
expect(container.querySelector('.material-symbols-outlined')).toBeNull()
unmount()
}
})
@@ -243,8 +243,10 @@ describe('Settings > Plugins tab', () => {
render(<Settings />)
switchToPluginsTab()
expect(screen.getByText('Browse installed plugins')).toBeInTheDocument()
expect(screen.getByText('Plugin Manager')).toBeInTheDocument()
// The settings page head names the page; the list opens on its numbers
// instead of repeating a second title block under it.
expect(screen.queryByText('Browse installed plugins')).not.toBeInTheDocument()
expect(screen.getByText('Total plugins')).toBeInTheDocument()
expect(screen.getAllByText('Needs attention').length).toBeGreaterThan(0)
expect(screen.getByText('github')).toBeInTheDocument()
expect(screen.getByText('Python language tooling')).toBeInTheDocument()
@@ -133,8 +133,10 @@ describe('Settings > Skills tab', () => {
render(<Settings />)
switchToSkillsTab()
expect(screen.getByText('Browse installed skills')).toBeInTheDocument()
expect(screen.getByText('Skill Browser')).toBeInTheDocument()
// The settings page head names the browser; the list opens on search and
// its numbers rather than a second title block.
expect(screen.queryByText('Browse installed skills')).not.toBeInTheDocument()
expect(screen.getByRole('searchbox', { name: 'Search skills' })).toBeInTheDocument()
expect(screen.getByText('Total skills')).toBeInTheDocument()
expect(screen.getByText('Alpha Skill')).toBeInTheDocument()
expect(screen.getByText('Second skill description')).toBeInTheDocument()
@@ -299,6 +301,8 @@ describe('Settings > Skills tab', () => {
await Promise.resolve()
})
expect(screen.getByText('Installed Plugins')).toBeInTheDocument()
// The Plugins pane is titled with its rail label, which the rail entry also
// carries — the heading is what proves the pane itself opened.
expect(screen.getByRole('heading', { name: 'Plugins' })).toBeInTheDocument()
})
})
@@ -41,7 +41,8 @@ const NON_PROSE_ELEMENTS = new Set(['kbd', 'Keycap', 'code', 'pre', 'style', 'sc
const LANGUAGE_NEUTRAL: Record<string, string[]> = {
'components/agentTeams/AgentTeamsCanvas.tsx': ['zZ'], // a sleeping agent's glyph
'components/agentTeams/AgentTeamsWorkbench.tsx': ['Agent Teams ·'], // product name, kept in every locale
'components/chat/MermaidRenderer.tsx': ['Mermaid'], // the diagram language
'components/chat/MermaidRenderer.tsx': ['mermaid'], // the diagram language, as a code block labels it
'components/chat/ToolCallBlock.tsx': ['bash', 'powershell'], // shell names, as a code block labels them
'components/layout/H5ConnectionView.tsx': ['https://chat.example.com'], // example URL
'components/layout/Sidebar.tsx': ['cc-', 'haha', 'GitHub'], // wordmark, brand
'components/market/FrontmatterPanel.tsx': ['true', 'false'], // YAML values, shown as written
@@ -52,8 +53,8 @@ const LANGUAGE_NEUTRAL: Record<string, string[]> = {
'components/workspace/WorkspaceDiffSurface.tsx': ['diff --git'], // the git header, read to screen readers
'features/pets/PetSettings.tsx': ['px', 'moon-cat'], // unit, example ID
'pages/Connectors.tsx': ['MCP', 'CLI'], // transport names
'pages/McpSettings.tsx': ['STDIO'], // transport name
'pages/MemorySettings.tsx': ['MARKDOWN'], // file format
'components/search/GlobalSearchModal.tsx': ['esc'], // the key, as printed on its keycap
'pages/MemorySettings.tsx': ['Markdown'], // file format
'pages/settings/AboutSettings.tsx': [ // product, repository and author; example proxy
'Claude Code Haha', 'GitHub', 'NanmiCoder/cc-haha', '程序员阿江-Relakkes', 'http://127.0.0.1:7890',
],
+12
View File
@@ -154,6 +154,18 @@ style={{ zIndex: 'var(--z-dialog)' }}
>
> **`SearchField` 的 `clearLabel`** — 组件内部一度用 `` `Clear ${label}` `` 拼英文,导致任何已经翻译过清除按钮的调用方采用它反而是 i18n 退化。**组件库自己不许拼用户可见的英文。**
### 3.8 「素 Porcelain」视觉约束
2026-10 的全量改版把界面收敛到一套可检查的约束,`src/theme/designSystem.test.ts` 逐条守住(违规会列出 文件:行):
- **字号只用** 11 / 12 / 13 / 14 / 15 / 18 / 22 / 26(`text-xs`、`text-sm`、`text-lg` 可用;`text-base/xl/2xl…` 与小数字号不可用)。
- **字重最高 semibold**:Inter 只自托管到 600,`font-bold` 会让浏览器合成糊掉的粗体;`font-mono` 不配 semibold(JetBrains Mono 只有 400)。
- **中文不用斜体、不用宽字距大写标签**;摘要列表用「、」。
- **图标只用 lucide**(`strokeWidth={1.75}`);`IconButton` 的 `icon` 只接受元素,不再接受 Material 字符串。
- **圆角只用 token**:`--radius-xs` 4 / `sm` 6 / `md` 8 / `lg` 12 / `xl` 16 / `3xl` 20 / `full`。
- **状态色语义固定**:运行中=info、等你确认=warning、完成=success、失败=error;陶土色只给品牌、发送键与选中指示。
- 设置页统一用 `components/settings/SettingsSection.tsx` 的骨架(PageHeader → Section → Group → Row)。
---
## 四、桌面端硬约束
+1 -1
View File
@@ -36,7 +36,7 @@ function ErrorBoundaryFallback() {
return (
<div className="h-screen w-screen bg-[var(--color-background)] text-[var(--color-text-primary)] flex items-center justify-center p-6">
<div className="max-w-md w-full text-center">
<div className="text-base font-semibold">{t('errorBoundary.title')}</div>
<div className="text-[15px] font-semibold">{t('errorBoundary.title')}</div>
<div className="mt-2 text-sm text-[var(--color-text-tertiary)]">
{t('errorBoundary.description')}
</div>
@@ -85,7 +85,8 @@ function ringClassName(tone: AgentMascotSpec['tone']): string {
if (tone === 'danger') return 'border-[color-mix(in_srgb,var(--color-error)_42%,transparent)]'
if (tone === 'success') return 'border-[color-mix(in_srgb,var(--color-success)_34%,transparent)]'
if (tone === 'muted') return 'border-[color-mix(in_srgb,var(--color-text-tertiary)_24%,transparent)]'
return 'border-[color-mix(in_srgb,var(--color-brand)_42%,transparent)]'
// Running is information, not brand: the app reads blue as "in progress".
return 'border-[color-mix(in_srgb,var(--color-info)_42%,transparent)]'
}
export function AgentMascot({ seed, status }: { seed: string; status: ActivityStatus }) {
@@ -108,7 +109,7 @@ export function AgentMascot({ seed, status }: { seed: string; status: ActivitySt
{isActive ? (
<span
data-testid="agent-mascot-motion-ring"
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"
className="absolute -inset-0.5 rounded-[var(--radius-lg)] border border-transparent border-t-[var(--color-info)] opacity-80 motion-safe:animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : null}
@@ -116,7 +117,7 @@ export function AgentMascot({ seed, status }: { seed: string; status: ActivitySt
src={imageSrc}
alt=""
draggable={false}
className="relative z-[1] h-[34px] w-[34px] max-w-none select-none object-contain"
className="relative h-[34px] w-[34px] max-w-none select-none object-contain"
/>
</span>
)
@@ -18,17 +18,15 @@ export function SessionActivityButton({
const toggle = useActivityPanelStore((state) => state.toggle)
return (
<IconButton
icon={<ListChecks size={17} strokeWidth={1.9} />}
icon={<ListChecks size={16} strokeWidth={1.75} />}
label={resolvedLabel}
size="md"
// 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}
// An open panel is a pressed toolbar toggle: the neutral selected fill,
// not a terracotta wash — brand is kept for the send key and selection
// marks. `pressed` also owns `aria-pressed`.
tone="muted"
pressed={isOpen}
aria-expanded={isOpen}
aria-pressed={isOpen}
onClick={() => toggle(sessionId)}
data-active={isOpen ? 'true' : 'false'}
data-session-activity-trigger="true"
@@ -137,9 +137,11 @@ describe('SessionActivityPanel', () => {
// 30px matches AgentMascot, so SubAgent and background rows share a text column.
expect(running?.className).toContain('h-[30px]')
expect(running?.className).toContain('bg-[var(--color-brand-soft)]')
// Running reads as info, never brand: terracotta is not a status colour.
expect(running?.className).toContain('bg-[var(--color-info-container)]')
expect(running?.className).not.toContain('brand')
// Paired tokens, never a raw accent on its own container (AGENTS.md 3.2).
expect(running?.className).toContain('text-[var(--color-on-brand-soft)]')
expect(running?.className).toContain('text-[var(--color-on-info-container)]')
expect(failed?.className).toContain('bg-[var(--color-error-container)]')
expect(failed?.className).toContain('text-[var(--color-on-error-container)]')
})
@@ -203,7 +205,7 @@ describe('SessionActivityPanel', () => {
expect(screen.getByLabelText('Task completed')).toBeInTheDocument()
expect(screen.getByLabelText('Task in progress')).toBeInTheDocument()
expect(screen.getByLabelText('Task pending')).toBeInTheDocument()
// The in-progress marker is the design's terracotta ring — a bordered
// The in-progress marker is the design's info-blue 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`.
@@ -165,9 +165,9 @@ 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-full bg-[var(--color-success)] text-[var(--color-surface)]"
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-[var(--color-success-container)] text-[var(--color-on-success-container)]"
>
<Check size={13} strokeWidth={3} aria-hidden="true" />
<Check size={12} strokeWidth={2} aria-hidden="true" />
</span>
)
}
@@ -178,7 +178,7 @@ function TaskStatusMarker({ status, t }: { status: ActivityRow['status']; t: Tra
aria-label={t('session.activity.status.stopped')}
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" />
<X size={12} strokeWidth={2} aria-hidden="true" />
</span>
)
}
@@ -191,7 +191,7 @@ function TaskStatusMarker({ status, t }: { status: ActivityRow['status']; t: Tra
return (
<span
aria-label={t('session.activity.task.inProgress')}
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"
className="inline-flex h-5 w-5 shrink-0 rounded-full border-2 border-[var(--color-info-container)] border-t-[var(--color-info)] motion-safe:animate-spin motion-reduce:animate-none"
/>
)
}
@@ -199,7 +199,7 @@ function TaskStatusMarker({ status, t }: { status: ActivityRow['status']; t: Tra
return (
<span
aria-label={t('session.activity.task.pending')}
className="inline-flex h-5 w-5 shrink-0 rounded-full border-[1.8px] border-[var(--color-outline)]"
className="inline-flex h-5 w-5 shrink-0 rounded-full border-[1.5px] border-[var(--color-border-strong)]"
/>
)
}
@@ -223,7 +223,7 @@ function getRowIcon(row: ActivityRow) {
}
function getStatusTone(status: ActivityRow['status']): Tone {
if (status === 'running' || status === 'in_progress') return 'brand'
if (status === 'running' || status === 'in_progress') return 'info'
if (status === 'completed' || status === 'idle') return 'success'
if (status === 'failed' || status === 'error' || status === 'stopped') return 'danger'
return 'neutral'
@@ -251,7 +251,7 @@ function getTaskProgress(rows: ActivityRow[]): { completed: number; total: numbe
*/
function getRowIconToneClass(status: ActivityRow['status']): string {
if (status === 'running' || status === 'in_progress') {
return 'bg-[var(--color-brand-soft)] text-[var(--color-on-brand-soft)]'
return 'bg-[var(--color-info-container)] text-[var(--color-on-info-container)]'
}
if (status === 'completed' || status === 'idle') {
return 'bg-[var(--color-success-container)] text-[var(--color-on-success-container)]'
@@ -287,7 +287,7 @@ function ActivityRowIcon({
data-tone-status={status}
className={`inline-flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-[var(--radius-md)] ${getRowIconToneClass(status)}`}
>
<Icon size={15} strokeWidth={2} aria-hidden="true" />
<Icon size={16} strokeWidth={1.75} aria-hidden="true" />
</span>
)
}
@@ -333,9 +333,9 @@ function BackgroundTaskStopButton({
return (
<IconButton
icon={stopping ? (
<LoaderCircle size={14} strokeWidth={2.2} className="motion-safe:animate-spin motion-reduce:animate-none" aria-hidden="true" />
<LoaderCircle size={14} strokeWidth={1.75} className="motion-safe:animate-spin motion-reduce:animate-none" aria-hidden="true" />
) : (
<Square size={12} strokeWidth={2.4} aria-hidden="true" />
<Square size={12} strokeWidth={2} aria-hidden="true" />
)}
label={label}
size="md"
@@ -370,7 +370,7 @@ function WorkflowPhaseHeader({
data-status={status}
className="flex items-center gap-2 px-2 pb-1 pt-2.5 first:pt-1"
>
<span className="truncate text-[11px] font-semibold uppercase tracking-wide text-[var(--color-text-secondary)]">
<span className="truncate text-[12px] font-semibold text-[var(--color-text-tertiary)]">
{label}
</span>
<span className="h-px flex-1 bg-[var(--color-border)]" aria-hidden="true" />
@@ -456,7 +456,7 @@ function ActivityRowView({
</span>
{detail ? (
<span
className="mt-px block truncate text-[11.5px] leading-4 text-[var(--color-text-tertiary)]"
className="mt-px block truncate text-[12px] leading-4 text-[var(--color-text-tertiary)]"
title={detail}
>
{detail}
@@ -471,12 +471,12 @@ function ActivityRowView({
/>
)}
{!isTask && row.openable ? (
<ChevronRight size={13} strokeWidth={2.2} className="shrink-0 text-[var(--color-text-tertiary)]" aria-hidden="true" />
<ChevronRight size={14} strokeWidth={1.75} className="shrink-0 text-[var(--color-text-tertiary)]" aria-hidden="true" />
) : null}
</>
)
const interactiveRowClassName =
'flex min-w-0 items-center gap-2.5 rounded-[var(--radius-md)] px-2 py-1.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)]'
'flex min-w-0 items-center gap-2.5 rounded-[var(--radius-sm)] px-2 py-1.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}
@@ -539,7 +539,7 @@ function ActivityRowView({
aria-label={t('session.activity.openBackgroundTask', { name: row.label })}
aria-expanded={selected}
onClick={() => onOpenBackgroundTask(row)}
className={`${interactiveRowClassName} ${stopButton ? 'flex-1' : 'w-full'} ${selected ? 'bg-[var(--color-surface-container)]' : ''}`}
className={`${interactiveRowClassName} ${stopButton ? 'flex-1' : 'w-full'} ${selected ? 'bg-[var(--color-surface-selected)]' : ''}`}
>
{content}
</button>
@@ -556,7 +556,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-[var(--radius-md)] px-2 py-1.5">
<div className="flex min-w-0 flex-1 items-center gap-2.5 rounded-[var(--radius-sm)] px-2 py-1.5">
{content}
</div>
{stopButton}
@@ -565,7 +565,7 @@ function ActivityRowView({
}
return (
<div className="flex items-center gap-2.5 rounded-[var(--radius-md)] px-2 py-1.5">
<div className="flex items-center gap-2.5 rounded-[var(--radius-sm)] px-2 py-1.5">
{content}
</div>
)
@@ -601,14 +601,14 @@ function BackgroundTaskDetail({ row }: { row: ActivityRow }) {
if (details.length === 0) return null
return (
<div className="mx-2 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)]">
<div className="mx-2 mb-1.5 rounded-[var(--radius-md)] bg-[var(--color-surface-container)] p-3">
<div className="mb-2 text-[12px] font-semibold text-[var(--color-text-tertiary)]">
{t('session.activity.details.title')}
</div>
<dl className="space-y-2">
{details.map((detail) => (
<div key={detail.label} className="min-w-0">
<dt className="text-[11px] font-semibold text-[var(--color-text-tertiary)]">
<dt className="text-[11px] font-medium text-[var(--color-text-tertiary)]">
{detail.label}
</dt>
<dd className="max-h-28 overflow-auto whitespace-pre-wrap break-words text-[12px] leading-relaxed text-[var(--color-text-secondary)]">
@@ -713,12 +713,12 @@ export function SessionActivityPanel({
data-placement={placement}
className={className}
>
<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)]">
<div className="flex h-12 shrink-0 items-center gap-2 pl-4 pr-3">
<h2 className="min-w-0 flex-1 truncate text-[14px] font-semibold text-[var(--color-text-primary)]">
{t('session.activity.title')}
</h2>
<IconButton
icon={<X size={14} strokeWidth={2.2} aria-hidden="true" />}
icon={<X size={14} strokeWidth={1.75} aria-hidden="true" />}
label={t('session.activity.close')}
size="sm"
tone="muted"
@@ -742,7 +742,7 @@ export function SessionActivityPanel({
>
<div className="mb-1.5 flex items-center justify-between gap-2 px-2">
<div className="flex min-w-0 items-center gap-2">
<h3 className="text-[12px] font-semibold text-[var(--color-text-secondary)]">
<h3 className="text-[12px] font-semibold text-[var(--color-text-tertiary)]">
{sectionTitle}
</h3>
{section.rows.length > 0 ? (
@@ -121,8 +121,8 @@ function memberStatusColor(state: MemberWorkState): string {
return 'var(--color-text-secondary)'
}
function memberDotColor(state: MemberWorkState, accent: string): string {
if (state === 'working') return accent
function memberDotColor(state: MemberWorkState): string {
if (state === 'working') return 'var(--color-info)'
// The raw warning accent is too light for label text, but reads as a fill.
if (state === 'retrying') return 'var(--color-warning)'
return memberStatusColor(state)
@@ -192,8 +192,9 @@ function taskStateColors(state: WorkbenchTaskState, accent: string) {
return {
background: 'var(--color-surface-container-lowest)',
border: accent,
pillBackground: 'var(--color-brand-soft)',
pillForeground: 'var(--color-brand)',
// Running is the app-wide "in progress" tone, never the brand.
pillBackground: 'var(--color-info-container)',
pillForeground: 'var(--color-on-info-container)',
title: 'var(--color-text-primary)',
progress: accent,
}
@@ -541,7 +542,7 @@ function MemberNode({
aria-label={memberName(member)}
aria-pressed={selected}
onClick={onSelect}
className="agent-teams-person absolute z-20 flex w-[176px] cursor-pointer flex-col items-center rounded-[14px] outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-background)]"
className="agent-teams-person absolute z-[var(--z-sticky)] flex w-[176px] cursor-pointer flex-col items-center rounded-[var(--radius-lg)] outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-background)]"
style={{ left: centerX - MEMBER_SLOT_WIDTH / 2, top }}
>
<span className="relative block" style={{ width: avatarSize, height: avatarSize }}>
@@ -560,13 +561,13 @@ function MemberNode({
{hasActiveMessage ? (
<span
aria-hidden="true"
className="agent-teams-member-ring absolute -inset-[6px] rounded-full border-2 border-[var(--color-brand)]"
className="agent-teams-member-ring absolute -inset-[6px] rounded-full border-2 border-[var(--color-info)]"
/>
) : null}
{!isLead && state === 'idle' ? (
<span
aria-hidden="true"
className="agent-teams-zz absolute -right-1 -top-1 text-[9px] font-extrabold text-[var(--color-text-tertiary)]"
className="agent-teams-zz absolute -right-1 -top-1 text-[11px] font-semibold text-[var(--color-text-tertiary)]"
>
zZ
</span>
@@ -574,15 +575,15 @@ function MemberNode({
</span>
<span
className="mt-0.5 flex max-w-[168px] items-center gap-1.5 overflow-hidden rounded-[14px] border bg-[var(--color-surface-container-lowest)] px-2.5 py-1 shadow-[var(--shadow-card)]"
className="mt-0.5 flex max-w-[168px] items-center gap-1.5 overflow-hidden rounded-[var(--radius-lg)] border bg-[var(--color-surface-container-lowest)] px-2.5 py-1 shadow-[var(--shadow-card)]"
style={{ borderColor: selected ? 'var(--color-brand)' : 'var(--color-border)' }}
>
<span className="truncate font-mono text-[10.5px] font-extrabold text-[var(--color-text-primary)]">
<span className="truncate font-mono text-[11px] font-medium text-[var(--color-text-primary)]">
{memberName(member)}
</span>
{model ? (
<span
className="max-w-[54px] shrink-0 truncate rounded-full border border-[var(--color-border)] px-1.5 py-px text-[9px] font-extrabold text-[var(--color-text-tertiary)]"
className="max-w-[54px] shrink-0 truncate rounded-full border border-[var(--color-border)] px-1.5 py-px text-[11px] font-semibold text-[var(--color-text-tertiary)]"
title={model.full}
data-testid={`agent-teams-canvas-member-model-${member.agentId}`}
data-model-inherited={model.inherited ? 'true' : 'false'}
@@ -591,20 +592,20 @@ function MemberNode({
</span>
) : null}
{isLead ? (
<span className="shrink-0 rounded-full border border-[var(--color-brand)] bg-[var(--color-brand-soft)] px-1.5 py-px text-[9px] font-extrabold text-[var(--color-brand)]">
<span className="shrink-0 rounded-full bg-[var(--color-surface-container)] px-1.5 py-px text-[11px] font-medium text-[var(--color-text-secondary)]">
{t('agentTeams.leader')}
</span>
) : null}
</span>
<span
className="mt-1 flex max-w-[172px] items-center gap-1.5 text-[10.5px] font-semibold"
className="mt-1 flex max-w-[172px] items-center gap-1.5 text-[11px] font-semibold"
style={{ color: isLead ? 'var(--color-text-secondary)' : memberStatusColor(state) }}
>
{!isLead ? (
<span
className="h-1.5 w-1.5 shrink-0 rounded-full"
style={{ backgroundColor: memberDotColor(state, accent) }}
style={{ backgroundColor: memberDotColor(state) }}
/>
) : null}
<span
@@ -635,7 +636,7 @@ function MemberNode({
data-task-status={task.status}
data-task-next={next ? 'true' : undefined}
title={task.subject}
className="rounded-full border px-1.5 py-px font-mono text-[9px] font-extrabold"
className="rounded-full border px-1.5 py-px font-mono text-[11px] font-medium"
style={memberTaskChipStyle(task, next, accent)}
>
#{task.id}
@@ -678,7 +679,7 @@ function TaskCard({
}) {
const { task, state, depth, x, y } = positioned
const owner = taskOwnerVisual(task, snapshot, members, depth)
const accent = owner?.accent ?? 'var(--color-brand)'
const accent = owner?.accent ?? 'var(--color-info)'
const colors = taskStateColors(state, accent)
// Still in progress on the list, but its owner stopped, failed or waits to
// retry: say so instead of animating work nobody is doing.
@@ -708,7 +709,7 @@ function TaskCard({
onMouseLeave={onHoverEnd}
onFocus={onFocus}
onBlur={onBlur}
className={`agent-teams-task absolute z-20 flex h-[92px] w-[200px] cursor-pointer flex-col gap-[5px] rounded-[10px] border p-[9px_10px] text-left shadow-none outline-none transition-[opacity,border-color,box-shadow,transform] hover:-translate-y-px hover:border-[var(--color-border-strong)] hover:shadow-[var(--shadow-card)] focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-background)] active:translate-y-0 ${justUnlocked ? 'agent-teams-unlocked' : ''}`}
className={`agent-teams-task absolute z-[var(--z-sticky)] flex h-[92px] w-[200px] cursor-pointer flex-col gap-[5px] rounded-[var(--radius-md)] border p-[9px_10px] text-left shadow-none outline-none transition-[opacity,border-color,box-shadow,transform] hover:-translate-y-px hover:border-[var(--color-border-strong)] hover:shadow-[var(--shadow-card)] focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-background)] active:translate-y-0 ${justUnlocked ? 'agent-teams-unlocked' : ''}`}
style={{
left: x,
top: y,
@@ -718,11 +719,11 @@ function TaskCard({
}}
>
<span className="flex items-center justify-between gap-1.5">
<span data-task-id={task.id} className="shrink-0 font-mono text-[10px] font-extrabold text-[var(--color-text-tertiary)]">
<span data-task-id={task.id} className="shrink-0 font-mono text-[11px] font-medium text-[var(--color-text-tertiary)]">
#{task.id}
</span>
<span
className="shrink-0 rounded-full border px-1.5 py-px text-[9.5px] font-extrabold"
className="shrink-0 rounded-full border px-1.5 py-px text-[11px] font-semibold"
style={{
backgroundColor: justUnlocked ? 'var(--color-success-container)' : stall?.background ?? colors.pillBackground,
borderColor: justUnlocked ? 'var(--color-success)' : stall?.border ?? colors.border,
@@ -747,10 +748,10 @@ function TaskCard({
) : (
<span aria-hidden="true" className="h-5 w-5 shrink-0" />
)}
<span className="min-w-0 flex-1 truncate font-mono text-[9.5px] font-bold text-[var(--color-text-secondary)]" title={owner?.inferred ? t('agentTeams.task.reconstructedOwner') : undefined}>
<span className="min-w-0 flex-1 truncate font-mono text-[11px] font-medium text-[var(--color-text-secondary)]" title={owner?.inferred ? t('agentTeams.task.reconstructedOwner') : undefined}>
{ownerLabel}
</span>
<span className="shrink-0 whitespace-nowrap font-mono text-[9.5px] text-[var(--color-text-tertiary)]">
<span className="shrink-0 whitespace-nowrap font-mono text-[11px] text-[var(--color-text-tertiary)]">
{dependencies
? state === 'blocked'
? `${t('agentTeams.task.dependsOn')} ${dependencies}`
@@ -1024,7 +1025,7 @@ export function AgentTeamsCanvas({
data-edge-active={focusedChain && related ? 'true' : 'false'}
d={dependencyPath(from, to)}
fill="none"
stroke={fresh ? 'var(--color-success)' : satisfied ? owner?.accent ?? 'var(--color-brand)' : 'var(--color-border)'}
stroke={fresh ? 'var(--color-success)' : satisfied ? owner?.accent ?? 'var(--color-text-secondary)' : 'var(--color-border)'}
strokeWidth={fresh ? 3 : focusedChain && related ? 2.5 : 2}
strokeDasharray="7 7"
opacity={related ? (satisfied ? 0.95 : 0.8) : 0.2}
@@ -1034,7 +1035,7 @@ export function AgentTeamsCanvas({
cx={to.x - 4}
cy={to.y + TASK_HEIGHT / 2}
r={3.2}
fill={fresh ? 'var(--color-success)' : satisfied ? owner?.accent ?? 'var(--color-brand)' : 'var(--color-border)'}
fill={fresh ? 'var(--color-success)' : satisfied ? owner?.accent ?? 'var(--color-text-secondary)' : 'var(--color-border)'}
opacity={related ? 1 : 0.2}
/>
</g>
@@ -1075,13 +1076,13 @@ export function AgentTeamsCanvas({
</svg>
<div
className="absolute z-10 whitespace-nowrap text-[10px] font-bold uppercase tracking-[0.12em] text-[var(--color-text-tertiary)]"
className="absolute z-[var(--z-raised)] whitespace-nowrap text-[11px] font-semibold text-[var(--color-text-tertiary)]"
style={{ left: FORMATION_TITLE_X, top: FORMATION_TITLE_Y }}
>
{t('agentTeams.canvas.formation')}
</div>
<div
className="absolute z-10 whitespace-nowrap text-[10px] font-bold uppercase tracking-[0.12em] text-[var(--color-text-tertiary)]"
className="absolute z-[var(--z-raised)] whitespace-nowrap text-[11px] font-semibold text-[var(--color-text-tertiary)]"
style={{ left: FORMATION_TITLE_X, top: TASKS_TITLE_Y }}
>
{t('agentTeams.canvas.tasks')}
@@ -1098,13 +1099,13 @@ export function AgentTeamsCanvas({
return (
<div
key={lane.depth}
className="absolute z-10 flex items-center justify-between px-3"
className="absolute z-[var(--z-raised)] flex items-center justify-between px-3"
style={{ left: lane.x, top: LANE_TOP + 8, width: lane.width }}
>
<span className="truncate text-[10px] font-extrabold uppercase tracking-[0.1em] text-[var(--color-text-secondary)]">
<span className="truncate text-[11px] font-semibold text-[var(--color-text-secondary)]">
{title}
</span>
<span className="shrink-0 font-mono text-[10px] font-bold text-[var(--color-text-tertiary)]">
<span className="shrink-0 font-mono text-[11px] font-medium text-[var(--color-text-tertiary)]">
{completed}/{lane.count}
</span>
</div>
@@ -1158,7 +1159,7 @@ export function AgentTeamsCanvas({
aria-hidden="true"
data-testid="agent-teams-active-flight"
data-flight-channel={route.channel}
className="agent-teams-flight pointer-events-none absolute z-30 flex items-center gap-1.5 whitespace-nowrap rounded-full border-[1.5px] bg-[var(--color-surface-container-lowest)] px-2 py-1 shadow-[var(--shadow-card)]"
className="agent-teams-flight pointer-events-none absolute z-[var(--z-nav)] flex items-center gap-1.5 whitespace-nowrap rounded-full border-[1.5px] bg-[var(--color-surface-container-lowest)] px-2 py-1 shadow-[var(--shadow-card)]"
style={{
borderColor: route.color,
color: route.color,
@@ -1168,13 +1169,13 @@ export function AgentTeamsCanvas({
} as CSSProperties}
>
<span className="h-[7px] w-[7px] rounded-full" style={{ backgroundColor: route.color }} />
<span className="font-mono text-[10px] font-extrabold">{route.label}</span>
<span className="font-mono text-[11px] font-medium">{route.label}</span>
</div>
))}
<div
data-testid="agent-teams-legend"
className="absolute z-10 flex items-center gap-[22px] whitespace-nowrap text-[10.5px] font-semibold text-[var(--color-text-secondary)]"
className="absolute z-[var(--z-raised)] flex items-center gap-[22px] whitespace-nowrap text-[11px] font-semibold text-[var(--color-text-secondary)]"
style={{ left: 32, top: layout.legendY }}
>
<span className="flex items-center gap-1.5">
@@ -1182,7 +1183,7 @@ export function AgentTeamsCanvas({
{t('agentTeams.legend.unmet')}
</span>
<span className="flex items-center gap-1.5">
<svg aria-hidden="true" width="26" height="6"><path d="M 0,3 L 26,3" stroke="var(--color-brand)" strokeWidth="2" strokeDasharray="7 7" className="agent-teams-flow" /></svg>
<svg aria-hidden="true" width="26" height="6"><path d="M 0,3 L 26,3" stroke="var(--color-text-secondary)" strokeWidth="2" strokeDasharray="7 7" className="agent-teams-flow" /></svg>
{t('agentTeams.legend.satisfied')}
</span>
<span className="flex items-center gap-1.5">
@@ -1194,7 +1195,7 @@ export function AgentTeamsCanvas({
{t('agentTeams.legend.unlocked')}
</span>
<span className="flex items-center gap-1.5">
<span aria-hidden="true" className="h-4 w-4 rounded-full border-[1.5px] border-[var(--color-brand)]" />
<span aria-hidden="true" className="h-4 w-4 rounded-full border-[1.5px] border-[var(--color-info)]" />
{t('agentTeams.legend.flight')}
</span>
</div>
@@ -94,21 +94,21 @@ function categoryLabel(category: FeedCategory, t: TranslationFn): string {
function categoryTagClass(category: FeedCategory): string {
if (category === 'assignment') {
return 'border-[var(--color-primary-fixed-dim)] bg-[var(--color-brand-soft)] text-[var(--color-on-brand-soft)]'
return 'border-transparent bg-[var(--color-info-container)] text-[var(--color-on-info-container)]'
}
if (category === 'peer') {
return 'border-[var(--color-memory-border)] bg-[var(--color-tertiary-container)] text-[var(--color-tertiary)]'
}
if (category === 'system') {
return 'border-[var(--color-success)] bg-[var(--color-success-container)] text-[var(--color-on-success-container)]'
return 'border-transparent bg-[var(--color-surface-container)] text-[var(--color-text-secondary)]'
}
return 'border-[var(--color-outline)] bg-[var(--color-surface-container-lowest)] text-[var(--color-text-secondary)]'
}
function categoryAccent(category: FeedCategory): string {
if (category === 'assignment') return 'var(--color-brand)'
if (category === 'assignment') return 'var(--color-info)'
if (category === 'peer') return 'var(--color-tertiary)'
if (category === 'system') return 'var(--color-success)'
if (category === 'system') return 'var(--color-text-tertiary)'
return 'var(--color-text-secondary)'
}
@@ -223,7 +223,7 @@ export function AgentTeamsCommunicationFeed({
>
<div className="shrink-0 border-b border-[var(--color-border)] px-3.5 py-3">
<div className="flex min-w-0 items-baseline gap-2">
<h3 className="text-[15px] font-extrabold tracking-[-0.2px]">
<h3 className="text-[15px] font-semibold tracking-[-0.2px]">
{t('agentTeams.communication.title')}
</h3>
<span
@@ -250,7 +250,7 @@ export function AgentTeamsCommunicationFeed({
aria-pressed={selected}
onClick={() => setFilter(option.key)}
className={[
'inline-flex shrink-0 items-center rounded-full border px-2.5 py-1 text-[11px] font-bold outline-none transition-[transform,background-color,border-color,color] duration-200 focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] active:scale-[0.98]',
'inline-flex shrink-0 items-center rounded-full border px-2.5 py-1 text-[11px] font-semibold outline-none transition-[transform,background-color,border-color,color] duration-200 focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] active:scale-[0.98]',
selected
? 'border-[var(--color-text-primary)] bg-[var(--color-text-primary)] text-[var(--color-surface)]'
: 'border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]',
@@ -343,34 +343,34 @@ function FeedRow({
aria-hidden="true"
/>
<span
className="shrink-0 text-[9.5px] font-extrabold uppercase tracking-[0.08em]"
className="shrink-0 text-[11px] font-semibold"
style={{ color: categoryAccent(category) }}
>
{categoryLabel(category, t)}
</span>
<p
data-testid={`agent-teams-message-${message.id}-body`}
className="min-w-0 flex-1 truncate text-[11.5px] leading-[1.4] text-[var(--color-text-secondary)]"
className="min-w-0 flex-1 truncate text-xs leading-[1.4] text-[var(--color-text-secondary)]"
title={text}
>
{text}
</p>
{message.taskId ? (
<span className="shrink-0 rounded-full bg-[var(--color-surface-container-high)] px-1.5 py-0.5 font-mono text-[9px] text-[var(--color-text-tertiary)]">
<span className="shrink-0 rounded-full bg-[var(--color-surface-container-high)] px-1.5 py-0.5 font-mono text-[11px] text-[var(--color-text-tertiary)]">
#{message.taskId}
</span>
) : null}
{repeats > 1 ? (
<span
data-testid={`agent-teams-message-${message.id}-repeats`}
className="shrink-0 rounded-full bg-[var(--color-surface-container-high)] px-1.5 py-0.5 font-mono text-[9px] tabular-nums text-[var(--color-text-tertiary)]"
className="shrink-0 rounded-full bg-[var(--color-surface-container-high)] px-1.5 py-0.5 font-mono text-[11px] tabular-nums text-[var(--color-text-tertiary)]"
>
×{repeats}
</span>
) : null}
<time
dateTime={message.timestamp}
className="shrink-0 font-mono text-[9px] tabular-nums text-[var(--color-text-tertiary)]"
className="shrink-0 font-mono text-[11px] tabular-nums text-[var(--color-text-tertiary)]"
>
{time}
</time>
@@ -393,13 +393,13 @@ function FeedRow({
return (
<article {...rowProps} className="relative px-3.5 py-3">
{isLatest ? (
<span className="absolute bottom-3 left-0 top-3 w-0.5 rounded-r-full bg-[var(--color-brand)]" aria-hidden="true" />
<span className="absolute bottom-3 left-0 top-3 w-0.5 rounded-r-full bg-[var(--color-info)]" aria-hidden="true" />
) : null}
<div className="flex min-w-0 items-center gap-2">
<span
className={[
'inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2 py-0.5 text-[9.5px] font-extrabold uppercase tracking-[0.08em]',
'inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] font-semibold',
categoryTagClass(category),
].join(' ')}
>
@@ -407,13 +407,13 @@ function FeedRow({
{categoryLabel(category, t)}
</span>
{message.taskId ? (
<span className="shrink-0 rounded-full bg-[var(--color-surface-container-high)] px-1.5 py-0.5 font-mono text-[9px] text-[var(--color-text-tertiary)]">
<span className="shrink-0 rounded-full bg-[var(--color-surface-container-high)] px-1.5 py-0.5 font-mono text-[11px] text-[var(--color-text-tertiary)]">
#{message.taskId}
</span>
) : null}
<time
dateTime={message.timestamp}
className="ml-auto shrink-0 font-mono text-[9px] tabular-nums text-[var(--color-text-tertiary)]"
className="ml-auto shrink-0 font-mono text-[11px] tabular-nums text-[var(--color-text-tertiary)]"
>
{time}
</time>
@@ -443,10 +443,10 @@ function FeedRow({
className="inline-flex min-w-0 flex-1 items-center gap-1.5"
title={recipient}
>
<span className="inline-flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] text-[var(--color-brand)]">
<span className="inline-flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] text-[var(--color-text-secondary)]">
<Megaphone size={11} strokeWidth={2} aria-hidden="true" />
</span>
<span className="min-w-0 truncate font-mono text-[10px] font-semibold text-[var(--color-text-secondary)]">
<span className="min-w-0 truncate font-mono text-[11px] font-medium text-[var(--color-text-secondary)]">
{recipient}
</span>
</span>
@@ -456,13 +456,13 @@ function FeedRow({
<div
data-testid={`agent-teams-message-${message.id}-body`}
data-collapsed={collapsed ? 'true' : 'false'}
className="relative mt-[7px] text-[12.5px] leading-[1.55]"
className="relative mt-[7px] text-[13px] leading-[1.55]"
>
<div className={collapsed ? 'max-h-[148px] overflow-hidden' : ''}>
<MarkdownRenderer
content={text}
variant="compact"
className="prose-p:first:mt-0 prose-p:last:mb-0 prose-p:text-[12.5px] prose-p:leading-[1.55] prose-li:text-[12.5px] prose-li:leading-[1.55] prose-headings:first:mt-0 prose-headings:text-[var(--color-text-primary)] prose-code:text-[11px]"
className="prose-p:first:mt-0 prose-p:last:mb-0 prose-p:text-[13px] prose-p:leading-[1.55] prose-li:text-[13px] prose-li:leading-[1.55] prose-headings:first:mt-0 prose-headings:text-[var(--color-text-primary)] prose-code:text-[11px]"
/>
</div>
{collapsed ? (
@@ -477,7 +477,7 @@ function FeedRow({
<button
type="button"
onClick={() => setExpanded((current) => !current)}
className="relative mt-2 rounded-[var(--radius-sm)] text-[10px] font-semibold text-[var(--color-brand)] outline-none transition-[transform,color] duration-200 hover:underline focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] active:scale-[0.98]"
className="relative mt-2 rounded-[var(--radius-sm)] text-[11px] font-medium text-[var(--color-text-accent)] outline-none transition-[transform,color] duration-200 hover:underline focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] active:scale-[0.98]"
>
{t(expanded ? 'agentTeams.communication.collapse' : 'agentTeams.communication.expand')}
</button>
@@ -518,7 +518,7 @@ function ParticipantFigure({
</span>
<span
data-testid={nameTestId}
className="min-w-0 truncate font-mono text-[10px] font-bold text-[var(--color-text-secondary)]"
className="min-w-0 truncate font-mono text-[11px] font-medium text-[var(--color-text-secondary)]"
>
{name}
</span>
@@ -212,7 +212,7 @@ function stalledTone(state: StalledTaskOwnerState): Tone {
}
function taskTone(state: WorkbenchTaskState): Tone {
if (state === 'running') return 'brand'
if (state === 'running') return 'info'
if (state === 'completed') return 'success'
if (state === 'open') return 'warning'
return 'neutral'
@@ -228,7 +228,7 @@ function memberStateLabel(state: MemberWorkState, member: TeamMember, t: Transla
}
function memberTone(state: MemberWorkState): Tone {
if (state === 'working') return 'brand'
if (state === 'working') return 'info'
if (state === 'error') return 'danger'
if (state === 'retrying') return 'warning'
if (state === 'exited' || state === 'stopped') return 'neutral'
@@ -441,46 +441,46 @@ export function AgentTeamsMemberInspector({
/>
</span>
<div className="min-w-0">
<h2 id={headingId} className="truncate font-mono text-[14px] font-extrabold">
<h2 id={headingId} className="truncate text-sm font-semibold">
{name}
</h2>
<p className="truncate text-[10.5px] text-[var(--color-text-secondary)]">
<p className="truncate text-[11px] text-[var(--color-text-secondary)]">
{t('agentTeams.inspector.independentContext', { role: member.role })}
</p>
</div>
</div>
<dl className="mt-3 grid grid-cols-3 gap-4 text-[11.5px]">
<dl className="mt-3 grid grid-cols-3 gap-4 text-xs">
<div 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)]">
{t('agentTeams.inspector.current')}
</dt>
<dd className="mt-0.5 flex min-w-0 items-center gap-1.5 font-extrabold">
<dd className="mt-0.5 flex min-w-0 items-center gap-1.5 font-semibold">
<StatusDot tone={memberTone(workState)} pulse={workState === 'working'} />
<span className="truncate">{currentStatusLabel}</span>
</dd>
</div>
<div>
<dt className="text-[10px] font-semibold text-[var(--color-text-tertiary)]">
<dt className="text-[11px] font-semibold text-[var(--color-text-tertiary)]">
{t('agentTeams.inspector.completedTasks')}
</dt>
<dd className="mt-0.5 font-extrabold tabular-nums">{completedTasks}/{taskHistory.length}</dd>
<dd className="mt-0.5 font-semibold tabular-nums">{completedTasks}/{taskHistory.length}</dd>
</div>
<div>
<dt className="text-[10px] font-semibold text-[var(--color-text-tertiary)]">
<dt className="text-[11px] font-semibold text-[var(--color-text-tertiary)]">
{t('agentTeams.inspector.messages')}
</dt>
<dd className="mt-0.5 font-extrabold tabular-nums">{messages.length}</dd>
<dd className="mt-0.5 font-semibold tabular-nums">{messages.length}</dd>
</div>
{/* Model spans the full width: a model id plus an "inherited from" prefix
does not fit a third of the drawer, and truncating it would cut the
model name itself — the one part that matters. */}
<div className="col-span-3 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)]">
{t('agentTeams.model.label')}
</dt>
<dd
className="mt-0.5 truncate font-extrabold"
className="mt-0.5 truncate font-semibold"
title={model?.full}
data-testid="agent-teams-member-model"
data-model-inherited={model?.inherited ? 'true' : 'false'}
@@ -493,8 +493,8 @@ export function AgentTeamsMemberInspector({
</dd>
</div>
{(member.providerName || member.providerId !== undefined) && <div className="col-span-3 min-w-0">
<dt className="text-[10px] font-semibold text-[var(--color-text-tertiary)]">{t('teamPlan.provider')}</dt>
<dd className="mt-0.5 truncate font-extrabold" data-testid="agent-teams-member-provider">{member.providerName || member.providerId || t('teamPlan.official')}</dd>
<dt className="text-[11px] font-semibold text-[var(--color-text-tertiary)]">{t('teamPlan.provider')}</dt>
<dd className="mt-0.5 truncate font-semibold" data-testid="agent-teams-member-provider">{member.providerName || member.providerId || t('teamPlan.official')}</dd>
</div>}
</dl>
@@ -532,7 +532,7 @@ export function AgentTeamsMemberInspector({
>
<h3
id={`${headingId}-tasks`}
className="text-[10px] font-bold uppercase tracking-[0.1em] text-[var(--color-text-tertiary)]"
className="text-[11px] font-semibold text-[var(--color-text-tertiary)]"
>
{t('agentTeams.inspector.taskHistory')}
<span className="ml-1 normal-case tracking-normal">
@@ -549,7 +549,7 @@ export function AgentTeamsMemberInspector({
if (group === 'upcoming') entries.sort((left, right) => left.task.id.localeCompare(right.task.id, undefined, { numeric: true }))
return (
<section key={group} data-testid={`agent-teams-member-task-group-${group}`} className="mt-2">
<h4 className="text-[10px] font-semibold text-[var(--color-text-secondary)]">
<h4 className="text-[11px] font-semibold text-[var(--color-text-secondary)]">
{t(label)} · {entries.length}
</h4>
<ol className="mt-0.5">
@@ -569,10 +569,10 @@ export function AgentTeamsMemberInspector({
data-task-stalled={stalled}
className="flex min-w-0 items-center gap-2 border-b border-[var(--color-border)] py-1.5 last:border-b-0"
>
<span className="w-[26px] shrink-0 font-mono text-[10px] font-extrabold text-[var(--color-text-tertiary)]">
<span className="w-[26px] shrink-0 font-mono text-[11px] font-medium text-[var(--color-text-tertiary)]">
#{entry.task.id}
</span>
<span className="min-w-0 flex-1 truncate text-[11.5px] leading-[1.3]" title={entry.task.subject}>
<span className="min-w-0 flex-1 truncate text-xs leading-[1.3]" title={entry.task.subject}>
{entry.task.subject}
</span>
<Badge
@@ -587,7 +587,7 @@ export function AgentTeamsMemberInspector({
</Badge>
<time
dateTime={entry.startedAt === null ? undefined : new Date(entry.startedAt).toISOString()}
className="w-[82px] shrink-0 truncate text-right font-mono text-[9.5px] tabular-nums text-[var(--color-text-tertiary)]"
className="w-[82px] shrink-0 truncate text-right font-mono text-[11px] tabular-nums text-[var(--color-text-tertiary)]"
title={aside}
>
{aside}
@@ -610,7 +610,7 @@ export function AgentTeamsMemberInspector({
<section aria-labelledby={`${headingId}-messages`} className="px-3.5 py-3">
<h3
id={`${headingId}-messages`}
className="text-[10px] font-bold uppercase tracking-[0.1em] text-[var(--color-text-tertiary)]"
className="text-[11px] font-semibold text-[var(--color-text-tertiary)]"
>
{t('agentTeams.inspector.messages')}
</h3>
@@ -624,21 +624,21 @@ export function AgentTeamsMemberInspector({
data-testid={`agent-teams-member-message-${row.message.id}`}
data-message-direction={row.direction}
data-message-body={row.body.kind}
className={`mb-2 border-l-2 pb-2 pl-2.5 last:mb-0 ${sent ? 'border-l-[var(--color-brand)]' : 'border-l-[var(--color-tertiary)]'}`}
className={`mb-2 border-l-2 pb-2 pl-2.5 last:mb-0 ${sent ? 'border-l-[var(--color-info)]' : 'border-l-[var(--color-tertiary)]'}`}
>
<div className="flex min-w-0 items-center gap-1.5">
<span className={`shrink-0 text-[9.5px] font-extrabold ${sent ? 'text-[var(--color-brand)]' : 'text-[var(--color-tertiary)]'}`}>
<span className={`shrink-0 text-[11px] font-semibold ${sent ? 'text-[var(--color-info)]' : 'text-[var(--color-tertiary)]'}`}>
{t(sent ? 'agentTeams.inspector.sent' : 'agentTeams.inspector.received')}
</span>
{sent
? <ArrowRight size={11} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
: <ArrowLeft size={11} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />}
<span className="min-w-0 truncate font-mono text-[10px] text-[var(--color-text-tertiary)]">
<span className="min-w-0 truncate font-mono text-[11px] text-[var(--color-text-tertiary)]">
{row.peerName}
</span>
<time
dateTime={row.message.timestamp}
className="ml-auto shrink-0 font-mono text-[10px] tabular-nums text-[var(--color-text-tertiary)]"
className="ml-auto shrink-0 font-mono text-[11px] tabular-nums text-[var(--color-text-tertiary)]"
>
{formatWorkbenchMessageTime(row.message.timestamp)}
</time>
@@ -647,10 +647,10 @@ export function AgentTeamsMemberInspector({
<MarkdownRenderer
content={row.body.text}
variant="compact"
className="mt-1 text-[12.5px] leading-[1.55] text-[var(--color-text-primary)]"
className="mt-1 text-[13px] leading-[1.55] text-[var(--color-text-primary)]"
/>
) : (
<p className="mt-1 text-[11.5px] leading-[1.5] text-[var(--color-text-secondary)]">
<p className="mt-1 text-xs leading-[1.5] text-[var(--color-text-secondary)]">
{protocolNarration(row, snapshot, t)}
</p>
)}
@@ -20,17 +20,6 @@
background: var(--color-surface-hover);
}
.team-plan-chevron {
width: 6px;
height: 6px;
flex-shrink: 0;
margin: -3px 2px 0 0;
border-right: 1px solid currentColor;
border-bottom: 1px solid currentColor;
transform: rotate(45deg);
opacity: 0.6;
}
.team-plan-detail,
.team-plan-batch {
animation: team-plan-reveal 220ms cubic-bezier(0.16, 1, 0.3, 1) both;
@@ -1,5 +1,7 @@
import { useEffect, useState } from 'react'
import { ChevronDown, UsersRound } from 'lucide-react'
import { ModelSelector } from '@/components/controls/ModelSelector'
import { Badge, StatusDot, type Tone } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { Checkbox } from '@/components/ui/Checkbox'
import { Modal } from '@/components/ui/Modal'
@@ -13,7 +15,7 @@ import { useSessionRuntimeStore } from '@/stores/sessionRuntimeStore'
import { useProviderStore } from '@/stores/providerStore'
import type { RuntimeSelection } from '@/types/runtime'
import { CLAUDE_OFFICIAL_PROVIDER_ID } from '@/constants/openaiOfficialProvider'
import { isValidTeamMemberName, type TeamPlanRuntime, type TeamPlanMember } from '../../../../src/shared/teamPlan'
import { isValidTeamMemberName, type TeamPlanRecord, type TeamPlanRuntime, type TeamPlanMember } from '../../../../src/shared/teamPlan'
function selection(runtime: TeamPlanRuntime): RuntimeSelection {
return {
@@ -28,6 +30,23 @@ function runtimeFor(selection: RuntimeSelection): TeamPlanRuntime {
return { ...selection, providerId: selection.providerId ?? CLAUDE_OFFICIAL_PROVIDER_ID }
}
/**
* The app-wide status vocabulary: waiting on a person is `warning`, work under
* way is `info`, a failed launch is `danger`, and a plan nobody is acting on
* any more is `neutral`.
*/
const STATE_TONE: Record<TeamPlanRecord['state'], Tone> = {
draft: 'info',
review_pending: 'warning',
launching: 'info',
running: 'info',
launch_failed: 'danger',
cancelled: 'neutral',
interrupted: 'neutral',
}
const CHEVRON = <ChevronDown size={14} strokeWidth={1.75} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
/** Durable plan review is independent of a running chat turn or permission request. */
export function AgentTeamsPlanCard({ sessionId }: { sessionId: string }) {
const t = useTranslation()
@@ -96,14 +115,23 @@ export function AgentTeamsPlanCard({ sessionId }: { sessionId: string }) {
interrupted: t('teamPlan.state.interrupted'),
}[plan.state]
const stateTone = STATE_TONE[plan.state]
return (
<section aria-label={t('teamPlan.title')} className="my-4 rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] p-5">
<div className="flex flex-wrap items-center justify-between gap-4">
<section aria-label={t('teamPlan.title')} className="my-4 overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]">
<div className="flex h-[42px] items-center gap-2 border-b border-[var(--color-border)] px-3.5">
<UsersRound size={15} strokeWidth={1.75} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
<span className="min-w-0 flex-1 truncate text-[13px] font-semibold text-[var(--color-text-primary)]">{t('teamPlan.title')}</span>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 px-3.5 py-3">
<div className="min-w-0">
<p className="mb-1 text-xs text-[var(--color-text-secondary)]" role="status">{stateLabel} · {t('teamPlan.memberCount', { count: members.length })}</p>
<h3 className="truncate text-base font-semibold tracking-tight text-[var(--color-text-primary)]">{plan.teamName}</h3>
<h3 className="truncate text-sm font-medium text-[var(--color-text-primary)]">{plan.teamName}</h3>
<p className="mt-1 flex min-w-0 items-center gap-1.5 text-xs text-[var(--color-text-tertiary)]" role="status">
<StatusDot tone={stateTone} pulse={stateTone === 'warning'} />
<span className="truncate">{stateLabel} · {t('teamPlan.memberCount', { count: members.length })}</span>
</p>
</div>
<Button data-testid="team-plan-open" variant="secondary" onClick={openEditor}>{t('teamPlan.open')}</Button>
<Button data-testid="team-plan-open" size="base" variant={plan.state === 'review_pending' ? 'primary' : 'secondary'} onClick={openEditor}>{t('teamPlan.open')}</Button>
</div>
<Modal open={open} onClose={() => { if (!entry.busy) setOpen(false) }} title={t('teamPlan.title')} width={1080} className="team-plan-dialog" typography="interface" footer={(
<div className="team-plan-footer flex w-full flex-wrap items-center justify-between gap-3">
@@ -132,7 +160,7 @@ export function AgentTeamsPlanCard({ sessionId }: { sessionId: string }) {
<p className="text-sm text-[var(--color-text-secondary)]">{t('teamPlan.reviewHint')}</p>
<p className="mt-2 max-w-[65ch] text-xs leading-relaxed text-[var(--color-text-tertiary)]">{t('teamPlan.description')}</p>
</div>
<span className="rounded-full bg-[var(--color-surface-container)] px-3 py-1 text-xs text-[var(--color-text-secondary)]">{stateLabel}</span>
<Badge tone={stateTone} size="sm">{stateLabel}</Badge>
</div>
{plan.launch?.error && <p role="alert" className="mb-4 text-sm text-[var(--color-error)]">{plan.launch.error}</p>}
{entry.error && <p role="alert" className="mb-4 text-sm text-[var(--color-error)]">{entry.error}</p>}
@@ -151,7 +179,7 @@ export function AgentTeamsPlanCard({ sessionId }: { sessionId: string }) {
{plan.state === 'review_pending' && <Checkbox size="sm" label={t('teamPlan.selectAll')} labelHidden checked={members.length > 0 && members.every(member => selected.includes(member.id))} indeterminate={selected.length > 0 && !members.every(member => selected.includes(member.id))} disabled={!editable} onChange={event => setSelected(event.target.checked ? members.map(member => member.id) : [])} />}
</div>
<nav aria-label={t('teamPlan.roster')} className="grid min-w-0 gap-1.5">
{members.map((member, index) => <div key={member.id} className={`team-plan-member flex min-w-0 items-center gap-2 rounded-[var(--radius-lg)] p-2 ${member.id === activeMember?.id ? 'bg-[var(--color-surface-container)]' : ''}`}>
{members.map((member, index) => <div key={member.id} className={`team-plan-member flex min-w-0 items-center gap-2 rounded-[var(--radius-md)] p-2 ${member.id === activeMember?.id ? 'bg-[var(--color-surface-selected)]' : ''}`}>
{plan.state === 'review_pending' && <Checkbox label={member.name} labelHidden size="sm" disabled={!editable} checked={selected.includes(member.id)} onChange={event => setSelected(ids => event.target.checked ? [...ids, member.id] : ids.filter(id => id !== member.id))} />}
<button type="button" aria-label={t('teamPlan.configureMember', { name: member.name })} aria-current={member.id === activeMember?.id ? 'true' : undefined} onClick={() => setActiveMemberId(member.id)} className="flex min-w-0 flex-1 items-start gap-3 rounded-[var(--radius-sm)] py-1 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]">
<span aria-hidden="true" className={`mt-0.5 font-mono text-[11px] ${member.id === activeMember?.id ? 'text-[var(--color-brand)]' : 'text-[var(--color-text-tertiary)]'}`}>{String(index + 1).padStart(2, '0')}</span>
@@ -166,7 +194,7 @@ export function AgentTeamsPlanCard({ sessionId }: { sessionId: string }) {
<p className="text-xs leading-relaxed text-[var(--color-text-secondary)]">{t('teamPlan.leader')}: {runtimeName(leaderRuntime)}</p>
</div>
{plan.state === 'review_pending' && <div className="mt-4 space-y-3">
{selected.length > 0 && <div className="team-plan-batch space-y-3 rounded-[var(--radius-lg)] bg-[var(--color-surface-container)] p-3">
{selected.length > 0 && <div className="team-plan-batch space-y-3 rounded-[var(--radius-md)] bg-[var(--color-surface-container)] p-3">
<p className="text-xs font-medium text-[var(--color-text-primary)]">{t('teamPlan.selectedCount', { count: selected.length })}</p>
<ModelSelector appearance="field" fluid ariaLabel={t('teamPlan.batch')} runtimeSelection={selection(members.find(member => selected.includes(member.id))?.runtime ?? leaderRuntime)} onRuntimeSelectionChange={batchRuntime} disabled={!editable} />
<Button block size="base" variant="ghost" disabled={!editable} onClick={() => batchRuntime(selection(leaderRuntime))}>{t('teamPlan.followLeader')}</Button>
@@ -179,9 +207,9 @@ export function AgentTeamsPlanCard({ sessionId }: { sessionId: string }) {
<div className="mb-6 flex flex-wrap items-start justify-between gap-3">
<div>
<p className="mb-1 text-xs text-[var(--color-text-tertiary)]">{t('teamPlan.assignment')}</p>
<h3 className="text-xl font-semibold tracking-tight text-[var(--color-text-primary)]">{activeMember.name}</h3>
<h3 className="text-lg font-semibold text-[var(--color-text-primary)]">{activeMember.name}</h3>
</div>
<span className="rounded-full border border-[var(--color-border)] px-2.5 py-1 text-[11px] text-[var(--color-text-secondary)]">{t('teamPlan.difficulty')}: {difficultyLabel(activeMember)}</span>
<Badge size="sm">{t('teamPlan.difficulty')}: {difficultyLabel(activeMember)}</Badge>
</div>
<div className="grid gap-5 sm:grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)]">
<div className="min-w-0 space-y-2">
@@ -189,7 +217,7 @@ export function AgentTeamsPlanCard({ sessionId }: { sessionId: string }) {
<Dropdown className="w-full" width="100%" maxHeight={280} label={`${activeMember.name} · ${t('teamPlan.agent')}`} value={activeMember.agentType} items={[
...(!agents.some(agent => agent.agentType === activeMember.agentType) ? [{ value: activeMember.agentType, label: activeMember.agentType }] : []),
...agents.map(agent => ({ value: agent.agentType, label: agent.agentType, description: agent.source ? t(`settings.agents.source.${agent.source}` as TranslationKey) : agent.description })),
]} onChange={agentType => updateMember(activeMember.id, { agentType })} trigger={<Button block variant="secondary" size="lg" aria-label={`${activeMember.name} · ${t('teamPlan.agent')}`} disabled={!editable || agents.length === 0}><span className="flex min-w-0 flex-1 items-center justify-between gap-2"><span className="truncate">{activeMember.agentType}</span><span aria-hidden="true" className="team-plan-chevron" /></span></Button>} />
]} onChange={agentType => updateMember(activeMember.id, { agentType })} trigger={<Button block variant="secondary" size="lg" aria-label={`${activeMember.name} · ${t('teamPlan.agent')}`} disabled={!editable || agents.length === 0}><span className="flex min-w-0 flex-1 items-center justify-between gap-2"><span className="truncate">{activeMember.agentType}</span>{CHEVRON}</span></Button>} />
</div>
<div className="min-w-0 space-y-2">
<p className="text-xs font-medium text-[var(--color-text-secondary)]">{t('teamPlan.model')}</p>
@@ -222,7 +250,7 @@ export function AgentTeamsPlanCard({ sessionId }: { sessionId: string }) {
</div>
<div className="min-w-0 space-y-2">
<p className="text-[11px] text-[var(--color-text-tertiary)]">{t('teamPlan.owner')}</p>
<Dropdown className="w-full" width="100%" maxHeight={200} placement="top" label={`${task.subject} · ${t('teamPlan.owner')}`} value={owner?.id ?? ''} items={[{ value: '', label: t('teamPlan.unassigned'), disabled: true }, ...members.map(member => ({ value: member.id, label: member.name, description: member.agentType }))]} onChange={ownerId => edit(sessionId, { tasks: tasks.map(row => row.id === task.id ? { ...row, ownerId: ownerId || undefined } : row) })} trigger={<Button block size="base" variant="secondary" aria-label={`${task.subject} · ${t('teamPlan.owner')}`} aria-invalid={!owner || undefined} aria-describedby={!owner ? `team-plan-unassigned-${task.id}` : undefined} disabled={!editable}><span className="flex min-w-0 flex-1 items-center justify-between gap-2"><span className="truncate">{owner?.name ?? t('teamPlan.unassigned')}</span><span aria-hidden="true" className="team-plan-chevron" /></span></Button>} />
<Dropdown className="w-full" width="100%" maxHeight={200} placement="top" label={`${task.subject} · ${t('teamPlan.owner')}`} value={owner?.id ?? ''} items={[{ value: '', label: t('teamPlan.unassigned'), disabled: true }, ...members.map(member => ({ value: member.id, label: member.name, description: member.agentType }))]} onChange={ownerId => edit(sessionId, { tasks: tasks.map(row => row.id === task.id ? { ...row, ownerId: ownerId || undefined } : row) })} trigger={<Button block size="base" variant="secondary" aria-label={`${task.subject} · ${t('teamPlan.owner')}`} aria-invalid={!owner || undefined} aria-describedby={!owner ? `team-plan-unassigned-${task.id}` : undefined} disabled={!editable}><span className="flex min-w-0 flex-1 items-center justify-between gap-2"><span className="truncate">{owner?.name ?? t('teamPlan.unassigned')}</span>{CHEVRON}</span></Button>} />
{plan.state === 'review_pending' && !owner && <p id={`team-plan-unassigned-${task.id}`} role="alert" className="text-xs text-[var(--color-error)]">{t('teamPlan.assignRequired')}</p>}
</div>
</div>
@@ -14,11 +14,12 @@ import {
/** Faces beyond this collapse into a `+N`, so the strip never wraps. */
const MAX_STACKED_AVATARS = 5
/**
* The app-wide status vocabulary: work under way is `info`, done is `success`.
* Forming, running and finishing are all a team still at work.
*/
function phaseTone(phase: WorkbenchPhase): Tone {
if (phase === 'forming') return 'warning'
if (phase === 'running') return 'brand'
if (phase === 'finishing') return 'info'
return 'success'
return phase === 'completed' ? 'success' : 'info'
}
function AvatarStack({
@@ -56,7 +57,7 @@ function AvatarStack({
))}
{overflow > 0 ? (
<span
className="inline-flex items-center justify-center rounded-full bg-[var(--color-surface-container)] font-mono text-[9px] font-extrabold text-[var(--color-text-secondary)] ring-1 ring-[var(--color-surface)]"
className="inline-flex items-center justify-center rounded-full bg-[var(--color-surface-container)] text-[11px] font-medium tabular-nums text-[var(--color-text-secondary)] ring-1 ring-[var(--color-surface)]"
style={{ width: size, height: size, marginLeft: -Math.round(size * 0.32) }}
>
+{overflow}
@@ -92,14 +93,14 @@ export function AgentTeamsStrip({
onClick={onOpen}
title={snapshot.team.name}
className={[
'mt-2 flex w-full max-w-full items-center gap-2 rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-2.5 py-1.5 text-left transition-colors',
'hover:border-[var(--color-border-strong)] hover:bg-[var(--color-surface-hover)]',
'mt-2 flex w-full max-w-full items-center gap-2 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-2.5 py-1.5 text-left transition-colors',
'hover:border-[var(--color-outline)]',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]',
compact ? 'text-[11px]' : 'text-[12px]',
compact ? 'text-[11px]' : 'text-xs',
].join(' ')}
>
<UsersRound size={compact ? 13 : 14} strokeWidth={2.25} aria-hidden="true" className="shrink-0 text-[var(--color-brand)]" />
<span className="shrink-0 font-semibold text-[var(--color-text-primary)]">
<UsersRound size={compact ? 12 : 14} strokeWidth={1.75} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
<span className="shrink-0 font-medium text-[var(--color-text-primary)]">
{t('agentTeams.strip.label')}
</span>
<AvatarStack members={members} leadAgentId={snapshot.team.leadAgentId} size={compact ? 18 : 20} />
@@ -112,9 +113,9 @@ export function AgentTeamsStrip({
{t('agentTeams.inline.tasks', { completed: progress.completed, total: progress.total })}
</span>
) : null}
<span className="ml-auto flex shrink-0 items-center gap-0.5 font-medium text-[var(--color-brand)]">
<span className="ml-auto flex shrink-0 items-center gap-0.5 text-[var(--color-text-secondary)]">
{t('agentTeams.inline.open')}
<ChevronRight size={12} strokeWidth={2.4} aria-hidden="true" />
<ChevronRight size={12} strokeWidth={2} aria-hidden="true" />
</span>
</button>
)
@@ -153,26 +154,26 @@ export function AgentTeamsInlineCard({
data-testid="agent-teams-inline-card"
onClick={onOpen}
disabled={!canOpen}
className="flex w-full items-center gap-3 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3.5 py-2.5 text-left shadow-[var(--shadow-card)] transition-colors hover:border-[var(--color-border-strong)] hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
className="flex w-full items-center gap-3 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3 py-2.5 text-left transition-colors enabled:hover:border-[var(--color-outline)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
>
{members.length > 0 ? (
<AvatarStack members={members} leadAgentId={snapshot?.team.leadAgentId} size={30} />
) : (
<span className="inline-flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-full bg-[var(--color-surface-container)] text-[var(--color-brand)] ring-1 ring-[var(--color-border)]">
<UsersRound size={16} strokeWidth={2.2} aria-hidden="true" />
<span className="inline-flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-full bg-[var(--color-surface-container)] text-[var(--color-text-secondary)]">
<UsersRound size={16} strokeWidth={1.75} aria-hidden="true" />
</span>
)}
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-center gap-1.5">
<span className="min-w-0 truncate font-mono text-[12px] font-extrabold text-[var(--color-text-primary)]">
<span className="min-w-0 truncate text-[13px] font-medium text-[var(--color-text-primary)]">
{teamName}
</span>
<Badge tone={phaseTone(phase)} size="xs" bordered>
<Badge tone={phaseTone(phase)} size="xs">
{t(`agentTeams.phase.${phase}` as TranslationKey)}
</Badge>
</span>
{snapshot ? (
<span className="mt-0.5 flex items-center gap-1.5 text-[11px] text-[var(--color-text-tertiary)]">
<span className="mt-0.5 flex items-center gap-1.5 text-xs text-[var(--color-text-tertiary)]">
<span>{t('agentTeams.inline.created', { count: members.length })}</span>
{progress.total > 0 ? (
<>
@@ -186,9 +187,9 @@ export function AgentTeamsInlineCard({
) : null}
</span>
{canOpen ? (
<span className="flex shrink-0 items-center gap-0.5 text-[11.5px] font-medium text-[var(--color-brand)]">
<span className="flex shrink-0 items-center gap-0.5 text-xs text-[var(--color-text-secondary)]">
{t('agentTeams.inline.open')}
<ChevronRight size={13} strokeWidth={2.4} aria-hidden="true" />
<ChevronRight size={14} strokeWidth={1.75} aria-hidden="true" />
</span>
) : null}
</button>
@@ -91,16 +91,16 @@ function stateCounts(snapshot: TeamWorkbenchSnapshot) {
function TimelineStat({ label, value, tone }: {
label: string
value: string | number
tone?: 'brand' | 'muted'
tone?: 'info' | 'muted'
}) {
return (
<div className="min-w-[52px]">
<div className="text-[10px] font-semibold text-[var(--color-text-tertiary)]">{label}</div>
<div className="text-[11px] font-semibold text-[var(--color-text-tertiary)]">{label}</div>
<div
className={[
'whitespace-nowrap font-mono text-[14px] font-extrabold tabular-nums',
tone === 'brand'
? 'text-[var(--color-brand)]'
'whitespace-nowrap font-mono text-[14px] font-medium tabular-nums',
tone === 'info'
? 'text-[var(--color-info)]'
: tone === 'muted'
? 'text-[var(--color-text-tertiary)]'
: 'text-[var(--color-text-primary)]',
@@ -324,17 +324,17 @@ export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) {
<header className="shrink-0 overflow-x-auto border-b border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]">
<div className="flex min-w-[1180px] items-center gap-5 px-[18px] py-2.5">
<div className="min-w-[260px]">
<div className="text-[10px] font-bold uppercase tracking-[0.12em] text-[var(--color-text-tertiary)]">
<div className="text-[11px] font-semibold text-[var(--color-text-tertiary)]">
Agent Teams · {t('agentTeams.sharedTaskList')}
</div>
<div className="mt-0.5 flex min-w-0 items-center gap-2">
<span
className="max-w-[300px] truncate font-mono text-[16px] font-extrabold"
className="max-w-[300px] truncate text-[15px] font-semibold"
title={snapshot.team.name}
>
{snapshot.team.name}
</span>
<span className="shrink-0 rounded-full border border-[var(--color-primary-fixed-dim)] bg-[var(--color-brand-soft)] px-2 py-0.5 text-[10px] font-bold text-[var(--color-on-brand-soft)]">
<span className="shrink-0 rounded-full bg-[var(--color-surface-container)] px-2 py-0.5 text-[11px] font-medium text-[var(--color-text-secondary)]">
{t('agentTeams.experimental')}
</span>
</div>
@@ -342,10 +342,10 @@ export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) {
<div className="flex shrink-0 items-center gap-[18px] border-x border-[var(--color-border)] px-[18px]">
<div className="min-w-[74px]">
<div className="text-[10px] font-semibold text-[var(--color-text-tertiary)]">
<div className="text-[11px] font-semibold text-[var(--color-text-tertiary)]">
{t('agentTeams.stats.phase')}
</div>
<div className="whitespace-nowrap text-[14px] font-extrabold">
<div className="whitespace-nowrap text-[14px] font-semibold">
{phaseLabel(phase, t)}
</div>
</div>
@@ -353,20 +353,20 @@ export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) {
label={t('agentTeams.stats.completed')}
value={`${counts.completed}/${snapshot.tasks.length}`}
/>
<TimelineStat label={t('agentTeams.stats.running')} value={counts.running} tone="brand" />
<TimelineStat label={t('agentTeams.stats.running')} value={counts.running} tone="info" />
<TimelineStat label={t('agentTeams.stats.available')} value={counts.open} />
<TimelineStat label={t('agentTeams.stats.blocked')} value={counts.blocked} tone="muted" />
</div>
{followingLive ? (
<div data-testid="agent-teams-live-controls" className="flex min-w-0 flex-1 items-center gap-3.5">
<div className="flex shrink-0 items-center gap-2 rounded-full border border-[var(--color-primary-fixed-dim)] bg-[var(--color-brand-soft)] px-3 py-[7px]">
<span className="agent-teams-live-dot h-2 w-2 rounded-full bg-[var(--color-brand)]" aria-hidden="true" />
<span className="whitespace-nowrap text-[12.5px] font-extrabold text-[var(--color-on-brand-soft)]">
<div className="flex shrink-0 items-center gap-1.5 rounded-full bg-[var(--color-info-container)] px-2.5 py-1">
<span className="agent-teams-live-dot h-1.5 w-1.5 rounded-full bg-[var(--color-info)]" aria-hidden="true" />
<span className="whitespace-nowrap text-xs font-medium text-[var(--color-on-info-container)]">
{t('agentTeams.live.following')}
</span>
</div>
<span className="min-w-0 flex-1 truncate text-[11.5px] text-[var(--color-text-secondary)]">
<span className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-secondary)]">
{liveHint}
</span>
<Button variant="secondary" size="sm" onClick={enterReplay}>
@@ -382,7 +382,7 @@ export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) {
) : (
<div data-testid="agent-teams-replay-controls" className="flex min-w-0 flex-1 items-center gap-3.5">
<Button
variant="accent"
variant="primary"
size="base"
className="w-[86px]"
icon={playing ? <Pause size={13} aria-hidden="true" /> : <Play size={13} aria-hidden="true" />}
@@ -397,7 +397,7 @@ export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) {
<div
role="group"
aria-label={t('agentTeams.replay.speed')}
className="flex shrink-0 overflow-hidden rounded-[var(--radius-md)] border border-[var(--color-outline)]"
className="flex shrink-0 overflow-hidden rounded-[var(--radius-md)] border border-[var(--color-border)]"
>
{REPLAY_SPEEDS.map((speed) => {
const selected = replaySpeed === speed
@@ -408,9 +408,9 @@ export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) {
aria-pressed={selected}
onClick={() => setReplaySpeed(speed)}
className={[
'h-7 border-r border-[var(--color-border)] px-2.5 font-mono text-[11px] font-bold outline-none transition-colors last:border-r-0 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-border-focus)]',
'h-7 border-r border-[var(--color-border)] px-2.5 font-mono text-[11px] font-medium outline-none transition-colors last:border-r-0 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-border-focus)]',
selected
? 'bg-[var(--color-brand)] text-[var(--color-on-primary)]'
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)]'
: 'bg-[var(--color-surface-container-lowest)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]',
].join(' ')}
>
@@ -420,7 +420,7 @@ export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) {
})}
</div>
<div className="min-w-[200px] flex-1">
<div className="mb-1 flex justify-between text-[10px] font-semibold text-[var(--color-text-tertiary)]">
<div className="mb-1 flex justify-between text-[11px] font-semibold text-[var(--color-text-tertiary)]">
<span>{t('agentTeams.replay.timeline')}</span>
<span className="font-mono tabular-nums">
{formatDuration(cursorTime - startTime)} / {formatDuration(totalElapsed)}
@@ -554,17 +554,17 @@ export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) {
<span className="flex h-[30px] w-[30px] items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-outline)] text-[var(--color-text-secondary)]">
<ChevronLeft size={14} strokeWidth={2.4} aria-hidden="true" />
</span>
<span className="text-[11px] font-extrabold tracking-[0.28em] text-[var(--color-text-secondary)] [writing-mode:vertical-rl]">
<span className="text-[11px] font-semibold text-[var(--color-text-secondary)] [writing-mode:vertical-rl]">
{t('agentTeams.communication.title')}
</span>
<span className="rounded-full bg-[var(--color-brand)] px-1.5 py-0.5 font-mono text-[10px] font-extrabold text-[var(--color-on-primary)]">
<span className="rounded-full bg-[var(--color-surface-container)] px-1.5 py-0.5 font-mono text-[11px] tabular-nums text-[var(--color-text-secondary)]">
{snapshot.messages.length}
</span>
<span
className={[
'h-2 w-2 rounded-full',
activeMessageId
? 'agent-teams-live-dot bg-[var(--color-brand)]'
? 'agent-teams-live-dot bg-[var(--color-info)]'
: 'bg-[var(--color-outline)]',
].join(' ')}
aria-hidden="true"
@@ -607,15 +607,15 @@ function TaskDetailPanel({
<aside
data-testid="agent-teams-task-detail"
data-task-id={task.id}
className="agent-teams-drawer absolute bottom-4 z-[var(--z-drawer)] w-[360px] rounded-[var(--radius-lg)] border border-[var(--color-outline)] bg-[var(--color-surface-container-lowest)] p-4 shadow-[var(--shadow-overlay)]"
className="agent-teams-drawer absolute bottom-4 z-[var(--z-drawer)] w-[360px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] p-4 shadow-[var(--shadow-dropdown)]"
style={{ right: communicationOpen ? 416 : 72 }}
>
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1">
<div className="font-mono text-[10px] font-extrabold text-[var(--color-text-tertiary)]">
<div className="font-mono text-[11px] font-medium text-[var(--color-text-tertiary)]">
#{task.id}
</div>
<h3 className="mt-1 text-[13px] font-extrabold leading-snug">{task.subject}</h3>
<h3 className="mt-1 text-[13px] font-semibold leading-snug">{task.subject}</h3>
</div>
<IconButton
icon={<X aria-hidden="true" />}
@@ -630,7 +630,7 @@ function TaskDetailPanel({
{task.description}
</p>
) : null}
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 border-t border-[var(--color-border)] pt-3 text-[10.5px] text-[var(--color-text-secondary)]">
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 border-t border-[var(--color-border)] pt-3 text-[11px] text-[var(--color-text-secondary)]">
<span>{t('agentTeams.task.ownerLabel')}: {ownerMember?.name ?? owner?.identity ?? '—'}</span>
<span>{t('agentTeams.task.dependsOn')}: {task.blockedBy.map((id) => `#${id}`).join(', ') || '—'}</span>
<span>{t('agentTeams.task.unblocks')}: {task.blocks.map((id) => `#${id}`).join(', ') || '—'}</span>
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { fireEvent, render, screen, within } from '@testing-library/react'
import { ActivityGroup } from './ActivityGroup'
import { buildActivitySegments, type ActivityStep } from './activityGroupModel'
import { buildActivitySegments, formatActivitySummary, type ActivityStep } from './activityGroupModel'
import { useSettingsStore } from '../../stores/settingsStore'
import { translate } from '../../i18n'
import type { UIMessage } from '../../types/chat'
@@ -256,6 +256,79 @@ describe('ActivityGroup', () => {
expect(screen.getByTestId('activity-group').getAttribute('data-running')).toBe('false')
})
it('hangs every row of an open run on one timeline rail', () => {
const { container } = render(
<ActivityGroup
steps={[
thinkingStep('think-1', 'Check the call sites.', 500),
{ kind: 'tool', toolCall: readCall },
{ kind: 'tool', toolCall: bashCall },
]}
resultMap={resultsOf([
toolResult({ id: 'res-read', toolUseId: 'read-1', timestamp: 1_400 }),
toolResult({ id: 'res-bash', toolUseId: 'bash-1', timestamp: 3_000 }),
])}
childToolCallsByParent={new Map()}
isLive
/>,
)
const rail = container.querySelector('[data-tool-timeline]')!
expect(rail).toBeTruthy()
// Each tool is a node on the rail, led by its verb, never its raw name.
const rows = rail.querySelectorAll('[data-tool-call-chrome="row"]')
expect(rows).toHaveLength(2)
expect(rail.querySelectorAll('[data-tool-node]')).toHaveLength(2)
expect(rows[0]?.textContent).toContain(t('toolVerb.read'))
expect(rows[1]?.textContent).toContain(t('toolVerb.run'))
expect(rows[1]?.textContent).not.toContain('Bash')
})
it('marks a call waiting on a permission prompt amber, in its row and in the header', () => {
render(
<ActivityGroup
steps={[
{ kind: 'tool', toolCall: readCall },
{ kind: 'tool', toolCall: bashCall },
]}
resultMap={resultsOf([toolResult({ id: 'res-read', toolUseId: 'read-1', timestamp: 1_400 })])}
childToolCallsByParent={new Map()}
awaitingToolUseIds={new Set(['bash-1'])}
isLive
/>,
)
const group = screen.getByTestId('activity-group')
expect(group.querySelector('[data-chat-disclosure="true"]')?.textContent).toContain(t('permission.awaitingApproval'))
const waiting = group.querySelector('[data-tool-status="waiting"]')
expect(waiting?.closest('[data-chat-anchor-id]')?.getAttribute('data-chat-anchor-id')).toBe('use-bash')
expect(group.querySelector('[data-tool-status="done"]')?.closest('[data-chat-anchor-id]')?.getAttribute('data-chat-anchor-id')).toBe('use-read')
})
it('shows a resultless call as running only while its run is live', () => {
const props = {
steps: [
{ kind: 'tool' as const, toolCall: readCall },
{ kind: 'tool' as const, toolCall: bashCall },
],
resultMap: resultsOf([toolResult({ id: 'res-read', toolUseId: 'read-1', timestamp: 1_400 })]),
childToolCallsByParent: new Map(),
}
const { rerender } = render(<ActivityGroup {...props} isLive />)
let group = screen.getByTestId('activity-group')
expect(group.querySelector('[data-tool-status="running"]')).toBeTruthy()
expect(group.querySelector('[data-chat-disclosure="true"]')?.textContent).toContain(t('agentStatus.running'))
// A replayed transcript whose last call never got a result is not running —
// the process that would have produced it is gone.
rerender(<ActivityGroup {...props} isLive={false} />)
group = screen.getByTestId('activity-group')
expect(group.querySelector('[data-chat-disclosure="true"]')?.textContent).not.toContain(t('agentStatus.running'))
fireEvent.click(group.querySelector('[data-chat-disclosure="true"]')!)
expect(group.querySelector('[data-tool-status="running"]')).toBeNull()
expect(group.querySelector('[data-tool-status="idle"]')).toBeTruthy()
})
})
describe('buildActivitySegments', () => {
@@ -300,7 +373,7 @@ describe('buildActivitySegments', () => {
it('names a verbless tool rather than hiding it behind a generic count', () => {
const segments = buildActivitySegments(
[
{ kind: 'tool', toolCall: toolCall({ id: 'a', toolUseId: 'a', toolName: 'TaskUpdate' }) },
{ kind: 'tool', toolCall: toolCall({ id: 'a', toolUseId: 'a', toolName: 'mcp__linear__create_issue' }) },
{ kind: 'tool', toolCall: toolCall({ id: 'b', toolUseId: 'b', toolName: 'SendMessage' }) },
{ kind: 'tool', toolCall: toolCall({ id: 'c', toolUseId: 'c', toolName: 'SendMessage' }) },
],
@@ -308,6 +381,41 @@ describe('buildActivitySegments', () => {
)
// Which tool ran is the point; a single call drops the redundant "(1)".
expect(segments.map((segment) => segment.label)).toEqual(['TaskUpdate', 'SendMessage (2)'])
expect(segments.map((segment) => segment.label)).toEqual(['mcp__linear__create_issue', 'SendMessage (2)'])
})
it('says what happened to the todo list instead of printing TodoWrite', () => {
const segments = buildActivitySegments(
[
{ kind: 'tool', toolCall: toolCall({ id: 'a', toolUseId: 'a', toolName: 'TodoWrite' }) },
{ kind: 'tool', toolCall: toolCall({ id: 'b', toolUseId: 'b', toolName: 'TodoWrite' }) },
],
t,
)
expect(segments.map((segment) => segment.label)).toEqual([t('toolGroup.updatedTodos')])
expect(segments[0]?.label).not.toContain('TodoWrite')
})
it('folds the four task tools into one clause', () => {
const segments = buildActivitySegments(
['TaskCreate', 'TaskUpdate', 'TaskList', 'TaskUpdate'].map((toolName, index) => (
{ kind: 'tool' as const, toolCall: toolCall({ id: `t${index}`, toolUseId: `t${index}`, toolName }) }
)),
t,
)
expect(segments.map((segment) => segment.label)).toEqual([t('toolGroup.managedTasks')])
})
it('joins clauses with the locale\'s own list mark', () => {
const steps: ActivityStep[] = [
{ kind: 'tool', toolCall: toolCall({ id: 'a', toolUseId: 'a', toolName: 'Grep' }) },
{ kind: 'tool', toolCall: toolCall({ id: 'b', toolUseId: 'b', toolName: 'Read' }) },
{ kind: 'tool', toolCall: toolCall({ id: 'c', toolUseId: 'c', toolName: 'Read' }) },
]
const zh = (key: Parameters<typeof translate>[1], params?: Record<string, string | number>) => translate('zh', key, params)
const zhSummary = formatActivitySummary(buildActivitySegments(steps, zh), zh)
expect(zhSummary).toBe(`${zh('toolGroup.searchedOne')}、${zh('toolGroup.readMany', { count: 2 })}`)
expect(zhSummary).not.toContain(',')
expect(formatActivitySummary(buildActivitySegments(steps, t), t)).toBe(`${t('toolGroup.searchedOne')}, ${t('toolGroup.readMany', { count: 2 })}`)
})
})
+128 -49
View File
@@ -1,7 +1,7 @@
import { memo, useEffect, useMemo, useState } from 'react'
import { memo, useEffect, useMemo, useState, type ReactNode } from 'react'
import { getDisclosure, setDisclosure } from '../../lib/disclosureMemory'
import { toolResultImagesFor, type ToolResultImageExtraction } from '@/lib/toolResultContent'
import { CircleX } from 'lucide-react'
import { ChevronRight, CirclePause, CircleX, LoaderCircle } from 'lucide-react'
import { ToolCallBlock, formatDuration } from './ToolCallBlock'
import { ThinkingBlock } from './ThinkingBlock'
import { ToolResultImages } from './ToolResultImages'
@@ -10,6 +10,7 @@ import {
activityStepToolCalls,
buildActivitySegments,
countFailedToolCalls,
formatActivitySummary,
hasUnresolvedToolCalls,
toolCallDurationMs,
type ActivityStep,
@@ -41,18 +42,45 @@ type Props = {
* row is marked while the jump's highlight lasts.
*/
revealToolUseId?: string
/** Calls whose permission prompt is waiting on the user. */
awaitingToolUseIds?: ReadonlySet<string>
}
const MAX_HEADER_ICONS = 3
function isAwaiting(toolCall: ToolCall, awaiting?: ReadonlySet<string>): boolean {
if (!awaiting || awaiting.size === 0) return false
return awaiting.has(toolCall.toolUseId) ||
(toolCall.originalToolUseId !== undefined && awaiting.has(toolCall.originalToolUseId))
}
/**
* The vertical rail of a run: a 1px line through the centres of the rows'
* 22px nodes. It runs behind the details too, so an opened diff or terminal
* still reads as hanging off its step.
*/
export function ToolTimeline({ children, className = '' }: { children: ReactNode; className?: string }) {
return (
<div data-tool-timeline="" className={`relative ${className}`}>
<span
aria-hidden="true"
className="pointer-events-none absolute bottom-3 left-[10.5px] top-3 w-px bg-[var(--color-outline)]"
/>
{children}
</div>
)
}
/**
* One contiguous run of thinking + tool calls.
*
* It plays open while it runs, so the reader can watch the work, then folds
* itself into a single counted line the moment it finishes: `thought 5 times,
* read 2 files, ran 1 command`. Standing rows open forever flattened the
* transcript — machinery took as much room as the sentences it produced, and
* nothing looked more important than anything else. A counted digest is small
* enough to skip and specific enough to be worth reading, which a vaguer
* "ran some commands" never was.
* itself into a single counted line the moment it finishes: `思考 2 次、读取
* 1 个文件、运行 1 条命令`. Standing rows open forever flattened the transcript —
* machinery took as much room as the sentences it produced, and nothing looked
* more important than anything else. A counted digest is small enough to skip
* and specific enough to be worth reading, which a vaguer "ran some commands"
* never was.
*
* Clicking pins the reader's choice: from then on that run stays as they left
* it, instead of snapping shut under them when the next step resolves.
@@ -69,6 +97,7 @@ export const ActivityGroup = memo(function ActivityGroup({
isLive = false,
disclosureKey,
revealToolUseId,
awaitingToolUseIds,
}: Props) {
const t = useTranslation()
/** null = follow the run's own state; set = the reader decided. */
@@ -96,6 +125,11 @@ export const ActivityGroup = memo(function ActivityGroup({
Boolean(isStreaming) ||
hasActiveThinking ||
hasUnresolvedToolCalls(toolCalls, resultMap, childToolCallsByParent)
// Only a run that is still being written to may claim to be in progress. A
// transcript whose last call never got a result (the process died) is
// unresolved forever, and a spinner on it would be a lie.
const showsProgress = isRunning && (isLive || Boolean(isStreaming) || hasActiveThinking)
const awaitingCount = toolCalls.filter((toolCall) => !resultMap.has(toolCall.toolUseId) && isAwaiting(toolCall, awaitingToolUseIds)).length
// Keyed off "still being written to", never off "a tool is executing right
// now". The latter flickers: a run of six tools resolves and restarts six
// times, and folding on each gap made the whole block open and shut under the
@@ -103,6 +137,13 @@ export const ActivityGroup = memo(function ActivityGroup({
// Open in the same render the request arrives in, so the row is in the DOM
// for the scroll that follows; the effect above makes it stick.
const collapsed = revealsHere ? false : (pinnedCollapsed ?? !isLive)
const rowProps = {
resultMap,
childToolCallsByParent,
revealToolUseId,
awaitingToolUseIds,
live: showsProgress,
}
const soleToolCall = steps.length === 1 && steps[0]?.kind === 'tool' ? steps[0].toolCall : null
if (soleToolCall) {
@@ -112,12 +153,9 @@ export const ActivityGroup = memo(function ActivityGroup({
data-testid="activity-group"
data-single-step="true"
>
<ActivityToolRow
toolCall={soleToolCall}
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
revealToolUseId={revealToolUseId}
/>
<ToolTimeline>
<ActivityToolRow toolCall={soleToolCall} {...rowProps} />
</ToolTimeline>
</div>
</div>
)
@@ -126,7 +164,12 @@ export const ActivityGroup = memo(function ActivityGroup({
const segments = buildActivitySegments(steps, t)
const elapsed = activityDurationMs(steps, resultMap)
const durationLabel = !isRunning && typeof elapsed === 'number' ? formatDuration(elapsed) : ''
const summaryText = segments.map((segment) => segment.label).join(', ')
const summaryText = formatActivitySummary(segments, t)
// One glyph per kind of work: searching the web and reading a page share the
// globe, and two globes side by side read as a rendering glitch.
const headerIcons = segments
.filter((segment, index) => segments.findIndex((other) => other.icon === segment.icon) === index)
.slice(0, MAX_HEADER_ICONS)
return (
<div>
@@ -134,13 +177,12 @@ export const ActivityGroup = memo(function ActivityGroup({
data-testid="activity-group"
data-expanded={collapsed ? 'false' : 'true'}
data-running={isRunning ? 'true' : 'false'}
className="rounded-[var(--radius-md)]"
>
{/*
Deliberately a whisper: 12px, tertiary, no icons, commas rather than
middots. Its whole job is to be skippable — the prose above and below
it is the conversation, and anything with weight here competes with
that. The counts are what make it worth having at all.
One quiet line: the families' glyphs, the counted sentence, then what
needs the reader — failures in red, a pending approval in amber, live
work in blue — and otherwise the run's duration. The counts are what
make it worth having at all.
*/}
<button
type="button"
@@ -148,54 +190,82 @@ export const ActivityGroup = memo(function ActivityGroup({
onClick={() => setPinnedCollapsed(!collapsed)}
aria-expanded={!collapsed}
title={summaryText}
className="-mx-2 flex w-[calc(100%+1rem)] items-center gap-2 rounded-[var(--radius-md)] px-2 py-1 text-left text-[12px] leading-[1.6] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-secondary)] focus:outline-none focus-visible:shadow-[var(--shadow-focus-ring)]"
className="group/activity flex min-h-[28px] w-full items-center gap-2 rounded-[var(--radius-sm)] text-left text-[13px] focus:outline-none focus-visible:shadow-[var(--shadow-focus-ring)]"
>
<span className="min-w-0 flex-1 truncate">{summaryText}</span>
<span className="flex shrink-0 items-center gap-2">
{failedCount > 0 && (
<span className="flex items-center gap-[5px] whitespace-nowrap font-medium text-[var(--color-error)]">
<CircleX size={12} strokeWidth={2} aria-hidden="true" />
{t('toolGroup.failedCount', { count: failedCount })}
</span>
)}
{durationLabel && (
<span className="whitespace-nowrap font-mono tabular-nums">{durationLabel}</span>
)}
<span aria-hidden="true" className={`w-3 text-center text-[8px] ${collapsed ? '' : 'rotate-90'}`}>▸</span>
<span aria-hidden="true" className="flex shrink-0 items-center gap-0.5 text-[var(--color-text-tertiary)]">
{headerIcons.map((segment) => {
const Icon = segment.icon
return <Icon key={segment.key} size={14} strokeWidth={1.75} />
})}
</span>
<span
data-activity-summary=""
className="min-w-0 flex-1 truncate text-[var(--color-text-secondary)] transition-colors group-hover/activity:text-[var(--color-text-primary)]"
>
{summaryText}
</span>
{failedCount > 0 && (
<span className="inline-flex shrink-0 items-center gap-1 whitespace-nowrap text-[12px] text-[var(--color-error)]">
<CircleX size={12} strokeWidth={2} aria-hidden="true" />
{t('toolGroup.failedCount', { count: failedCount })}
</span>
)}
{awaitingCount > 0 ? (
<span className="inline-flex shrink-0 items-center gap-1 whitespace-nowrap text-[12px] font-medium text-[var(--color-on-warning-container)]">
<CirclePause size={12} strokeWidth={2} aria-hidden="true" />
{t('permission.awaitingApproval')}
</span>
) : showsProgress ? (
<span className="inline-flex shrink-0 items-center gap-1 whitespace-nowrap text-[12px] text-[var(--color-info)]">
<LoaderCircle size={12} strokeWidth={2} className="animate-spin" aria-hidden="true" />
{t('agentStatus.running')}
</span>
) : durationLabel ? (
<span className="shrink-0 whitespace-nowrap font-mono text-[11px] tabular-nums text-[var(--color-text-tertiary)]">{durationLabel}</span>
) : null}
<ChevronRight
size={14}
strokeWidth={1.75}
aria-hidden="true"
className={`shrink-0 text-[var(--color-text-tertiary)] transition-transform duration-150 ${collapsed ? '' : 'rotate-90'}`}
/>
</button>
{/* Folding hides the rows, and with them any picture a tool returned.
Open, each row shows its own, so these exist only while folded. */}
{collapsed ? <CollapsedRunImages toolCalls={toolCalls} resultMap={resultMap} /> : null}
{/* Rows hang off the summary that names them, so they take the guide
line and its indent. That is also why they can afford it here and not
when there is no summary — nothing to hang from, nothing to indent. */}
{!collapsed && (
<div className="ml-[3px] flex flex-col border-l border-[var(--color-border)] pl-3">
<ToolTimeline className="mt-0.5">
{steps.map((step) => step.kind === 'thinking' ? (
<ThinkingBlock
<TimelineThinking
key={step.message.id}
content={step.message.content}
isActive={step.message.id === activeThinkingId}
/>
) : (
<ActivityToolRow
key={step.toolCall.id}
toolCall={step.toolCall}
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
revealToolUseId={revealToolUseId}
/>
<ActivityToolRow key={step.toolCall.id} toolCall={step.toolCall} {...rowProps} />
))}
</div>
</ToolTimeline>
)}
</div>
</div>
)
})
/** A thought on the rail: a small hollow stop, its row hung on the text column. */
function TimelineThinking({ content, isActive }: { content: string; isActive: boolean }) {
return (
<div className="relative pl-[30px]">
<span
aria-hidden="true"
className="absolute left-[7px] top-[10px] h-2 w-2 rounded-full border border-[var(--color-outline)] bg-[var(--color-surface)]"
/>
<ThinkingBlock content={content} isActive={isActive} />
</div>
)
}
type RunImageStrip = ToolResultImageExtraction & {
id: string
toolName: string
@@ -255,17 +325,22 @@ function CollapsedRunImages({
)
}
/** A tool row plus, indented under it, the rows of anything it dispatched. */
/** A tool row plus, indented under it on its own rail, the rows of anything it dispatched. */
function ActivityToolRow({
toolCall,
resultMap,
childToolCallsByParent,
revealToolUseId,
awaitingToolUseIds,
live = false,
}: {
toolCall: ToolCall
resultMap: Map<string, ToolResult>
childToolCallsByParent: Map<string, ToolCall[]>
revealToolUseId?: string
awaitingToolUseIds?: ReadonlySet<string>
/** The run is still being written to, so a resultless call is executing. */
live?: boolean
}) {
const result = resultMap.get(toolCall.toolUseId)
const childToolCalls = childToolCallsByParent.get(toolCall.toolUseId) ?? []
@@ -283,18 +358,22 @@ function ActivityToolRow({
durationMs={toolCallDurationMs(toolCall, result)}
toolUseId={toolCall.parentToolUseId ? undefined : toolCall.toolUseId}
navigationHighlighted={revealToolUseId !== undefined && toolCall.toolUseId === revealToolUseId}
awaitingApproval={!result && isAwaiting(toolCall, awaitingToolUseIds)}
running={live && !result && toolCall.status !== 'stopped'}
/>
{childToolCalls.length > 0 && (
<div className="ml-2 border-l border-[var(--color-border)] pl-3">
<ToolTimeline className="ml-[30px]">
{childToolCalls.map((childToolCall) => (
<ActivityToolRow
key={childToolCall.id}
toolCall={childToolCall}
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
awaitingToolUseIds={awaitingToolUseIds}
live={live}
/>
))}
</div>
</ToolTimeline>
)}
</div>
)
@@ -919,8 +919,7 @@ describe('AskUserQuestion', () => {
expect((screen.getByPlaceholderText('Type your answer...') as HTMLTextAreaElement).value)
.toBe('custom q2')
fireEvent.click(screen.getByRole('button', { name: /Q1$/ }))
expect(screen.getByRole('button', { name: /^A1$/ }).getAttribute('class'))
.toContain('border-[var(--color-secondary)]')
expect(screen.getByRole('button', { name: /^A1$/ }).getAttribute('aria-pressed')).toBe('true')
})
it('does not resurrect the form once the question was submitted', () => {
+140 -149
View File
@@ -6,6 +6,8 @@ import {
} from '../../stores/chatStore'
import { useTabStore } from '../../stores/tabStore'
import { useTranslation } from '../../i18n'
import { ArrowRight, Check, CircleCheck, Info, MessageCircleQuestion, MessagesSquare, Send } from 'lucide-react'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import {
ASK_USER_QUESTION_CLARIFY_WITH_QUESTIONS_PREFIX,
@@ -439,47 +441,40 @@ export function AskUserQuestion({
const activeAnswered =
Boolean(freeTexts[safeActiveTab]?.trim()) || (selections[safeActiveTab]?.length ?? 0) > 0
const statusBadgeKey = automaticallyAnswered
? 'question.autoAnswered'
: hasSentAsMessage || expired
? 'question.expiredBadge'
: hasRequestedChat
? 'question.chatBadge'
: terminalWithoutAnswers ? 'question.completed' : 'question.answered'
return (
<div className={`rounded-[var(--radius-lg)] border overflow-hidden ${
submitted
? 'border-[var(--color-border)] bg-[var(--color-surface-container-low)] opacity-70'
: 'border-[var(--color-secondary)] bg-[var(--color-surface-container-lowest)]'
<div className={`overflow-hidden rounded-[var(--radius-lg)] border bg-[var(--color-surface-container-lowest)] ${
submitted ? 'border-[var(--color-border)]' : 'border-[var(--color-outline)]'
}`}>
{/* Header */}
<div className={`flex items-center gap-3 px-4 py-3 ${
submitted
? '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-container)]">
<span className="material-symbols-outlined text-[18px] text-[var(--color-secondary)]">
help
{/* Header: 42px, the shared card head of the ask / plan cards. */}
<div className={`flex h-[42px] items-center gap-2 px-3.5 ${submitted ? '' : 'border-b border-[var(--color-border)]'}`}>
<MessageCircleQuestion aria-hidden="true" size={15} strokeWidth={1.75} className="shrink-0 text-[var(--color-text-tertiary)]" />
<span className="min-w-0 truncate text-[13px] font-semibold text-[var(--color-text-primary)]">
{t('question.needsInput')}
</span>
{(submitted || expired) && (
// handing the question back is not an answer — saying "answered"
// there misreports what the user did; and an expired question was
// never answered at all, whatever happened to the answers
<Badge className="shrink-0">{t(statusBadgeKey)}</Badge>
)}
{!submitted && questions.length > 1 && (
<span className="ml-auto shrink-0 font-mono text-xs tabular-nums text-[var(--color-text-tertiary)]">
<span className="font-medium text-[var(--color-text-primary)]">{safeActiveTab + 1}</span> / {questions.length}
</span>
</div>
<div className="flex-1 min-w-0">
<span className="text-sm font-semibold text-[var(--color-text-primary)]">
{t('question.needsInput')}
</span>
{(submitted || expired) && (
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)]">
{/* handing the question back is not an answer — saying "answered"
there misreports what the user did; and an expired question was
never answered at all, whatever happened to the answers */}
{t(automaticallyAnswered
? 'question.autoAnswered'
: hasSentAsMessage || expired
? 'question.expiredBadge'
: hasRequestedChat
? 'question.chatBadge'
: terminalWithoutAnswers ? 'question.completed' : 'question.answered')}
</span>
)}
</div>
)}
</div>
{/* Question tabs — horizontal tab bar (only show when multiple questions) */}
{questions.length > 1 && (
<div className="flex px-4 border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)] overflow-x-auto">
{/* Question tabs (only when there are several questions) */}
{!submitted && questions.length > 1 && (
<div className="flex gap-0.5 overflow-x-auto px-2.5 pt-2">
{questions.map((q, i) => {
const isActive = safeActiveTab === i
const isAnswered = Boolean(freeTexts[i]?.trim()) || (selections[i]?.length ?? 0) > 0
@@ -487,20 +482,19 @@ export function AskUserQuestion({
return (
<button
key={i}
type="button"
onClick={() => setActiveTab(i)}
className={`relative flex items-center gap-1.5 px-4 py-2.5 text-xs font-medium whitespace-nowrap transition-colors ${
aria-current={isActive || undefined}
className={`flex h-7 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-[var(--radius-sm)] px-2.5 text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${
isActive
? 'text-[var(--color-secondary)]'
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]'
? 'bg-[var(--color-surface-container)] font-medium text-[var(--color-text-primary)]'
: 'text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)]'
}`}
>
{isAnswered && (
<span className="material-symbols-outlined text-[14px] text-[var(--color-success)]">check_circle</span>
<CircleCheck aria-hidden="true" size={12} strokeWidth={2} className="text-[var(--color-success)]" />
)}
{tabLabel}
{isActive && (
<div className="absolute bottom-0 left-2 right-2 h-[2px] bg-[var(--color-secondary)] rounded-t" />
)}
</button>
)
})}
@@ -508,81 +502,80 @@ export function AskUserQuestion({
)}
{/* Active question content */}
<div className="px-4 py-3">
{/* Nothing is waiting on this question any more. The form below stays
usable on purpose — the answers are still worth sending — but which
channel they take changes, and that has to be visible. */}
{expired && (
<div
role="status"
aria-live="polite"
className="mb-3 flex items-start gap-2 text-xs text-[var(--color-text-secondary)]"
>
<span className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)]" aria-hidden="true">
info
</span>
<span>{t('question.expiredNotice')}</span>
</div>
)}
<p className="text-sm font-medium text-[var(--color-text-primary)] mb-3">
{activeQuestion.question}
</p>
{!submitted && (
<div className="px-2.5 pt-3">
{/* Nothing is waiting on this question any more. The form below stays
usable on purpose — the answers are still worth sending — but which
channel they take changes, and that has to be visible. */}
{expired && (
<div
role="status"
aria-live="polite"
className="mx-1 mb-3 flex items-start gap-2 rounded-[var(--radius-md)] bg-[var(--color-surface-container)] px-3 py-2 text-xs text-[var(--color-text-secondary)]"
>
<Info aria-hidden="true" size={14} strokeWidth={1.75} className="mt-px shrink-0 text-[var(--color-text-tertiary)]" />
<span>{t('question.expiredNotice')}</span>
</div>
)}
<p className="mb-2 px-1 text-sm font-medium text-[var(--color-text-primary)]">
{activeQuestion.question}
</p>
{/* Option cards */}
{activeQuestion.options && activeQuestion.options.length > 0 && (
<div className="space-y-2 mb-3">
{activeQuestion.options.map((opt, optIndex) => {
const isSelected = selections[safeActiveTab]?.includes(opt.label) ?? false
const isMultiSelect = activeQuestion.multiSelect === true
return (
<button
key={optIndex}
onClick={() => handleSelect(safeActiveTab, opt.label)}
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-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">
{/* Selection indicator */}
<div className={`mt-0.5 flex-shrink-0 w-4 h-4 rounded-full border-2 flex items-center justify-center transition-colors ${
{/* Option cards: hairline at rest, a 1.5px terracotta edge on the
faint brand wash when picked. */}
{activeQuestion.options && activeQuestion.options.length > 0 && (
<div className="grid gap-1.5">
{activeQuestion.options.map((opt, optIndex) => {
const isSelected = selections[safeActiveTab]?.includes(opt.label) ?? false
const isMultiSelect = activeQuestion.multiSelect === true
return (
<button
key={optIndex}
type="button"
onClick={() => handleSelect(safeActiveTab, opt.label)}
disabled={submitted}
aria-pressed={isSelected}
className={`flex w-full cursor-pointer items-start gap-2.5 rounded-[var(--radius-md)] px-3 py-2.5 text-left transition-[background-color,box-shadow] duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${
isSelected
? 'border-[var(--color-secondary)] bg-[var(--color-secondary)]'
: 'border-[var(--color-outline)]'
} ${isMultiSelect ? 'rounded-[var(--radius-xs)]' : 'rounded-full'}`}>
{isSelected && (
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
)}
</div>
<div className="flex-1 min-w-0">
<span className={`text-sm font-medium ${
? 'bg-[var(--color-brand-soft)] shadow-[inset_0_0_0_1.5px_var(--color-brand)]'
: 'shadow-[inset_0_0_0_1px_var(--color-border)] hover:shadow-[inset_0_0_0_1px_var(--color-outline)]'
}`}
>
{/* Selection indicator: a ring that fills in for single
choice, a checkbox for multi-select. */}
<span
aria-hidden="true"
className={`mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center transition-[background-color,box-shadow] ${
isMultiSelect ? 'rounded-[var(--radius-xs)]' : 'rounded-full'
} ${
isSelected
? 'text-[var(--color-secondary)]'
: 'text-[var(--color-text-primary)]'
}`}>
? isMultiSelect
? 'bg-[var(--color-brand)] text-[var(--color-on-primary)]'
: 'shadow-[inset_0_0_0_5px_var(--color-brand)]'
: 'shadow-[inset_0_0_0_1.5px_var(--color-border-strong)]'
}`}
>
{isSelected && isMultiSelect ? <Check size={12} strokeWidth={2.5} /> : null}
</span>
<span className="min-w-0 flex-1">
<span className="block text-[13px] font-medium text-[var(--color-text-primary)]">
{opt.label}
</span>
{opt.description && (
<p className="text-xs text-[var(--color-text-secondary)] mt-0.5">
<span className="mt-0.5 block text-xs text-[var(--color-text-tertiary)]">
{opt.description}
</p>
</span>
)}
</div>
</div>
</button>
)
})}
</div>
)}
</span>
</button>
)
})}
</div>
)}
{/* Free text input */}
{!submitted && (
<div>
<label className="text-xs text-[var(--color-text-tertiary)] mb-1.5 block">
{/* Free text input: the sunken answer slot. */}
<div className="mt-2.5">
<label className="mb-1 block px-1 text-xs text-[var(--color-text-tertiary)]">
{t('question.customResponse')}
</label>
<textarea
@@ -600,63 +593,63 @@ export function AskUserQuestion({
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-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)]"
className="max-h-48 min-h-[72px] w-full resize-y rounded-[var(--radius-md)] border border-transparent bg-[var(--color-surface-container)] px-3 py-2.5 text-[13px] leading-relaxed text-[var(--color-text-primary)] transition-colors placeholder:text-[var(--color-text-tertiary)] focus:border-[var(--color-outline)] focus:bg-[var(--color-surface-container-lowest)] focus:outline-none"
/>
</div>
)}
</div>
)}
{/* Submitted answer display — the chat handoff wins over any terminal
result, whose text is the deny payload and not worth showing. */}
{submitted && (hasSentAsMessage ? (
<div className="flex items-center gap-2 text-xs text-[var(--color-text-secondary)]">
<span className="material-symbols-outlined text-[14px] text-[var(--color-secondary)]">send</span>
<span>
{t('question.sentAsMessagePrefix')}<strong>{answeredText}</strong>
</span>
</div>
) : hasRequestedChat ? (
<div className="flex items-center gap-2 text-xs text-[var(--color-text-secondary)]">
<span className="material-symbols-outlined text-[14px] text-[var(--color-secondary)]">forum</span>
<span>{t('question.chatRequested')}</span>
</div>
) : (
<div className="flex items-center gap-2 text-xs text-[var(--color-text-secondary)]">
<span className="material-symbols-outlined text-[14px] text-[var(--color-success)]">check_circle</span>
<span>
{t(terminalWithoutAnswers ? 'question.resultPrefix' : 'question.answeredPrefix')}<strong>{answeredText}</strong>
</span>
</div>
))}
</div>
{/* Submitted answer display — the chat handoff wins over any terminal
result, whose text is the deny payload and not worth showing. */}
{submitted && (
<div className="border-t border-[var(--color-border)] px-3.5 py-2.5">
<p className="mb-1 truncate text-xs text-[var(--color-text-tertiary)]">{activeQuestion.question}</p>
{hasSentAsMessage ? (
<div className="flex items-center gap-2 text-[13px] text-[var(--color-text-secondary)]">
<Send aria-hidden="true" size={14} strokeWidth={1.75} className="shrink-0 text-[var(--color-text-tertiary)]" />
<span>
{t('question.sentAsMessagePrefix')}<strong className="font-medium text-[var(--color-text-primary)]">{answeredText}</strong>
</span>
</div>
) : hasRequestedChat ? (
<div className="flex items-center gap-2 text-[13px] text-[var(--color-text-secondary)]">
<MessagesSquare aria-hidden="true" size={14} strokeWidth={1.75} className="shrink-0 text-[var(--color-text-tertiary)]" />
<span>{t('question.chatRequested')}</span>
</div>
) : (
<div className="flex items-center gap-2 text-[13px] text-[var(--color-text-secondary)]">
<CircleCheck aria-hidden="true" size={14} strokeWidth={1.75} className="shrink-0 text-[var(--color-success)]" />
<span>
{t(terminalWithoutAnswers ? 'question.resultPrefix' : 'question.answeredPrefix')}<strong className="font-medium text-[var(--color-text-primary)]">{answeredText}</strong>
</span>
</div>
)}
</div>
)}
{/* Action bar. Wraps rather than overflows: the buttons plus a translated
label (kr/jp run long) can outgrow a narrow side-by-side pane. Next is
pushed to the end of the row and wraps alone onto a second line when
it runs out of room. */}
{!submitted && (
<div className="flex flex-wrap items-center gap-2 px-4 py-3 border-t border-[var(--color-border)] bg-[var(--color-surface-container-low)]">
<div className="flex flex-wrap items-center gap-2 px-3.5 pb-3.5 pt-3">
<Button
variant="primary"
size="sm"
size="base"
// `expired` is the one state where a button may work without a live
// request: it sends the answers as a message instead of answering.
disabled={!allAnswered || (!expired && !pendingRequest)}
onClick={handleSubmit}
icon={
<span className="material-symbols-outlined text-[14px]">send</span>
}
>
{t(expired ? 'question.sendAsMessage' : 'question.submit')}
</Button>
<Button
variant="secondary"
size="sm"
variant="ghost"
size="base"
disabled={!expired && !pendingRequest}
onClick={handleChatAboutThis}
title={t('question.chatAboutThisHint')}
icon={
<span className="material-symbols-outlined text-[14px]">forum</span>
}
icon={<MessagesSquare aria-hidden="true" size={14} strokeWidth={1.75} />}
>
{t('question.chatAboutThis')}
</Button>
@@ -665,14 +658,12 @@ export function AskUserQuestion({
the action that actually ends the exchange. */}
{questions.length > 1 && safeActiveTab < questions.length - 1 && (
<Button
variant="tonal"
size="sm"
variant="secondary"
size="base"
className="ml-auto"
disabled={!activeAnswered}
onClick={() => setActiveTab(safeActiveTab + 1)}
icon={
<span className="material-symbols-outlined text-[14px]">arrow_forward</span>
}
icon={<ArrowRight aria-hidden="true" size={14} strokeWidth={1.75} />}
iconPosition="end"
>
{t('question.next')}
@@ -64,7 +64,7 @@ export function AssistantOutputTargetCard({ target, sessionId, workDir }: Props)
}, [openWith, sessionId, t, target.href, workDir])
return (
<section className="flex items-stretch overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] shadow-[var(--shadow-card)]">
<section className="flex items-stretch overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]">
{/* The whole row body is the hit area, not just the trailing icon button:
the icon is a discoverability affordance. Everything inside is a span
because a `button` may only contain phrasing content. */}
@@ -72,50 +72,48 @@ export function AssistantOutputTargetCard({ target, sessionId, workDir }: Props)
type="button"
onClick={handleOpen}
aria-label={t('assistantOutputs.openAria', { title: target.title })}
className="flex min-w-0 flex-1 cursor-pointer items-start gap-3 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)]"
className="flex min-w-0 flex-1 cursor-pointer items-center gap-3 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)]"
>
<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)]">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-[var(--radius-md)] bg-[var(--color-surface-container)] text-[var(--color-text-secondary)]">
{isLocalhost ? (
<Globe size={17} strokeWidth={2.1} aria-hidden="true" />
<Globe size={15} strokeWidth={1.75} aria-hidden="true" />
) : (
<FileTypeIcon path={target.normalizedPath ?? target.href} size={26} />
<FileTypeIcon path={target.normalizedPath ?? target.href} size={16} />
)}
</span>
<span className="flex min-w-0 flex-1 flex-col">
<span className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-semibold text-[var(--color-text-primary)]">
<span className="truncate text-[13px] font-medium text-[var(--color-text-primary)]">
{target.title}
</span>
<Badge bordered className="font-semibold uppercase tracking-[0.08em]">
<Badge>
{badge}
</Badge>
</span>
{showSubtitle && (
<span className="mt-1 truncate text-xs text-[var(--color-text-tertiary)]" title={subtitle}>
<span className="mt-0.5 truncate font-mono text-[11px] text-[var(--color-text-tertiary)]" title={subtitle}>
{subtitle}
</span>
)}
</span>
</button>
<div className="flex shrink-0 items-center gap-1.5 pr-3">
<div className="flex shrink-0 items-center gap-1 pr-2">
<IconButton
icon={<ExternalLink size={14} strokeWidth={2.2} aria-hidden="true" />}
icon={<ExternalLink size={14} strokeWidth={1.75} aria-hidden="true" />}
label={t('assistantOutputs.open')}
size="md"
tone="secondary"
shape="circle"
filled
size="sm"
tone="muted"
onClick={handleOpen}
/>
<Button
variant="secondary"
size="base"
size="sm"
aria-label={t('openWith.title')}
onClick={handleOpenWith}
className="shrink-0 rounded-full"
icon={<ChevronDown size={13} strokeWidth={2.2} aria-hidden="true" />}
className="shrink-0"
icon={<ChevronDown size={12} strokeWidth={2} aria-hidden="true" />}
iconPosition="end"
>
{t('openWith.title')}
@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react'
import type { MouseEvent as ReactMouseEvent } from 'react'
import { ChevronDown, MessageSquare, X } from 'lucide-react'
import { ChevronDown, Folder, MessageSquare, MousePointerClick, X } from 'lucide-react'
import { useTranslation, type TranslationKey } from '../../i18n'
import { attachmentImageSource } from '../../lib/attachmentImages'
import { getDesktopHost } from '../../lib/desktopHost'
@@ -158,7 +158,7 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove }
<div
data-testid="preview-selection-batch"
aria-label={t('attachments.selectionBatch', { count: attachments.length })}
className="grid w-full max-w-[520px] grid-cols-2 gap-px overflow-hidden rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-border)] shadow-[var(--shadow-card)]"
className="grid w-full max-w-[520px] grid-cols-2 gap-px overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-border)]"
>
{attachments.map((attachment, index) => {
const src = attachmentImageSource(attachment)
@@ -201,7 +201,7 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove }
</Badge>
</span>
<span className="flex min-h-11 min-w-0 items-center gap-2 border-t border-[var(--color-border)] px-2.5 py-1.5">
<span className="shrink-0 font-mono text-[11px] font-semibold text-[var(--color-text-secondary)]">
<span className="shrink-0 font-mono text-[11px] font-medium text-[var(--color-text-secondary)]">
{attachment.name}
</span>
{note && (
@@ -235,7 +235,7 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove }
className={
isComposer
? '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'
: 'overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-left transition-[border-color] hover:border-[var(--color-outline)] motion-reduce:transition-none'
}
>
<img
@@ -260,7 +260,7 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove }
className="max-w-[min(340px,calc(100vw-3rem))] text-[13px]"
content={
<>
<span className="block text-[11px] font-medium uppercase tracking-wide opacity-70">
<span className="block text-[11px] font-medium opacity-70">
{t('attachments.selectionNoteTitle')}
</span>
<span className="mt-1 block whitespace-pre-wrap break-words">
@@ -276,14 +276,12 @@ 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-[var(--shadow-card)]',
'text-[12px] font-medium leading-none text-[var(--color-text-primary)]',
'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)]">
ads_click
</span>
<MousePointerClick size={14} strokeWidth={1.75} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
<span className="min-w-0 truncate">{attachment.name}</span>
</span>
</Tooltip>
@@ -292,10 +290,10 @@ 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-[var(--color-on-error)] opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
className="absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full bg-[var(--color-error)] 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 })}
>
×
<X size={12} strokeWidth={2} aria-hidden="true" />
</button>
)}
</div>
@@ -321,7 +319,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-[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)]"
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-lowest)] px-2.5 py-2 text-left"
>
<MessageSquare aria-hidden="true" size={15} className="mt-0.5 shrink-0 text-[var(--color-text-tertiary)]" />
<span className="min-w-0 flex-1">
@@ -377,14 +375,14 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove }
style={{ color: fileIconAccent(fileIcon) }}
>
{attachment.isDirectory
? <span className="material-symbols-outlined text-[19px]">{fileIcon}</span>
? <Folder size={18} strokeWidth={1.75} aria-hidden="true" />
: <FileTypeIcon path={attachment.path || attachment.name} size={20} />}
</span>
<span className="min-w-0 flex-1">
<span className="block min-w-0 max-w-[260px] truncate text-[13px] font-semibold leading-5 text-[var(--color-text-primary)]">
<span className="block min-w-0 max-w-[260px] truncate text-[13px] font-medium leading-5 text-[var(--color-text-primary)]">
{attachment.name}{lineLabel}
</span>
<span className="block truncate text-[10px] font-semibold uppercase leading-3 tracking-[0.08em] text-[var(--color-text-tertiary)]">
<span className="block truncate text-[11px] leading-4 text-[var(--color-text-tertiary)]">
{typeLabel}
</span>
{hasQuotePreview && (
@@ -402,7 +400,7 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove }
data-file-extension={typeInfo.ext || undefined}
className={[
'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)]',
'bg-[var(--color-surface-container-lowest)] text-[var(--color-text-secondary)]',
'transition-colors hover:border-[var(--color-outline)] hover:bg-[var(--color-surface-container)]',
].join(' ')}
>
@@ -433,14 +431,13 @@ 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-[var(--radius-lg)] bg-[var(--color-surface-container-low)] px-2.5 py-1.5 text-[var(--color-text-secondary)]',
'shadow-[var(--shadow-card)]',
'rounded-[var(--radius-lg)] bg-[var(--color-surface-container-lowest)] px-2.5 py-1.5 text-[var(--color-text-secondary)]',
].join(' ')}
>
{fileVisual}
{onRemove && attachment.id && (
<IconButton
icon={<span className="material-symbols-outlined text-[17px]" aria-hidden="true">close</span>}
icon={<X size={14} strokeWidth={1.75} aria-hidden="true" />}
label={t('attachments.remove', { name: attachment.name })}
size="2xs"
tone="muted"
+32 -23
View File
@@ -1134,16 +1134,17 @@ describe('ChatInput file mentions', () => {
render(<ChatInput variant="hero" />)
const panel = screen.getByTestId('chat-input-panel')
// 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')
// The floating card (「素」): `--radius-xl` + the composer step of the
// shadow scale, the same shell EmptySession renders.
expect(panel).toHaveClass('rounded-[var(--radius-xl)]', 'glass-panel--composer')
expect(panel).not.toHaveClass('rounded-b-none')
// One pill in the toolbar instead of a three-button bar welded to the
// panel's bottom edge — which is what used to square off that edge.
const pill = await screen.findByRole('button', { name: 'Location: repo / main' })
expect(panel).toContainElement(pill)
expect(pill).toHaveClass('h-9')
// A 28px toolbar control like its neighbours, not a bordered pill.
expect(pill).toHaveClass('h-7')
expect(screen.queryByText('Select a project...')).not.toBeInTheDocument()
await openLocationMenu()
@@ -1280,7 +1281,9 @@ describe('ChatInput file mentions', () => {
const send = screen.getByRole('button', { name: 'Run' })
expect(send).toHaveClass('rounded-full', 'h-8', 'w-8')
expect(send).toHaveTextContent('arrow_upward')
// Terracotta is the send key's alone.
expect(send).toHaveClass('bg-[var(--color-brand)]')
expect(send.querySelector('[data-icon="send"]')).toBeInTheDocument()
await act(async () => {
useChatStore.setState({
@@ -1293,7 +1296,11 @@ describe('ChatInput file mentions', () => {
const stop = screen.getByRole('button', { name: 'Stop' })
expect(stop).toHaveClass('rounded-full', 'h-8', 'w-8')
expect(stop).toHaveTextContent('stop')
// Mid-turn the same circle turns ink with a filled square — a running turn
// must not read as the error-red danger button.
expect(stop).toHaveClass('bg-[var(--color-btn-primary-bg)]')
expect(stop).not.toHaveClass('bg-[var(--color-error)]')
expect(stop.querySelector('[data-icon="stop"]')).toBeInTheDocument()
expect(stop).not.toBeDisabled()
})
@@ -1465,18 +1472,18 @@ describe('ChatInput file mentions', () => {
expect(screen.queryByTestId('run-location-outside')).not.toBeInTheDocument()
})
// The band cancels the panel's `p-3`, so it has to follow the panel's padding
// rather than the control layout. A wide column beside an open panel renders
// the wide toolbar inside a `p-3` panel; keying the band on the controls would
// have inset the divider by 12px there.
it('keeps the toolbar band matched to the panel padding when a wide column sits beside a panel', async () => {
// The card has one geometry at every width: the old narrow-layout band
// (`-mx-3` cancelling a `p-3` panel) is gone with the divider it drew, so a
// wide column beside a panel and a full-width column render the same card.
it('keeps one card geometry when a wide column sits beside a panel', async () => {
stubComposerColumnWidth(580)
render(<ChatInput compact />)
await screen.findByTestId('run-location-readonly')
expect(screen.getByTestId('chat-input-panel')).toHaveClass('p-3')
expect(screen.getByTestId('chat-input-toolbar')).toHaveClass('-mx-3')
expect(screen.getByTestId('chat-input-panel')).toHaveClass('p-2')
expect(screen.getByTestId('chat-input-toolbar')).toHaveClass('pt-1.5')
expect(screen.getByTestId('chat-input-toolbar').className).not.toMatch(/-m[xy]-\d/)
})
it('uses the persisted message count to keep reopened sessions in context mode while history loads', async () => {
@@ -2771,7 +2778,7 @@ describe('ChatInput file mentions', () => {
// 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')).toHaveClass('rounded-[var(--radius-xl)]')
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')
@@ -2797,7 +2804,7 @@ describe('ChatInput file mentions', () => {
const toolbar = screen.getByTestId('chat-input-toolbar')
expect(toolbar).not.toHaveClass('absolute')
expect(toolbar).toHaveClass('mt-3')
expect(toolbar).toHaveClass('pt-1.5')
expect(input).not.toHaveClass('pb-12')
expect(input).not.toHaveClass('pb-14')
})
@@ -2807,26 +2814,28 @@ describe('ChatInput file mentions', () => {
// same place in both. The live one used to weld itself to the panel edge
// with `-mx-4 -mb-4`, which pulled every control 4px left and stretched the
// divider across the panel the moment the first message landed.
it('keeps the wide composer toolbar inset when a draft turns into a live session', async () => {
it('keeps the composer toolbar in place when a draft turns into a live session', async () => {
const { unmount } = render(<ChatInput variant="hero" />)
const draftToolbar = screen.getByTestId('chat-input-toolbar')
expect(draftToolbar).toHaveClass('pt-3')
const draftPanel = screen.getByTestId('chat-input-panel')
const draftGeometry = { toolbar: draftToolbar.className, panel: draftPanel.className }
expect(draftToolbar.className).not.toMatch(/-m[xy]-\d/)
unmount()
const live = render(<ChatInput variant="default" />)
const liveToolbar = screen.getByTestId('chat-input-toolbar')
expect(liveToolbar).toHaveClass('pt-3')
expect(liveToolbar.className).not.toMatch(/-m[xy]-\d/)
// Same row, same card: nothing shifts when the first message lands.
expect(screen.getByTestId('chat-input-toolbar').className).toBe(draftGeometry.toolbar)
expect(screen.getByTestId('chat-input-panel').className).toBe(draftGeometry.panel)
live.unmount()
// The narrow composer keeps the band: `p-3` leaves too little room to
// spend on inset, and it never swaps variants mid-session.
// The narrow composer is the same card too; only the shell's outer
// padding follows `compact`.
render(<ChatInput compact />)
expect(screen.getByTestId('chat-input-toolbar')).toHaveClass('-mx-3')
expect(screen.getByTestId('chat-input-toolbar').className).toBe(draftGeometry.toolbar)
expect(screen.getByTestId('chat-input-toolbar').className).not.toMatch(/-m[xy]-\d/)
})
// The hero row is `flex`, and a paragraph holding an unbreakable run (a
+72 -88
View File
@@ -5,6 +5,7 @@ import { getSessionReferences } from '@/lib/composerMentions'
import { normalizeSessionReferences } from '@/lib/sessionReferences'
import { isComposerReferenceVisible, isComposerSlashCommandVisible } from '@/lib/composerCapabilityVisibility'
import { useState, useRef, useEffect, useCallback, useMemo, useId } from 'react'
import { ArrowUp, CornerDownRight, Pencil, Plus, Square, Trash2 } from 'lucide-react'
import { useDismissable } from '@/hooks/useDismissable'
import { Button } from '@/components/ui/Button'
import { IconButton } from '@/components/ui/IconButton'
@@ -335,17 +336,11 @@ export function ChatInput({ variant = 'default', compact = false, sessionId, vis
const isHeroComposer = variant === 'hero' && !isMemberSession && !compact
const resolvedWorkDir = activeSession?.workDir || gitInfo?.workDir || undefined
const showLaunchControls = !isMemberSession && !sideChat && messageCount === 0
// Two different questions, and they used to share one answer.
//
// `useCompactChrome` is about context: the shell's padding, its top divider
// and the toolbar's edge-to-edge band belong to the panel-beside-the-composer
// and mobile layouts regardless of how much room those layouts got.
//
// `useCompactControls` is about room, so it asks the column how wide it is.
// Until a measurement lands (jsdom, first paint) it defers to the caller's
// `compact`, which keeps the pre-measurement frame from flashing the wrong
// layout.
const useCompactChrome = compact || isMobileComposer
// `useCompactControls` is about room, so it asks the column how wide it is —
// never "is a side panel open". Until a measurement lands (jsdom, first
// paint) it defers to the caller's `compact`, which keeps the
// pre-measurement frame from flashing the wrong layout. (The card itself has
// one geometry everywhere; only the shell's outer padding follows `compact`.)
const fitsAtLeast = (minWidth: number) => shellWidth === null ? !compact : shellWidth >= minWidth
const useCompactControls = isMobileComposer || !fitsAtLeast(TOOLBAR_LOCATION_MIN_WIDTH)
const activeLaunchWorkDir = showLaunchControls ? (launchWorkDir || resolvedWorkDir || '') : (resolvedWorkDir || '')
@@ -1280,12 +1275,18 @@ export function ChatInput({ variant = 'default', compact = false, sessionId, vis
ref={shellRef}
data-testid="chat-input-shell"
data-session-id={activeTabId ?? undefined}
// The docked composer floats over the end of the transcript: no top
// divider, and `composer-fade` dissolves the last 28px of the thread into
// the page ground above the card instead of cutting it off. The hero
// composer sits in open space, so it needs neither.
className={
isHeroComposer
? `bg-[var(--color-surface)] ${isMobileComposer ? 'px-4 pb-3' : 'px-8 pb-4'}`
: compact
? `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'}`
: `composer-fade bg-[var(--color-surface)] ${
isMobileComposer
? 'px-3 pb-[calc(env(safe-area-inset-bottom)+10px)] pt-1'
: compact ? 'px-4 pb-4 pt-1' : 'px-6 pb-5 pt-1'
}`
}
>
<div
@@ -1299,19 +1300,12 @@ export function ChatInput({ variant = 'default', compact = false, sessionId, vis
<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
// Always fully rounded now: the launch controls used to be a bar
// welded to the panel's bottom edge, which is what squared it off.
// They are a single pill today — in the toolbar, or on their own
// line below — so nothing butts against the panel any more.
? `glass-panel glass-panel--composer relative flex flex-col gap-3 overflow-visible rounded-[var(--radius-2xl)] p-4 transition-colors ${isDragActive ? 'composer-drop-target-active' : ''}`
: compact
? `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' : ''}`}
// The floating card (「素」): `glass-panel--composer` supplies the
// opaque lifted fill, the hairline and the composer step of the shadow
// scale; `--radius-xl` is the composer corner. One geometry for every
// variant — the editor and the toolbar carry their own insets, so a
// draft turning into a live session moves nothing.
className={`glass-panel glass-panel--composer relative flex flex-col overflow-visible rounded-[var(--radius-xl)] p-2 ${isDragActive ? 'composer-drop-target-active' : ''}`}
{...dragHandlers}
>
{isDragActive && (
@@ -1396,7 +1390,7 @@ export function ChatInput({ variant = 'default', compact = false, sessionId, vis
// rather than as messages waiting their turn.
<div
data-testid="pending-user-message-list"
className={`flex flex-col gap-1.5 ${isHeroComposer ? '' : 'mb-2'}`}
className="flex flex-col gap-1.5 px-1 pb-1 pt-1"
>
{queuedUserMessages.map((message) => {
const isEditing = editingQueuedMessageId === message.id
@@ -1405,11 +1399,11 @@ export function ChatInput({ variant = 'default', compact = false, sessionId, vis
key={message.id}
data-testid="pending-user-message"
className={[
'flex min-w-0 items-center gap-2.5 rounded-[var(--radius-lg)] px-3.5 py-2',
'flex min-h-9 min-w-0 items-center gap-2.5 rounded-[var(--radius-md)] py-1 pl-3 pr-1',
// `--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)]',
'text-[13px] text-[var(--color-text-secondary)]',
].join(' ')}
>
{/* The handoff labels the row in words rather than with a
@@ -1438,11 +1432,11 @@ export function ChatInput({ variant = 'default', compact = false, sessionId, vis
autoFocus
/>
<Button
variant="tonal"
variant="primary"
size="sm"
onClick={saveQueuedMessageEdit}
disabled={!editingQueuedMessageText.trim()}
className="shrink-0 font-semibold"
className="shrink-0"
>
{t('common.save')}
</Button>
@@ -1464,26 +1458,26 @@ export function ChatInput({ variant = 'default', compact = false, sessionId, vis
while a question is waiting: sending now would race the
prompt the model is blocked on. */}
<Button
variant="link"
variant="ghost"
size="sm"
disabled={questionPending}
onClick={() => sendQueuedUserMessage(activeTabId, message.id)}
aria-label={t('chat.pendingMessageGuideNow')}
title={t('chat.pendingMessageGuideNow')}
className="shrink-0 font-semibold"
icon={<span className="material-symbols-outlined text-[15px]" aria-hidden="true">subdirectory_arrow_right</span>}
className="shrink-0"
icon={<CornerDownRight size={14} strokeWidth={1.75} aria-hidden="true" />}
>
{t('chat.pendingMessageGuide')}
</Button>
<IconButton
icon="edit"
icon={<Pencil size={14} strokeWidth={1.75} aria-hidden="true" />}
label={t('chat.pendingMessageEdit')}
size="sm"
tone="muted"
onClick={() => startEditingQueuedMessage(message.id, message.displayContent)}
/>
<IconButton
icon="delete"
icon={<Trash2 size={14} strokeWidth={1.75} aria-hidden="true" />}
label={t('chat.pendingMessageDelete')}
size="sm"
tone="muted"
@@ -1499,17 +1493,13 @@ export function ChatInput({ variant = 'default', compact = false, sessionId, vis
)}
{composerAttachments.length > 0 && (
isHeroComposer ? (
<div className="px-2 pt-2">
<AttachmentGallery attachments={composerAttachments} variant="composer" onRemove={removeAttachment} />
) : (
<div className="px-3 pt-3">
<AttachmentGallery attachments={composerAttachments} variant="composer" onRemove={removeAttachment} />
</div>
)
</div>
)}
{isHeroComposer ? (
<div className="flex items-start gap-3">
<div className="flex items-start">
<MentionComposer
ref={composerRef}
rootRef={composerContainerRef}
@@ -1531,7 +1521,9 @@ export function ChatInput({ variant = 'default', compact = false, sessionId, vis
// break-word` does not shrink it. Without this the flex item
// refuses to shrink and the whole editor paints past the panel.
className="flex-1 min-w-0"
editorClassName="chat-reading-text max-h-[200px] overflow-y-auto py-2 leading-relaxed text-[var(--color-text-primary)]"
// The new-session card gives the prompt room to start: a taller
// resting editor than the docked composer, same type.
editorClassName="chat-reading-text min-h-[72px] max-h-[200px] overflow-y-auto px-2.5 pb-1 pt-2 text-[var(--color-text-primary)]"
aria={{
role: isSlashMenuVisible || isReferenceMenuVisible ? 'combobox' : 'textbox',
'aria-autocomplete': isSlashMenuVisible || isReferenceMenuVisible ? 'list' : undefined,
@@ -1560,9 +1552,9 @@ export function ChatInput({ variant = 'default', compact = false, sessionId, vis
onCompositionEnd={() => { composingRef.current = false; dictation.compositionHandlers.onCompositionEnd() }}
placeholder={composerPlaceholder}
disabled={composerDisabled}
editorClassName={`chat-reading-text max-h-[200px] overflow-y-auto text-sm leading-relaxed text-[var(--color-text-primary)] ${
useCompactChrome ? 'py-1.5' : 'py-2'
}`}
// Type follows the reader's chat size (`chat-reading-text`); the
// insets put the first line 14px/16px in from the card edge.
editorClassName="chat-reading-text min-h-[36px] max-h-[200px] overflow-y-auto px-2 pb-1 pt-1.5 text-[var(--color-text-primary)]"
aria={{
role: isSlashMenuVisible || isReferenceMenuVisible ? 'combobox' : 'textbox',
'aria-autocomplete': isSlashMenuVisible || isReferenceMenuVisible ? 'list' : undefined,
@@ -1576,30 +1568,20 @@ export function ChatInput({ variant = 'default', compact = false, sessionId, vis
)}
{/*
The wide composer keeps one geometry for the whole session. The
draft and the live session used to render two different rows — the
draft's divider was inset inside the panel's padding, the live one
ran edge to edge over a `-mx-4 -mb-4` band — so the first message
shifted every control left by 4px and widened the divider by 34px.
The hero spacing wins because EmptySession renders the same row.
Its top gap comes from the panel's own `flex-col gap-3`, which the
live panel does not have, so that one repeats here as `mt-3`.
The narrow layouts keep the band: `p-3` leaves too little room to
spend on inset, and they never swap variants mid-session anyway.
The band is keyed to the chrome, not to the control layout — its
`-mx-3` has to cancel the panel's `p-3` exactly, and the panel is
padded by the same chrome rule.
One toolbar geometry for every variant. The draft and the live
session used to render two different rows (an inset divider versus
an edge-to-edge band), so the first message shifted every control.
The card has no divider now — the editor's own bottom inset and this
row's top inset separate the two — so the narrow layouts no longer
need a band of their own either.
*/}
<div data-testid="chat-input-toolbar" className={`flex min-w-0 items-center justify-between gap-2 ${
isHeroComposer
? 'pt-3'
: useCompactChrome
? `mt-2 -mx-3 -mb-3 px-2.5 py-2 ${isMobileComposer ? 'gap-1' : 'gap-2'}`
: 'mt-3 pt-3'
}`}>
<div
data-testid="chat-input-toolbar"
className={`flex min-w-0 items-center justify-between pt-1.5 ${isMobileComposer ? 'gap-1' : 'gap-2'}`}
>
<div
data-testid="chat-input-toolbar-leading"
className={`flex min-w-0 shrink-0 items-center ${showLocationInToolbar ? 'max-w-[55%]' : ''} ${isMobileComposer ? 'gap-1' : 'gap-2'}`}
className={`flex min-w-0 shrink-0 items-center gap-1 ${showLocationInToolbar ? 'max-w-[55%]' : ''}`}
>
{!isMemberSession && (
<>
@@ -1616,12 +1598,14 @@ export function ChatInput({ variant = 'default', compact = false, sessionId, vis
aria-label={t('chat.composerTools')}
aria-haspopup="menu"
aria-expanded={plusMenuOpen}
// Bordered on desktop so the tools affordance reads as a
// control at rest, not only on hover — it sits next to
// the permission chip, which is bordered too.
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 border border-[var(--color-border)]'}`}
// A quiet 28px square like every other toolbar control;
// the open state keeps the hover fill so the menu reads as
// anchored to it.
className={`inline-flex items-center justify-center rounded-[var(--radius-sm)] 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-[var(--color-border-focus)] ${
plusMenuOpen ? 'bg-[var(--color-surface-hover)] text-[var(--color-text-primary)]' : ''
} ${isMobileComposer ? 'h-11 w-11' : 'h-7 w-7'}`}
>
<span className="material-symbols-outlined text-[18px]">add</span>
<Plus size={isMobileComposer ? 18 : 16} strokeWidth={1.75} aria-hidden="true" />
</button>
{plusMenuOpen && (
@@ -1686,7 +1670,7 @@ export function ChatInput({ variant = 'default', compact = false, sessionId, vis
<div
data-testid="chat-input-toolbar-trailing"
className={`flex min-w-0 flex-1 items-center justify-end ${isMobileComposer ? 'gap-1' : 'gap-2'}`}
className="flex min-w-0 flex-1 items-center justify-end gap-1"
>
{!isMemberSession && activeTabId && (
<ContextUsageIndicator
@@ -1714,19 +1698,17 @@ export function ChatInput({ variant = 'default', compact = false, sessionId, vis
)}
<VoiceInputButton dictation={dictation} blocked={composerDisabled} mobile={isMobileComposer} />
{!isMemberSession && !isActive && (hasRunningSubagents || hasRunningTeam) ? (
// Stopping background work while the composer can still send:
// the same ink stop circle as a running turn, beside the send key.
<Button
variant="danger"
variant="primary"
size="base"
shape="circle"
onClick={() => stopGeneration(activeTabId!)}
aria-label={t('common.stop')}
title={t('chat.stopTitle')}
className={`shrink-0 ${isMobileComposer ? 'h-11 w-11' : ''}`}
icon={(
<span className="material-symbols-outlined text-[18px]">
stop
</span>
)}
icon={<Square size={isMobileComposer ? 12 : 10} strokeWidth={2} fill="currentColor" aria-hidden="true" />}
/>
) : null}
{/* Same component, shape and icon as EmptySession's send button.
@@ -1741,9 +1723,13 @@ export function ChatInput({ variant = 'default', compact = false, sessionId, vis
location chip. The arrow points *up* — into the transcript the
message is being sent to — which is also what makes it read as
send without a word next to it. Dropping the label is why the
name now lives only in `aria-label`, on both breakpoints. */}
name now lives only in `aria-label`, on both breakpoints.
Terracotta (`accent`) is the send key's alone; mid-turn the
same circle turns ink (`primary`) with a filled square, so a
running turn does not read as an error. */}
<Button
variant={!isMemberSession && isActive ? 'danger' : 'accent'}
variant={!isMemberSession && isActive ? 'primary' : 'accent'}
size="base"
shape="circle"
onClick={!isMemberSession && isActive ? () => stopGeneration(activeTabId!) : handleSubmit}
@@ -1759,11 +1745,9 @@ export function ChatInput({ variant = 'default', compact = false, sessionId, vis
// 44px on touch is the platform minimum for a primary target;
// the desktop circle stays at the size's own 32px.
className={`shrink-0 ${isMobileComposer ? 'h-11 w-11' : ''}`}
icon={(
<span className="material-symbols-outlined text-[18px]">
{!isMemberSession && isActive ? 'stop' : 'arrow_upward'}
</span>
)}
icon={!isMemberSession && isActive
? <Square data-icon="stop" size={isMobileComposer ? 12 : 10} strokeWidth={2} fill="currentColor" aria-hidden="true" />
: <ArrowUp data-icon="send" size={isMobileComposer ? 18 : 16} strokeWidth={2} aria-hidden="true" />}
/>
</div>
</div>
@@ -19,6 +19,21 @@ describe('CodeViewer', () => {
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
})
it('heads the block with one quiet 30px line: language in mono, no uppercase tracking, a labelled ghost copy', () => {
const { container } = render(<CodeViewer code={'const a = 1'} language="ts" label="Input" />)
const header = container.querySelector('[data-code-viewer-chrome] > div') as HTMLElement
expect(header.className).toContain('h-[30px]')
expect(container.innerHTML).not.toMatch(/\buppercase\b|tracking-\[/)
expect(screen.getByText('ts').className).toContain('font-mono')
const copy = screen.getByRole('button', { name: 'Copy' })
// The icon is decoration; the word is what the button says.
expect(copy.textContent).toBe('Copy')
expect(copy.querySelector('svg.lucide-copy')).not.toBeNull()
expect(copy.className.split(/\s+/)).not.toContain('border')
// The card is a code block, so it takes the 8px block radius.
expect((container.firstElementChild as HTMLElement).className).toContain('rounded-[var(--radius-md)]')
})
it('counts a single line in the singular', () => {
render(<CodeViewer code="ls" language="bash" />)
expect(screen.getByText('1 line')).toBeTruthy()
@@ -35,7 +50,9 @@ describe('CodeViewer', () => {
const contentWrapper = container.querySelector('[data-code-viewer-content]') as HTMLElement | null
expect(contentWrapper).toBeTruthy()
expect(contentWrapper?.style.padding).toBe('0.5rem 12px')
// Vertical only: the horizontal gutter is on each line (globals.css), the
// same for Prism and Shiki, so a hovered line spans the full width.
expect(contentWrapper?.style.padding).toBe('10px 0px 12px')
expect(contentWrapper?.style.whiteSpace).toBe('pre')
expect(contentWrapper?.style.wordBreak).toBe('normal')
expect(contentWrapper?.style.fontSize).toBe('var(--code-viewer-font-size, 13px)')
+25 -24
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState, type ComponentType, type CSSProperties } from 'react'
import { Highlight, type PrismTheme } from 'prism-react-renderer'
import { Check, Copy } from 'lucide-react'
import { CopyButton } from '@/components/ui/CopyButton'
import { useTranslation } from '../../i18n'
@@ -27,7 +28,9 @@ const warmPrismTheme: PrismTheme = {
backgroundColor: 'transparent',
},
styles: [
{ types: ['comment', 'prolog', 'doctype', 'cdata'], style: { color: 'var(--color-code-comment)', fontStyle: 'italic' as const } },
// Comments are told apart by colour alone: a slanted comment is often
// Chinese, and CJK has no italic — the browser just shears the glyphs.
{ types: ['comment', 'prolog', 'doctype', 'cdata'], style: { color: 'var(--color-code-comment)' } },
{ types: ['string', 'attr-value', 'template-string'], style: { color: 'var(--color-code-string)' } },
{ types: ['keyword', 'selector', 'important', 'atrule'], style: { color: 'var(--color-code-keyword)' } },
{ types: ['function'], style: { color: 'var(--color-code-function)' } },
@@ -50,7 +53,7 @@ const warmShikiTheme = {
fg: 'var(--color-code-fg)',
bg: 'transparent',
tokenColors: [
{ scope: ['comment', 'punctuation.definition.comment'], settings: { foreground: 'var(--color-code-comment)', fontStyle: 'italic' } },
{ scope: ['comment', 'punctuation.definition.comment'], settings: { foreground: 'var(--color-code-comment)' } },
{ scope: ['string', 'string.quoted', 'string.template', 'string.other.link'], settings: { foreground: 'var(--color-code-string)' } },
{ scope: ['string.regexp'], settings: { foreground: 'var(--color-primary-container)' } },
{ scope: ['keyword', 'keyword.control', 'storage', 'storage.type', 'storage.modifier'], settings: { foreground: 'var(--color-code-keyword)' } },
@@ -69,14 +72,14 @@ const warmShikiTheme = {
{ scope: ['meta.decorator', 'punctuation.decorator'], settings: { foreground: 'var(--color-code-type)' } },
{ scope: ['markup.inserted', 'punctuation.definition.inserted'], settings: { foreground: 'var(--color-code-inserted)' } },
{ scope: ['markup.deleted', 'punctuation.definition.deleted'], settings: { foreground: 'var(--color-code-deleted)' } },
{ scope: ['markup.heading', 'entity.name.section'], settings: { foreground: 'var(--color-code-function)', fontStyle: 'bold' } },
{ scope: ['markup.bold'], settings: { fontStyle: 'bold' } },
{ scope: ['markup.italic'], settings: { fontStyle: 'italic' } },
// No bold: the mono face ships at 400 only, so "bold" is synthesized smear.
{ scope: ['markup.heading', 'entity.name.section'], settings: { foreground: 'var(--color-code-function)' } },
],
}
const CODE_AREA_PADDING = '0.5rem 12px'
const CODE_LINE_HEIGHT = 1.7
/** Vertical only: the 14px gutter sits on each line (globals.css), so a hovered line is a full-width band. */
const CODE_AREA_PADDING = '10px 0 12px'
const CODE_LINE_HEIGHT = 1.65
type ShikiHighlighterProps = {
language: string
@@ -175,7 +178,7 @@ function PrismCodeContent({
data-line-number={showLineNumbers ? index + 1 : undefined}
>
{showLineNumbers && (
<span className="mr-3 inline-block min-w-[2.5ch] select-none text-right text-[var(--color-text-tertiary)]">
<span className="mr-3 inline-block min-w-[2.5ch] select-none text-right text-[11px] text-[var(--color-text-tertiary)]">
{index + 1}
</span>
)}
@@ -328,32 +331,29 @@ export function CodeViewer({
className={
isEmbedded
? 'overflow-hidden bg-[var(--color-code-bg)]'
: 'overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-code-bg)]'
: 'overflow-hidden rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-code-bg)]'
}
>
{/* Header */}
<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.5">
{/* Header: one quiet 30px line on the code's own ground. */}
<div className="flex h-[30px] items-center justify-between gap-2 border-b border-[var(--color-border)] pl-3 pr-1.5 text-[11px] text-[var(--color-text-tertiary)]">
<div className="flex min-w-0 items-center gap-1.5">
{label ? (
<>
<span className="font-semibold uppercase tracking-[0.14em]">{label}</span>
<span aria-hidden="true">·</span>
<span className="shrink-0 font-medium text-[var(--color-text-secondary)]">{label}</span>
<span aria-hidden="true" className="h-[3px] w-[3px] shrink-0 rounded-full bg-[var(--color-text-tertiary)]" />
</>
) : null}
<span className={`${label ? 'font-medium' : 'font-semibold'} uppercase tracking-[0.14em]`}>
{languageLabel}
</span>
<span>{lineCountLabel}</span>
<span className="truncate font-mono">{languageLabel}</span>
<span aria-hidden="true" className="h-[3px] w-[3px] shrink-0 rounded-full bg-[var(--color-text-tertiary)]" />
<span className="shrink-0 tabular-nums">{lineCountLabel}</span>
</div>
<CopyButton
text={code}
label={t('common.copy')}
copiedLabel={t('common.copied')}
className={
isEmbedded
? 'rounded-[var(--radius-sm)] 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)]'
: '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)]'
}
displayLabel={<><Copy size={12} strokeWidth={2} aria-hidden="true" />{t('common.copy')}</>}
displayCopiedLabel={<><Check size={12} strokeWidth={2} aria-hidden="true" />{t('common.copied')}</>}
className="inline-flex h-[22px] shrink-0 items-center gap-1 rounded-[var(--radius-xs)] px-1.5 text-[11px] 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-[var(--color-border-focus)]"
/>
</div>
@@ -369,8 +369,9 @@ export function CodeViewer({
{/* Expand/collapse toggle */}
{showExpandToggle && (
<button
type="button"
onClick={() => setExpanded((value) => !value)}
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)]"
className="flex h-[30px] w-full items-center justify-center border-t border-[var(--color-border)] text-[11px] font-medium 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)]"
>
{expanded
? t('codeViewer.collapse')
@@ -9,6 +9,7 @@ import { ComposerReferenceMenu, type ComposerReferenceMenuHandle } from '@/compo
import type { NewComposerMention } from '@/lib/composerMentions'
import type { ComposerReferenceCandidate } from '@/types/composerReference'
import type { CapabilityAction, CapabilityIcon, CapabilityMenuItem, CapabilityMenuSection } from './capabilityMenuModel'
import { COMPOSER_KBD } from './composerMenuStyles'
type Props = {
id: string
@@ -32,11 +33,11 @@ function descendants(items: CapabilityMenuItem[], path: string[] = []): Array<{
function RowIcon({ icon, iconColor }: { icon: CapabilityIcon, iconColor?: string }) {
if (icon.kind === 'image') {
return <img src={publicAssetPath(icon.src)} alt="" className="h-5 w-5 shrink-0 object-contain" />
return <img src={publicAssetPath(icon.src)} alt="" className="h-4 w-4 shrink-0 object-contain" />
}
if (icon.kind === 'slash') {
return (
<span aria-hidden="true" className="flex h-5 w-5 shrink-0 items-center justify-center text-[15px] font-bold text-[var(--color-text-secondary)]">
<span aria-hidden="true" className="flex h-4 w-4 shrink-0 items-center justify-center font-mono text-[13px] text-[var(--color-text-tertiary)]">
/
</span>
)
@@ -45,9 +46,9 @@ function RowIcon({ icon, iconColor }: { icon: CapabilityIcon, iconColor?: string
return (
<Icon
aria-hidden="true"
className="h-5 w-5 shrink-0 text-[var(--color-text-secondary)]"
className="h-4 w-4 shrink-0 text-[var(--color-text-tertiary)]"
style={iconColor ? { color: iconColor } : undefined}
strokeWidth={1.7}
strokeWidth={1.75}
/>
)
}
@@ -138,25 +139,25 @@ export function ComposerCapabilityMenu({ id, sections, cwd = '', referencesLoadi
onMouseEnter={() => setHighlight(index)} onClick={() => activate(item)}
trailing={item.switch ? <span className="-my-1 shrink-0" onClick={event => event.stopPropagation()}>
<Switch size="sm" checked={item.switch.checked} disabled={item.switch.disabled} label={t('chat.capabilities.computerUseToggle')} labelHidden onChange={() => item.action && onAction(item.action)} />
</span> : item.children ? <ChevronRight aria-hidden="true" className="h-4 w-4 shrink-0 text-[var(--color-text-tertiary)]" /> : item.key === 'slash-commands' ? <kbd>/</kbd> : null}
</span> : item.children ? <ChevronRight aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-[var(--color-text-tertiary)]" /> : item.key === 'slash-commands' ? <kbd className={COMPOSER_KBD}>/</kbd> : null}
/>
let offset = 0
return <div className={`absolute bottom-full left-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)] ${mobile ? 'w-[min(360px,calc(100vw-32px))]' : showReferences ? 'w-[min(480px,calc(100vw-32px))]' : 'w-[min(288px,calc(100vw-32px))]'}`} onMouseDown={event => event.preventDefault()}>
<div className="flex items-center gap-2 border-b border-[var(--color-border-separator)] px-3 py-2">
{drillParent ? <IconButton icon={<ChevronLeft className="h-4 w-4" />} label={t('chat.capabilities.back')} size="xs" onClick={goBack} /> : null}
<Search aria-hidden="true" className="h-4 w-4 shrink-0 text-[var(--color-text-tertiary)]" />
return <div className={`absolute bottom-full left-0 z-[var(--z-dropdown)] mb-2 overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)] ${mobile ? 'w-[min(360px,calc(100vw-32px))]' : showReferences ? 'w-[min(480px,calc(100vw-32px))]' : 'w-[min(288px,calc(100vw-32px))]'}`} onMouseDown={event => event.preventDefault()}>
<div className="flex h-10 items-center gap-2 border-b border-[var(--color-border)] px-3">
{drillParent ? <IconButton icon={<ChevronLeft size={14} strokeWidth={1.75} />} label={t('chat.capabilities.back')} size="xs" tone="muted" onClick={goBack} /> : null}
<Search aria-hidden="true" size={14} strokeWidth={1.75} className="shrink-0 text-[var(--color-text-tertiary)]" />
<input autoFocus value={query} onChange={event => { setQuery(event.target.value); setHighlight(0) }} onKeyDown={handleKeyDown} onClick={event => event.currentTarget.focus()}
placeholder={drillParent?.label ?? t('chat.capabilities.searchPlaceholder')} aria-label={t('chat.capabilities.searchPlaceholder')}
role="combobox" aria-expanded="true" aria-controls={listId} aria-activedescendant={activeOptionId}
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-[13px] text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]" />
</div>
{showReferences ? <ComposerReferenceMenu key={path.join('/')} ref={referenceRef} id={listId} cwd={cwd} filter={query} embedded browseReferences={browseReferences} references={references} actions={actions}
referencesLoading={referencesLoading} referencesError={referencesError} onSelect={selectMention} onActiveChange={setReferenceOptionId} /> :
<div ref={listRef} id={listId} role="listbox" aria-label={t('chat.composerTools')} className="max-h-[min(360px,50vh)] overflow-y-auto p-1.5">
<div ref={listRef} id={listId} role="listbox" aria-label={t('chat.composerTools')} className="max-h-[min(360px,50vh)] overflow-y-auto p-1">
{drillParent ? items.map(renderRow) : sections.map(section => {
const start = offset
offset += section.items.length
return <div key={section.id} role="group" aria-label={section.title} className="border-b border-[var(--color-border-separator)] py-1 last:border-b-0">{section.items.map((item, index) => renderRow(item, start + index))}</div>
return <div key={section.id} role="group" aria-label={section.title} className="border-b border-[var(--color-border)] py-1 first:pt-0 last:border-b-0 last:pb-0">{section.items.map((item, index) => renderRow(item, start + index))}</div>
})}
</div>}
</div>
@@ -1,3 +1,5 @@
import { Upload } from 'lucide-react'
type ComposerDropOverlayProps = {
title: string
description: string
@@ -11,15 +13,15 @@ export function ComposerDropOverlay({ title, description, testId }: ComposerDrop
// 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]"
className="composer-drop-overlay pointer-events-none absolute inset-0 z-[var(--z-scrim)] flex items-center justify-center rounded-[inherit] border border-dashed border-[var(--color-outline)] bg-[var(--color-surface-glass)] p-4 backdrop-blur-[2px]"
aria-hidden="true"
>
<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
<div className="flex max-w-[280px] items-center gap-3 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-4 py-3 text-left shadow-[var(--shadow-dropdown)]">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-[var(--radius-md)] bg-[var(--color-surface-container)] text-[var(--color-text-secondary)]">
<Upload size={16} strokeWidth={1.75} />
</span>
<span className="min-w-0">
<span className="block text-sm font-semibold leading-5 text-[var(--color-text-primary)]">{title}</span>
<span className="block text-[13px] font-medium leading-5 text-[var(--color-text-primary)]">{title}</span>
<span className="block text-xs leading-5 text-[var(--color-text-tertiary)]">{description}</span>
</span>
</div>
@@ -1,3 +1,4 @@
import { Box, Puzzle } from 'lucide-react'
import { Modal } from '@/components/ui/Modal'
import { useTranslation } from '@/i18n'
import { safeMentionIcon, type ComposerMention } from '@/lib/composerMentions'
@@ -5,14 +6,17 @@ import { safeMentionIcon, type ComposerMention } from '@/lib/composerMentions'
export function ComposerReferenceDetail({ mention, onClose }: { mention: ComposerMention | null, onClose: () => void }) {
const t = useTranslation()
const icon = safeMentionIcon(mention?.icon)
const KindIcon = mention?.kind === 'plugin' ? Puzzle : Box
return <Modal open={!!mention} onClose={onClose} title={mention?.label || t('chat.referenceDetails')} width={440}>
{mention && <div className="space-y-4 pb-1">
<div className="flex items-center gap-2 text-xs text-[var(--color-text-secondary)]">
{icon ? <img src={`${import.meta.env.BASE_URL}${icon.slice(1)}`} alt="" className="h-6 w-6 object-contain" /> : <span aria-hidden="true" className="material-symbols-outlined text-xl">{mention.kind === 'plugin' ? 'extension' : 'deployed_code'}</span>}
<div className="flex items-center gap-2 text-xs text-[var(--color-text-tertiary)]">
{icon
? <img src={`${import.meta.env.BASE_URL}${icon.slice(1)}`} alt="" className="h-5 w-5 object-contain" />
: <KindIcon aria-hidden="true" size={16} strokeWidth={1.75} />}
<span>{t(mention.kind === 'plugin' ? 'chat.referencePlugins' : 'chat.referenceSkills')}</span>
</div>
<p className="whitespace-pre-wrap text-sm leading-6 text-[var(--color-text-primary)]">{mention.description || mention.label}</p>
{mention.id && <p className="break-words text-xs text-[var(--color-text-tertiary)]">{mention.id}</p>}
<p className="whitespace-pre-wrap text-[13px] leading-6 text-[var(--color-text-primary)]">{mention.description || mention.label}</p>
{mention.id && <p className="break-words font-mono text-[11px] text-[var(--color-text-tertiary)]">{mention.id}</p>}
</div>}
</Modal>
}
@@ -12,6 +12,7 @@ import { safeMentionIcon, type NewComposerMention } from '@/lib/composerMentions
import { publicAssetPath } from '@/lib/publicAsset'
import type { ComposerReferenceCandidate } from '@/types/composerReference'
import { referenceFallbackIcon, skillSourceLabelKey } from './referencePresentation'
import { COMPOSER_KBD, COMPOSER_MENU_SECTION } from './composerMenuStyles'
type FileEntry = { name: string, path: string, isDirectory: boolean, relativePath?: string }
type Row = { key: string, label: string, description: string, searchTerms?: string[], contentMatch?: boolean, session?: SessionCandidate, shortId?: string, source?: string, mention?: NewComposerMention, file?: FileEntry, icon?: ReactNode, onSelect?: () => void }
@@ -160,14 +161,14 @@ export const ComposerReferenceMenu = forwardRef<ComposerReferenceMenuHandle, Pro
let offset = 0
return (
<div className={embedded ? 'min-w-0' : `absolute bottom-full left-0 w-full 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)] `} onMouseDown={event => event.preventDefault()}>
<div ref={listRef} id={id} role="listbox" aria-label={t('chat.references')} aria-busy={loading || referencesLoading || sessionLoading} className="min-w-0 max-h-[min(320px,45vh)] overflow-y-auto p-1.5">
<div className={embedded ? 'min-w-0' : `absolute bottom-full left-0 w-full z-[var(--z-dropdown)] mb-2 overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)] `} onMouseDown={event => event.preventDefault()}>
<div ref={listRef} id={id} role="listbox" aria-label={t('chat.references')} aria-busy={loading || referencesLoading || sessionLoading} className="min-w-0 max-h-[min(320px,45vh)] overflow-y-auto p-1">
{visibleGroups.map(group => {
const start = offset
offset += group.rows.length
if (!group.rows.length) return null
return <div key={group.kind} role="group" aria-label={group.label}>
{!isSearching ? <div className="px-3 pb-1 pt-2 text-xs font-medium text-[var(--color-text-tertiary)]">{group.label}</div> : null}
{!isSearching ? <div className={COMPOSER_MENU_SECTION}>{group.label}</div> : null}
{group.rows.map((row, position) => {
const index = start + position
const Icon = referenceFallbackIcon(row.file ? (row.file.isDirectory ? 'directory' : 'file') : row.mention?.kind ?? 'skill')
@@ -179,23 +180,23 @@ export const ComposerReferenceMenu = forwardRef<ComposerReferenceMenuHandle, Pro
if ((event.target as Element).closest('[data-navigate-directory]')) navigate(row)
else select(row)
}}
icon={row.icon ?? (row.mention?.icon ? <img src={publicAssetPath(row.mention.icon)} alt="" className="h-5 w-5 shrink-0 object-contain" /> : <Icon aria-hidden="true" className="h-5 w-5 shrink-0 text-[var(--color-text-secondary)]" strokeWidth={1.7} />)}
icon={row.icon ?? (row.mention?.icon ? <img src={publicAssetPath(row.mention.icon)} alt="" className="h-4 w-4 shrink-0 object-contain" /> : <Icon aria-hidden="true" className="h-4 w-4 shrink-0 text-[var(--color-text-tertiary)]" strokeWidth={1.75} />)}
trailing={<>
{sourceLabel ? <span className="shrink-0 text-xs text-[var(--color-text-tertiary)]">{t(sourceLabel)}</span> : null}
{row.file?.isDirectory ? <span data-navigate-directory title={t('fileSearch.openFolder')} className="-my-2 -mr-2 flex h-8 w-8 shrink-0 items-center justify-center"><ChevronRight aria-hidden="true" className="h-4 w-4 text-[var(--color-text-tertiary)]" /></span> : null}
{row.file?.isDirectory ? <span data-navigate-directory title={t('fileSearch.openFolder')} className="-my-1.5 -mr-1.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-[var(--radius-sm)] hover:bg-[var(--color-surface-selected)]"><ChevronRight aria-hidden="true" className="h-3.5 w-3.5 text-[var(--color-text-tertiary)]" /></span> : null}
</>}
/>
})}
</div>
})}
{loading || referencesLoading || sessionLoading ? <div role="status" className="px-3 py-2 text-xs text-[var(--color-text-tertiary)]">{t('fileSearch.searching')}</div> : null}
{currentResult?.error ? <div role="alert" className="px-3 py-2 text-xs text-[var(--color-error)]">{t(currentResult.error === 'denied' ? 'fileSearch.accessDenied' : 'fileSearch.loadFailed')}</div> : null}
{sessionError ? <div role="alert" className="px-3 py-2 text-xs text-[var(--color-error)]">{t('chat.sessionReferencesLoadFailed')}</div> : null}
{referencesError ? <div role="alert" className="px-3 py-2 text-xs text-[var(--color-error)]">{t('chat.referencesLoadFailed')}</div> : null}
{!rows.length && !loading && !referencesLoading && !sessionLoading && !sessionError && !currentResult?.error && !referencesError ? <div className="px-3 py-3 text-xs text-[var(--color-text-tertiary)]">{t('chat.referencesEmpty')}</div> : null}
{loading || referencesLoading || sessionLoading ? <div role="status" className="px-2 py-2 text-xs text-[var(--color-text-tertiary)]">{t('fileSearch.searching')}</div> : null}
{currentResult?.error ? <div role="alert" className="px-2 py-2 text-xs text-[var(--color-error)]">{t(currentResult.error === 'denied' ? 'fileSearch.accessDenied' : 'fileSearch.loadFailed')}</div> : null}
{sessionError ? <div role="alert" className="px-2 py-2 text-xs text-[var(--color-error)]">{t('chat.sessionReferencesLoadFailed')}</div> : null}
{referencesError ? <div role="alert" className="px-2 py-2 text-xs text-[var(--color-error)]">{t('chat.referencesLoadFailed')}</div> : null}
{!rows.length && !loading && !referencesLoading && !sessionLoading && !sessionError && !currentResult?.error && !referencesError ? <div className="px-2 py-3 text-xs text-[var(--color-text-tertiary)]">{t('chat.referencesEmpty')}</div> : null}
</div>
{!isSearching && !embedded ? <div className="border-t border-[var(--color-border)] px-3 py-2 text-xs text-[var(--color-text-tertiary)]">{t('chat.referenceSearchHint')}</div> : null}
{!compact && !embedded ? <div className="flex items-center gap-2 border-t border-[var(--color-border)] px-4 py-2 text-[10px] text-[var(--color-text-tertiary)]"><kbd>↑↓</kbd><span>{t('fileSearch.navigate')}</span><kbd className="ml-2">Enter / Tab</kbd><span>{t('fileSearch.select')}</span><kbd className="ml-2">→</kbd><span>{t('fileSearch.open')}</span><kbd className="ml-2">Esc</kbd><span>{t('fileSearch.close')}</span></div> : null}
{!compact && !embedded ? <div className="flex items-center gap-1.5 border-t border-[var(--color-border)] px-3 py-2 text-[11px] text-[var(--color-text-tertiary)]"><kbd className={COMPOSER_KBD}>↑↓</kbd><span>{t('fileSearch.navigate')}</span><kbd className={`${COMPOSER_KBD} ml-2`}>Enter / Tab</kbd><span>{t('fileSearch.select')}</span><kbd className={`${COMPOSER_KBD} ml-2`}>→</kbd><span>{t('fileSearch.open')}</span><kbd className={`${COMPOSER_KBD} ml-2`}>Esc</kbd><span>{t('fileSearch.close')}</span></div> : null}
</div>
)
})
@@ -10,19 +10,23 @@ type Props = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
selected?: boolean
}
/** Shared dense row for composer capability and reference suggestions. */
/**
* Shared dense row for composer capability and reference suggestions — the
* 「素」 menu item: 32px minimum, the small row corner, 13px label, and the
* hover fill doubling as the keyboard highlight.
*/
export const ComposerSuggestionRow = forwardRef<HTMLDivElement, Props>(function ComposerSuggestionRow({
id, label, description, details, icon, trailing, selected = false, className = '', ...rest
}, ref) {
return <div ref={ref} id={id} role="option" tabIndex={-1} aria-selected={selected}
aria-labelledby={`${id}-label`} aria-describedby={description || details ? `${id}-description` : undefined}
className={`flex min-w-0 cursor-default items-center gap-3 rounded-[var(--radius-md)] px-3 py-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${selected ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'} ${className}`} {...rest}>
className={`flex min-h-8 min-w-0 cursor-default items-center gap-2.5 rounded-[var(--radius-sm)] px-2 py-1.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${selected ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'} ${className}`} {...rest}>
{icon}
{details ? <span className="min-w-0 flex-1 space-y-1">
<span id={`${id}-label`} title={label} className="block truncate text-sm font-medium text-[var(--color-text-primary)]">{label}</span>
{details ? <span className="min-w-0 flex-1 space-y-0.5">
<span id={`${id}-label`} title={label} className="block truncate text-[13px] font-medium text-[var(--color-text-primary)]">{label}</span>
<span id={`${id}-description`} className="flex min-w-0 items-center gap-2 text-[11px] leading-4 text-[var(--color-text-tertiary)]">{details}</span>
</span> : <>
<span id={`${id}-label`} className={`${description ? 'max-w-[45%] shrink-0' : 'min-w-0 flex-1'} truncate text-sm font-medium text-[var(--color-text-primary)]`}>{label}</span>
<span id={`${id}-label`} className={`${description ? 'max-w-[45%] shrink-0' : 'min-w-0 flex-1'} truncate text-[13px] font-medium text-[var(--color-text-primary)]`}>{label}</span>
{description ? <span id={`${id}-description`} className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-tertiary)]">{description}</span> : null}
</>}
{trailing}
@@ -1,5 +1,6 @@
import { useState } from 'react'
import { ChevronRight } from 'lucide-react'
import { Badge } from '@/components/ui/Badge'
import {
formatCacheHitRate,
formatCompactTokens,
@@ -85,7 +86,7 @@ function UsageMeter({
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(percent)}
className="h-[6px] overflow-hidden rounded-full bg-[var(--color-surface-hover)]"
className="h-1.5 overflow-hidden rounded-full bg-[var(--color-surface-container)]"
data-testid="context-segmented-bar"
>
<div
@@ -110,14 +111,14 @@ function SessionStatGrid({
labels: ContextUsageDetailsProps['labels']
density: 'compact' | 'comfortable'
}) {
const labelClass = density === 'compact'
? 'text-[12.5px] text-[var(--color-text-tertiary)]'
: 'text-xs text-[var(--color-text-tertiary)]'
const valueClass = 'mt-[3px] font-mono text-sm text-[var(--color-text-primary)]'
const labelClass = 'truncate text-xs text-[var(--color-text-tertiary)]'
const valueClass = density === 'compact'
? 'mt-0.5 font-mono text-[13px] tabular-nums text-[var(--color-text-primary)]'
: 'mt-0.5 font-mono text-sm tabular-nums text-[var(--color-text-primary)]'
return (
<div className="mt-4 grid grid-cols-3 gap-3">
<div>
<div className="mt-3 grid grid-cols-3 gap-3 border-t border-[var(--color-border)] pt-3">
<div className="min-w-0">
<div className={labelClass}>{labels.sessionSpeed}</div>
<div className={valueClass} data-testid="session-speed">
{formatTokensPerSecond(stats.tokensPerSecond ?? 0)}
@@ -126,13 +127,13 @@ function SessionStatGrid({
)}
</div>
</div>
<div>
<div className="min-w-0">
<div className={labelClass}>{labels.sessionCacheHit}</div>
<div className={valueClass} data-testid="session-cache-hit">
{stats.cacheHitRate === null ? '--' : formatCacheHitRate(stats.cacheHitRate)}
</div>
</div>
<div>
<div className="min-w-0">
<div className={labelClass}>{labels.sessionCost}</div>
<div className={`${valueClass} truncate`} data-testid="session-cost" title={stats.costDisplay}>
{stats.costDisplay}
@@ -154,7 +155,7 @@ function CategoryBars({
if (categories.length === 0) return null
return (
<div className={density === 'compact' ? 'mt-3 flex flex-col gap-3' : 'mt-4 space-y-3'}>
<div className={density === 'compact' ? 'mt-2 flex flex-col gap-2.5' : 'mt-3 space-y-3'}>
{categories.map((category) => {
const percent = maxTokens > 0
? Math.max(0.5, Math.min(100, (category.tokens / maxTokens) * 100))
@@ -162,14 +163,14 @@ function CategoryBars({
return (
<div key={category.name}>
<div className={`flex items-baseline justify-between gap-3 ${density === 'compact' ? '' : 'text-xs'}`}>
<span className={`min-w-0 truncate ${density === 'compact' ? 'text-[13.5px] text-[var(--color-text-primary)]' : 'text-[var(--color-text-secondary)]'}`}>
<span className={`min-w-0 truncate ${density === 'compact' ? 'text-[13px] text-[var(--color-text-primary)]' : 'text-[var(--color-text-secondary)]'}`}>
{category.name}
</span>
<span className={`shrink-0 font-mono ${density === 'compact' ? 'text-[13px] text-[var(--color-text-secondary)]' : 'text-[var(--color-text-tertiary)]'}`}>
<span className={`shrink-0 font-mono tabular-nums ${density === 'compact' ? 'text-xs text-[var(--color-text-secondary)]' : 'text-[var(--color-text-tertiary)]'}`}>
{formatNumber(category.tokens)}
</span>
</div>
<div className={`overflow-hidden rounded-full bg-[var(--color-surface-hover)] ${density === 'compact' ? 'mt-[7px] h-[3px]' : 'mt-1.5 h-1.5'}`}>
<div className={`mt-1.5 overflow-hidden rounded-full bg-[var(--color-surface-container)] ${density === 'compact' ? 'h-[3px]' : 'h-1.5'}`}>
<div
className="h-full rounded-full"
style={{ width: `${percent}%`, backgroundColor: category.color || 'var(--color-brand)' }}
@@ -207,12 +208,11 @@ function ReadyBody({
return (
<>
<div className="mt-3 flex items-baseline justify-between gap-3">
<div className="text-[12.5px] text-[var(--color-text-tertiary)]">{labels.remaining}</div>
{/* The headline serif carries the one large number on the panel —
the same treatment the handoff gives every hero statistic. */}
<div className="text-xs text-[var(--color-text-tertiary)]">{labels.remaining}</div>
{/* The one large number on the panel: 26 semibold with tabular figures,
in the body face — mono never pairs with semibold. */}
<div
className="shrink-0 text-[27px] font-bold leading-none text-[var(--color-text-primary)]"
style={{ fontFamily: 'var(--font-headline)' }}
className="shrink-0 text-[26px] font-semibold leading-none tabular-nums text-[var(--color-text-primary)]"
data-testid="context-remaining"
>
{remainingLabel}
@@ -225,7 +225,7 @@ function ReadyBody({
{/* This timestamp describes the window composition, so it sits with the used/window figures
rather than next to the live session totals. */}
<div className="mt-2 text-[11px] text-[var(--color-text-tertiary)]">
<div className="mt-2 text-xs tabular-nums text-[var(--color-text-tertiary)]">
{labels.used} {formatNumber(usedTokens)}
{' · '}
{labels.window} {maxTokens > 0 ? formatNumber(maxTokens) : '--'}
@@ -242,21 +242,24 @@ function ReadyBody({
)}
{categories.length > 0 && (
<div className="mt-3 border-t border-[var(--color-border)] pt-2.5">
<div className="mt-3 border-t border-[var(--color-border)] pt-2">
<button
type="button"
aria-expanded={breakdownOpen}
onClick={() => setBreakdownOpen((open) => !open)}
data-testid="context-breakdown-toggle"
className="flex w-full items-center justify-between gap-3 rounded-[var(--radius-sm)] text-left text-[12.5px] text-[var(--color-text-secondary)] transition-colors duration-150 hover:text-[var(--color-text-primary)]"
className="flex min-h-7 w-full items-center justify-between gap-3 rounded-[var(--radius-sm)] text-left text-xs text-[var(--color-text-secondary)] transition-colors duration-150 hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
>
<span className="flex items-center gap-1.5">
<ChevronRight
className={`h-3.5 w-3.5 transition-transform duration-150 ${breakdownOpen ? 'rotate-90' : ''}`}
size={14}
strokeWidth={1.75}
aria-hidden="true"
className={`text-[var(--color-text-tertiary)] transition-transform duration-150 ${breakdownOpen ? 'rotate-90' : ''}`}
/>
{labels.breakdown}
</span>
<span className="shrink-0 font-mono text-[11px] text-[var(--color-text-tertiary)]">
<span className="shrink-0 font-mono text-[11px] tabular-nums text-[var(--color-text-tertiary)]">
{formatCompactTokens(usedTokens)}
</span>
</button>
@@ -305,8 +308,8 @@ export function ContextUsageDetails({
/>
) : (
<div className={variant === 'sheet'
? 'mt-5 rounded-[var(--radius-lg)] bg-[var(--color-surface-container)] p-4 text-sm leading-6 text-[var(--color-text-secondary)]'
: 'mt-4 text-sm leading-6 text-[var(--color-text-secondary)]'
? 'mt-4 rounded-[var(--radius-md)] bg-[var(--color-surface-container)] p-4 text-[13px] leading-6 text-[var(--color-text-secondary)]'
: 'mt-3 text-[13px] leading-6 text-[var(--color-text-secondary)]'
}>
{statusMessage}
</div>
@@ -317,9 +320,7 @@ export function ContextUsageDetails({
return (
<div data-testid="context-usage-details" data-variant="sheet">
{estimate && status === 'ready' && (
<span className="inline-flex rounded-full border border-[var(--color-border)] px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
{labels.estimate}
</span>
<Badge variant="outline">{labels.estimate}</Badge>
)}
{body}
</div>
@@ -330,17 +331,16 @@ export function ContextUsageDetails({
<div data-testid="context-usage-details" data-variant="popover">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-xs font-semibold tracking-[0.08em] text-[var(--color-text-tertiary)]">
<div className="text-xs font-semibold text-[var(--color-text-tertiary)]">
{labels.title}
</div>
<div className="mt-1 truncate text-base font-bold text-[var(--color-text-primary)]">
{/* Model ids are identifiers, so mono — at the regular weight. */}
<div className="mt-0.5 truncate font-mono text-[13px] text-[var(--color-text-primary)]">
{modelLabel}
</div>
</div>
{estimate && status === 'ready' && (
<span className="shrink-0 rounded-full border border-[var(--color-border)] px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
{labels.estimate}
</span>
<Badge variant="outline">{labels.estimate}</Badge>
)}
</div>
{body}
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, 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 '@testing-library/jest-dom'
const { sessionsApiMock, runtimeMocks } = vi.hoisted(() => ({
@@ -630,13 +630,13 @@ describe('ContextUsageIndicator touch target', () => {
// `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', () => {
it('keeps the desktop trigger at the 28px toolbar height 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', 'w-8')
expect(trigger).toHaveClass('h-7')
expect(trigger).not.toHaveClass('h-11')
})
@@ -648,7 +648,7 @@ describe('ContextUsageIndicator touch target', () => {
const trigger = screen.getByTestId('context-usage-indicator')
expect(trigger).toHaveClass('h-11', 'w-11')
expect(trigger).not.toHaveClass('h-8')
expect(trigger).not.toHaveClass('h-7')
})
it('leaves the desktop shell alone on a narrow Electron window', () => {
@@ -658,7 +658,7 @@ describe('ContextUsageIndicator touch target', () => {
render(<ContextUsageIndicator sessionId="session-1" chatState="idle" messageCount={1} compact />)
expect(screen.getByTestId('context-usage-indicator')).toHaveClass('h-8')
expect(screen.getByTestId('context-usage-indicator')).toHaveClass('h-7')
})
})
@@ -702,8 +702,13 @@ describe('ContextUsageIndicator presentation', () => {
expect(screen.getByTestId('context-usage-indicator')).toHaveTextContent('21%')
})
const trigger = screen.getByTestId('context-usage-indicator')
expect(trigger).toHaveClass('h-8', 'w-8')
expect(trigger.querySelector('.font-mono')).not.toBeInTheDocument()
expect(trigger).toHaveClass('h-7')
// The desktop toolbar shows the ring and the percentage side by side; the
// number is visible text in mono 11, not just the accessible name.
const percent = within(trigger).getByText('21%')
expect(percent).not.toHaveClass('sr-only')
expect(percent).toHaveClass('font-mono', 'text-[11px]')
expect(within(trigger).getByTestId('context-usage-ring')).toBeInTheDocument()
expect(screen.queryByTestId('context-usage-popover')).not.toBeInTheDocument()
fireEvent.click(screen.getByTestId('context-usage-indicator'))
@@ -98,6 +98,53 @@ function isDocumentVisible() {
return typeof document === 'undefined' || document.visibilityState !== 'hidden'
}
/**
* The toolbar's context ring: an outline-colored track with an arc drawn to
* `percentage`. `null` means no reading yet (track only); `loading` swaps the
* arc for a quarter-turn spinner on the same track, so the control keeps its
* footprint while the first inspection is in flight.
*/
function ContextRing({
size,
loading,
percentage,
color,
}: {
size: number
loading: boolean
percentage: number | null
color: string
}) {
const radius = 6.25
const circumference = 2 * Math.PI * radius
const arc = loading ? 0.25 : percentage === null ? 0 : Math.max(0, Math.min(100, percentage)) / 100
return (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
aria-hidden="true"
data-testid="context-usage-ring"
className={`shrink-0 -rotate-90 ${loading ? 'motion-safe:animate-spin' : ''}`}
>
<circle cx="8" cy="8" r={radius} fill="none" stroke="var(--color-outline)" strokeWidth="2.5" />
{arc > 0 && (
<circle
cx="8"
cy="8"
r={radius}
fill="none"
stroke={loading ? 'var(--color-text-tertiary)' : color}
strokeWidth="2.5"
strokeLinecap="round"
strokeDasharray={`${circumference} ${circumference}`}
strokeDashoffset={circumference * (1 - arc)}
/>
)}
</svg>
)
}
function shouldFetchContext(
sessionId: string | undefined,
draft: boolean,
@@ -388,16 +435,13 @@ export function ContextUsageIndicator({
const percentage = displayContext ? Math.max(0, Math.min(100, displayContext.percentage)) : 0
const usedTokens = displayContext?.totalTokens ?? 0
const maxTokens = displayContext?.rawMaxTokens ?? 0
// Neutral ink until the window is genuinely tight; only then does the ring
// borrow a status color (warning near the limit, error at it).
const strokeColor = percentage >= 90
? 'var(--color-error)'
: percentage >= 75
? 'var(--color-warning)'
: 'var(--color-secondary)'
const ringStyle = {
background: displayContext
? `conic-gradient(${strokeColor} ${percentage * 3.6}deg, var(--color-surface-container-high) 0deg)`
: 'var(--color-surface-container-high)',
}
: 'var(--color-text-tertiary)'
const displayPercent = displayContext ? formatPercent(percentage) : '--'
const displayInspectionModel = !context || contextDataSessionIdRef.current === sessionId
? inspectionModel
@@ -542,31 +586,27 @@ export function ContextUsageIndicator({
onClick={handleTriggerClick}
title={t('contextIndicator.title')}
data-testid="context-usage-indicator"
className={`group grid shrink-0 place-items-center rounded-full bg-transparent text-[var(--color-text-secondary)] transition-[background-color,color] duration-150 ease-out 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 w-11' : 'h-8 w-8'
} ${detailsOpen ? 'bg-[var(--color-surface-hover)] text-[var(--color-text-primary)]' : ''}`}
// Desktop: a 28px toolbar control showing the ring and the percentage
// side by side. The phone shell keeps a 44px square touch target and
// leaves the number to the accessible name — it sits between two
// other 44px buttons and has no width to spare.
className={`inline-flex shrink-0 items-center rounded-[var(--radius-sm)] bg-transparent transition-colors duration-150 ease-out hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${
isMobileBrowser ? 'h-11 w-11 justify-center' : 'h-7 gap-1.5 px-1.5'
} ${detailsOpen ? 'bg-[var(--color-surface-hover)]' : ''}`}
>
<span className={`relative grid shrink-0 place-items-center rounded-full ${isMobileBrowser ? 'h-[22px] w-[22px]' : 'h-5 w-5'}`}>
{loading && !displayContext ? (
<span className="absolute inset-[2px] rounded-full border-2 border-[var(--color-text-tertiary)] border-t-transparent motion-safe:animate-spin" />
) : (
<span
className={`relative grid place-items-center rounded-full ${isMobileBrowser ? 'h-[22px] w-[22px]' : 'h-5 w-5'}`}
style={ringStyle}
>
<span className={`absolute inset-[3.5px] rounded-full transition-colors duration-150 ${
detailsOpen
? 'bg-[var(--color-surface-hover)]'
: 'bg-[var(--color-surface-container-lowest)] group-hover:bg-[var(--color-surface-hover)]'
}`} />
<span
className="relative h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: displayContext ? strokeColor : 'var(--color-text-tertiary)' }}
/>
</span>
)}
<ContextRing
size={isMobileBrowser ? 18 : 16}
loading={loading && !displayContext}
percentage={displayContext ? percentage : null}
color={strokeColor}
/>
<span
className={isMobileBrowser
? 'sr-only'
: 'font-mono text-[11px] tabular-nums text-[var(--color-text-tertiary)]'}
>
{displayPercent}
</span>
<span className="sr-only">{displayPercent}</span>
</button>
{!preferSheet && detailsOpen && popoverPosition && createPortal(
@@ -575,7 +615,7 @@ export function ContextUsageIndicator({
role="dialog"
aria-label={t('contextIndicator.title')}
data-testid="context-usage-popover"
className="fixed z-[var(--z-popover)] overflow-y-auto rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-[22px] py-5 text-left shadow-[var(--shadow-overlay)]"
className="fixed z-[var(--z-popover)] overflow-y-auto rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] p-4 text-left shadow-[var(--shadow-dropdown)]"
style={{
top: popoverPosition.top,
bottom: popoverPosition.bottom,
@@ -598,7 +638,7 @@ export function ContextUsageIndicator({
ariaLabel={t('contextIndicator.title')}
testId="context-usage-sheet"
headerExtra={(
<div className="truncate text-base font-semibold text-[var(--color-text-primary)]">
<div className="truncate font-mono text-[13px] text-[var(--color-text-secondary)]">
{modelLabel}
</div>
)}
@@ -236,7 +236,7 @@ export function ConversationNavigator({
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)]">
<div className="mb-1 text-xs font-semibold tabular-nums text-[var(--color-text-tertiary)]">
{t('chat.conversationNavigator.turn', {
current: previewItem.turnNumber,
total: items.length,
@@ -279,10 +279,13 @@ describe('CurrentTurnChangeCard – rich file row (icon / name / type)', () => {
expect(screen.getByText('main.ts')).toBeInTheDocument()
expect(screen.getByText('generated.ts')).toBeInTheDocument()
expect(screen.getByText('chat.turnChangesConversationOnlySubtitle')).toBeInTheDocument()
// An unrestorable checkpoint must not cost the user the conversation
// rollback too — the dialog is where the remaining action is chosen.
const undoButton = screen.getByRole('button', { name: 'chat.turnChangesLatestUndoAria' })
// The limitation is the undo button's hint (its accessible description),
// not a line of grey text under the card.
expect(undoButton).toHaveAttribute('title', 'chat.turnChangesConversationOnlySubtitle')
expect(screen.queryByText('chat.turnChangesConversationOnlySubtitle')).toBeNull()
expect(undoButton).toBeEnabled()
fireEvent.click(undoButton)
expect(onUndo).toHaveBeenCalledTimes(1)
@@ -332,8 +335,9 @@ describe('CurrentTurnChangeCard – rich file row (icon / name / type)', () => {
expect(screen.queryByText('chat.turnChangesPartialCoverageSubtitle', { exact: false }))
.toBeNull()
expect(screen.getByText('chat.turnChangesLatestSubtitle')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'chat.turnChangesLatestUndoAria' })).toBeEnabled()
const undoButton = screen.getByRole('button', { name: 'chat.turnChangesLatestUndoAria' })
expect(undoButton).toHaveAttribute('title', 'chat.turnChangesLatestSubtitle')
expect(undoButton).toBeEnabled()
})
it('prefers the conversation-only message over the coverage warning when restore is unavailable', () => {
@@ -341,10 +345,11 @@ describe('CurrentTurnChangeCard – rich file row (icon / name / type)', () => {
// Both conditions hold, but "files cannot be restored at all" is the one
// that changes what the user can do, so it wins the subtitle.
expect(screen.getByText('chat.turnChangesConversationOnlySubtitle')).toBeInTheDocument()
const undoButton = screen.getByRole('button', { name: 'chat.turnChangesLatestUndoAria' })
expect(undoButton).toHaveAttribute('title', 'chat.turnChangesConversationOnlySubtitle')
expect(screen.queryByText('chat.turnChangesPartialCoverageSubtitle', { exact: false }))
.toBeNull()
expect(screen.getByRole('button', { name: 'chat.turnChangesLatestUndoAria' })).toBeEnabled()
expect(undoButton).toBeEnabled()
})
})
@@ -513,9 +518,12 @@ describe('CurrentTurnChangeCard – open-with buttons', () => {
})
it('shows the same destination chevron on every changed-file row', () => {
const { container } = renderExpandedCard(['/w/proj/README.md', '/w/proj/src/main.ts'])
renderExpandedCard(['/w/proj/README.md', '/w/proj/src/main.ts'])
expect(container.querySelectorAll('.lucide-chevron-right')).toHaveLength(2)
// The card header carries its own disclosure chevron; count the rows'.
const rows = screen.getAllByRole('button', { name: /turnChangesOpen(InWorkspace|File)Aria/ })
expect(rows).toHaveLength(2)
expect(rows.map((row) => row.querySelectorAll('.lucide-chevron-right').length)).toEqual([1, 1])
})
it('clicking README.md open-with opens menu with workspace preview item', async () => {
@@ -646,7 +654,8 @@ describe('CurrentTurnChangeCard – conversation continuity', () => {
it('truthfully labels a historical row as opening the current workspace diff', () => {
renderExpandedCard(['/w/proj/src/main.ts'], false)
expect(screen.getByText('chat.turnChangesCurrentWorkspaceDiff')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'chat.turnChangesHistoricalUndoAria' }))
.toHaveAttribute('title', 'chat.turnChangesCurrentWorkspaceDiff')
})
it('records a stable opener id and semantic turn key before opening the diff', () => {
@@ -1,9 +1,10 @@
import { useCallback, useId, useMemo, useState } from 'react'
import type { MouseEvent as ReactMouseEvent } from 'react'
import { ChevronDown, ChevronRight, ChevronUp } from 'lucide-react'
import { ChevronDown, ChevronRight, ChevronUp, Ellipsis, FileDiff, TriangleAlert, Undo2 } from 'lucide-react'
import type { SessionTurnCheckpoint } from '../../api/sessions'
import { useTranslation, type TranslationKey } from '../../i18n'
import { Button } from '@/components/ui/Button'
import { IconButton } from '@/components/ui/IconButton'
import { OpenWithMenu } from '@/components/composite/OpenWithMenu'
import { FileTypeIcon } from '@/components/ui/FileTypeIcon'
import { describeFileType, isPreviewableChangedFile, type OpenWithItem } from '../../lib/openWithItems'
@@ -167,10 +168,13 @@ export function CurrentTurnChangeCard({
// Follows the message it belongs to inside the same rail box, so it takes a
// top margin and no width of its own — `max-w-[900px]` here would have
// overflowed the column once the rail indented it.
className="mt-2 w-full overflow-hidden rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-[var(--shadow-card)]"
className="mt-2.5 w-full overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]"
aria-label={cardLabel}
>
<div className="flex flex-wrap items-center justify-between gap-2 bg-[var(--color-surface-container-low)] px-3 py-2">
{/* One 40px line. The disclosure's hit area is stretched over the whole
row (its ::after), so the trailing chevron toggles too; the undo
button is lifted above that layer to keep its own click. */}
<div className="relative flex h-10 items-center gap-2 pl-3 pr-2">
<button
type="button"
data-chat-disclosure="true"
@@ -182,45 +186,58 @@ export function CurrentTurnChangeCard({
setOpenWith(null)
onExpandedChange(!expanded)
}}
className="flex min-h-8 min-w-0 flex-1 basis-40 flex-wrap items-center gap-2 rounded-[var(--radius-md)] text-left text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
className="flex min-w-0 flex-1 items-center gap-2 text-left text-[13px] focus-visible:outline-none after:absolute after:inset-0 after:rounded-[var(--radius-lg)] focus-visible:after:ring-2 focus-visible:after:ring-inset focus-visible:after:ring-[var(--color-border-focus)]"
>
{expanded ? <ChevronDown size={16} aria-hidden="true" /> : <ChevronRight size={16} aria-hidden="true" />}
<span className="font-semibold text-[var(--color-text-primary)]">
<FileDiff size={15} strokeWidth={1.75} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
<span className="truncate font-medium text-[var(--color-text-primary)]">
{t('chat.turnChangesTitle', { count: files.length })}
</span>
<span className="font-mono text-xs font-semibold text-[var(--color-diff-added-text)]">+{checkpoint.code.insertions}</span>
<span className="font-mono text-xs font-semibold text-[var(--color-diff-removed-text)]">-{checkpoint.code.deletions}</span>
<span className="shrink-0 font-mono text-xs tabular-nums text-[var(--color-diff-added-text)]">+{checkpoint.code.insertions}</span>
<span className="shrink-0 font-mono text-xs tabular-nums text-[var(--color-diff-removed-text)]">-{checkpoint.code.deletions}</span>
</button>
{/* Never disabled: rolling the conversation back is always possible, even
when the files are not restorable. The dialog picks what to touch. */}
when the files are not restorable. The dialog picks what to touch.
What the checkpoint covers is the button's hint rather than a line
of grey text under every card; the confirm dialog repeats it. */}
<Button
variant="secondary"
size="base"
variant="ghost"
size="sm"
loading={isUndoing}
onClick={onUndo}
aria-label={undoAria}
className="shrink-0"
icon={<span className="material-symbols-outlined text-[15px]" aria-hidden="true">undo</span>}
title={subtitle}
className="relative shrink-0"
icon={<Undo2 size={13} strokeWidth={1.75} aria-hidden="true" />}
>
{isUndoing ? t('chat.turnChangesUndoing') : undoLabel}
</Button>
<ChevronRight
size={14}
strokeWidth={1.75}
aria-hidden="true"
className={`pointer-events-none shrink-0 text-[var(--color-text-tertiary)] transition-transform duration-150 motion-reduce:transition-none ${expanded ? 'rotate-90' : ''}`}
/>
</div>
{(expanded || !restoreAvailable || hasUnverifiedChanges) && (
<div className={`border-t border-[var(--color-border)] px-3 py-2 text-xs ${hasUnverifiedChanges ? 'text-[var(--color-warning)]' : 'text-[var(--color-text-tertiary)]'}`}>
{subtitle}
{/* A partial checkpoint changes what undo will do, so that one stays in
view; the routine "what this card compares" notes live in the hint. */}
{hasUnverifiedChanges && (
<div className="flex items-start gap-2 border-t border-[var(--color-border)] px-3 py-2 text-xs text-[var(--color-text-secondary)]">
<TriangleAlert size={13} strokeWidth={1.75} aria-hidden="true" className="mt-0.5 shrink-0 text-[var(--color-warning)]" />
<span className="min-w-0">{subtitle}</span>
</div>
)}
<div id={filesId} hidden={!expanded}>
{expanded && <div className="divide-y divide-[var(--color-border)]">
{expanded && <div className="border-t border-[var(--color-border)] py-1">
{visibleFiles.map((fileEntry) => {
const fileName = fileEntry.displayPath.split('/').pop() || fileEntry.displayPath
const directory = fileEntry.displayPath.slice(0, fileEntry.displayPath.length - fileName.length)
const typeInfo = describeFileType(fileEntry.displayPath)
const workspacePreviewable = isWorkspacePreviewableFile(fileEntry.displayPath)
return (
<div key={fileEntry.apiPath} className="flex items-center gap-2">
<div key={fileEntry.apiPath} className="flex h-8 items-center gap-0.5 px-1.5">
<button
type="button"
id={`turn-change-opener-${checkpoint.target.targetUserMessageId}-${encodeURIComponent(fileEntry.apiPath)}`}
@@ -233,26 +250,25 @@ export function CurrentTurnChangeCard({
{ 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-border-focus)]"
className="flex h-full min-w-0 flex-1 items-center gap-2 rounded-[var(--radius-sm)] px-1.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)]"
>
<FileTypeIcon path={fileEntry.displayPath} size={24} />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-[var(--color-text-primary)]">{fileName}</span>
<span className="block truncate text-xs text-[var(--color-text-tertiary)]">{`${t(typeInfo.categoryKey as Parameters<typeof t>[0])} · ${typeInfo.ext}`}</span>
<FileTypeIcon path={fileEntry.displayPath} size={14} />
<span className="min-w-0 flex-1 truncate font-mono text-xs text-[var(--color-text-tertiary)]">
{directory}
<span className="font-medium text-[var(--color-text-primary)]">{fileName}</span>
</span>
<ChevronRight size={17} strokeWidth={1.9} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
<span className="shrink-0 text-[11px] text-[var(--color-text-tertiary)]">
{typeInfo.ext || t(typeInfo.categoryKey as Parameters<typeof t>[0])}
</span>
<ChevronRight size={14} strokeWidth={1.75} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
</button>
<Button
variant="secondary"
size="base"
aria-label={t('openWith.title')}
<IconButton
icon={<Ellipsis size={14} strokeWidth={1.75} aria-hidden="true" />}
label={t('openWith.title')}
size="xs"
tone="muted"
onClick={(event) => handleOpenWith(event, fileEntry)}
className="mr-2 shrink-0"
icon={<ChevronDown size={14} strokeWidth={1.9} aria-hidden="true" />}
iconPosition="end"
>
{t('openWith.title')}
</Button>
/>
</div>
)
})}
@@ -264,17 +280,17 @@ export function CurrentTurnChangeCard({
data-chat-disclosure="true"
aria-expanded={showAllFiles}
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-border-focus)]"
className="flex h-8 w-full items-center justify-center gap-1 border-t border-[var(--color-border)] px-4 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 ? (
<>
{t('chat.turnChangesShowLess')}
<ChevronUp size={14} strokeWidth={1.9} />
<ChevronUp size={14} strokeWidth={1.75} />
</>
) : (
<>
{t('chat.turnChangesShowMore', { count: String(files.length - COLLAPSED_COUNT) })}
<ChevronDown size={14} strokeWidth={1.9} />
<ChevronDown size={14} strokeWidth={1.75} />
</>
)}
</button>
@@ -283,7 +299,7 @@ export function CurrentTurnChangeCard({
</div>
{error && (
<div role="alert" className="border-t border-[var(--color-error)] bg-[var(--color-error-container)] px-4 py-3 text-xs text-[var(--color-on-error-container)]">
<div role="alert" className="border-t border-[var(--color-border)] bg-[var(--color-error-container)] px-3 py-2 text-xs text-[var(--color-on-error-container)]">
{error}
</div>
)}
+81 -52
View File
@@ -1,8 +1,10 @@
import ReactDiffViewer, { DiffMethod } from 'react-diff-viewer-continued'
import { Highlight, type PrismTheme } from 'prism-react-renderer'
import { CircleCheck, Copy, FileCode } from 'lucide-react'
import { CopyButton } from '@/components/ui/CopyButton'
import { useTranslation } from '../../i18n'
import { useUIStore } from '../../stores/uiStore'
import { countDiffLines } from './toolCallPresentation'
type Props = {
filePath: string
@@ -29,7 +31,7 @@ const warmSyntaxTheme: PrismTheme = {
backgroundColor: 'transparent',
},
styles: [
{ types: ['comment', 'prolog', 'doctype', 'cdata'], style: { color: 'var(--color-code-comment)', fontStyle: 'italic' as const } },
{ types: ['comment', 'prolog', 'doctype', 'cdata'], style: { color: 'var(--color-code-comment)' } },
{ types: ['string', 'attr-value', 'template-string'], style: { color: 'var(--color-code-string)' } },
{ types: ['keyword', 'selector', 'important', 'atrule'], style: { color: 'var(--color-code-keyword)' } },
{ types: ['function'], style: { color: 'var(--color-code-function)' } },
@@ -64,87 +66,113 @@ function highlightSyntax(str: string, language: string) {
)
}
/**
* Every colour is a theme token, so one palette serves both the library's light
* and dark modes; leaving `dark` unset dropped ink themes onto its defaults.
*/
const diffPalette = {
diffViewerBackground: 'var(--color-surface-container-lowest)',
diffViewerColor: 'var(--color-code-fg)',
addedBackground: 'var(--color-diff-added-bg)',
addedColor: 'var(--color-code-fg)',
removedBackground: 'var(--color-diff-removed-bg)',
removedColor: 'var(--color-code-fg)',
wordAddedBackground: 'var(--color-diff-added-word)',
wordRemovedBackground: 'var(--color-diff-removed-word)',
addedGutterBackground: 'var(--color-diff-added-bg)',
removedGutterBackground: 'var(--color-diff-removed-bg)',
gutterBackground: 'var(--color-surface-container-lowest)',
gutterBackgroundDark: 'var(--color-surface-container-lowest)',
highlightBackground: 'var(--color-diff-highlight-bg)',
highlightGutterBackground: 'var(--color-diff-highlight-gutter)',
codeFoldGutterBackground: 'var(--color-surface-container)',
codeFoldBackground: 'var(--color-surface-container)',
emptyLineBackground: 'var(--color-surface-container-lowest)',
gutterColor: 'var(--color-text-tertiary)',
addedGutterColor: 'var(--color-diff-added-text)',
removedGutterColor: 'var(--color-diff-removed-text)',
codeFoldContentColor: 'var(--color-text-tertiary)',
diffViewerTitleBackground: 'var(--color-diff-title-bg)',
diffViewerTitleColor: 'var(--color-diff-title-color)',
diffViewerTitleBorderColor: 'var(--color-diff-title-border)',
}
const diffStyles = {
variables: {
light: {
diffViewerBackground: 'var(--color-code-bg)',
diffViewerColor: 'var(--color-code-fg)',
addedBackground: 'var(--color-diff-added-bg)',
addedColor: 'var(--color-code-fg)',
removedBackground: 'var(--color-diff-removed-bg)',
removedColor: 'var(--color-code-fg)',
wordAddedBackground: 'var(--color-diff-added-word)',
wordRemovedBackground: 'var(--color-diff-removed-word)',
addedGutterBackground: 'var(--color-diff-added-gutter)',
removedGutterBackground: 'var(--color-diff-removed-gutter)',
gutterBackground: 'var(--color-surface-container-low)',
gutterBackgroundDark: 'var(--color-surface-container)',
highlightBackground: 'var(--color-diff-highlight-bg)',
highlightGutterBackground: 'var(--color-diff-highlight-gutter)',
codeFoldGutterBackground: 'var(--color-surface-container-high)',
codeFoldBackground: 'var(--color-surface-container-highest)',
emptyLineBackground: 'var(--color-surface-container-low)',
gutterColor: 'var(--color-text-tertiary)',
addedGutterColor: 'var(--color-diff-added-text)',
removedGutterColor: 'var(--color-diff-removed-text)',
codeFoldContentColor: 'var(--color-text-tertiary)',
diffViewerTitleBackground: 'var(--color-diff-title-bg)',
diffViewerTitleColor: 'var(--color-diff-title-color)',
diffViewerTitleBorderColor: 'var(--color-diff-title-border)',
},
light: diffPalette,
dark: diffPalette,
},
// The library pins the table to 1000px so long lines scroll sideways. In a
// conversation that turned every diff into a horizontal scroller; lines wrap
// at the column instead, which the content cell already supports.
diffContainer: {
minWidth: 'unset',
borderRadius: '0',
fontSize: '13px',
lineHeight: '1.7',
fontSize: '12px',
lineHeight: '20px',
fontFamily: 'var(--font-mono)',
fontVariantLigatures: 'none',
},
line: {
padding: '0',
},
gutter: {
padding: '0 10px',
minWidth: '44px',
padding: '0 8px',
minWidth: '36px',
fontSize: '12px',
fontVariantNumeric: 'tabular-nums',
},
marker: {
padding: '0 6px',
},
wordDiff: {
padding: '1px 2px',
borderRadius: '2px',
padding: '1px 0',
borderRadius: 'var(--radius-xs)',
},
}
/**
* A file change as a block: a 32px head (path, `+N −N`, copy path) over a
* unified diff with old/new line numbers.
*/
export function DiffViewer({ filePath, oldString, newString }: Props) {
const t = useTranslation()
const theme = useUIStore((state) => state.theme)
const language = inferLanguage(filePath)
const oldLines = oldString.split('\n')
const newLines = newString.split('\n')
const additions = newLines.filter((l, i) => l !== (oldLines[i] ?? null)).length
const deletions = oldLines.filter((l, i) => l !== (newLines[i] ?? null)).length
const { additions, deletions } = countDiffLines(oldString, newString)
// Split so a long absolute path gives way from the left: the directory
// truncates, the file name never does.
const slash = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'))
const directory = slash >= 0 ? filePath.slice(0, slash + 1) : ''
const fileName = slash >= 0 ? filePath.slice(slash + 1) : filePath
return (
<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}
<div
data-diff-block=""
className="overflow-hidden rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]"
>
<div className="flex h-8 items-center gap-2 border-b border-[var(--color-border)] bg-[var(--color-surface-container)] pl-2.5 pr-1.5 text-[12px]">
<FileCode size={14} strokeWidth={1.75} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
<span className="flex min-w-0 font-mono text-[12px]" title={filePath}>
{directory ? <span className="min-w-0 truncate text-[var(--color-text-tertiary)]">{directory}</span> : null}
<span className="shrink-0 text-[var(--color-text-primary)]">{fileName}</span>
</span>
<span className="flex shrink-0 items-center gap-1.5 font-mono tabular-nums">
<span className="text-[var(--color-diff-added-text)]">+{additions}</span>
<span className="text-[var(--color-diff-removed-text)]">−{deletions}</span>
</span>
<span className="flex-1" />
<CopyButton
text={`--- ${filePath}\n+++ ${filePath}`}
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)]"
copiedLabel={t('common.copied')}
displayLabel={<><Copy size={12} strokeWidth={2} aria-hidden="true" />{t('chat.copyPath')}</>}
displayCopiedLabel={<><CircleCheck size={12} strokeWidth={2} aria-hidden="true" />{t('common.copied')}</>}
className="inline-flex h-6 shrink-0 items-center gap-1 rounded-[var(--radius-sm)] px-1.5 text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus:outline-none focus-visible:shadow-[var(--shadow-focus-ring)]"
/>
</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">
<div className="max-h-[340px] overflow-auto">
<ReactDiffViewer
oldValue={oldString}
newValue={newString}
@@ -152,6 +180,7 @@ export function DiffViewer({ filePath, oldString, newString }: Props) {
compareMethod={DiffMethod.WORDS}
renderContent={(str) => highlightSyntax(str, language)}
hideLineNumbers={false}
hideSummary
styles={diffStyles}
useDarkTheme={theme === 'dark'}
/>
@@ -1,5 +1,5 @@
import { useEffect } from 'react'
import { ExternalLink, X } from 'lucide-react'
import { ChevronLeft, ChevronRight, ExternalLink, X } from 'lucide-react'
import { IconButton } from '@/components/ui/IconButton'
import { Modal } from '@/components/ui/Modal'
import { ZoomableImage, type ZoomableImageProps } from '@/components/ui/ZoomableImage'
@@ -112,7 +112,7 @@ export function ImageGalleryModal({ open, images, activeIndex, onClose, onSelect
<>
<div className="absolute left-3 top-1/2 -translate-y-1/2">
<IconButton
icon="chevron_left"
icon={<ChevronLeft size={20} strokeWidth={1.75} aria-hidden="true" />}
label={t('attachments.previousImage')}
size="xl"
tone="secondary"
@@ -124,7 +124,7 @@ export function ImageGalleryModal({ open, images, activeIndex, onClose, onSelect
</div>
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<IconButton
icon="chevron_right"
icon={<ChevronRight size={20} strokeWidth={1.75} aria-hidden="true" />}
label={t('attachments.nextImage')}
size="xl"
tone="secondary"
@@ -185,7 +185,7 @@ function ImageGenerationCollection({
data-testid="image-generation-slot"
data-state="complete"
onClick={() => setActiveIndex(slot.galleryIndex!)}
className="group/image relative aspect-square w-full overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border-separator)] bg-[var(--color-surface-container-low)] text-left shadow-[var(--shadow-card)] transition-[border-color,box-shadow,transform] duration-200 hover:-translate-y-0.5 hover:border-[var(--color-outline)] hover:shadow-[var(--shadow-composer)] active:translate-y-0 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
className="group/image relative aspect-square w-full overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border-separator)] bg-[var(--color-surface-container-low)] text-left transition-[border-color,transform] duration-200 hover:border-[var(--color-outline)] active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
>
<AuthedImage
src={src}
@@ -220,7 +220,7 @@ function ImageGenerationCollection({
data-state="error"
data-error="true"
title={slot.message}
className="flex aspect-square w-full flex-col items-center justify-center gap-2 rounded-[var(--radius-lg)] border border-[var(--color-error)] bg-[var(--color-error-container)] p-3 text-center text-[11px] leading-relaxed text-[var(--color-on-error-container)]"
className="flex aspect-square w-full flex-col items-center justify-center gap-2 rounded-[var(--radius-lg)] bg-[var(--color-error-container)] p-3 text-center text-[11px] leading-relaxed text-[var(--color-on-error-container)]"
>
<TriangleAlert aria-hidden size={17} strokeWidth={1.75} className="shrink-0" />
<span className="line-clamp-4">{slot.message}</span>
@@ -255,7 +255,7 @@ function ImageGenerationCollection({
/>
) : null}
{!hasPending && typeof visibleDuration === 'number' ? (
<span className="font-mono tabular-nums text-[var(--color-outline)]">
<span className="font-mono text-[11px] tabular-nums">
{formatDuration(visibleDuration)}
</span>
) : null}
@@ -1,4 +1,5 @@
import { useMemo, useState } from 'react'
import { Image as ImageIcon, Maximize2 } from 'lucide-react'
import { ErrorState } from '@/components/ui/ErrorState'
import { useTranslation } from '@/i18n'
import { AuthedImage } from './AuthedImage'
@@ -188,8 +189,8 @@ export function InlineImageGallery({ text, sessionId, workDir, changedFiles, sup
return (
<>
<div className="mt-3 space-y-2">
<div className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-outline)]">
<span className="material-symbols-outlined text-[12px]">image</span>
<div className="flex items-center gap-1.5 text-xs text-[var(--color-text-tertiary)]">
<ImageIcon size={12} strokeWidth={2} aria-hidden="true" />
{t(visibleImages.length === 1 ? 'chat.imageCountOne' : 'chat.imageCountOther', { count: visibleImages.length })}
</div>
<div className={`grid gap-2 ${visibleImages.length === 1 ? 'grid-cols-1' : 'grid-cols-2'}`}>
@@ -216,7 +217,7 @@ export function InlineImageGallery({ text, sessionId, workDir, changedFiles, sup
key={`${sessionId ?? ''}|${workDir ?? ''}|${img.src}`}
type="button"
onClick={() => setActiveIndex(i)}
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)]"
className="group/image relative overflow-hidden rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-left transition-[border-color] duration-150 hover:border-[var(--color-outline)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
>
<AuthedImage
src={img.src}
@@ -230,12 +231,12 @@ export function InlineImageGallery({ text, sessionId, workDir, changedFiles, sup
}))}
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/0 opacity-0 transition-all group-hover/image:bg-black/20 group-hover/image:opacity-100">
<span className="material-symbols-outlined rounded-full bg-white/90 p-2 text-[20px] text-[var(--color-text-primary)] shadow-lg">
fullscreen
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-[var(--color-surface-container-lowest)] text-[var(--color-text-primary)] shadow-[var(--shadow-dropdown)]">
<Maximize2 size={16} strokeWidth={1.75} aria-hidden="true" />
</span>
</div>
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent px-2.5 pb-2 pt-6">
<span className="text-[10px] font-medium text-white/90 drop-shadow-sm">
<span className="text-[11px] font-medium text-white/90 drop-shadow-sm">
{img.name}
</span>
</div>
@@ -0,0 +1,37 @@
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { beforeEach, describe, expect, it } from 'vitest'
import { InlineTaskSummary } from './InlineTaskSummary'
import { useSettingsStore } from '../../stores/settingsStore'
describe('InlineTaskSummary', () => {
beforeEach(() => {
useSettingsStore.setState({ locale: 'en' })
})
it('counts finished tasks and marks each status with a lucide icon in its state colour', () => {
const { container } = render(
<InlineTaskSummary
tasks={[
{ id: '1', subject: 'Write the parser', status: 'completed' },
{ id: '2', subject: 'Wire the store', status: 'in_progress' },
{ id: '3', subject: 'Ship it', status: 'pending' },
]}
/>,
)
expect(screen.getByText('1/3')).toBeInTheDocument()
// Material ligature names used to be the icons' text, which find-in-chat
// and copy both picked up.
expect(container.querySelector('.material-symbols-outlined')).toBeNull()
expect(container.textContent).not.toMatch(/check_circle|radio_button_unchecked|task_alt/)
const row = (subject: string) => screen.getByText(subject).parentElement as HTMLElement
expect(row('Write the parser').querySelector('svg.lucide-circle-check')).toHaveStyle({ color: 'var(--color-success)' })
// In progress is a running state: info, never the brand accent.
expect(row('Wire the store').querySelector('svg.lucide-circle-dot')).toHaveStyle({ color: 'var(--color-info)' })
expect(row('Ship it').querySelector('svg.lucide-circle')).toHaveStyle({ color: 'var(--color-text-tertiary)' })
expect(screen.getByText('Write the parser')).toHaveClass('line-through')
expect(screen.getByText('#2')).toHaveClass('font-mono')
})
})
@@ -1,16 +1,12 @@
import { Circle, CircleCheck, CircleDot, ListChecks, type LucideIcon } from 'lucide-react'
import type { TaskSummaryItem } from '../../types/chat'
import { useTranslation } from '../../i18n'
const statusIcon: Record<TaskSummaryItem['status'], string> = {
pending: 'radio_button_unchecked',
in_progress: 'pending',
completed: 'check_circle',
}
const statusColor: Record<TaskSummaryItem['status'], string> = {
pending: 'var(--color-text-tertiary)',
in_progress: 'var(--color-warning)',
completed: 'var(--color-success)',
/** In progress is a running state, so it takes `info` — never the brand colour. */
const statusIcon: Record<TaskSummaryItem['status'], { Icon: LucideIcon; color: string }> = {
pending: { Icon: Circle, color: 'var(--color-text-tertiary)' },
in_progress: { Icon: CircleDot, color: 'var(--color-info)' },
completed: { Icon: CircleCheck, color: 'var(--color-success)' },
}
export function InlineTaskSummary({ tasks }: { tasks: TaskSummaryItem[] }) {
@@ -19,41 +15,37 @@ export function InlineTaskSummary({ tasks }: { tasks: TaskSummaryItem[] }) {
const total = tasks.length
return (
<div className="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-container)]">
<span className="material-symbols-outlined text-[13px] text-[var(--color-success)]" style={{ fontVariationSettings: "'FILL' 1" }}>
task_alt
</span>
</div>
<span className="text-xs font-semibold text-[var(--color-text-primary)]">
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]">
<div className="flex h-10 items-center gap-2 border-b border-[var(--color-border)] px-3 text-[13px]">
<ListChecks size={15} strokeWidth={1.75} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
<span className="font-medium text-[var(--color-text-primary)]">
{t('tasks.completed')}
</span>
<span className="text-[10px] text-[var(--color-text-tertiary)] tabular-nums">
<span className="font-mono text-[11px] tabular-nums text-[var(--color-text-tertiary)]">
{completed}/{total}
</span>
</div>
<div className="px-4 py-2 flex flex-col gap-0.5">
{tasks.map((task) => (
<div key={task.id} className="flex items-center gap-2 py-1 px-1">
<span
className="material-symbols-outlined text-[14px] shrink-0"
style={{ color: statusColor[task.status], fontVariationSettings: "'FILL' 1" }}
>
{statusIcon[task.status]}
</span>
<span className="text-[10px] font-mono text-[var(--color-text-tertiary)]">
#{task.id}
</span>
<span className={`text-xs ${
task.status === 'completed'
? 'text-[var(--color-text-tertiary)] line-through'
: 'text-[var(--color-text-primary)]'
}`}>
{task.subject}
</span>
</div>
))}
<div className="flex flex-col py-1">
{tasks.map((task) => {
const { Icon, color } = statusIcon[task.status]
return (
<div key={task.id} className="flex min-h-7 items-center gap-2 px-3 py-1">
<Icon size={14} strokeWidth={1.75} aria-hidden="true" className="shrink-0" style={{ color }} />
<span className="shrink-0 font-mono text-[11px] tabular-nums text-[var(--color-text-tertiary)]">
#{task.id}
</span>
<span className={`min-w-0 text-[13px] ${
task.status === 'completed'
? 'text-[var(--color-text-tertiary)] line-through'
: task.status === 'in_progress'
? 'font-medium text-[var(--color-text-primary)]'
: 'text-[var(--color-text-primary)]'
}`}>
{task.subject}
</span>
</div>
)
})}
</div>
</div>
)
@@ -1,4 +1,5 @@
import { useMemo, useState } from 'react'
import { Film } from 'lucide-react'
import { AssistantOutputTargetCard } from '@/components/chat/AssistantOutputTargetCard'
import { extractAssistantOutputTargets } from '../../lib/assistantOutputTargets'
import { isAbsoluteLocalPath, localFileUrl, previewFsUrl } from '../../lib/handlePreviewLink'
@@ -140,18 +141,18 @@ function VideoPreview({ video, sessionId, workDir }: { video: GalleryVideo; sess
}
return (
<div className="overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] shadow-sm">
<div className="overflow-hidden rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]">
<video
src={video.src}
controls
preload="metadata"
playsInline
className="w-full rounded-t-xl bg-black"
className="w-full bg-black"
style={{ maxHeight: 420 }}
onError={() => setFailed(true)}
/>
<div className="flex items-center gap-1.5 px-2.5 py-1.5 text-[10px] font-medium text-[var(--color-text-tertiary)]">
<span className="material-symbols-outlined text-[12px]">movie</span>
<div className="flex h-[30px] items-center gap-1.5 border-t border-[var(--color-border)] px-3 text-[11px] text-[var(--color-text-tertiary)]">
<Film size={12} strokeWidth={2} aria-hidden="true" className="shrink-0" />
<span className="truncate">{video.name}</span>
</div>
</div>
@@ -7,10 +7,14 @@ import {
type SessionInspectionResponse,
type SessionUsageSnapshot,
} from '../../api/sessions'
import { Info, PowerOff, RefreshCw, Server, X } from 'lucide-react'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { EmptyState } from '@/components/ui/EmptyState'
import { ErrorState } from '@/components/ui/ErrorState'
import { IconButton } from '@/components/ui/IconButton'
import { LoadingState } from '@/components/ui/LoadingState'
import { SegmentedControl } from '@/components/ui/SegmentedControl'
import { mcpStatusTone } from '@/lib/mcpStatus'
import { useTranslation, type TranslationKey } from '../../i18n'
import { useUIStore } from '../../stores/uiStore'
@@ -105,29 +109,31 @@ function assertSessionInspectionResponse(value: unknown, t: Translate): SessionI
throw new Error(t('slash.inspector.error.unavailable'))
}
/** Small section label: 12px semibold tertiary, sentence case. */
function InspectorSectionTitle({ children, action }: { children: React.ReactNode; action?: React.ReactNode }) {
return (
<div className="mb-3 flex items-center justify-between gap-4">
<div className="font-mono text-[12px] font-semibold uppercase tracking-[0.24em] text-[var(--color-inspector-heading)]">{children}</div>
<div className="mb-2 flex min-h-6 items-center justify-between gap-4">
<div className="text-xs font-semibold text-[var(--color-text-tertiary)]">{children}</div>
{action}
</div>
)
}
/** Sunken tile inside the panel card: no border, so the card does not nest frames. */
function MetricCard({ label, value, detail }: { label: string; value: React.ReactNode; detail?: React.ReactNode }) {
return (
<div className="min-h-[82px] rounded-md border border-[var(--color-inspector-border)] bg-[var(--color-inspector-panel)] px-4 py-4 font-mono">
<div className="text-[12px] uppercase tracking-[0.2em] text-[var(--color-inspector-heading)]">{label}</div>
<div className="mt-3 whitespace-pre-line text-[15px] leading-6 text-[var(--color-inspector-text)]">{value}</div>
{detail && <div className="mt-1 text-[13px] leading-5 text-[var(--color-inspector-muted)]">{detail}</div>}
<div className="min-h-[72px] rounded-[var(--radius-md)] bg-[var(--color-surface-container)] px-3 py-2.5">
<div className="truncate text-xs text-[var(--color-text-tertiary)]">{label}</div>
<div className="mt-1 whitespace-pre-line text-[15px] font-medium leading-6 tabular-nums text-[var(--color-text-primary)]">{value}</div>
{detail && <div className="mt-0.5 text-xs leading-5 text-[var(--color-text-tertiary)]">{detail}</div>}
</div>
)
}
function InspectorNotice({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3 rounded-md border border-[var(--color-inspector-border)] bg-[var(--color-inspector-surface)] px-4 py-3 text-[14px] text-[var(--color-inspector-heading)]">
<span className="material-symbols-outlined text-[18px] text-[var(--color-inspector-muted)]">info</span>
<div className="flex items-center gap-2 rounded-[var(--radius-md)] bg-[var(--color-surface-container)] px-3 py-2 text-[13px] text-[var(--color-text-secondary)]">
<Info size={14} strokeWidth={1.75} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
<span>{children}</span>
</div>
)
@@ -135,13 +141,13 @@ function InspectorNotice({ children }: { children: React.ReactNode }) {
function KeyValueRows({ rows }: { rows: Array<[string, React.ReactNode]> }) {
return (
<div className="overflow-hidden rounded-md border border-[var(--color-inspector-border)] bg-[var(--color-inspector-surface)] font-mono">
<div className="overflow-hidden rounded-[var(--radius-md)] border border-[var(--color-border)]">
{rows.map(([label, value]) => (
<div key={label} className="grid grid-cols-[220px_minmax(0,1fr)] border-t border-[var(--color-inspector-border)] first:border-t-0">
<div className="border-r border-[var(--color-inspector-border)] bg-[var(--color-inspector-panel)] px-4 py-3 text-[12px] font-semibold uppercase tracking-[0.24em] text-[var(--color-inspector-heading)]">
<div key={label} className="grid grid-cols-[180px_minmax(0,1fr)] border-t border-[var(--color-border)] first:border-t-0">
<div className="bg-[var(--color-surface-container)] px-3 py-2 text-xs font-medium text-[var(--color-text-secondary)]">
{label}
</div>
<div className="min-w-0 break-words px-4 py-3 text-[14px] text-[var(--color-inspector-text)]">{value}</div>
<div className="min-w-0 break-words px-3 py-2 text-[13px] text-[var(--color-text-primary)]">{value}</div>
</div>
))}
</div>
@@ -200,23 +206,23 @@ function UsageTab({
: t('slash.inspector.usage.source.currentProcess')
return (
<div className="space-y-7">
<div className="space-y-5">
{useContextUsageFallback && (
<InspectorNotice>
{t('slash.inspector.usage.contextSnapshotNotice')}
</InspectorNotice>
)}
{usage.source === 'transcript' && (
<div className="rounded-md border border-[var(--color-inspector-border)] bg-[var(--color-inspector-surface)] px-4 py-3 text-sm text-[var(--color-inspector-muted-strong)]">
<div className="rounded-[var(--radius-md)] bg-[var(--color-surface-container)] px-3 py-2 text-[13px] text-[var(--color-text-secondary)]">
{t('slash.inspector.usage.transcriptNotice')}
</div>
)}
{usage.hasUnknownModelCost && (
<div className="rounded-xl border border-[var(--color-inspector-border)] bg-[var(--color-inspector-panel)] px-4 py-3 text-sm text-[var(--color-warning)]">
<div className="rounded-[var(--radius-md)] bg-[var(--color-warning-container)] px-3 py-2 text-[13px] text-[var(--color-on-warning-container)]">
{t('slash.inspector.usage.unknownCost')}
</div>
)}
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
<MetricCard label={t('slash.inspector.usage.totalCost')} value={useContextUsageFallback ? 'n/a' : usage.costDisplay} />
<MetricCard label={t('slash.inspector.usage.source')} value={sourceLabel} />
<MetricCard label={t('slash.inspector.usage.apiDuration')} value={usage.source === 'transcript' || useContextUsageFallback ? '0ms' : formatDuration(usage.totalAPIDuration)} />
@@ -231,37 +237,37 @@ function UsageTab({
<MetricCard label={t('slash.inspector.usage.webSearch')} value={formatNumber(usage.totalWebSearchRequests)} />
</div>
<section>
<div className="mb-3 text-[22px] font-semibold text-[var(--color-inspector-text)]">{t('slash.inspector.usage.byModel')}</div>
<InspectorSectionTitle>{t('slash.inspector.usage.byModel')}</InspectorSectionTitle>
{models.length === 0 ? (
<EmptyState title={t('slash.inspector.usage.noModelTitle')} description={t('slash.inspector.usage.noModelBody')} />
) : (
<div className="overflow-hidden rounded-md border border-[var(--color-inspector-border)] bg-[var(--color-inspector-surface)] font-mono">
<div className="overflow-hidden rounded-[var(--radius-md)] border border-[var(--color-border)]">
{models.map((model) => (
<div key={model.model} className="border-t border-[var(--color-inspector-border)] first:border-t-0">
<div className="grid grid-cols-[minmax(0,1fr)_120px] items-center gap-4 border-b border-[var(--color-inspector-border)] px-4 py-3">
<div key={model.model} className="border-t border-[var(--color-border)] first:border-t-0">
<div className="grid grid-cols-[minmax(0,1fr)_120px] items-center gap-4 border-b border-[var(--color-border)] bg-[var(--color-surface-container)] px-3 py-2">
<div className="min-w-0">
<div className="truncate text-[13px] font-semibold text-[var(--color-inspector-text)]">{model.displayName || model.model}</div>
<div className="truncate font-mono text-[13px] text-[var(--color-text-primary)]">{model.displayName || model.model}</div>
{(model.contextWindow > 0 || context?.rawMaxTokens) && (
<div className="mt-1 truncate text-[11px] text-[var(--color-inspector-muted)]">
<div className="mt-0.5 truncate text-[11px] tabular-nums text-[var(--color-text-tertiary)]">
{t('slash.inspector.status.contextWindow')}: {formatNumber(model.contextWindow || context?.rawMaxTokens)}
</div>
)}
</div>
<div className="text-right text-[12px] font-semibold uppercase tracking-[0.18em] text-[var(--color-inspector-heading)]">{t('slash.inspector.usage.tokens')}</div>
<div className="text-right text-xs font-semibold text-[var(--color-text-tertiary)]">{t('slash.inspector.usage.tokens')}</div>
</div>
<div className="grid grid-cols-[160px_minmax(0,1fr)_120px] items-center gap-4 border-b border-[var(--color-inspector-border)] px-4 py-3 last:border-b-0">
<div className="text-[12px] uppercase tracking-[0.18em] text-[var(--color-inspector-heading)]">{t('slash.inspector.usage.input')}</div>
<div className="h-1 overflow-hidden rounded-full bg-[var(--color-inspector-chip)]">
<div className="h-full rounded-full bg-[var(--color-inspector-accent)]" style={{ width: '95%' }} />
<div className="grid grid-cols-[120px_minmax(0,1fr)_120px] items-center gap-4 border-b border-[var(--color-border)] px-3 py-2 last:border-b-0">
<div className="text-xs text-[var(--color-text-secondary)]">{t('slash.inspector.usage.input')}</div>
<div className="h-1 overflow-hidden rounded-full bg-[var(--color-surface-container)]">
<div className="h-full rounded-full bg-[var(--color-brand)]" style={{ width: '95%' }} />
</div>
<div className="text-right text-[13px] text-[var(--color-inspector-text)]">{formatNumber(model.inputTokens)}</div>
<div className="text-right font-mono text-xs tabular-nums text-[var(--color-text-primary)]">{formatNumber(model.inputTokens)}</div>
</div>
<div className="grid grid-cols-[160px_minmax(0,1fr)_120px] items-center gap-4 px-4 py-3">
<div className="text-[12px] uppercase tracking-[0.18em] text-[var(--color-inspector-heading)]">{t('slash.inspector.usage.output')}</div>
<div className="h-1 overflow-hidden rounded-full bg-[var(--color-inspector-chip)]">
<div className="h-full rounded-full bg-[var(--color-inspector-accent-secondary)]" style={{ width: `${Math.max(4, Math.min(100, (model.outputTokens / Math.max(1, model.inputTokens)) * 100))}%` }} />
<div className="grid grid-cols-[120px_minmax(0,1fr)_120px] items-center gap-4 px-3 py-2">
<div className="text-xs text-[var(--color-text-secondary)]">{t('slash.inspector.usage.output')}</div>
<div className="h-1 overflow-hidden rounded-full bg-[var(--color-surface-container)]">
<div className="h-full rounded-full bg-[var(--color-text-tertiary)]" style={{ width: `${Math.max(4, Math.min(100, (model.outputTokens / Math.max(1, model.inputTokens)) * 100))}%` }} />
</div>
<div className="text-right text-[13px] text-[var(--color-inspector-text)]">{formatNumber(model.outputTokens)}</div>
<div className="text-right font-mono text-xs tabular-nums text-[var(--color-text-primary)]">{formatNumber(model.outputTokens)}</div>
</div>
</div>
))}
@@ -285,8 +291,8 @@ function ContextStackedBar({ categories, rawMaxTokens, t }: { categories: Contex
if (activeCategories.length === 0) return null
return (
<div className="overflow-hidden rounded-full bg-[var(--color-inspector-chip)]">
<div className="flex h-2.5 w-full">
<div className="overflow-hidden rounded-full bg-[var(--color-surface-container)]">
<div className="flex h-2 w-full">
{activeCategories.map((category) => (
<div
key={category.name}
@@ -309,9 +315,9 @@ function CategoryBreakdown({ categories, rawMaxTokens, t }: { categories: Contex
}
return (
<div className="rounded-md border border-[var(--color-inspector-border)] bg-[var(--color-inspector-panel)] px-5 py-5 font-mono">
<div className="rounded-[var(--radius-md)] border border-[var(--color-border)] px-4 py-3">
<InspectorSectionTitle>{t('slash.inspector.context.categoryTitle')}</InspectorSectionTitle>
<div className="grid gap-x-10 gap-y-5 sm:grid-cols-2">
<div className="grid gap-x-8 gap-y-3 sm:grid-cols-2">
{visibleCategories.map((category) => {
const percent = rawMaxTokens > 0 ? (category.tokens / rawMaxTokens) * 100 : 0
const muted = isCapacityCategory(category)
@@ -320,23 +326,21 @@ function CategoryBreakdown({ categories, rawMaxTokens, t }: { categories: Contex
key={category.name}
className="min-w-0"
>
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3">
<div className="flex min-w-0 items-center gap-2">
<span className={`min-w-0 truncate text-[14px] font-semibold ${muted ? 'text-[var(--color-inspector-muted-strong)]' : 'text-[var(--color-inspector-text)]'}`}>
{category.name}
</span>
</div>
<div className="shrink-0 text-right leading-tight">
<div className="text-sm text-[var(--color-inspector-text)]">{formatNumber(category.tokens)}</div>
<div className="mt-0.5 text-[12px] text-[var(--color-inspector-muted)]">{formatPercent(percent)}</div>
</div>
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-baseline gap-3">
<span className={`min-w-0 truncate text-[13px] ${muted ? 'text-[var(--color-text-tertiary)]' : 'font-medium text-[var(--color-text-primary)]'}`}>
{category.name}
</span>
<span className="shrink-0 text-right font-mono text-xs tabular-nums text-[var(--color-text-secondary)]">
{formatNumber(category.tokens)}
<span className="ml-1.5 text-[11px] text-[var(--color-text-tertiary)]">{formatPercent(percent)}</span>
</span>
</div>
<div className="mt-2 h-1 overflow-hidden rounded-full bg-[var(--color-inspector-chip)]">
<div className="mt-1.5 h-1 overflow-hidden rounded-full bg-[var(--color-surface-container)]">
<div
className={muted ? 'h-full rounded-full opacity-65' : 'h-full rounded-full'}
className="h-full rounded-full"
style={{
width: `${Math.min(100, Math.max(0.5, percent))}%`,
backgroundColor: muted ? 'var(--color-inspector-capacity)' : 'var(--color-inspector-accent)',
backgroundColor: muted ? 'var(--color-outline)' : 'var(--color-brand)',
}}
/>
</div>
@@ -362,53 +366,45 @@ function MemoryFilesBreakdown({ files, t }: { files: ContextMemoryFile[]; t: Tra
useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings')
}
const openMemoryButton = (path?: string) => (
<Button variant="secondary" size="sm" onClick={() => openSettings(path)}>
{t('slash.inspector.context.openMemory')}
</Button>
)
if (files.length === 0) {
return (
<div className="rounded-md border border-[var(--color-inspector-border)] bg-[var(--color-inspector-panel)] px-5 py-5">
<div className="flex items-center justify-between gap-3">
<InspectorSectionTitle>{t('slash.inspector.context.memoryFiles')}</InspectorSectionTitle>
<button
type="button"
onClick={() => openSettings()}
className="rounded-sm border border-[var(--color-inspector-border)] bg-[var(--color-inspector-chip)] px-2.5 py-1 text-xs font-semibold text-[var(--color-inspector-muted-strong)] hover:text-[var(--color-inspector-text)]"
>
{t('slash.inspector.context.openMemory')}
</button>
</div>
<div className="mt-4 text-sm text-[var(--color-inspector-muted)]">{t('slash.inspector.context.noMemoryFiles')}</div>
<div className="rounded-[var(--radius-md)] border border-[var(--color-border)] px-4 py-3">
<InspectorSectionTitle action={openMemoryButton()}>
{t('slash.inspector.context.memoryFiles')}
</InspectorSectionTitle>
<div className="text-[13px] text-[var(--color-text-tertiary)]">{t('slash.inspector.context.noMemoryFiles')}</div>
</div>
)
}
return (
<div className="rounded-md border border-[var(--color-inspector-border)] bg-[var(--color-inspector-panel)] px-5 py-5">
<div className="flex items-center justify-between gap-3">
<InspectorSectionTitle>{t('slash.inspector.context.memoryFiles')}</InspectorSectionTitle>
<button
type="button"
onClick={() => openSettings(files[0]?.path)}
className="rounded-sm border border-[var(--color-inspector-border)] bg-[var(--color-inspector-chip)] px-2.5 py-1 text-xs font-semibold text-[var(--color-inspector-muted-strong)] hover:text-[var(--color-inspector-text)]"
>
{t('slash.inspector.context.openMemory')}
</button>
</div>
<div className="mt-4 grid gap-2">
<div className="rounded-[var(--radius-md)] border border-[var(--color-border)] px-4 py-3">
<InspectorSectionTitle action={openMemoryButton(files[0]?.path)}>
{t('slash.inspector.context.memoryFiles')}
</InspectorSectionTitle>
<div className="grid gap-1.5">
{files.map((file) => (
<div
key={`${file.type}:${file.path}`}
className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-md border border-[var(--color-inspector-border)] bg-[var(--color-inspector-surface)] px-3 py-2"
className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-[var(--radius-sm)] bg-[var(--color-surface-container)] px-3 py-2"
>
<div className="min-w-0">
<div className="truncate font-mono text-sm font-semibold text-[var(--color-inspector-text)]" title={file.path}>
<div className="truncate font-mono text-[13px] font-medium text-[var(--color-text-primary)]" title={file.path}>
{memoryContextFileLabel(file.path)}
</div>
<div className="mt-0.5 truncate font-mono text-[11px] text-[var(--color-inspector-muted)]" title={file.path}>
<div className="mt-0.5 truncate font-mono text-[11px] text-[var(--color-text-tertiary)]" title={file.path}>
{file.path}
</div>
</div>
<div className="shrink-0 text-right font-mono">
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-[var(--color-inspector-muted-strong)]">{file.type}</div>
<div className="mt-0.5 text-[11px] text-[var(--color-inspector-muted)]">{t('common.tokens', { count: formatNumber(file.tokens) })}</div>
<div className="shrink-0 text-right">
<div className="text-xs text-[var(--color-text-secondary)]">{file.type}</div>
<div className="mt-0.5 font-mono text-[11px] tabular-nums text-[var(--color-text-tertiary)]">{t('common.tokens', { count: formatNumber(file.tokens) })}</div>
</div>
</div>
))}
@@ -419,10 +415,10 @@ function MemoryFilesBreakdown({ files, t }: { files: ContextMemoryFile[]; t: Tra
function ContextStatPill({ label, value, detail }: { label: string; value: string; detail?: string }) {
return (
<div className="min-w-0 font-mono">
<div className="truncate text-[12px] font-semibold uppercase tracking-[0.22em] text-[var(--color-inspector-muted)]">{label}</div>
<div className="mt-2 truncate text-[16px] font-semibold text-[var(--color-inspector-text)]">{value}</div>
{detail && <div className="mt-1 truncate text-[13px] text-[var(--color-inspector-muted)]">{detail}</div>}
<div className="min-w-0">
<div className="truncate text-xs text-[var(--color-text-tertiary)]">{label}</div>
<div className="mt-1 truncate text-[15px] font-semibold tabular-nums text-[var(--color-text-primary)]">{value}</div>
{detail && <div className="mt-0.5 truncate text-xs text-[var(--color-text-tertiary)]">{detail}</div>}
</div>
)
}
@@ -436,30 +432,29 @@ function statusDisplayLabel(status: string, t: Translate) {
function InspectorStatusBadge({ status, t }: { status: string; t: Translate }) {
const normalized = status.toLowerCase()
const isConnected = normalized === 'connected'
const isFailed = normalized === 'failed'
const badgeClass = isConnected
? 'bg-[var(--color-inspector-success-bg)] text-[var(--color-inspector-success)]'
: isFailed
? 'bg-[var(--color-inspector-danger-bg)] text-[var(--color-inspector-danger)]'
: 'bg-[var(--color-inspector-chip)] text-[var(--color-inspector-muted-strong)]'
const dotClass = isConnected ? 'bg-[var(--color-inspector-success)]' : isFailed ? 'bg-[var(--color-inspector-danger)]' : 'bg-[var(--color-inspector-muted)]'
const tone = normalized === 'connected' ? 'success' : normalized === 'failed' ? 'danger' : 'neutral'
return (
<span className={`inline-flex items-center gap-1.5 rounded-sm px-2.5 py-1 font-mono text-[10px] font-semibold uppercase tracking-[0.08em] ${badgeClass}`}>
<span className={`h-1.5 w-1.5 rounded-full ${dotClass}`} />
<Badge
tone={tone}
size="md"
icon={<span aria-hidden="true" className="h-1.5 w-1.5 rounded-full bg-current" />}
>
{statusDisplayLabel(status, t)}
</span>
</Badge>
)
}
function McpServerIcon({ status }: { status: string }) {
const isFailed = status === 'failed'
const icon = isFailed ? 'power_off' : 'dns'
const Icon = isFailed ? PowerOff : Server
return (
<span className={`material-symbols-outlined text-[20px] ${isFailed ? 'text-[var(--color-inspector-danger)]' : 'text-[var(--color-inspector-success)]'}`}>
{icon}
</span>
<Icon
size={16}
strokeWidth={1.75}
aria-hidden="true"
className={`shrink-0 ${isFailed ? 'text-[var(--color-error)]' : 'text-[var(--color-success)]'}`}
/>
)
}
@@ -468,31 +463,38 @@ function ContextOverview({ context, categories, t }: { context: SessionContextSn
const freeTokens = Math.max(0, context.rawMaxTokens - context.totalTokens)
const freePercent = context.rawMaxTokens > 0 ? (freeTokens / context.rawMaxTokens) * 100 : 0
return (
<div className="rounded-md border border-[var(--color-inspector-border)] bg-[var(--color-inspector-panel)] px-5 py-6">
<div className="mb-8 flex items-start justify-between gap-4">
<InspectorSectionTitle>{t('slash.inspector.context.windowUsage')}</InspectorSectionTitle>
<span className="rounded-sm border border-[var(--color-inspector-border)] bg-[var(--color-inspector-chip)] px-2 py-1 font-mono text-xs text-[var(--color-inspector-muted-strong)]">{context.model}</span>
<div className="rounded-[var(--radius-md)] border border-[var(--color-border)] px-4 py-3">
<InspectorSectionTitle
action={(
<span className="max-w-[50%] truncate rounded-[var(--radius-xs)] bg-[var(--color-surface-container)] px-1.5 py-0.5 font-mono text-[11px] text-[var(--color-text-secondary)]">
{context.model}
</span>
)}
>
{t('slash.inspector.context.windowUsage')}
</InspectorSectionTitle>
<div className="flex flex-wrap items-baseline gap-x-2 text-[22px] font-semibold tabular-nums text-[var(--color-text-primary)]">
<span>
{formatNumber(context.totalTokens)}
<span className="mx-1 font-normal text-[var(--color-text-tertiary)]">/</span>
<span>{formatNumber(context.rawMaxTokens)}</span>
</span>
<span className="text-[13px] font-normal text-[var(--color-text-tertiary)]">{formatPercent(usedPercent)} {t('slash.inspector.context.used')}</span>
</div>
<div className="font-mono text-[24px] font-semibold text-[var(--color-inspector-text)]">
{formatNumber(context.totalTokens)}
<span className="mx-1.5 text-[var(--color-inspector-text)]">/</span>
<span>{formatNumber(context.rawMaxTokens)}</span>
<span className="ml-3 align-middle text-sm font-normal text-[var(--color-inspector-accent-secondary)]">[{formatPercent(usedPercent)} {t('slash.inspector.context.used')}]</span>
</div>
<div className="mt-7">
<div className="mt-3">
<ContextStackedBar categories={categories} rawMaxTokens={context.rawMaxTokens} t={t} />
</div>
<div className="mt-8 grid grid-cols-2 gap-3 lg:grid-cols-4">
<div className="rounded-md border border-[var(--color-inspector-border)] bg-[var(--color-inspector-surface)] px-4 py-3">
<div className="mt-4 grid grid-cols-2 gap-2 lg:grid-cols-4">
<div className="rounded-[var(--radius-md)] bg-[var(--color-surface-container)] px-3 py-2.5">
<ContextStatPill label={t('slash.inspector.context.free')} value={formatNumber(freeTokens)} detail={formatPercent(freePercent)} />
</div>
<div className="rounded-md border border-[var(--color-inspector-border)] bg-[var(--color-inspector-surface)] px-4 py-3">
<div className="rounded-[var(--radius-md)] bg-[var(--color-surface-container)] px-3 py-2.5">
<ContextStatPill label={t('slash.inspector.context.messages')} value={formatNumber(context.messageBreakdown?.assistantMessageTokens ?? 0)} detail={t('slash.inspector.context.assistant')} />
</div>
<div className="rounded-md border border-[var(--color-inspector-border)] bg-[var(--color-inspector-surface)] px-4 py-3">
<div className="rounded-[var(--radius-md)] bg-[var(--color-surface-container)] px-3 py-2.5">
<ContextStatPill label={t('slash.inspector.context.toolResults')} value={formatNumber(context.messageBreakdown?.toolResultTokens ?? 0)} />
</div>
<div className="rounded-md border border-[var(--color-inspector-border)] bg-[var(--color-inspector-surface)] px-4 py-3">
<div className="rounded-[var(--radius-md)] bg-[var(--color-surface-container)] px-3 py-2.5">
<ContextStatPill label={t('slash.inspector.context.context')} value={formatPercent(usedPercent)} />
</div>
</div>
@@ -519,7 +521,7 @@ function ContextTab({
const categories = Array.isArray(context.categories) ? context.categories : []
return (
<div className="space-y-6">
<div className="space-y-3">
<ContextOverview context={context} categories={categories} t={t} />
<MemoryFilesBreakdown files={Array.isArray(context.memoryFiles) ? context.memoryFiles : []} t={t} />
<CategoryBreakdown categories={categories} rawMaxTokens={context.rawMaxTokens} t={t} />
@@ -547,13 +549,13 @@ function StatusTab({
const connectedMcp = mcpServers.filter((server) => server.status === 'connected').length
const failedMcp = mcpServers.filter((server) => server.status === 'failed').length
return (
<div className="space-y-8">
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<div className="space-y-5">
<div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
<MetricCard
label={t('slash.inspector.status.cliStatus')}
value={(
<span className="inline-flex items-center gap-2">
<span className={`h-2 w-2 rounded-full ${data.active ? 'bg-[var(--color-inspector-success)]' : 'bg-[var(--color-inspector-danger)]'}`} />
<span className={`h-2 w-2 rounded-full ${data.active ? 'bg-[var(--color-success)]' : 'bg-[var(--color-error)]'}`} />
{data.active ? t('slash.inspector.status.running') : t('slash.inspector.status.notRunning')}
</span>
)}
@@ -567,16 +569,16 @@ function StatusTab({
label={t('slash.inspector.status.mcpConnections')}
value={(
<span>
<span className="text-[var(--color-inspector-success)]">{formatNumber(connectedMcp)}</span>
<span className="mx-5 text-[var(--color-inspector-text)]">/</span>
<span className="text-[var(--color-inspector-danger)]">{formatNumber(failedMcp)}</span>
<span className="text-[var(--color-success)]">{formatNumber(connectedMcp)}</span>
<span className="mx-2 font-normal text-[var(--color-text-tertiary)]">/</span>
<span className="text-[var(--color-error)]">{formatNumber(failedMcp)}</span>
</span>
)}
detail={(
<span>
<span className="text-[var(--color-inspector-success)]">{t('slash.inspector.status.connected')}</span>
<span className="mx-5 text-[var(--color-inspector-text)]" />
<span className="text-[var(--color-inspector-danger)]">{t('slash.inspector.status.failed')}</span>
<span>{t('slash.inspector.status.connected')}</span>
<span className="mx-1.5" aria-hidden="true">·</span>
<span>{t('slash.inspector.status.failed')}</span>
</span>
)}
/>
@@ -587,9 +589,9 @@ function StatusTab({
<KeyValueRows
rows={[
[t('slash.inspector.status.version'), data.status.version ?? t('slash.inspector.status.unknown')],
[t('slash.inspector.status.sessionId'), <span className="font-mono text-[13px]">{data.status.sessionId}</span>],
[t('slash.inspector.status.workingDirectory'), <span className="font-mono text-[13px]">{data.status.cwd ?? data.status.workDir}</span>],
[t('slash.inspector.status.permissionMode'), <span className="rounded-sm bg-[var(--color-inspector-chip)] px-1.5 py-1">{data.status.permissionMode}</span>],
[t('slash.inspector.status.sessionId'), <span className="font-mono text-xs">{data.status.sessionId}</span>],
[t('slash.inspector.status.workingDirectory'), <span className="font-mono text-xs">{data.status.cwd ?? data.status.workDir}</span>],
[t('slash.inspector.status.permissionMode'), <span className="rounded-[var(--radius-xs)] bg-[var(--color-surface-container)] px-1.5 py-0.5 font-mono text-xs">{data.status.permissionMode}</span>],
[t('slash.inspector.status.authToken'), data.status.apiKeySource ?? t('slash.inspector.status.unknown')],
[t('slash.inspector.status.outputStyle'), data.status.outputStyle ?? t('slash.inspector.status.default')],
]}
@@ -598,19 +600,27 @@ function StatusTab({
{mcpServers.length > 0 && (
<section>
<InspectorSectionTitle
action={<button type="button" className="font-mono text-[12px] tracking-[0.18em] text-[var(--color-inspector-accent)] hover:text-[var(--color-inspector-accent-hover)]">↻ {t('slash.inspector.status.refresh')}</button>}
action={(
<Button
variant="ghost"
size="sm"
icon={<RefreshCw size={12} strokeWidth={2} aria-hidden="true" />}
>
{t('slash.inspector.status.refresh')}
</Button>
)}
>
{t('slash.inspector.status.mcpServers')}
</InspectorSectionTitle>
<div className="grid gap-3 lg:grid-cols-2">
<div className="grid gap-2 lg:grid-cols-2">
{mcpServers.map((server) => (
<div
key={`${server.name}:${server.status}`}
className="flex min-h-[48px] items-center justify-between gap-4 rounded-md border border-[var(--color-inspector-border)] bg-[var(--color-inspector-panel)] px-4 py-3 font-mono"
className="flex min-h-10 items-center justify-between gap-3 rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-2"
>
<div className="flex min-w-0 items-center gap-3">
<div className="flex min-w-0 items-center gap-2.5">
<McpServerIcon status={server.status} />
<span className="min-w-0 truncate text-[14px] text-[var(--color-inspector-text)]">{server.name}</span>
<span className="min-w-0 truncate text-[13px] text-[var(--color-text-primary)]">{server.name}</span>
</div>
<InspectorStatusBadge status={server.status} t={t} />
</div>
@@ -639,37 +649,29 @@ function SessionInspectorShell({
}) {
return (
<div
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)]"
className="absolute bottom-full left-0 right-0 z-[var(--z-dropdown)] mb-2 overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] text-[var(--color-text-primary)] shadow-[var(--shadow-dropdown)]"
>
<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>
<div className="flex items-center gap-8">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => onSelectTab(tab.id)}
className={`relative h-10 px-0 font-sans text-sm transition-colors ${
selectedTab === tab.id ? 'text-[var(--color-inspector-accent)]' : 'text-[var(--color-inspector-muted-strong)] hover:text-[var(--color-inspector-accent)]'
}`}
>
{tab.label}
{selectedTab === tab.id && <span className="absolute bottom-1 left-0 right-0 h-[2px] bg-[var(--color-inspector-accent)]" />}
</button>
))}
</div>
<div className="grid min-h-12 grid-cols-[1fr_auto_1fr] items-center gap-3 border-b border-[var(--color-border)] px-4 py-2">
<div className="truncate text-sm font-semibold text-[var(--color-text-primary)]">{t('slash.inspector.title')}</div>
<SegmentedControl
as="tablist"
size="sm"
label={t('slash.inspector.title')}
items={tabs.map((tab) => ({ value: tab.id, label: tab.label }))}
value={selectedTab}
onChange={onSelectTab}
/>
<div className="flex justify-end">
<button
type="button"
<IconButton
icon={<X size={16} strokeWidth={1.75} aria-hidden="true" />}
label={t('slash.inspector.close')}
size="sm"
tone="muted"
onClick={onClose}
aria-label={t('slash.inspector.close')}
className="flex h-10 w-10 items-center justify-center text-[var(--color-inspector-accent)] transition-colors hover:text-[var(--color-inspector-accent-hover)]"
>
<span className="material-symbols-outlined text-[24px]">close</span>
</button>
/>
</div>
</div>
<div className="max-h-[min(540px,58vh)] overflow-y-auto bg-[var(--color-inspector-surface)] px-6 py-6">{children}</div>
<div className="max-h-[min(540px,58vh)] overflow-y-auto p-4">{children}</div>
</div>
)
}
@@ -848,14 +850,14 @@ function McpPanel({ cwd, onClose }: { cwd?: string; onClose: () => void }) {
) : servers.length === 0 ? (
<EmptyState title={t('slash.mcp.emptyTitle')} description={t('slash.mcp.emptyBody')} />
) : (
<div className="space-y-5">
<div className="space-y-4">
{['user', 'local', 'project'].filter((scope) => grouped.has(scope)).map((scope) => (
<section key={scope}>
<div className="mb-2 flex items-center justify-between">
<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 className="mb-1.5 flex items-center justify-between px-1">
<div className="text-xs font-semibold text-[var(--color-text-tertiary)]">{scopeLabel(scope, t)}</div>
<div className="font-mono text-[11px] tabular-nums text-[var(--color-text-tertiary)]">{grouped.get(scope)?.length ?? 0}</div>
</div>
<div className="overflow-hidden rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)]">
<div className="overflow-hidden rounded-[var(--radius-md)] border border-[var(--color-border)]">
{grouped.get(scope)?.map((server) => (
<button
type="button"
@@ -866,15 +868,15 @@ function McpPanel({ cwd, onClose }: { cwd?: string; onClose: () => void }) {
useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings')
onClose()
}}
className="block w-full border-t border-[var(--color-border)] px-4 py-4 text-left first:border-t-0 hover:bg-[var(--color-surface-hover)]"
className="block w-full border-t border-[var(--color-border)] px-3 py-2.5 text-left transition-colors first:border-t-0 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)]"
>
<div className="flex items-center gap-3">
<div className="text-sm font-semibold text-[var(--color-text-primary)]">{server.name}</div>
<Badge tone={mcpStatusTone(server.status)} size="md" bordered className="font-semibold">
<div className="flex items-center gap-2">
<div className="text-[13px] font-medium text-[var(--color-text-primary)]">{server.name}</div>
<Badge tone={mcpStatusTone(server.status)} size="md">
{server.statusLabel}
</Badge>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-[var(--color-text-tertiary)]">
<div className="mt-1.5 flex flex-wrap items-center gap-1.5 text-xs text-[var(--color-text-tertiary)]">
<Badge size="md">{server.transport}</Badge>
{server.projectPath && (
<Badge size="md" title={server.projectPath}>
@@ -929,7 +931,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-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)]">
<div className="overflow-hidden rounded-[var(--radius-md)] border border-[var(--color-border)]">
{skills.map((skill) => (
<button
type="button"
@@ -940,13 +942,13 @@ function SkillsPanel({ cwd, onClose }: { cwd?: string; onClose: () => void }) {
useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings')
onClose()
}}
className="block w-full border-t border-[var(--color-border)] px-4 py-4 text-left first:border-t-0 hover:bg-[var(--color-surface-hover)]"
className="block w-full border-t border-[var(--color-border)] px-3 py-2.5 text-left transition-colors first:border-t-0 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)]"
>
<div className="flex items-center gap-3">
<div className="text-sm font-semibold text-[var(--color-text-primary)]">/{skill.name}</div>
<div className="flex items-center gap-2">
<div className="font-mono text-[13px] text-[var(--color-text-primary)]">/{skill.name}</div>
<Badge size="md">{skill.source}</Badge>
</div>
<div className="mt-2 text-xs leading-6 text-[var(--color-text-tertiary)]">{skill.description}</div>
<div className="mt-1 text-xs leading-5 text-[var(--color-text-tertiary)]">{skill.description}</div>
</button>
))}
</div>
@@ -996,9 +998,9 @@ function HelpPanel({
)
const renderCommand = (command: SlashCommandOption) => (
<div key={command.name} className="flex min-w-0 items-start gap-3 border-t border-[var(--color-border)] px-4 py-3 first:border-t-0">
<div key={command.name} className="flex min-w-0 items-start gap-3 border-t border-[var(--color-border)] px-3 py-2 first:border-t-0">
<div className="flex min-w-[120px] max-w-[45%] shrink-0 flex-wrap items-baseline gap-x-1.5 font-mono">
<span className="text-sm font-semibold text-[var(--color-text-primary)]">/{command.name}</span>
<span className="text-[13px] text-[var(--color-text-primary)]">/{command.name}</span>
{command.argumentHint ? (
<span className="text-[11px] leading-5 text-[var(--color-text-tertiary)]">{command.argumentHint}</span>
) : null}
@@ -1021,8 +1023,8 @@ function HelpPanel({
if (entries.length === 0) return null
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-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)]">
<div className="mb-1.5 px-1 text-xs font-semibold text-[var(--color-text-tertiary)]">{t(group.titleKey)}</div>
<div className="overflow-hidden rounded-[var(--radius-md)] border border-[var(--color-border)]">
{entries.map(renderCommand)}
</div>
</section>
@@ -1031,8 +1033,8 @@ 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-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)]">
<div className="mb-1.5 px-1 text-xs font-semibold text-[var(--color-text-tertiary)]">{t('slash.help.group.more')}</div>
<div className="overflow-hidden rounded-[var(--radius-md)] border border-[var(--color-border)]">
{otherCommands.map(renderCommand)}
</div>
{hiddenOtherCommandCount > 0 && (
+23 -24
View File
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/Button'
import { IconButton } from '@/components/ui/IconButton'
import { Modal } from '@/components/ui/Modal'
import { CopyButton } from '@/components/ui/CopyButton'
import { CircleAlert, LoaderCircle, Maximize2, Minus, Network, Plus } from 'lucide-react'
import { useUIStore } from '../../stores/uiStore'
import { useTranslation } from '../../i18n'
import { isDarkTheme, type ThemeMode } from '../../types/settings'
@@ -20,7 +21,7 @@ type Props = {
*/
const COPY_CHIP_CLASS = [
'inline-flex h-6 items-center justify-center gap-1.5 rounded-[var(--radius-md)]',
'border border-[var(--color-border)] px-2 text-xs font-medium',
'px-2 text-xs font-medium',
'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)]',
@@ -659,9 +660,9 @@ export function MermaidRenderer({ code }: Props) {
if (error) {
return (
<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>
<div className="my-4 overflow-hidden rounded-[var(--radius-md)] border border-[var(--color-error-soft-hover)]">
<div className="flex h-[30px] items-center gap-1.5 border-b border-[var(--color-error-soft-hover)] bg-[var(--color-error-container)] px-3 text-[11px] font-medium text-[var(--color-on-error-container)]">
<CircleAlert size={13} strokeWidth={1.75} aria-hidden="true" />
{t('mermaid.renderError')}
</div>
<div className="bg-[var(--color-error-soft)] px-3 py-2 font-mono text-[11px] text-[var(--color-on-error-container)]">
@@ -673,9 +674,9 @@ 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)] 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>
<div className="my-4 flex items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] py-8">
<div className="flex items-center gap-2 text-xs text-[var(--color-text-tertiary)]">
<LoaderCircle size={14} strokeWidth={1.75} className="animate-spin" aria-hidden="true" />
{t('mermaid.rendering')}
</div>
</div>
@@ -684,20 +685,19 @@ export function MermaidRenderer({ code }: Props) {
return (
<>
<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-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>
</div>
<div className="my-4 overflow-hidden rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-code-bg)]">
{/* Header: the same quiet 30px line as a code block. */}
<div className="flex h-[30px] items-center justify-between border-b border-[var(--color-border)] pl-3 pr-1 text-[11px] text-[var(--color-text-tertiary)]">
<div className="flex items-center gap-1.5">
<Network size={12} strokeWidth={2} aria-hidden="true" />
<span className="font-mono">mermaid</span>
</div>
<div className="flex items-center gap-0.5">
<Button
variant="ghost"
size="sm"
onClick={handlePreview}
className="border border-[var(--color-border)]"
icon={<span className="material-symbols-outlined text-[12px]" aria-hidden="true">fullscreen</span>}
icon={<Maximize2 size={12} strokeWidth={2} aria-hidden="true" />}
>
{t('mermaid.preview')}
</Button>
@@ -728,13 +728,13 @@ export function MermaidRenderer({ code }: Props) {
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-semibold text-[var(--color-text-primary)]">
<span className="material-symbols-outlined text-[18px]">account_tree</span>
<Network size={16} strokeWidth={1.75} aria-hidden="true" className="text-[var(--color-text-tertiary)]" />
{t('mermaid.previewTitle')}
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1 py-1">
<div className="flex items-center gap-1 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-0.5">
<IconButton
icon="remove"
icon={<Minus size={16} strokeWidth={1.75} aria-hidden="true" />}
label={t('mermaid.zoomOut')}
showTooltip={false}
size="md"
@@ -745,7 +745,7 @@ export function MermaidRenderer({ code }: Props) {
variant="ghost"
size="base"
onClick={resetZoom}
className="min-w-[68px] font-semibold"
className="min-w-[68px] tabular-nums"
>
{Math.round(previewZoom * 100)}%
</Button>
@@ -754,12 +754,11 @@ export function MermaidRenderer({ code }: Props) {
size="base"
onClick={fitPreview}
aria-label={t('mermaid.fitDiagram')}
className="font-semibold"
>
{t('mermaid.fit')}
</Button>
<IconButton
icon="add"
icon={<Plus size={16} strokeWidth={1.75} aria-hidden="true" />}
label={t('mermaid.zoomIn')}
showTooltip={false}
size="md"
@@ -773,7 +772,7 @@ export function MermaidRenderer({ code }: Props) {
<div
ref={previewViewportRef}
data-testid="mermaid-preview-viewport"
className="overflow-auto rounded-xl bg-[var(--color-surface-container-lowest)]"
className="overflow-auto rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]"
style={{
maxHeight: '75vh',
cursor: isDraggingPreview ? 'grabbing' : 'grab',
@@ -800,7 +799,7 @@ export function MermaidRenderer({ code }: Props) {
</div>
</div>
</div>
<div className="text-[11px] text-[var(--color-text-tertiary)]">
<div className="text-xs text-[var(--color-text-tertiary)]">
{t('mermaid.previewHint')}
</div>
</div>
@@ -19,16 +19,19 @@ export type MessageEditAction = {
/**
* The copy chip and the branch chip sit side by side and must look identical.
* The branch one is an `IconButton size="sm" tone="muted" shape="circle"`;
* `CopyButton` is styled by className, so its shell is mirrored here.
* The branch one is an `IconButton size="xs" tone="muted"`; `CopyButton` is
* styled by className, so its shell is mirrored here.
*/
const ACTION_CHIP_CLASS = [
'inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full',
'inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-[var(--radius-sm)]',
'text-[var(--color-text-tertiary)] transition-colors duration-150 cursor-pointer',
'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)]',
].join(' ')
const ICON_SIZE = 14
const ICON_STROKE = 1.75
type Props = {
copyText?: string
copyLabel: string
@@ -44,6 +47,13 @@ type Props = {
* present affordance hard to find.
*/
alwaysVisible?: boolean
/**
* `overlay` hangs the bar just under its message, absolutely positioned, so a
* hover-only bar never holds the transcript open by its own height. The
* caller's shell must be `relative`; the space it lands in is the turn gap
* that `.chat-turn-rail--none` already reserves, not something the bar adds.
*/
placement?: 'inline' | 'overlay'
}
export function MessageActionBar({
@@ -55,6 +65,7 @@ export function MessageActionBar({
timestamp,
metadata,
alwaysVisible = false,
placement = 'inline',
}: Props) {
const locale = useSettingsStore((state) => state.locale)
const hasCopy = Boolean(copyText?.trim())
@@ -71,30 +82,35 @@ export function MessageActionBar({
<div
data-message-actions
data-align={align}
className={`mt-2 flex h-7 w-full transition-opacity duration-150 ${
data-placement={placement}
className={[
'flex h-6 transition-opacity duration-150',
placement === 'overlay'
? `absolute top-full ${align === 'end' ? 'right-0' : 'left-0'}`
: 'mt-1.5 w-full',
alwaysVisible
? ''
: 'pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100'
} ${align === 'end' ? 'justify-end' : 'justify-start'}`}
: 'pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100',
align === 'end' ? 'justify-end' : 'justify-start',
].filter(Boolean).join(' ')}
>
<div className="flex min-h-7 min-w-0 items-center gap-1.5">
<div className="flex min-h-6 min-w-0 items-center gap-0.5">
{hasCopy ? (
<CopyButton
text={copyText!}
label={copyLabel}
displayLabel={<Copy size={13} strokeWidth={2.2} aria-hidden="true" />}
displayCopiedLabel={<Check size={13} strokeWidth={2.4} aria-hidden="true" />}
displayLabel={<Copy size={ICON_SIZE} strokeWidth={ICON_STROKE} aria-hidden="true" />}
displayCopiedLabel={<Check size={ICON_SIZE} strokeWidth={ICON_STROKE} aria-hidden="true" />}
onPointerUp={(event) => event.currentTarget.blur()}
className={ACTION_CHIP_CLASS}
/>
) : null}
{branchAction ? (
<IconButton
icon={<GitFork size={13} strokeWidth={2.2} aria-hidden="true" />}
icon={<GitFork size={ICON_SIZE} strokeWidth={ICON_STROKE} aria-hidden="true" />}
label={branchAction.label}
size="sm"
size="xs"
tone="muted"
shape="circle"
disabled={branchAction.loading}
onClick={branchAction.onBranch}
onPointerUp={(event) => event.currentTarget.blur()}
@@ -102,24 +118,23 @@ export function MessageActionBar({
) : null}
{editAction ? (
<IconButton
icon={<Pencil size={13} strokeWidth={2.2} aria-hidden="true" />}
icon={<Pencil size={ICON_SIZE} strokeWidth={ICON_STROKE} aria-hidden="true" />}
label={editAction.label}
size="sm"
size="xs"
tone="muted"
shape="circle"
disabled={editAction.disabled}
onClick={editAction.onEdit}
onPointerUp={(event) => event.currentTarget.blur()}
/>
) : null}
{metadata ? (
<span className={hasCopy || branchAction || editAction ? 'ml-3 min-w-0' : 'min-w-0'}>
<span className={hasCopy || branchAction || editAction ? 'ml-2 min-w-0' : 'min-w-0'}>
{metadata}
</span>
) : null}
{hoverTimeLabel ? (
<span
className="ml-1 inline-flex items-center text-[11px] font-medium tabular-nums text-[var(--color-text-tertiary)]"
className="ml-1.5 inline-flex items-center whitespace-nowrap text-xs tabular-nums text-[var(--color-text-tertiary)]"
title={exactTimeLabel || hoverTimeLabel}
>
{hoverTimeLabel}
@@ -2180,22 +2180,27 @@ describe('MessageList nested tool calls', () => {
}
setChatState('thinking')
render(<MessageList />)
const { container } = render(<MessageList />)
fireEvent.click(screen.getByRole('button', { name: /dispatched 4 agents/i }))
// One pill for the group; each row carries its state on its avatar.
const runningRows = () => container.querySelectorAll('[data-agent-call-layout="row"][data-agent-status="running"]')
expect(screen.queryByText('Starting')).toBeNull()
expect(screen.getAllByText('Running')).toHaveLength(5)
expect(screen.getAllByText('Running')).toHaveLength(1)
expect(runningRows()).toHaveLength(4)
act(() => setChatState('tool_executing'))
await waitFor(() => {
expect(screen.queryByText('Starting')).toBeNull()
expect(screen.getAllByText('Running')).toHaveLength(5)
expect(screen.getAllByText('Running')).toHaveLength(1)
expect(runningRows()).toHaveLength(4)
})
act(() => setChatState('thinking'))
await waitFor(() => {
expect(screen.queryByText('Starting')).toBeNull()
expect(screen.getAllByText('Running')).toHaveLength(5)
expect(screen.getAllByText('Running')).toHaveLength(1)
expect(runningRows()).toHaveLength(4)
})
})
@@ -2588,7 +2593,8 @@ describe('MessageList nested tool calls', () => {
render(<MessageList sessionId={ACTIVE_TAB} />)
expect(screen.getByText('1 memory reference(s)')).toBeTruthy()
expect(screen.getByText('Bash')).toBeTruthy()
// Led by its verb; the raw tool name is the row's tooltip.
expect(screen.getByText('Run')).toBeTruthy()
expect(screen.getByText('bun test')).toBeTruthy()
})
@@ -5884,11 +5890,20 @@ describe('MessageList nested tool calls', () => {
expect(assistantShell?.className).not.toContain('ml-10')
expect(userActions?.getAttribute('data-align')).toBe('end')
expect(assistantActions?.getAttribute('data-align')).toBe('start')
expect(userActions?.className).toContain('h-7')
expect(userActions?.className).toContain('mt-2')
// The prompt's bar hangs under the bubble out of flow: hovering never moves
// the transcript, and the bar holds no height of its own when hidden.
expect(userShell?.className).toContain('relative')
expect(userActions?.getAttribute('data-placement')).toBe('overlay')
expect(userActions?.className).toContain('absolute')
expect(userActions?.className).toContain('top-full')
expect(userActions?.className).toContain('right-0')
expect(userActions?.className).not.toContain('mt-2')
expect(userActions?.className).not.toContain('h-0')
expect(userActions?.className).not.toContain('group-hover:h-7')
expect(userActions?.className).not.toContain('group-hover:h-')
expect(userActions?.className).not.toContain('invisible')
// The closing reply's bar stays in flow: it is always shown.
expect(assistantActions?.getAttribute('data-placement')).toBe('inline')
expect(assistantActions?.className).not.toContain('absolute')
expect(userTime.getAttribute('title')).toBe(formatExactMessageTimestamp(userTimestamp, 'en'))
// The closing reply's bar is not hover-gated: it is rare and deliberate now,
// so hiding it until hover would only make a present affordance hard to find.
+12 -12
View File
@@ -429,7 +429,7 @@ function BackgroundTaskEventCard({ message }: { message: BackgroundTaskEvent })
>
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center">
{isRunning ? (
<LoaderCircle size={15} strokeWidth={2.25} className="animate-spin text-[var(--color-brand)]" aria-hidden="true" />
<LoaderCircle size={15} strokeWidth={1.75} className="animate-spin text-[var(--color-info)]" aria-hidden="true" />
) : isFailed ? (
<XCircle size={15} strokeWidth={2.25} className="text-[var(--color-error)]" aria-hidden="true" />
) : isStopped ? (
@@ -1141,7 +1141,7 @@ function TeamCoordinationAudit({ toolCalls }: { toolCalls: ToolCall[] }) {
const summary = coordinationToolSummary(toolCall)
return (
<li key={toolCall.toolUseId} className="flex min-w-0 items-start gap-2">
<code className="shrink-0 font-mono text-[10px] font-semibold text-[var(--color-text-tertiary)]">
<code className="shrink-0 font-mono text-[11px] text-[var(--color-text-tertiary)]">
{toolCall.toolName}
</code>
{summary ? <span className="min-w-0 break-words">{summary}</span> : null}
@@ -1512,14 +1512,14 @@ function MemoryEventCard({ message }: { message: MemoryEvent }) {
return (
<div className="mb-3 flex justify-center px-3">
<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="w-full max-w-2xl rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3.5 py-3 text-xs">
<div className="flex items-start gap-3">
<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 className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-[var(--radius-md)] bg-[var(--color-surface-container)] text-[var(--color-text-secondary)]">
<BookMarked size={15} strokeWidth={1.75} aria-hidden="true" />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="font-medium text-[var(--color-text-primary)]">
<div className="text-[13px] font-medium text-[var(--color-text-primary)]">
{t('chat.memorySavedTitle', { count: message.files.length })}
</div>
<Button
@@ -1539,13 +1539,13 @@ function MemoryEventCard({ message }: { message: MemoryEvent }) {
<span
key={file.path}
title={file.path}
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)]"
className="max-w-full truncate rounded-[var(--radius-xs)] bg-[var(--color-surface-container)] px-1.5 py-0.5 font-mono text-[11px] text-[var(--color-text-secondary)]"
>
{memoryFileLabel(file.path)}
</span>
))}
{hiddenCount > 0 ? (
<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)]">
<span className="rounded-[var(--radius-xs)] bg-[var(--color-surface-container)] px-1.5 py-0.5 text-[11px] text-[var(--color-text-tertiary)]">
{t('chat.memoryMoreFiles', { count: hiddenCount })}
</span>
) : null}
@@ -1795,7 +1795,7 @@ function collectConversationFindRanges(root: Node, query: string) {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
if (!node.nodeValue || !node.nodeValue.trim()) return NodeFilter.FILTER_REJECT
if (node.parentElement?.closest('[data-find-bar], script, style, noscript, .material-symbols-outlined')) {
if (node.parentElement?.closest('[data-find-bar], script, style, noscript')) {
return NodeFilter.FILTER_REJECT
}
return NodeFilter.FILTER_ACCEPT
@@ -3871,7 +3871,7 @@ export function MessageList({
)}
{!isLoadingTurnChangeCards && visibleTurnChangeCards.length === 0 && turnChangeLoadError && (
<div className="mx-auto mb-5 w-full max-w-[var(--chat-content-max-width)] 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)]">
<div className="mx-auto mb-5 w-full max-w-[var(--chat-content-max-width)] rounded-[var(--radius-md)] bg-[var(--color-error-container)] px-3.5 py-2.5 text-xs text-[var(--color-on-error-container)]">
{turnChangeLoadError}
</div>
)}
@@ -3907,7 +3907,7 @@ export function MessageList({
aria-label={t('chat.jumpToLatest')}
// `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"
className="glass-panel absolute bottom-4 right-5 z-20 rounded-full text-[13px] font-medium hover:-translate-y-px motion-reduce:hover:translate-y-0"
icon={<ArrowDown size={15} aria-hidden="true" />}
>
{t('chat.jumpToLatest')}
@@ -4115,7 +4115,7 @@ export const MessageBlock = memo(function MessageBlock({
message.message.trim() !== '' &&
message.message !== displayMessage
return (
<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)]">
<div className="mb-3 rounded-[var(--radius-md)] bg-[var(--color-error-container)] px-3.5 py-2.5 text-[13px] 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)]">
@@ -1,7 +1,7 @@
import '@testing-library/jest-dom'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AttachmentGallery } from '@/components/chat/AttachmentGallery'
import { MessageActionBar } from '@/components/chat/MessageActionBar'
@@ -53,8 +53,10 @@ describe('complete message payloads', () => {
await act(async () => {
render(<ToolCallBlock toolName="Edit" input={message.input} result={{ content: 'Successfully edited file', isError: false }} defaultExpanded />)
})
expect(screen.getByText('+1')).toBeInTheDocument()
expect(screen.getByText('-130')).toBeInTheDocument()
// Counted over the whole payload, in the diff block's own head.
const diffHead = within(document.querySelector<HTMLElement>('[data-diff-block]')!)
expect(diffHead.getByText('+1')).toBeInTheDocument()
expect(diffHead.getByText('−130')).toBeInTheDocument()
expect(screen.getByText('HEAD_SENTINEL')).toBeInTheDocument()
expect(screen.getAllByText(oldLine)).toHaveLength(128)
expect(screen.getByText('TAIL_SENTINEL')).toBeInTheDocument()
+219 -232
View File
@@ -1,4 +1,24 @@
import { useState } from 'react'
import {
Bot,
Box,
Check,
ChevronDown,
ChevronUp,
Download,
FilePen,
FilePlus,
FileSearch,
FileText,
Folder,
Globe,
ListChecks,
NotebookPen,
Search,
Shield,
SquareTerminal,
type LucideIcon,
} from 'lucide-react'
import { getPendingPermission, useChatStore } from '../../stores/chatStore'
import { useTabStore } from '../../stores/tabStore'
import { useSessionRuntimeStore } from '../../stores/sessionRuntimeStore'
@@ -32,23 +52,38 @@ type Props = {
}
/**
* Icons for known tool types.
* Uses Material Symbols Outlined names.
* The glyph in the card's icon block, per tool. The block itself is always the
* warning pair (`--color-warning-container` / `--color-on-warning-container`):
* the card means "waiting for you", whatever the tool. It used to tint the
* block by appending an alpha suffix to a `var(...)` string (`${color}18`),
* which is not a color at all and rendered no background.
*/
const TOOL_META: Record<string, { icon: string; label: string; color: string }> = {
Bash: { icon: 'terminal', label: 'Bash', color: 'var(--color-warning)' },
Edit: { icon: 'edit_note', label: 'Edit File', color: 'var(--color-brand)' },
Write: { icon: 'edit_document', label: 'Write File', color: 'var(--color-success)' },
Read: { icon: 'description', label: 'Read File', color: 'var(--color-secondary)' },
Glob: { icon: 'search', label: 'Glob Search', color: 'var(--color-secondary)' },
Grep: { icon: 'find_in_page', label: 'Grep Search', color: 'var(--color-secondary)' },
Agent: { icon: 'smart_toy', label: 'Agent', color: 'var(--color-tertiary)' },
WebSearch: { icon: 'travel_explore', label: 'Web Search', color: 'var(--color-secondary)' },
WebFetch: { icon: 'cloud_download', label: 'Web Fetch', color: 'var(--color-secondary)' },
NotebookEdit: { icon: 'note', label: 'Notebook Edit', color: 'var(--color-brand)' },
Skill: { icon: 'auto_awesome', label: 'Skill', color: 'var(--color-tertiary)' },
const TOOL_ICONS: Record<string, LucideIcon> = {
Bash: SquareTerminal,
Edit: FilePen,
Write: FilePlus,
Read: FileText,
Glob: Search,
Grep: FileSearch,
Agent: Bot,
WebSearch: Globe,
WebFetch: Download,
NotebookEdit: NotebookPen,
Skill: Box,
}
/** Card shell shared by the permission and plan-approval cards (「素」). */
const PENDING_CARD =
'mb-4 overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-outline)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-raised)]'
/** A request that has been answered collapses to one quiet line. */
const RESOLVED_ROW =
'mb-4 flex min-h-9 min-w-0 items-center gap-2 rounded-[var(--radius-lg)] border border-[var(--color-border)] py-1.5 pl-3 pr-2 text-[13px] text-[var(--color-text-secondary)]'
/** Sunken inset for commands, paths and raw input inside the card. */
const INSET_BLOCK =
'rounded-[var(--radius-md)] bg-[var(--color-surface-container)] px-3 py-2.5 font-mono text-xs leading-[1.6] text-[var(--color-text-primary)]'
/**
* Extract human-readable detail lines from tool input.
*/
@@ -126,9 +161,9 @@ 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-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}
<div className={`overflow-x-auto ${INSET_BLOCK}`}>
<pre className="whitespace-pre-wrap break-words font-mono">
<span className="select-none text-[var(--color-brand)]">$ </span>{obj.command}
</pre>
</div>
)
@@ -160,7 +195,7 @@ export function PermissionDialog({ sessionId, requestId, toolName, input, descri
)
}
const meta = TOOL_META[toolName] || { icon: 'shield', label: toolName, color: 'var(--color-text-tertiary)' }
const ToolIcon = TOOL_ICONS[toolName] ?? Shield
const details = extractToolDetails(toolName, input, t)
const rawInput = typeof input === 'string' ? input : JSON.stringify(input, null, 2)
const preview = renderPermissionPreview(toolName, input)
@@ -168,82 +203,69 @@ export function PermissionDialog({ sessionId, requestId, toolName, input, descri
const allowRawToggle = !preview
const permissionContext = (details.primary || description || toolName).slice(0, 160)
if (!isPending) {
return (
<div role="group" aria-label={`${title}: ${permissionContext}`} className={RESOLVED_ROW}>
<ToolIcon aria-hidden="true" size={14} strokeWidth={1.75} className="shrink-0 text-[var(--color-text-tertiary)]" />
<span className="min-w-0 flex-1 truncate">{title}</span>
{details.primary ? (
<span className="hidden min-w-0 max-w-[45%] truncate font-mono text-xs text-[var(--color-text-tertiary)] sm:block" title={details.primary}>
{details.primary}
</span>
) : null}
<Badge icon={<Check aria-hidden="true" size={12} strokeWidth={2} />}>
{t('permission.responded')}
</Badge>
</div>
)
}
return (
<div
role="group"
aria-label={`${title}: ${permissionContext}`}
className={`mb-4 overflow-hidden rounded-[var(--radius-lg)] border ${
isPending
? 'border-[var(--color-warning)] bg-[var(--color-surface-container-lowest)]'
: 'border-[var(--color-border)] bg-[var(--color-surface-container-low)] opacity-70'
}`}
className={PENDING_CARD}
>
{/* Header */}
<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 items-center justify-center w-8 h-8 rounded-[var(--radius-md)]"
style={{ backgroundColor: `${meta.color}18` }}
>
<span
aria-hidden="true"
className="material-symbols-outlined text-[18px]"
style={{ color: meta.color }}
>
{meta.icon}
</span>
{/* Header: icon block, title over context, "awaiting" pill. */}
<div className="flex items-start gap-3 px-4 pb-3 pt-3.5">
<div className="flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-[var(--radius-md)] bg-[var(--color-warning-container)] text-[var(--color-on-warning-container)]">
<ToolIcon aria-hidden="true" size={16} strokeWidth={1.75} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-[var(--color-text-primary)]">
{title}
</span>
{isPending && (
<Badge
tone="warning"
icon={<StatusDot tone="warning" pulse />}
className="font-bold uppercase tracking-wider"
>
{t('permission.awaitingApproval')}
</Badge>
)}
{!isPending && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)]">
{t('permission.responded')}
</span>
)}
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold leading-[1.4] text-[var(--color-text-primary)]">
{title}
</div>
{description && (
<p className="mt-0.5 text-xs text-[var(--color-text-secondary)] truncate">{description}</p>
<p className="mt-0.5 truncate text-xs text-[var(--color-text-tertiary)]">{description}</p>
)}
</div>
<Badge
tone="warning"
icon={<StatusDot tone="warning" pulse />}
className="shrink-0"
>
{t('permission.awaitingApproval')}
</Badge>
</div>
{/* Tool details */}
<div className="border-t border-[var(--color-border)] px-4 py-3">
<div className="px-4">
{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-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>
<div className="flex items-center gap-2 rounded-[var(--radius-md)] bg-[var(--color-surface-container)] px-3 py-2 font-mono text-xs text-[var(--color-text-secondary)]">
<Folder aria-hidden="true" size={14} strokeWidth={1.75} className="shrink-0 text-[var(--color-text-tertiary)]" />
<span className="truncate">{details.primary}</span>
</div>
) : null}
{preview}
</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-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>
<span className="truncate">{details.primary}</span>
</div>
<div className="flex items-center gap-2 rounded-[var(--radius-md)] bg-[var(--color-surface-container)] px-3 py-2 font-mono text-xs text-[var(--color-text-secondary)]">
{toolName === 'Glob' || toolName === 'Grep'
? <Search aria-hidden="true" size={14} strokeWidth={1.75} className="shrink-0 text-[var(--color-text-tertiary)]" />
: <Folder aria-hidden="true" size={14} strokeWidth={1.75} className="shrink-0 text-[var(--color-text-tertiary)]" />}
<span className="truncate">{details.primary}</span>
</div>
) : null}
@@ -254,66 +276,53 @@ export function PermissionDialog({ sessionId, requestId, toolName, input, descri
{allowRawToggle && (
<Button
variant="link"
size="xs"
variant="ghost"
size="sm"
onClick={() => setShowRaw(!showRaw)}
className="mt-2"
icon={(
<span aria-hidden="true" className="material-symbols-outlined text-[14px]">
{showRaw ? 'expand_less' : 'expand_more'}
</span>
)}
className="-ml-2 mt-1.5"
icon={showRaw
? <ChevronUp aria-hidden="true" size={14} strokeWidth={1.75} />
: <ChevronDown aria-hidden="true" size={14} strokeWidth={1.75} />}
>
{showRaw ? t('permission.hideDetails') : t('permission.showFullInput')}
</Button>
)}
{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-mono text-[11px] leading-[1.3] text-[var(--color-terminal-fg)] whitespace-pre-wrap break-words">
<pre className={`mt-1.5 max-h-[220px] overflow-auto whitespace-pre-wrap break-words ${INSET_BLOCK}`}>
{rawInput}
</pre>
)}
</div>
{/* Action buttons */}
{isPending && (
<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"
aria-label={`${t('permission.allow')}: ${permissionContext}`}
onClick={() => targetSessionId && respondToPermission(targetSessionId, requestId, true)}
icon={
<span aria-hidden="true" className="material-symbols-outlined text-[14px]">check</span>
}
>
{t('permission.allow')}
</Button>
<Button
variant="ghost"
size="sm"
aria-label={`${t('permission.allowForSession')}: ${permissionContext}`}
onClick={() => targetSessionId && respondToPermission(targetSessionId, requestId, true, { rule: 'always' })}
icon={
<span aria-hidden="true" className="material-symbols-outlined text-[14px]">verified</span>
}
>
{t('permission.allowForSession')}
</Button>
<div className="flex-1" />
<Button
variant="danger"
size="sm"
aria-label={`${t('permission.deny')}: ${permissionContext}`}
onClick={() => targetSessionId && respondToPermission(targetSessionId, requestId, false)}
icon={
<span aria-hidden="true" className="material-symbols-outlined text-[14px]">close</span>
}
>
{t('permission.deny')}
</Button>
</div>
)}
{/* Actions: confirm on the left, the quiet way out on the right. */}
<div className="flex flex-wrap items-center gap-2 px-4 pb-3.5 pt-3">
<Button
variant="primary"
size="base"
aria-label={`${t('permission.allow')}: ${permissionContext}`}
onClick={() => targetSessionId && respondToPermission(targetSessionId, requestId, true)}
>
{t('permission.allow')}
</Button>
<Button
variant="secondary"
size="base"
aria-label={`${t('permission.allowForSession')}: ${permissionContext}`}
onClick={() => targetSessionId && respondToPermission(targetSessionId, requestId, true, { rule: 'always' })}
>
{t('permission.allowForSession')}
</Button>
<div className="flex-1" />
<Button
variant="ghost"
size="base"
aria-label={`${t('permission.deny')}: ${permissionContext}`}
onClick={() => targetSessionId && respondToPermission(targetSessionId, requestId, false)}
>
{t('permission.deny')}
</Button>
</div>
</div>
)
}
@@ -397,47 +406,40 @@ function ExitPlanModePermissionDialog({
approve({ permissionUpdates: buildPlanApprovalPermissionUpdates(mode, preview.allowedPrompts) })
}
if (!isPending) {
return (
<div className={RESOLVED_ROW}>
<ListChecks aria-hidden="true" size={14} strokeWidth={1.75} className="shrink-0 text-[var(--color-text-tertiary)]" />
<span className="min-w-0 flex-1 truncate">{t('permission.planReadyTitle')}</span>
<Badge icon={<Check aria-hidden="true" size={12} strokeWidth={2} />}>
{t('permission.responded')}
</Badge>
</div>
)
}
return (
<div className={`mb-4 overflow-hidden rounded-[var(--radius-lg)] border ${
isPending
? '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-soft)]">
<span className="material-symbols-outlined text-[18px] text-[var(--color-brand)]">architecture</span>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-[var(--color-text-primary)]">
{t('permission.planReadyTitle')}
</span>
{isPending ? (
<Badge
tone="brand"
icon={<StatusDot tone="brand" pulse />}
className="font-bold uppercase"
>
{t('permission.awaitingApproval')}
</Badge>
) : (
<Badge className="font-bold uppercase">
{t('permission.responded')}
</Badge>
)}
</div>
{description ? (
<p className="mt-0.5 truncate text-xs text-[var(--color-text-secondary)]">{description}</p>
) : null}
</div>
<div className={PENDING_CARD}>
<div className="flex h-[42px] items-center gap-2 border-b border-[var(--color-border)] px-3.5">
<ListChecks aria-hidden="true" size={15} strokeWidth={1.75} className="shrink-0 text-[var(--color-text-tertiary)]" />
<span className="min-w-0 flex-1 truncate text-[13px] font-semibold text-[var(--color-text-primary)]">
{t('permission.planReadyTitle')}
</span>
<Badge
tone="warning"
icon={<StatusDot tone="warning" pulse />}
className="shrink-0"
>
{t('permission.awaitingApproval')}
</Badge>
</div>
<div className="space-y-3 border-t border-[var(--color-border)] px-4 py-3">
<div className="space-y-3 px-4 pt-3">
{description ? (
<p className="truncate text-xs text-[var(--color-text-tertiary)]">{description}</p>
) : null}
<PlanPreviewCard
embedded
title={t('permission.planPreviewTitle')}
plan={preview.plan}
filePath={preview.filePath}
@@ -445,84 +447,69 @@ function ExitPlanModePermissionDialog({
requestedPermissionsTitle={t('permission.planRequestedPermissions')}
emptyLabel={t('permission.planEmpty')}
/>
{isPending ? (
<textarea
value={feedback}
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-border-focus)] focus:shadow-[var(--shadow-focus-ring)]"
/>
) : null}
</div>
{isPending ? (
<div className="border-t border-[var(--color-border)] bg-[var(--color-surface-container)] px-4 py-3">
<div className="flex items-center justify-between gap-3">
<span className="shrink-0 text-xs font-medium text-[var(--color-text-secondary)]">
{t('permission.planExecutionModel')}
</span>
<div
className={`flex min-w-0 items-center gap-1.5 rounded-[var(--radius-md)] px-2 py-1 ${
executionRuntime
? 'border border-[var(--color-primary-fixed-dim)] bg-[var(--color-brand-soft)]'
: ''
}`}
data-testid="plan-execution-model"
>
<ModelSelector
compact
ariaLabel={t('permission.planExecutionModel')}
runtimeSelection={executionRuntime ?? currentRuntime}
onRuntimeSelectionChange={handleExecutionRuntimeChange}
/>
{executionRuntime ? (
<span className="shrink-0 text-[10px] font-medium text-[var(--color-brand)]">
{t('permission.planExecutionModelPending')}
</span>
) : null}
</div>
<textarea
value={feedback}
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-transparent bg-[var(--color-surface-container)] px-3 py-2.5 text-[13px] text-[var(--color-text-primary)] outline-none transition-colors placeholder:text-[var(--color-text-tertiary)] focus:border-[var(--color-outline)] focus:bg-[var(--color-surface-container-lowest)]"
/>
<div className="flex items-center justify-between gap-3">
<span className="shrink-0 text-xs text-[var(--color-text-tertiary)]">
{t('permission.planExecutionModel')}
</span>
<div
className={`flex min-w-0 items-center gap-1.5 rounded-[var(--radius-sm)] ${
executionRuntime ? 'bg-[var(--color-surface-container)] pr-2' : ''
}`}
data-testid="plan-execution-model"
>
<ModelSelector
compact
ariaLabel={t('permission.planExecutionModel')}
runtimeSelection={executionRuntime ?? currentRuntime}
onRuntimeSelectionChange={handleExecutionRuntimeChange}
/>
{executionRuntime ? (
<span className="shrink-0 text-[11px] font-medium text-[var(--color-text-secondary)]">
{t('permission.planExecutionModelPending')}
</span>
) : null}
</div>
</div>
) : null}
</div>
{isPending ? (
<div className="flex flex-wrap items-center gap-2 border-t border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3">
<Button
variant="primary"
size="base"
onClick={() => approve(permissionUpdates.length ? { permissionUpdates } : undefined)}
icon={<span aria-hidden="true" className="material-symbols-outlined text-[14px]">check</span>}
>
{t('permission.planApprove')}
</Button>
<Button
variant="secondary"
size="base"
onClick={() => approveWithMode('acceptEdits')}
icon={<span aria-hidden="true" className="material-symbols-outlined text-[14px]">bolt</span>}
>
{t('permission.planApproveAcceptEdits')}
</Button>
<Button
variant="danger-outline"
size="base"
onClick={() => approveWithMode('bypassPermissions')}
icon={<span aria-hidden="true" className="material-symbols-outlined text-[14px]">gavel</span>}
>
{t('permission.planApproveBypass')}
</Button>
<div className="flex-1" />
<Button
variant="ghost"
size="base"
onClick={() => sessionId && respondToPermission(sessionId, requestId, false, trimmedFeedback ? { denyMessage: trimmedFeedback } : undefined)}
icon={<span aria-hidden="true" className="material-symbols-outlined text-[14px]">edit_note</span>}
>
{t('permission.planKeepPlanning')}
</Button>
</div>
) : null}
<div className="flex flex-wrap items-center gap-2 px-4 pb-3.5 pt-3">
<Button
variant="primary"
size="base"
onClick={() => approve(permissionUpdates.length ? { permissionUpdates } : undefined)}
>
{t('permission.planApprove')}
</Button>
<Button
variant="secondary"
size="base"
onClick={() => approveWithMode('acceptEdits')}
>
{t('permission.planApproveAcceptEdits')}
</Button>
<Button
variant="danger-ghost"
size="base"
onClick={() => approveWithMode('bypassPermissions')}
>
{t('permission.planApproveBypass')}
</Button>
<div className="flex-1" />
<Button
variant="ghost"
size="base"
onClick={() => sessionId && respondToPermission(sessionId, requestId, false, trimmedFeedback ? { denyMessage: trimmedFeedback } : undefined)}
>
{t('permission.planKeepPlanning')}
</Button>
</div>
</div>
)
}
+32 -22
View File
@@ -23,6 +23,8 @@ type Props = {
allowedPrompts?: AllowedPrompt[]
requestedPermissionsTitle?: string
emptyLabel?: string
/** Rendered inside a card that already frames it (the approval card). */
embedded?: boolean
}
export function isExitPlanModeTool(toolName: string): boolean {
@@ -40,26 +42,34 @@ export function PlanPreviewCard({
allowedPrompts = [],
requestedPermissionsTitle,
emptyLabel = 'No plan content available.',
embedded = false,
}: Props) {
const trimmedPlan = plan.trim()
// `embedded` is the plan inside the approval card, which already supplies
// the frame and the header — a second bordered card inside it was the
// card-in-card the 「素」 rules rule out. Standalone (a finished plan in the
// transcript) it keeps its own card.
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)] 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-mono text-[11px] text-[var(--color-text-tertiary)]">
{filePath}
</div>
) : null}
<div
data-testid="plan-preview-card"
className={embedded
? 'min-w-0'
: 'overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]'}
>
<div className={`flex min-w-0 items-center gap-2 ${embedded ? 'pb-2' : 'border-b border-[var(--color-border)] px-3.5 py-2.5'}`}>
<FileText size={14} strokeWidth={1.75} className="shrink-0 text-[var(--color-text-tertiary)]" aria-hidden="true" />
<div className="shrink-0 text-[13px] font-medium text-[var(--color-text-primary)]">
{title}
</div>
{filePath ? (
<div className="min-w-0 truncate font-mono text-[11px] text-[var(--color-text-tertiary)]" title={filePath}>
{filePath}
</div>
) : null}
</div>
<div className="max-h-[520px] overflow-auto px-3 py-3">
<div className={`max-h-[520px] overflow-auto ${embedded ? '' : 'px-3.5 py-3'}`}>
{trimmedPlan ? (
<MarkdownRenderer content={trimmedPlan} variant="compact" />
) : (
@@ -68,25 +78,25 @@ export function PlanPreviewCard({
</div>
{allowedPrompts.length > 0 && requestedPermissionsTitle ? (
<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" />
<div className={embedded ? 'pt-2.5' : 'border-t border-[var(--color-border)] px-3.5 py-2.5'}>
<div className="mb-1 flex items-center gap-1.5 text-xs text-[var(--color-text-tertiary)]">
<ShieldCheck size={14} strokeWidth={1.75} aria-hidden="true" />
{requestedPermissionsTitle}
</div>
<div className="space-y-1">
<ul className="space-y-0.5 pl-5">
{allowedPrompts.map((prompt, index) => (
<div
<li
key={`${prompt.tool}-${prompt.prompt}-${index}`}
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)]"
className="text-xs text-[var(--color-text-secondary)]"
>
<span className="font-mono font-semibold text-[var(--color-text-primary)]">
<span className="rounded-[var(--radius-xs)] bg-[var(--color-surface-container)] px-1 font-mono text-[11px] text-[var(--color-text-primary)]">
{prompt.tool}
</span>
<span className="text-[var(--color-text-tertiary)]"> · </span>
<span>{prompt.prompt}</span>
</div>
</li>
))}
</div>
</ul>
</div>
) : null}
</div>
@@ -1,4 +1,4 @@
import { GitFork } from 'lucide-react'
import { Folder, GitBranch, GitFork } from 'lucide-react'
import { Tooltip } from '@/components/ui/Tooltip'
import { useTranslation } from '../../i18n'
import { getFileNameFromPath } from '../../lib/composerAttachments'
@@ -69,39 +69,30 @@ export function ProjectContextChip({
if (!label) return null
const gitIcon = (
<svg
width={isToolbar ? 15 : compact ? 15 : 18}
height={isToolbar ? 15 : compact ? 15 : 18}
viewBox="0 0 16 16"
fill="currentColor"
aria-hidden="true"
className={`shrink-0 ${isToolbar ? 'text-[var(--color-text-tertiary)]' : 'text-[var(--color-text-secondary)]'}`}
>
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>
)
const worktreeBadgeClassName = 'inline-flex h-[18px] shrink-0 cursor-help items-center gap-1 rounded-[var(--radius-xs)] bg-[var(--color-surface-container)] px-1.5 text-[11px] font-medium leading-none text-[var(--color-text-secondary)] 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)]'
const iconClassName = 'shrink-0 text-[var(--color-text-tertiary)]'
if (isToolbar) {
return (
<div
title={isWorktree ? undefined : title}
data-testid="run-location-readonly"
className="inline-flex h-9 min-w-0 max-w-full shrink items-center gap-1.5 text-[13px] font-medium leading-none"
// Same frame as the run-location pill it replaces (h-7, px-1.5), minus
// the hover fill and chevron: this one is read-only.
className="inline-flex h-7 min-w-0 max-w-full shrink items-center gap-1.5 px-1.5 text-xs leading-none text-[var(--color-text-tertiary)]"
>
{showBranch || isWorktree ? gitIcon : (
<span aria-hidden="true" className="material-symbols-outlined shrink-0 text-[17px] text-[var(--color-text-tertiary)]">folder</span>
)}
<span className="min-w-[1.75rem] shrink truncate text-[var(--color-text-primary)]">{label}</span>
<Folder size={14} strokeWidth={1.75} aria-hidden="true" className={iconClassName} />
<span className="min-w-[1.75rem] shrink truncate">{label}</span>
{showBranch && (
<>
<span aria-hidden="true" className="shrink-0 text-[var(--color-outline-variant)]">/</span>
<span aria-hidden="true" className="shrink-0 text-[var(--color-outline)]">/</span>
<GitBranch size={14} strokeWidth={1.75} aria-hidden="true" className={iconClassName} />
{/* Ellipsis at the start so the branch keeps its tail — see the pill.
`shrink-[4]`: the two used to shrink in step, so a narrow column
took both down together and left `cc-…/…n` — two truncations,
neither readable. The project name is the one that identifies
the row, so the branch gives up its width first. */}
<span dir="rtl" className="min-w-0 shrink-[4] truncate text-left text-[var(--color-text-secondary)]">
<span dir="rtl" className="min-w-0 shrink-[4] truncate text-left">
<bdi>{branch}</bdi>
</span>
</>
@@ -111,9 +102,9 @@ export function ProjectContextChip({
<span
data-testid="worktree-details-trigger"
tabIndex={0}
className="inline-flex shrink-0 cursor-help items-center gap-1 rounded-[5px] bg-[var(--color-brand-soft)] px-1.5 py-1 text-[10px] font-bold leading-none text-[var(--color-brand)] 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)]"
className={worktreeBadgeClassName}
>
<GitFork size={11} aria-hidden="true" />
<GitFork size={12} strokeWidth={2} aria-hidden="true" />
{t('repoLaunch.worktreeBadge')}
</span>
</Tooltip>
@@ -126,33 +117,29 @@ export function ProjectContextChip({
<div
title={isWorktree ? undefined : title}
data-testid="run-location-outside"
className={`inline-flex max-w-full items-center rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] text-[var(--color-text-secondary)] ${
compact ? 'gap-1.5 px-3 py-1.5 text-xs' : 'gap-2 px-4 py-2 text-sm'
className={`inline-flex max-w-full items-center gap-1.5 px-1.5 text-xs leading-none text-[var(--color-text-tertiary)] ${
compact ? 'h-7' : 'h-8'
}`}
>
{showBranch ? gitIcon : (
<span className={`material-symbols-outlined text-[var(--color-text-secondary)] ${compact ? 'text-[15px]' : 'text-[18px]'}`}>folder</span>
)}
<span className="truncate font-medium text-[var(--color-text-primary)]">{label}</span>
<Folder size={14} strokeWidth={1.75} aria-hidden="true" className={iconClassName} />
<span className="truncate">{label}</span>
{showBranch ? (
<>
<span className="text-[var(--color-text-tertiary)]">|</span>
<span aria-hidden="true" className="shrink-0 text-[var(--color-outline)]">/</span>
<GitBranch size={14} strokeWidth={1.75} aria-hidden="true" className={iconClassName} />
<span className="truncate">{branch}</span>
</>
) : null}
{isWorktree ? (
<>
<span className="text-[var(--color-text-tertiary)]">|</span>
<Tooltip content={worktreeDetails} placement="top-start">
<span
data-testid="worktree-details-trigger"
tabIndex={0}
className="shrink-0 cursor-help rounded-full border border-[var(--color-border)] px-1.5 py-0.5 text-[10px] font-medium uppercase leading-none text-[var(--color-text-tertiary)] 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)]"
>
{t('sidebar.worktree')}
</span>
</Tooltip>
</>
<Tooltip content={worktreeDetails} placement="top-start">
<span
data-testid="worktree-details-trigger"
tabIndex={0}
className={worktreeBadgeClassName}
>
{t('sidebar.worktree')}
</span>
</Tooltip>
) : null}
</div>
)
@@ -39,7 +39,7 @@ export class RenderItemBoundary extends React.Component<Props, State> {
if (!this.state.failed) return this.props.children
return (
<div className="mb-3 flex items-center gap-2 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-2 text-xs text-[var(--color-text-tertiary)]">
<div className="mb-3 flex items-center gap-2 rounded-[var(--radius-md)] bg-[var(--color-surface-container)] px-3 py-2 text-xs text-[var(--color-text-tertiary)]">
<TriangleAlert aria-hidden size={14} strokeWidth={1.75} className="shrink-0" />
<span>{t('errorBoundary.item')}</span>
</div>
@@ -427,17 +427,29 @@ describe('RepositoryLaunchControls', () => {
renderControls({ placement: 'toolbar' })
const pill = await screen.findByRole('button', { name: 'Location: cc-haha / main' })
// 36px matches the other controls in the composer's toolbar; the standalone
// line uses 40px for touch.
expect(pill).toHaveClass('h-9')
// 28px matches the other controls in the composer's toolbar; the H5 line
// below the composer uses 40px for touch.
expect(pill).toHaveClass('h-7')
expect(pill).not.toHaveClass('h-10')
})
it('sizes the pill for touch when it stands on its own line', async () => {
it('keeps the pointer-sized pill on its own line in a narrow desktop column', async () => {
renderControls({ placement: 'outside' })
const pill = await screen.findByRole('button', { name: 'Location: cc-haha / main' })
expect(pill).toHaveClass('h-7')
expect(pill).not.toHaveClass('h-10')
})
it('sizes the pill for touch when it stands on its own line in the H5 browser', async () => {
viewportMocks.isMobile = true
viewportMocks.isTauri = false
// A toolbar placement still falls back to the standalone line on H5.
renderControls({ placement: 'toolbar' })
const pill = await screen.findByRole('button', { name: 'Location: cc-haha / main' })
expect(pill).toHaveClass('h-10')
expect(pill).not.toHaveClass('h-7')
})
it('keeps a dirty-branch warning compact and inline in the toolbar', async () => {
@@ -6,6 +6,8 @@ import {
ChevronDown,
ChevronLeft,
ChevronRight,
Folder,
FolderGit2,
GitBranch,
GitFork,
Loader2,
@@ -81,44 +83,27 @@ const BRANCH_CREATE_ERROR_KEYS = {
REPOSITORY_NO_COMMITS: 'repoLaunch.newBranchErrorNoCommits',
} as const
const GIT_MARK_PATH = 'M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z'
/**
* Git mark for a repo, plain folder otherwise. The folder glyph reads smaller
* than the mark at the same nominal size, hence the separate `folderSize`.
*/
function RepoIcon({ isGit, size, folderSize = size }: { isGit: boolean; size: number; folderSize?: number }) {
if (isGit) {
return (
<svg width={size} height={size} viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]">
<path d={GIT_MARK_PATH} />
</svg>
)
}
return (
<span
aria-hidden="true"
className="material-symbols-outlined shrink-0 text-[var(--color-text-tertiary)]"
style={{ fontSize: folderSize }}
>
folder
</span>
)
/** A folder with the git mark for a repo, a plain folder otherwise. */
function RepoIcon({ isGit, size }: { isGit: boolean; size: number }) {
const Icon = isGit ? FolderGit2 : Folder
return <Icon size={size} strokeWidth={1.75} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
}
/**
* Decorative dot for the worktree cards. The cards carry `aria-checked`
* already, so this is hidden from the accessibility tree rather than announced
* a second time.
* Decorative 16px radio for the worktree cards: a 1.5px ring at rest that fills
* to a terracotta dot when picked. The cards carry `aria-checked` already, so
* this is hidden from the accessibility tree rather than announced twice.
*/
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>
className={`mt-0.5 h-4 w-4 shrink-0 rounded-full transition-shadow duration-150 ${
selected
? 'shadow-[inset_0_0_0_5px_var(--color-brand)]'
: 'shadow-[inset_0_0_0_1.5px_var(--color-outline)]'
}`}
/>
)
}
@@ -563,43 +548,49 @@ export function RepositoryLaunchControls({
: t('repoLaunch.localBranch'))
: 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.
// A quiet toolbar chip, not an outlined pill: the composer toolbar reads
// `folder project / branch` in tertiary text and only fills on hover, the
// same as the permission chip and model name beside it. The focus ring uses
// the opaque focus token rather than `--color-brand/35`, whose `/N` modifier
// Safari 15 drops.
const pillClassName = [
'group inline-flex min-w-0 max-w-full items-center gap-1.5 rounded-[var(--radius-lg)]',
'border border-[var(--color-border)] bg-[var(--color-surface)] 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)]',
'group inline-flex min-w-0 max-w-full items-center gap-1.5 rounded-[var(--radius-sm)] px-1.5',
'text-xs leading-none text-[var(--color-text-tertiary)]',
'transition-[background-color,color] duration-150 ease-out',
'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)]',
'disabled:cursor-not-allowed disabled:opacity-50',
isToolbar ? 'h-9 px-3 text-[13.5px]' : 'h-10 px-3.5 text-[13.5px]',
// 28px in the toolbar, matching its other controls; on its own line below
// the composer the H5 layout needs a 40px touch target.
isToolbar || !isMobileBrowser ? 'h-7' : 'h-10',
].join(' ')
const rowClassName = [
'flex w-full items-center gap-3 rounded-[var(--radius-lg)] px-3 text-left',
'transition-[background-color,border-color] duration-150 ease-out',
'flex w-full items-center gap-2.5 rounded-[var(--radius-sm)] px-2 text-left',
'transition-[background-color] duration-150 ease-out',
'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)]',
isMobileBrowser ? 'min-h-[56px] py-3' : 'py-2.5',
'focus-visible:outline-none focus-visible:bg-[var(--color-surface-hover)]',
isMobileBrowser ? 'min-h-[56px] py-3' : 'min-h-8 py-1.5',
].join(' ')
// Both drill-downs head back the same way. The dropdown labels the crumb
// after the view you are in; the sheet, which has its own title bar, labels
// it after the view you are going to.
const dropdownBackClassName = 'inline-flex items-center gap-1 rounded-[var(--radius-sm)] px-1 py-0.5 text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)] transition-colors hover:text-[var(--color-text-secondary)]'
const sheetBackClassName = 'inline-flex items-center gap-1 self-start rounded-[var(--radius-md)] px-2 py-1 text-[12px] font-semibold text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)]'
const dropdownBackClassName = 'inline-flex h-7 items-center gap-1 rounded-[var(--radius-sm)] px-1.5 text-xs font-medium 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)]'
const sheetBackClassName = 'inline-flex items-center gap-1 self-start rounded-[var(--radius-sm)] px-2 py-1 text-xs font-medium text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)]'
// 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.
// a menu list: a hairline at rest, a 1.5px terracotta edge on a faint
// terracotta wash once picked. Inset shadows rather than borders so the edge
// can thicken without nudging the content.
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)]',
'flex w-full items-start gap-2.5 rounded-[var(--radius-md)] px-3 py-2.5 text-left',
'transition-[background-color,box-shadow] duration-150 ease-out',
'focus-visible:outline-none focus-visible:ring-2 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)]',
? 'bg-[var(--color-brand-soft)] shadow-[inset_0_0_0_1.5px_var(--color-brand)]'
: 'bg-[var(--color-surface-container-lowest)] shadow-[inset_0_0_0_1px_var(--color-border)] hover:shadow-[inset_0_0_0_1px_var(--color-outline)]',
].join(' ')
const branchList = (
@@ -607,7 +598,7 @@ export function RepositoryLaunchControls({
id={listboxId}
role="listbox"
aria-label={t('repoLaunch.selectBranch')}
className={isMobileBrowser ? 'py-1' : 'max-h-[280px] overflow-y-auto py-1'}
className={isMobileBrowser ? 'p-1.5' : 'max-h-[280px] overflow-y-auto'}
>
{filteredBranches.length === 0 ? (
<div className="px-4 py-8 text-center text-xs text-[var(--color-text-tertiary)]">
@@ -625,19 +616,18 @@ export function RepositoryLaunchControls({
aria-selected={isSelected}
onMouseEnter={() => setSelectedIndex(index)}
onClick={() => selectBranch(candidate)}
className={`flex w-full items-center gap-3 px-4 text-left transition-[background-color] duration-150 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-border-focus)] ${
isMobileBrowser ? 'min-h-[56px] py-3' : 'py-3'
className={`flex w-full items-center gap-2.5 rounded-[var(--radius-sm)] px-2 text-left transition-[background-color] duration-150 ease-out focus-visible:outline-none focus-visible:bg-[var(--color-surface-hover)] ${
isMobileBrowser ? 'min-h-[56px] py-3' : 'min-h-8 py-1.5'
} ${
index === selectedIndex || isSelected ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'
}`}
>
<span className={`h-8 w-1 rounded-full ${isSelected ? 'bg-[var(--color-brand)]' : 'bg-transparent'}`} />
<GitBranch size={17} className="shrink-0 text-[var(--color-text-secondary)]" />
<GitBranch size={16} strokeWidth={1.75} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold text-[var(--color-text-primary)]">
<span className="block truncate font-mono text-[13px] text-[var(--color-text-primary)]">
{candidate.name}
</span>
<span className="block truncate text-[11px] text-[var(--color-text-tertiary)]">
<span className="block truncate text-xs text-[var(--color-text-tertiary)]">
{candidate.current
? t('repoLaunch.currentBranch')
: candidate.checkedOut
@@ -647,7 +637,7 @@ export function RepositoryLaunchControls({
: t('repoLaunch.localBranch')}
</span>
</span>
{isSelected && <Check size={17} className="shrink-0 text-[var(--color-brand)]" />}
{isSelected && <Check size={14} strokeWidth={2} aria-hidden="true" className="shrink-0 text-[var(--color-brand)]" />}
</button>
)
})}
@@ -672,8 +662,8 @@ export function RepositoryLaunchControls({
onClick={openNewBranchView}
className={rowClassName}
>
<Plus size={17} className="shrink-0 text-[var(--color-text-tertiary)]" />
<span className="min-w-0 flex-1 truncate text-[13.5px] font-semibold text-[var(--color-text-primary)]">
<Plus size={16} strokeWidth={1.75} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
<span className="min-w-0 flex-1 truncate text-[13px] text-[var(--color-text-primary)]">
{t('repoLaunch.newBranch')}
</span>
</button>
@@ -681,7 +671,7 @@ export function RepositoryLaunchControls({
const newBranchForm = (
<form
className="flex flex-col gap-3 p-3"
className="flex flex-col gap-3 p-2"
onSubmit={(event) => {
event.preventDefault()
void createBranch()
@@ -718,9 +708,11 @@ export function RepositoryLaunchControls({
</form>
)
// The menu-search row: a bare field on the menu, set off by the hairline its
// host draws under it — not a boxed input inside a box.
const branchSearch = (
<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)]" />
<div className="flex h-8 items-center gap-2 px-2">
<Search size={14} strokeWidth={1.75} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
<input
id={searchInputId}
ref={searchRef}
@@ -730,7 +722,7 @@ export function RepositoryLaunchControls({
aria-controls={listboxId}
aria-activedescendant={filteredBranches[selectedIndex] ? `${listboxId}-option-${selectedIndex}` : undefined}
placeholder={t('repoLaunch.searchBranch')}
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-[13px] text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]"
/>
</div>
)
@@ -740,15 +732,15 @@ export function RepositoryLaunchControls({
// pixels taller the moment the context lands.
const revealSkeleton = (
<div aria-hidden="true" className="flex animate-pulse flex-col gap-1">
<div className="h-[52px] rounded-[var(--radius-lg)] bg-[var(--color-surface-container-low)]" />
<div className="mx-1.5 my-1 h-px bg-[var(--color-border-separator)]" />
<div className="h-[74px] rounded-[var(--radius-lg)] bg-[var(--color-surface-container-low)]" />
<div className="h-[74px] rounded-[var(--radius-lg)] bg-[var(--color-surface-container-low)]" />
<div className="h-11 rounded-[var(--radius-sm)] bg-[var(--color-surface-container)]" />
<div className="mx-1 my-1 h-px bg-[var(--color-border-separator)]" />
<div className="h-16 rounded-[var(--radius-md)] bg-[var(--color-surface-container)]" />
<div className="h-16 rounded-[var(--radius-md)] bg-[var(--color-surface-container)]" />
</div>
)
const rootView = (
<div role="menu" aria-label={t('repoLaunch.launchLocation')} className="flex flex-col gap-1 p-1.5">
<div role="menu" aria-label={t('repoLaunch.launchLocation')} className={`flex flex-col gap-0.5 ${isMobileBrowser ? 'p-1.5' : ''}`}>
<button
type="button"
role="menuitem"
@@ -756,16 +748,16 @@ export function RepositoryLaunchControls({
title={workDir || undefined}
className={rowClassName}
>
<RepoIcon isGit={isGitReady} size={18} />
<RepoIcon isGit={isGitReady} size={16} />
<span className="min-w-0 flex-1">
<span className="block text-[13px] font-semibold text-[var(--color-text-primary)]">
<span className="block text-[13px] font-medium text-[var(--color-text-primary)]">
{t('dirPicker.directory')}
</span>
<span className="block truncate text-[11px] text-[var(--color-text-tertiary)]">
<span className="block truncate text-xs text-[var(--color-text-tertiary)]">
{workDir ? repoLabel : t('dirPicker.selectProject')}
</span>
</span>
<ChevronRight size={16} className="shrink-0 text-[var(--color-text-tertiary)]" />
<ChevronRight size={14} strokeWidth={1.75} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
</button>
{revealAfterPick && revealSkeleton}
@@ -777,22 +769,22 @@ export function RepositoryLaunchControls({
onClick={() => setView('branch')}
className={rowClassName}
>
<GitBranch size={18} className="shrink-0 text-[var(--color-text-tertiary)]" />
<GitBranch size={16} strokeWidth={1.75} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
<span className="min-w-0 flex-1">
<span className="block text-[13.5px] font-semibold text-[var(--color-text-primary)]">
<span className="block text-[13px] font-medium text-[var(--color-text-primary)]">
{t('repoLaunch.branch')}
</span>
<span className="block truncate text-[12px] text-[var(--color-text-tertiary)]">
<span className="block truncate text-xs text-[var(--color-text-tertiary)]">
{selectedBranchName ? `${selectedBranchName} · ${branchSubtitle}` : t('repoLaunch.noBranch')}
</span>
</span>
<ChevronRight size={16} className="shrink-0 text-[var(--color-text-tertiary)]" />
<ChevronRight size={14} strokeWidth={1.75} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
</button>
)}
{isGitReady && (
<>
<div className="mx-1.5 my-1 h-px bg-[var(--color-border-separator)]" />
<div className="mx-1 my-1 h-px bg-[var(--color-border-separator)]" />
<button
type="button"
role="menuitemradio"
@@ -803,10 +795,10 @@ export function RepositoryLaunchControls({
>
<WorktreeRadio selected={!useWorktree} />
<span className="min-w-0 flex-1">
<span id={worktreeCurrentLabelId} className="block text-[14.5px] font-bold text-[var(--color-text-primary)]">
<span id={worktreeCurrentLabelId} className="block text-[13px] font-medium 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)]">
<span className="mt-0.5 block text-xs leading-[1.55] text-[var(--color-text-tertiary)]">
{t('repoLaunch.worktreeCurrentDesc', { branch: branchLabel })}
</span>
</span>
@@ -822,10 +814,10 @@ export function RepositoryLaunchControls({
>
<WorktreeRadio selected={useWorktree} />
<span className="min-w-0 flex-1">
<span id={worktreeIsolatedLabelId} className="block text-[14.5px] font-bold text-[var(--color-text-primary)]">
<span id={worktreeIsolatedLabelId} className="block text-[13px] font-medium 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)]">
<span className="mt-0.5 block text-xs leading-[1.55] text-[var(--color-text-tertiary)]">
{t('repoLaunch.worktreeIsolatedDesc', { branch: branchLabel })}
</span>
</span>
@@ -874,7 +866,7 @@ export function RepositoryLaunchControls({
}}
className={pillClassName}
>
<RepoIcon isGit={isGitReady} size={15} folderSize={17} />
<Folder size={14} strokeWidth={1.75} aria-hidden="true" className="shrink-0" />
<span className="min-w-[1.75rem] shrink truncate">
{repoLabel || t('dirPicker.selectProject')}
@@ -882,7 +874,8 @@ export function RepositoryLaunchControls({
{selectedBranchName && (
<>
<span aria-hidden="true" className="shrink-0 text-[var(--color-outline-variant)]">/</span>
<span aria-hidden="true" className="shrink-0 text-[var(--color-outline)]">/</span>
<GitBranch size={14} strokeWidth={1.75} aria-hidden="true" className="shrink-0" />
{/*
`dir="rtl"` puts the ellipsis at the *start*, so a truncated branch
keeps its tail: `…use-native-on-main` rather than `feature/comp…`.
@@ -890,26 +883,26 @@ export function RepositoryLaunchControls({
prefixes carry no information. `<bdi>` isolates the name so the RTL
container cannot reorder its own neutral characters (the slashes).
*/}
<span dir="rtl" className="min-w-0 shrink truncate text-left text-[var(--color-text-secondary)]">
<span dir="rtl" className="min-w-0 shrink truncate text-left">
<bdi>{selectedBranchName}</bdi>
</span>
</>
)}
{useWorktree && isGitReady && (
<span className="inline-flex shrink-0 items-center gap-1 rounded-[var(--radius-sm)] bg-[var(--color-brand-soft)] px-1.5 py-1 text-[10px] font-bold leading-none text-[var(--color-brand)]">
<GitFork size={11} aria-hidden="true" />
<span className="inline-flex h-[18px] shrink-0 items-center gap-1 rounded-[var(--radius-xs)] bg-[var(--color-surface-container)] px-1.5 text-[11px] font-medium leading-none text-[var(--color-text-secondary)]">
<GitFork size={12} strokeWidth={2} aria-hidden="true" />
{t('repoLaunch.worktreeBadge')}
</span>
)}
{loading && workDir
? <Loader2 size={15} className="shrink-0 animate-spin text-[var(--color-text-tertiary)]" />
: <ChevronDown size={15} className="shrink-0 text-[var(--color-text-tertiary)]" />}
? <Loader2 size={14} aria-hidden="true" className="shrink-0 animate-spin text-[var(--color-text-tertiary)]" />
: <ChevronDown size={14} strokeWidth={1.75} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />}
</button>
)
const menuClassName = 'w-[400px] overflow-hidden rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-overlay)]'
const menuClassName = 'w-[400px] overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] p-1 shadow-[var(--shadow-dropdown)]'
const menuStyle = {
position: 'fixed' as const,
left: menuPos?.left,
@@ -934,9 +927,9 @@ export function RepositoryLaunchControls({
role="status"
aria-label={warning.message}
title={warning.message}
className="inline-flex h-7 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-[var(--radius-md)] bg-[var(--color-warning-container)] px-2 text-[11px] font-medium text-[var(--color-on-warning-container)]"
className="inline-flex h-6 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-[var(--radius-sm)] bg-[var(--color-warning-container)] px-1.5 text-[11px] font-medium text-[var(--color-on-warning-container)]"
>
<AlertCircle size={13} aria-hidden="true" className="shrink-0 text-[var(--color-warning)]" />
<AlertCircle size={12} strokeWidth={2} aria-hidden="true" className="shrink-0" />
<span className="hidden 2xl:inline">{warning.compactLabel}</span>
</div>
)}
@@ -944,8 +937,8 @@ export function RepositoryLaunchControls({
) : pill}
{message && workDir && (
<div className="flex items-center gap-2 px-1 text-[11px] text-[var(--color-text-tertiary)]">
<AlertCircle size={13} className="shrink-0" />
<div className="flex items-center gap-1.5 px-1.5 text-xs text-[var(--color-text-tertiary)]">
<AlertCircle size={12} strokeWidth={2} aria-hidden="true" className="shrink-0" />
<span>{message === 'missing' ? t('repoLaunch.missingWorkdir') : message}</span>
</div>
)}
@@ -954,9 +947,9 @@ export function RepositoryLaunchControls({
<div
role="status"
aria-label={warning.message}
className="flex items-center gap-2 px-1 text-[11px] text-[var(--color-warning)]"
className="flex items-center gap-1.5 px-1.5 text-xs text-[var(--color-on-warning-container)]"
>
<AlertCircle size={13} aria-hidden="true" className="shrink-0" />
<AlertCircle size={12} strokeWidth={2} aria-hidden="true" className="shrink-0 text-[var(--color-warning)]" />
<span>{warning.message}</span>
</div>
)}
@@ -986,7 +979,7 @@ export function RepositoryLaunchControls({
onClick={() => setView('root')}
className={sheetBackClassName}
>
<ChevronLeft size={14} />
<ChevronLeft size={14} strokeWidth={1.75} aria-hidden="true" />
{t('repoLaunch.launchLocation')}
</button>
{branchSearch}
@@ -997,7 +990,7 @@ export function RepositoryLaunchControls({
onClick={leaveNewBranchView}
className={sheetBackClassName}
>
<ChevronLeft size={14} />
<ChevronLeft size={14} strokeWidth={1.75} aria-hidden="true" />
{t('repoLaunch.selectBranch')}
</button>
) : view === 'directory' && directoryBackLabel ? (
@@ -1006,7 +999,7 @@ export function RepositoryLaunchControls({
onClick={() => setView('root')}
className={sheetBackClassName}
>
<ChevronLeft size={14} />
<ChevronLeft size={14} strokeWidth={1.75} aria-hidden="true" />
{t('repoLaunch.launchLocation')}
</button>
) : undefined}
@@ -1021,31 +1014,31 @@ export function RepositoryLaunchControls({
<div ref={menuRef} id={menuId} className={menuClassName} style={menuStyle}>
{view === 'branch' ? (
<>
<div className="border-b border-[var(--color-border)] p-3">
<div className="mb-1 border-b border-[var(--color-border)]">
<button
type="button"
onClick={() => setView('root')}
className={`mb-2 ${dropdownBackClassName}`}
className={dropdownBackClassName}
>
<ChevronLeft size={13} />
<ChevronLeft size={14} strokeWidth={1.75} aria-hidden="true" />
{t('repoLaunch.selectBranch')}
</button>
{branchSearch}
</div>
{branchList}
<div className="border-t border-[var(--color-border-separator)] p-1.5">
<div className="mt-1 border-t border-[var(--color-border-separator)] pt-1">
{branchCreateRow}
</div>
</>
) : view === 'newBranch' ? (
<>
<div className="border-b border-[var(--color-border)] px-3 py-2.5">
<div className="border-b border-[var(--color-border)] pb-1">
<button
type="button"
onClick={leaveNewBranchView}
className={dropdownBackClassName}
>
<ChevronLeft size={13} />
<ChevronLeft size={14} strokeWidth={1.75} aria-hidden="true" />
{t('repoLaunch.newBranchTitle')}
</button>
</div>
@@ -1054,13 +1047,13 @@ export function RepositoryLaunchControls({
) : view === 'directory' ? (
<>
{directoryBackLabel && (
<div className="border-b border-[var(--color-border)] px-3 py-2.5">
<div className="border-b border-[var(--color-border)] pb-1">
<button
type="button"
onClick={() => setView('root')}
className={dropdownBackClassName}
>
<ChevronLeft size={13} />
<ChevronLeft size={14} strokeWidth={1.75} aria-hidden="true" />
{directoryBackLabel}
</button>
</div>
@@ -1069,7 +1062,7 @@ export function RepositoryLaunchControls({
</>
) : (
<>
<div className="px-4 pb-1 pt-3 text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)]">
<div className="px-2 pb-1 pt-2 text-[11px] font-semibold text-[var(--color-text-tertiary)]">
{t('repoLaunch.launchLocation')}
</div>
{rootView}
@@ -32,8 +32,12 @@ describe('SessionChatSurface', () => {
const header = screen.getByTestId('session-header')
expect(header.firstElementChild).toHaveClass('max-w-[var(--chat-content-max-width)]')
expect(within(header).getByRole('heading', { name: 'Child run' })).toHaveClass('truncate')
expect(header.querySelectorAll('[aria-hidden="true"]')).toHaveLength(1)
expect(within(header).getByRole('heading', { name: 'Child run' })).toHaveClass('truncate', 'text-[15px]', 'font-semibold')
const separators = header.querySelectorAll('[aria-hidden="true"]')
expect(separators).toHaveLength(1)
// A 3px dot, not a typed "·" glyph whose size and baseline follow the font.
expect(separators[0]!.textContent).toBe('')
expect(separators[0]).toHaveClass('rounded-full')
})
it('uses the same compact chat column contract when a side panel is present', () => {
@@ -27,18 +27,18 @@ export function SessionChatHeader({
data-testid="session-header"
className={[
'w-full border-b border-[var(--color-border)]',
compact ? 'px-4 py-2.5' : 'px-9 py-3',
compact ? 'px-4 py-2.5' : 'px-7 py-3.5',
].join(' ')}
>
<div className="mx-auto w-full min-w-0 max-w-[var(--chat-content-max-width)]">
<div className="flex min-w-0 items-start gap-3">
{/* Top-aligned so a goal / team strip below the meta line does not drag
the actions down; they are offset to centre on title + meta. */}
<div className="flex min-w-0 items-start gap-4">
{leading}
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<h1
className={`min-w-0 truncate font-bold leading-tight tracking-[-0.2px] text-[var(--color-text-primary)] ${
compact ? 'text-[15px]' : 'text-[17px]'
}`}
className="min-w-0 truncate text-[15px] font-semibold leading-[1.35] tracking-[-0.01em] text-[var(--color-text-primary)]"
style={{ fontFamily: 'var(--font-headline)' }}
title={title}
>
@@ -47,10 +47,12 @@ export function SessionChatHeader({
{titleAddon}
</div>
{metadata.length > 0 ? (
<div className="mt-1 flex min-w-0 items-center gap-1.5 overflow-hidden whitespace-nowrap text-[11px] text-[var(--color-text-tertiary)]">
<div className="mt-[3px] flex min-w-0 items-center gap-2 overflow-hidden whitespace-nowrap text-xs text-[var(--color-text-tertiary)] [&_svg]:shrink-0">
{metadata.map((item, index) => (
<Fragment key={item.key}>
{index > 0 ? <span aria-hidden="true" className="shrink-0">·</span> : null}
{index > 0 ? (
<span aria-hidden="true" className="h-[3px] w-[3px] shrink-0 rounded-full bg-[var(--color-text-tertiary)]" />
) : null}
{item.content}
</Fragment>
))}
@@ -58,7 +60,7 @@ export function SessionChatHeader({
) : null}
{children}
</div>
{actions ? <div className="flex shrink-0 items-center gap-1">{actions}</div> : null}
{actions ? <div className="flex shrink-0 items-center gap-2 pt-1">{actions}</div> : null}
</div>
</div>
</div>
@@ -1,5 +1,6 @@
import { forwardRef, type MutableRefObject } from 'react'
import {
BookOpen,
Bot,
Bug,
CircleDollarSign,
@@ -15,7 +16,6 @@ import {
PanelTop,
Settings,
ShieldCheck,
Sparkles,
Target,
Terminal,
Wrench,
@@ -28,6 +28,10 @@ import type { ComposerReferenceCandidate } from '@/types/composerReference'
import { safeMentionIcon } from '@/lib/composerMentions'
import { publicAssetPath } from '@/lib/publicAsset'
import { referenceFallbackIcon, skillSourceLabelKey } from './referencePresentation'
import { COMPOSER_KBD, COMPOSER_MENU_SECTION } from './composerMenuStyles'
/** One menu row: icon, then the name over its description (the 「素」 menu item). */
const ROW = 'flex min-h-8 w-full cursor-default items-center gap-2.5 rounded-[var(--radius-sm)] px-2 py-1.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]'
const SYSTEM_SLASH_COMMAND_ICONS: Record<string, LucideIcon> = {
agent: Bot,
@@ -38,7 +42,7 @@ const SYSTEM_SLASH_COMMAND_ICONS: Record<string, LucideIcon> = {
cost: CircleDollarSign,
context: PanelTop,
plugin: Package,
memory: Sparkles,
memory: BookOpen,
doctor: Wrench,
compact: Zap,
clear: Eraser,
@@ -109,22 +113,24 @@ export const SlashCommandMenu = forwardRef<HTMLDivElement, SlashCommandMenuProps
ref={(element) => { itemRefs.current[index] = element }}
onClick={() => onSelect(command.name)}
onMouseEnter={() => onHighlight(index)}
className={`flex w-full cursor-default items-center gap-3 rounded-[var(--radius-md)] px-3 py-2 text-left transition-colors ${
className={`${ROW} ${
index === selectedIndex
? 'bg-[var(--color-surface-hover)]'
: 'hover:bg-[var(--color-surface-hover)]'
} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]`}
}`}
>
<Icon
aria-hidden="true"
className="h-5 w-5 shrink-0 text-[var(--color-text-secondary)]"
strokeWidth={1.8}
className="h-4 w-4 shrink-0 text-[var(--color-text-tertiary)]"
strokeWidth={1.75}
/>
<span id={`${id}-label-${index}`} className="min-w-0 max-w-[60%] flex-none truncate text-sm font-medium text-[var(--color-text-primary)]">
/{command.name}
</span>
<span id={`${id}-description-${index}`} className="min-w-0 flex-1 truncate text-sm text-[var(--color-text-tertiary)]">
{command.description}
<span className="grid min-w-0 flex-1 gap-px">
<span id={`${id}-label-${index}`} className="truncate text-[13px] font-medium text-[var(--color-text-primary)]">
/{command.name}
</span>
<span id={`${id}-description-${index}`} className="truncate text-xs text-[var(--color-text-tertiary)]">
{command.description}
</span>
</span>
{isSearching && command.argumentHint ? (
<span className="max-w-[30%] shrink truncate font-mono text-[11px] text-[var(--color-text-tertiary)]" title={command.argumentHint}>
@@ -139,19 +145,19 @@ export const SlashCommandMenu = forwardRef<HTMLDivElement, SlashCommandMenuProps
<div
ref={ref}
onMouseDown={event => event.preventDefault()}
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)]"
className="absolute bottom-full left-0 right-0 z-[var(--z-dropdown)] mb-2 overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)]"
>
{isSearching ? <div className="px-4 pb-1 pt-3 text-xs font-medium text-[var(--color-text-tertiary)]">
{isSearching ? <div className={`${COMPOSER_MENU_SECTION} px-3 pt-2.5`}>
{t('chat.slashSearchResults')}
</div> : null}
<div
id={id}
role="listbox"
aria-label={t('chat.slashCommands')}
className="max-h-[min(360px,45vh)] overflow-y-auto p-1.5"
className="max-h-[min(360px,45vh)] overflow-y-auto p-1"
>
{groups.system.length > 0 ? <div role="group" aria-label={t(isSearching ? 'chat.slashCommands' : 'chat.slashFrequent')}>
{!isSearching ? <div className="px-3 pb-1 pt-2 text-xs font-medium text-[var(--color-text-tertiary)]">{t('chat.slashFrequent')}</div> : null}
{!isSearching ? <div className={COMPOSER_MENU_SECTION}>{t('chat.slashFrequent')}</div> : null}
{groups.system.map(renderSystemCommand)}
</div> : null}
@@ -160,7 +166,7 @@ export const SlashCommandMenu = forwardRef<HTMLDivElement, SlashCommandMenuProps
{ kind: 'plugin' as const, items: groups.plugins ?? [], label: t('chat.referencePlugins'), offset: groups.system.length + groups.skills.length },
]).map(group => group.items.length > 0 ? (
<div key={group.kind} role="group" aria-label={group.label}>
<div className="px-3 pb-1 pt-2 text-xs font-medium text-[var(--color-text-tertiary)]">{group.label}</div>
<div className={COMPOSER_MENU_SECTION}>{group.label}</div>
{group.items.map((command, position) => {
const index = group.offset + position
const candidate = references.find(item => item.kind === group.kind && (item.name === command.name || item.id === command.name))
@@ -173,24 +179,26 @@ export const SlashCommandMenu = forwardRef<HTMLDivElement, SlashCommandMenuProps
title={[candidate?.displayName || command.name, command.argumentHint, command.description].filter(Boolean).join(' — ')}
ref={element => { itemRefs.current[index] = element }}
onClick={() => onSelect(command.name)} onMouseEnter={() => onHighlight(index)}
className={`flex w-full cursor-default items-center gap-3 rounded-[var(--radius-md)] px-3 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${index === selectedIndex ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'}`}>
{icon ? <img src={publicAssetPath(icon)} alt="" className="h-5 w-5 shrink-0 object-contain" /> : <Icon aria-hidden="true" className="h-5 w-5 shrink-0 text-[var(--color-text-secondary)]" strokeWidth={1.8} />}
<span id={`${id}-label-${index}`} className="min-w-0 max-w-[60%] flex-none truncate text-sm font-medium text-[var(--color-text-primary)]">{candidate?.displayName || command.name}</span>
<span id={`${id}-description-${index}`} className="min-w-0 flex-1 truncate text-sm text-[var(--color-text-tertiary)]">{command.description}</span>
className={`${ROW} ${index === selectedIndex ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'}`}>
{icon ? <img src={publicAssetPath(icon)} alt="" className="h-4 w-4 shrink-0 object-contain" /> : <Icon aria-hidden="true" className="h-4 w-4 shrink-0 text-[var(--color-text-tertiary)]" strokeWidth={1.75} />}
<span className="grid min-w-0 flex-1 gap-px">
<span id={`${id}-label-${index}`} className="truncate text-[13px] font-medium text-[var(--color-text-primary)]">{candidate?.displayName || command.name}</span>
<span id={`${id}-description-${index}`} className="truncate text-xs text-[var(--color-text-tertiary)]">{command.description}</span>
</span>
{sourceLabel ? <span className="shrink-0 text-xs text-[var(--color-text-tertiary)]">{t(sourceLabel)}</span> : null}
</div>
})}
</div>
) : null)}
</div>
{!isSearching ? <div className="px-4 pb-2 text-xs text-[var(--color-text-tertiary)]">{t('chat.slashSearchHint')}</div> : null}
{!isSearching ? <div className="px-3 pb-2 text-xs text-[var(--color-text-tertiary)]">{t('chat.slashSearchHint')}</div> : null}
{showKeyboardHints ? (
<div className="flex items-center gap-1.5 border-t border-[var(--color-border)] px-4 py-2 text-xs text-[var(--color-text-tertiary)]">
<kbd className="rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1.5 py-0.5 font-mono text-[10px]">↑↓</kbd>
<div className="flex items-center gap-1.5 border-t border-[var(--color-border)] px-3 py-2 text-[11px] text-[var(--color-text-tertiary)]">
<kbd className={COMPOSER_KBD}>↑↓</kbd>
<span>{t('chat.navigate')}</span>
<kbd className="ml-2 rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1.5 py-0.5 font-mono text-[10px]">Enter</kbd>
<kbd className={`${COMPOSER_KBD} ml-2`}>Enter</kbd>
<span>{t('chat.select')}</span>
<kbd className="ml-2 rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1.5 py-0.5 font-mono text-[10px]">Esc</kbd>
<kbd className={`${COMPOSER_KBD} ml-2`}>Esc</kbd>
<span>{t('chat.dismiss')}</span>
</div>
) : null}
@@ -1,7 +1,15 @@
import type { ReactNode } from 'react'
import { X } from 'lucide-react'
import { IconButton } from '@/components/ui/IconButton'
import { useTranslation } from '../../i18n'
/**
* The floating card the local slash commands (/mcp, /skills, /help, /save-workflow)
* open above the composer. Same shell as every other composer popover — white,
* hairline border, `--radius-lg`, `--shadow-dropdown` — with a header band and a
* scrolling body instead of `p-1` menu padding, because it holds content rather
* than a list of choices.
*/
export function SlashCommandPanelShell({
title,
subtitle,
@@ -15,22 +23,21 @@ export function SlashCommandPanelShell({
}) {
const t = useTranslation()
return (
<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>
<p className="mt-1 text-sm text-[var(--color-text-tertiary)]">{subtitle}</p>
<div className="absolute bottom-full left-0 right-0 z-[var(--z-dropdown)] mb-2 overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)]">
<div className="flex items-start justify-between gap-3 border-b border-[var(--color-border)] px-4 py-3">
<div className="min-w-0">
<h3 className="text-sm font-semibold leading-snug text-[var(--color-text-primary)]">{title}</h3>
<p className="mt-0.5 truncate text-xs text-[var(--color-text-tertiary)]">{subtitle}</p>
</div>
<IconButton
icon="close"
icon={<X size={16} strokeWidth={1.75} aria-hidden="true" />}
label={t('tabs.close')}
size="lg"
tone="secondary"
shape="circle"
size="sm"
tone="muted"
onClick={onClose}
/>
</div>
<div className="max-h-[min(620px,72vh)] overflow-y-auto px-5 py-4">{children}</div>
<div className="max-h-[min(620px,72vh)] overflow-y-auto p-4">{children}</div>
</div>
)
}
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
vi.mock('../../api/websocket', () => ({
wsManager: {
@@ -86,6 +87,31 @@ describe('StreamingIndicator', () => {
expect(screen.getByText(/↓ 2\.2k tokens/)).toBeTruthy()
})
it('draws the live line as a running ring, a sweeping verb and monospaced meta', () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSession({ chatState: 'thinking', elapsedSeconds: 12, streamingResponseChars: 8976 }),
},
})
render(<StreamingIndicator />)
const status = screen.getByTestId('turn-status-indicator')
// The spinner a running tool shows, in the running colour — not the
// generic "AI" sparkle, which said nothing about the turn's state.
const ring = status.querySelector('svg.lucide-loader-circle')
expect(ring).toHaveClass('motion-safe:animate-spin', 'text-[var(--color-info)]')
expect(status.querySelector('svg.lucide-sparkles')).toBeNull()
expect(status.querySelector('svg')?.getAttribute('class')).not.toContain('--color-brand')
expect(status.textContent).not.toContain('✦')
expect(screen.getByText('Thinking...')).toHaveClass('turn-status-shimmer')
// Elapsed time and tokens are numbers that tick: mono, tabular, tertiary.
const tokens = screen.getByText(/↓ 2\.2k tokens/)
expect(tokens).toHaveClass('font-mono', 'tabular-nums', 'text-[11px]')
// The keyframes ship beside the line, not inside the live region.
expect(status.querySelector('style')).toBeNull()
})
it('hides the token estimate until this turn has streamed output', () => {
useChatStore.setState({
sessions: {
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { RefreshCw } from 'lucide-react'
import { LoaderCircle, RefreshCw } from 'lucide-react'
import { Badge } from '@/components/ui/Badge'
import { useChatStore } from '../../stores/chatStore'
import { useTabStore } from '../../stores/tabStore'
@@ -96,9 +96,9 @@ 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-[var(--shadow-card)]"
className="mb-2 flex w-full flex-wrap items-center gap-2 rounded-[var(--radius-md)] bg-[var(--color-warning-container)] px-3 py-2 text-xs text-[var(--color-on-warning-container)]"
>
<RefreshCw size={14} strokeWidth={2.2} className="shrink-0 animate-spin text-[var(--color-warning)]" aria-hidden="true" />
<RefreshCw size={14} strokeWidth={1.75} className="shrink-0 animate-spin" aria-hidden="true" />
<span className="font-medium">{t('chat.retry.title')}</span>
{/*
Neutral rather than `tone="warning"`: these chips sit on the warning
@@ -130,17 +130,17 @@ export function StreamingIndicator() {
data-testid="streaming-fallback-indicator"
role="status"
aria-live="polite"
className="flex w-fit items-center gap-[9px] py-1 text-[13.5px] text-[var(--color-text-secondary)]"
className="flex min-h-7 w-fit max-w-full flex-wrap items-center gap-x-2 text-[13px] text-[var(--color-text-secondary)]"
>
<RefreshCw size={13} strokeWidth={2.2} className="shrink-0 animate-spin text-[var(--color-text-secondary)]" aria-hidden="true" />
<RefreshCw size={14} strokeWidth={1.75} className="shrink-0 animate-spin text-[var(--color-text-tertiary)]" aria-hidden="true" />
<span className="font-medium text-[var(--color-text-primary)]">
{t('chat.fallback.title')}
</span>
<span className="text-[12.5px] text-[var(--color-text-tertiary)]">
<span className="text-xs text-[var(--color-text-tertiary)]">
{t('chat.fallback.detail')}
</span>
{elapsedSeconds > 0 && (
<span className="text-[12.5px] text-[var(--color-text-tertiary)]">
<span className={STATUS_META_CLASS}>
{formatDurationSeconds(elapsedSeconds, t)}
</span>
)}
@@ -151,28 +151,68 @@ export function StreamingIndicator() {
const verb = resolveTurnStatusVerb(t, chatState, statusVerb)
return (
<div
data-testid="turn-status-indicator"
role="status"
aria-live="polite"
// Bare line, not a pill: it now sits at the end of the live turn rail, and
// the rail already says "still going". A bordered chip here would read as
// a second, competing status object next to the one that is lit.
className="flex w-fit items-center gap-[9px] py-1 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>{formatDurationSeconds(elapsedSeconds, t)}</span>
)}
{streamingTokens > 0 && (
<>
<span aria-hidden="true">·</span>
<span className="font-mono text-[12.5px]">
↓ {t('common.tokens', { count: formatTokenCount(streamingTokens) })}
</span>
</>
)}
</div>
<>
<style>{TURN_STATUS_STYLES}</style>
<div
data-testid="turn-status-indicator"
role="status"
aria-live="polite"
// Bare line, not a pill: it sits at the end of the live turn, and a
// bordered chip here would read as a second, competing status object.
className="flex h-7 w-fit max-w-full items-center gap-2 text-[13px] text-[var(--color-text-secondary)]"
>
{/* The ring a running tool row spins, in the running colour: the
turn is working, and it says so the way the rest of the timeline
does rather than with a decorative "AI" glyph. */}
<LoaderCircle
size={15}
strokeWidth={1.75}
aria-hidden="true"
className="turn-status-spinner shrink-0 text-[var(--color-info)] motion-safe:animate-spin"
/>
<span className="turn-status-shimmer min-w-0 truncate font-medium">{verb}...</span>
{elapsedSeconds > 0 && (
<span className={STATUS_META_CLASS}>{formatDurationSeconds(elapsedSeconds, t)}</span>
)}
{streamingTokens > 0 && (
<>
<span aria-hidden="true" className="h-[3px] w-[3px] shrink-0 rounded-full bg-[var(--color-text-tertiary)]" />
<span className={STATUS_META_CLASS}>
↓ {t('common.tokens', { count: formatTokenCount(streamingTokens) })}
</span>
</>
)}
</div>
</>
)
}
/** Elapsed time and token count: small, monospaced, steady-width digits. */
const STATUS_META_CLASS = 'shrink-0 whitespace-nowrap font-mono text-[11px] tabular-nums text-[var(--color-text-tertiary)]'
/**
* The verb reads as live through a light sweeping across it, and the ring
* spins; neither moves the layout. Reduced motion keeps both still and leaves
* the text in its plain secondary colour.
*/
const TURN_STATUS_STYLES = `
@keyframes turn-status-sweep {
from { background-position: 100% 0; }
to { background-position: -100% 0; }
}
.turn-status-shimmer {
background: linear-gradient(90deg, var(--color-text-tertiary) 0%, var(--color-text-tertiary) 40%, var(--color-text-primary) 50%, var(--color-text-tertiary) 60%, var(--color-text-tertiary) 100%);
background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: turn-status-sweep 2.2s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.turn-status-shimmer {
animation: none;
background: none;
color: var(--color-text-secondary);
}
}
`
+21 -19
View File
@@ -1,33 +1,35 @@
import type { ReactNode } from 'react'
import { SquareTerminal } from 'lucide-react'
type Props = {
title?: string
/** Mono label at the head's left: the shell the command ran in. */
label?: string
/** Right side of the head: exit-code pill, duration, copy. */
meta?: ReactNode
children: ReactNode
className?: string
}
/**
* macOS-style terminal window decoration with traffic light buttons.
* Reusable wrapper for Bash commands, tool results, and code viewers.
* The frame a shell call's output sits in: a 32px head and a sunken body.
*
* Deliberately not a window. The macOS traffic lights it used to wear were
* decoration pretending to be controls, and the black slab they sat on was the
* loudest thing in a light transcript — a terminal block is a code block that
* happens to have run, so it takes the code block's ground in every theme.
*/
export function TerminalChrome({ title, children, className = '' }: Props) {
export function TerminalChrome({ label, meta, children, className = '' }: Props) {
return (
<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.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="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-1.5 truncate font-mono text-[12.5px] font-medium text-[var(--color-terminal-muted)]">
{title}
</span>
)}
<div
data-terminal-chrome=""
className={`overflow-hidden rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-code-bg)] ${className}`}
>
<div className="flex h-8 items-center gap-2.5 border-b border-[var(--color-border)] bg-[var(--color-surface-container)] pl-2.5 pr-1.5 text-[var(--color-text-tertiary)]">
<SquareTerminal size={14} strokeWidth={1.75} aria-hidden="true" className="shrink-0" />
<span className="min-w-0 flex-1 truncate font-mono text-[11px]">{label}</span>
{meta ? <span className="flex shrink-0 items-center gap-2">{meta}</span> : null}
</div>
{/* Content */}
<div className="bg-[var(--color-terminal-bg)] text-[var(--color-terminal-fg)]">
<div className="text-[var(--color-code-fg)]">
{children}
</div>
</div>
@@ -69,6 +69,26 @@ describe('ThinkingBlock', () => {
expect(screen.getByRole('button')).toHaveTextContent('Thought')
})
it('draws one upright row whose chevron turns and whose preview yields to the body when open', () => {
// CJK has no italic: the old slanted row was the browser shearing glyphs.
const { container } = render(<ThinkingBlock content={'Checking the retry path first'} />)
const row = screen.getByRole('button')
expect(container.querySelector('.italic')).toBeNull()
expect(row.textContent).not.toMatch(/[▸▾]/)
const chevron = row.querySelector('.lucide-chevron-right')
expect(chevron).not.toBeNull()
expect(chevron).not.toHaveClass('rotate-90')
const preview = screen.getByText('Checking the retry path first')
expect(preview).not.toHaveClass('invisible')
fireEvent.click(row)
expect(row.querySelector('.lucide-chevron-right')).toHaveClass('rotate-90')
// Hidden but kept, so the row does not reflow when it opens.
expect(preview).toHaveClass('invisible')
expect(container.querySelector('[data-thinking-content="expanded"]')).toHaveClass('border-l')
})
it('keeps the expanded state across a virtualized row unmount and remount', () => {
// Virtualization unmounts rows outside the window. Losing the reader's
// disclosure choice changes the row height on the way back, which is the
+16 -11
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useMemo, useRef } from 'react'
import { getDisclosure, setDisclosure } from '../../lib/disclosureMemory'
import { Brain } from 'lucide-react'
import { Brain, ChevronRight } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
@@ -63,33 +63,38 @@ export function ThinkingBlock({
if (disclosureKey) setDisclosure(disclosureKey, next)
}}
aria-expanded={expanded}
className="-mx-2 flex w-[calc(100%+1rem)] items-baseline gap-2 rounded-[var(--radius-md)] px-2 py-1 text-left transition-colors hover:bg-[var(--color-surface-hover)] focus:outline-none focus-visible:shadow-[var(--shadow-focus-ring)]"
className="-mx-2 flex h-7 w-[calc(100%+1rem)] min-w-0 items-center gap-2 rounded-[var(--radius-sm)] px-2 text-left text-[13px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] focus:outline-none focus-visible:shadow-[var(--shadow-focus-ring)]"
>
<Brain
size={13}
strokeWidth={1.8}
size={15}
strokeWidth={1.75}
aria-hidden="true"
className="mt-[3px] shrink-0 self-start text-[var(--color-text-tertiary)]"
className="shrink-0"
/>
<span className="shrink-0 text-[12.5px] italic text-[var(--color-text-tertiary)]">
<span className="shrink-0 font-medium text-[var(--color-text-secondary)]">
{label}
</span>
{preview ? (
<span className="min-w-0 flex-1 truncate text-[12.5px] italic leading-[1.7] text-[var(--color-text-tertiary)]">
// Hidden, not removed, once open: the body below says the same thing
// in full, and keeping the box holds the row's chevron in place.
<span className={`min-w-0 flex-1 truncate ${expanded ? 'invisible' : ''}`}>
{preview}
</span>
) : (
<span className="flex-1" />
)}
<span aria-hidden="true" className="shrink-0 text-[8px] text-[var(--color-text-tertiary)]">
{expanded ? '▾' : '▸'}
</span>
<ChevronRight
size={14}
strokeWidth={1.75}
aria-hidden="true"
className={`shrink-0 text-[var(--color-text-tertiary)] transition-transform duration-150 motion-reduce:transition-none ${expanded ? 'rotate-90' : ''}`}
/>
</button>
{expanded && hasDisplayContent && (
<div
ref={contentRef}
data-thinking-content="expanded"
className="relative mb-2 mt-1 max-h-[300px] overflow-y-auto rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3 py-2.5 text-[11px] text-[var(--color-text-secondary)]"
className="relative mb-1.5 ml-[7px] mt-1 max-h-[300px] overflow-y-auto border-l border-[var(--color-outline)] py-0.5 pl-[15px] text-[var(--color-text-secondary)]"
>
<MarkdownRenderer
content={displayContent}
@@ -151,8 +151,9 @@ describe('ToolCallBlock · images returned by a tool (#1397)', () => {
const strip = row.querySelector('[data-tool-result-images]')!
expect(strip).toBeTruthy()
expect(disclosureOf(container).contains(strip)).toBe(false)
// The row has no card padding; the strip lines up with the row's own text.
expect(strip.className).toMatch(/\bpl-\[21px\]/)
// The row has no card padding; the strip hangs under the text column, past
// the 22px node and its gap, where the expanded details also start.
expect(strip.className).toMatch(/\bpl-\[30px\]/)
expect(strip.className).not.toMatch(/\bpx-4\b/)
expect(screen.getByRole('button', { name: 'Open image 1 of 1' })).toBeInTheDocument()
})
@@ -0,0 +1,147 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { fireEvent, render, screen, within } from '@testing-library/react'
import { ToolCallBlock } from './ToolCallBlock'
import { useSettingsStore } from '../../stores/settingsStore'
import { translate } from '../../i18n'
const t = (key: Parameters<typeof translate>[1], params?: Record<string, string | number>) =>
translate('en', key, params)
function openRow(container: HTMLElement) {
fireEvent.click(container.querySelector('[data-chat-disclosure="true"]')!)
}
describe('ToolCallBlock · timeline row', () => {
beforeEach(() => {
useSettingsStore.setState({ locale: 'en' })
})
it('shows an edit as +N −N on the row and the same counts on its diff block', () => {
const { container } = render(
<ToolCallBlock
chrome="row"
toolName="Edit"
input={{ file_path: '/repo/src/validators.ts', old_string: 'a\nb', new_string: 'a\nB\nc' }}
result={{ content: 'The file has been updated.', isError: false }}
/>,
)
const head = container.querySelector<HTMLElement>('[data-chat-disclosure="true"]')!
expect(head.textContent).toContain(t('toolVerb.edit'))
expect(head.textContent).toContain('validators.ts')
expect(head.textContent).toContain('+2')
expect(head.textContent).toContain('−1')
expect(head.getAttribute('title')).toBe('Edit · /repo/src/validators.ts')
openRow(container)
const diff = within(container.querySelector<HTMLElement>('[data-diff-block]')!)
expect(diff.getByText('+2')).toBeTruthy()
expect(diff.getByText('−1')).toBeTruthy()
// The directory gives way first; the file name always stays readable.
expect(diff.getByText('validators.ts').className).toContain('shrink-0')
expect(diff.getByTitle('/repo/src/validators.ts')).toBeTruthy()
})
it('draws a TodoWrite call as its checklist rather than its JSON', () => {
const { container } = render(
<ToolCallBlock
chrome="row"
toolName="TodoWrite"
input={{
todos: [
{ content: 'Read the form', status: 'completed', activeForm: 'Reading' },
{ content: 'Extract validators', status: 'in_progress', activeForm: 'Extracting' },
{ content: 'Add tests', status: 'pending', activeForm: 'Adding' },
],
}}
result={{ content: 'Todos have been modified successfully.', isError: false }}
/>,
)
const head = container.querySelector<HTMLElement>('[data-chat-disclosure="true"]')!
expect(head.textContent).toContain(t('toolVerb.updateTodos'))
expect(head.textContent).not.toContain('TodoWrite')
expect(head.textContent).toContain(t('tool.itemCountPlural', { count: 3 }))
openRow(container)
const items = [...container.querySelectorAll('[data-todo-list] li')]
expect(items.map((item) => item.getAttribute('data-todo-status'))).toEqual(['completed', 'in_progress', 'pending'])
expect(items[0]?.querySelector('span')?.className).toContain('line-through')
expect(container.textContent).not.toContain('activeForm')
expect(container.textContent).not.toContain('Todos have been modified')
})
it('lays grep hits out as file:line plus code, with the match marked', () => {
const { container } = render(
<ToolCallBlock
chrome="row"
toolName="Grep"
input={{ pattern: 'EMAIL_RE', output_mode: 'content', '-n': true }}
result={{ content: 'src/a.ts:3:const EMAIL_RE = /x/\nsrc/b.ts:9:EMAIL_RE.test(v)', isError: false }}
/>,
)
const head = container.querySelector<HTMLElement>('[data-chat-disclosure="true"]')!
expect(head.textContent).toContain(t('toolVerb.search'))
expect(head.textContent).toContain('EMAIL_RE')
expect(head.textContent).toContain(`${t('tool.fileCountPlural', { count: 2 })} · ${t('tool.matchCountPlural', { count: 2 })}`)
openRow(container)
const hits = container.querySelector<HTMLElement>('[data-search-result]')!
expect(hits.textContent).toContain('src/a.ts:3')
expect([...hits.querySelectorAll('mark')].map((mark) => mark.textContent)).toEqual(['EMAIL_RE', 'EMAIL_RE'])
})
it('heads a shell call with its exit code, not window decoration', () => {
const { container } = render(
<ToolCallBlock
chrome="row"
toolName="Bash"
input={{ command: 'git show HEAD --no-stat', description: 'Show the commit' }}
result={{ content: 'fatal: unrecognized argument: --no-stat\nExit code 128', isError: true }}
durationMs={420}
/>,
)
openRow(container)
const terminal = container.querySelector<HTMLElement>('[data-terminal-chrome]')!
expect(within(terminal).getByText(t('tool.exitCode', { code: 128 }))).toBeTruthy()
expect(terminal.querySelector('[data-shell-exit]')?.className).toContain('--color-error-container')
expect(terminal.textContent).toContain('420ms')
expect(terminal.textContent).toContain('git show HEAD --no-stat')
// A light theme gets a sunken code ground, never the old black slab.
expect(terminal.className).toContain('bg-[var(--color-code-bg)]')
expect(terminal.className).not.toContain('terminal-bg')
})
it('reports a successful command as exit 0', () => {
const { container } = render(
<ToolCallBlock
chrome="row"
toolName="Bash"
input={{ command: 'ls' }}
result={{ content: 'a.txt', isError: false }}
/>,
)
openRow(container)
expect(screen.getByText(t('tool.exitCode', { code: 0 })).className).toContain('--color-success-container')
})
it('colours the node by state: running blue, waiting amber, failed red', () => {
const { container, rerender } = render(
<ToolCallBlock chrome="row" toolName="Bash" input={{ command: 'sleep 1' }} running />,
)
const node = () => container.querySelector<HTMLElement>('[data-tool-node]')!
expect(node().getAttribute('data-tool-node')).toBe('running')
expect(node().className).toContain('--color-info')
rerender(<ToolCallBlock chrome="row" toolName="Bash" input={{ command: 'sleep 1' }} awaitingApproval />)
expect(node().getAttribute('data-tool-node')).toBe('waiting')
expect(node().className).toContain('--color-warning')
expect(container.textContent).toContain(t('permission.awaitingApproval'))
rerender(<ToolCallBlock chrome="row" toolName="Bash" input={{ command: 'sleep 1' }} result={{ content: 'boom', isError: true }} />)
expect(node().getAttribute('data-tool-node')).toBe('error')
expect(node().className).toContain('--color-error-container')
})
})
File diff suppressed because it is too large Load Diff
+248 -137
View File
@@ -1,7 +1,17 @@
import { memo, useCallback, useMemo, useState } from 'react'
import { BookMarked, ChevronDown, ChevronRight, CircleCheck, Settings } from 'lucide-react'
import { ToolCallBlock, type ToolCallChrome } from './ToolCallBlock'
import { ActivityGroup } from './ActivityGroup'
import {
BookMarked,
Bot,
Check,
ChevronDown,
ChevronRight,
CircleStop,
CircleX,
LoaderCircle,
Settings,
} from 'lucide-react'
import { ToolCallBlock, formatDuration, type ToolCallChrome } from './ToolCallBlock'
import { ActivityGroup, ToolTimeline } from './ActivityGroup'
import { ThinkingBlock } from './ThinkingBlock'
import {
activityStepToolCalls,
@@ -12,6 +22,7 @@ import {
import { ImageGenerationGroup, type ImageGenerationItem } from './ImageGenerationBlock'
import { isImageGenerationToolName } from './imageGenerationTools'
import { useAgentRunActivity } from './useAgentRunActivity'
import { toolVerb } from './toolCallPresentation'
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
import { Badge, type Tone } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
@@ -20,6 +31,7 @@ import { Modal } from '@/components/ui/Modal'
import { useTranslation } from '../../i18n'
import type { TranslationKey } from '../../i18n'
import { SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore'
import { listPendingPermissions, useChatStore } from '../../stores/chatStore'
import { useUIStore } from '../../stores/uiStore'
import type { AgentTaskNotification, BackgroundAgentTask, UIMessage } from '../../types/chat'
import { AGENT_LIFECYCLE_TYPES } from '../../types/team'
@@ -212,6 +224,7 @@ function ToolCallGroupContent({
disclosureKey,
revealToolUseId,
}: ContentProps) {
const awaitingToolUseIds = useAwaitingToolUseIds(sessionId)
const toolCalls = activityStepToolCalls(steps)
const hasImageGeneration = toolCalls.some((toolCall) => isImageGenerationToolName(toolCall.toolName))
const hasNonImageSteps = steps.some(
@@ -324,10 +337,32 @@ function ToolCallGroupContent({
isLive={isLive}
disclosureKey={disclosureKey}
revealToolUseId={revealToolUseId}
awaitingToolUseIds={awaitingToolUseIds}
/>
)
}
const NO_AWAITING_TOOL_USE_IDS: ReadonlySet<string> = new Set()
/**
* Calls of this session whose permission prompt is waiting on the user, so the
* run can mark them amber. Selected as a joined string so a group re-renders
* only when that set actually changes, not on every streamed token.
*/
function useAwaitingToolUseIds(sessionId?: string | null): ReadonlySet<string> {
const key = useChatStore((state) => {
if (!sessionId) return ''
const session = state.sessions[sessionId]
if (!session) return ''
return listPendingPermissions(session)
.map((permission) => permission.toolUseId ?? '')
.filter(Boolean)
.sort()
.join('\n')
})
return useMemo(() => (key ? new Set(key.split('\n')) : NO_AWAITING_TOOL_USE_IDS), [key])
}
function MemoryToolActivityGroup({
activity,
toolCalls,
@@ -361,20 +396,21 @@ function MemoryToolActivityGroup({
data-chat-disclosure="true"
aria-expanded={expanded}
onClick={toggleExpanded}
className="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-[var(--color-surface-hover)]"
className="flex min-h-10 w-full items-center gap-2 px-3 text-left transition-colors hover:bg-[var(--color-surface-hover)] focus:outline-none focus-visible:shadow-[var(--shadow-focus-ring)]"
>
{expanded ? (
<ChevronDown size={15} className="shrink-0 text-[var(--color-text-tertiary)]" aria-hidden="true" />
) : (
<ChevronRight size={15} className="shrink-0 text-[var(--color-text-tertiary)]" aria-hidden="true" />
)}
<BookMarked size={15} className="shrink-0 text-[var(--color-memory-accent)]" aria-hidden="true" />
<BookMarked size={15} strokeWidth={1.75} className="shrink-0 text-[var(--color-memory-accent)]" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-[var(--color-text-primary)]">
{t(titleKey, { count: activity.files.length })}
</span>
{isStreaming ? (
<span className="h-1.5 w-1.5 rounded-full bg-[var(--color-memory-accent)] animate-pulse-dot" />
) : null}
<ChevronRight
size={14}
strokeWidth={1.75}
aria-hidden="true"
className={`shrink-0 text-[var(--color-text-tertiary)] transition-transform duration-150 ${expanded ? 'rotate-90' : ''}`}
/>
</button>
{expanded ? (
@@ -386,10 +422,10 @@ function MemoryToolActivityGroup({
type="button"
title={file.path}
onClick={() => openMemorySettings(file.path)}
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)]"
className="group flex w-full items-start gap-2 rounded-[var(--radius-sm)] 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-[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" />
<Settings size={12} strokeWidth={2} aria-hidden="true" />
</span>
<span className="min-w-0 flex-1">
<span className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
@@ -418,19 +454,20 @@ function MemoryToolActivityGroup({
</div>
<Button
variant="ghost"
variant="secondary"
size="sm"
onClick={() => setDetailsExpanded((value) => !value)}
className="mt-2 border border-[var(--color-border)]"
aria-expanded={detailsExpanded}
className="mt-2"
icon={detailsExpanded
? <ChevronDown size={13} aria-hidden="true" />
: <ChevronRight size={13} aria-hidden="true" />}
? <ChevronDown size={14} strokeWidth={1.75} aria-hidden="true" />
: <ChevronRight size={14} strokeWidth={1.75} aria-hidden="true" />}
>
{t('chat.memoryTechnicalDetails')}
</Button>
{detailsExpanded ? (
<div className="mt-2 space-y-1">
<ToolTimeline className="mt-2">
{toolCalls.map((toolCall) => (
<ToolCallTree
key={toolCall.id}
@@ -438,9 +475,10 @@ function MemoryToolActivityGroup({
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
compact
chrome="row"
/>
))}
</div>
</ToolTimeline>
) : null}
</div>
) : null}
@@ -471,10 +509,15 @@ function AgentToolGroup({
taskStatus: agentTaskNotifications[toolCall.toolUseId]?.status ?? agentTaskStatuses?.[toolCall.toolUseId],
}),
)
const isAnyRunning = statuses.some((status) => status === 'running' || status === 'starting')
const errorPresent = statuses.some((status) => status === 'failed')
const allComplete = statuses.every((status) => status === 'done')
const anyStopped = statuses.some((status) => status === 'stopped')
const groupStatus: AgentStatus = statuses.some((status) => status === 'running' || status === 'starting')
? 'running'
: statuses.some((status) => status === 'failed')
? 'failed'
: statuses.every((status) => status === 'done')
? 'done'
: statuses.some((status) => status === 'stopped')
? 'stopped'
: 'starting'
return (
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]">
@@ -483,57 +526,68 @@ function AgentToolGroup({
data-chat-disclosure="true"
aria-expanded={expanded}
onClick={toggleExpanded}
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-[var(--color-surface-hover)]"
className="flex min-h-10 w-full items-center gap-2 px-3 text-left transition-colors hover:bg-[var(--color-surface-hover)] focus:outline-none focus-visible:shadow-[var(--shadow-focus-ring)]"
>
<span className="shrink-0 text-[11px] leading-none text-[var(--color-text-tertiary)]" aria-hidden="true">
{expanded ? '▾' : '▸'}
</span>
<span className="flex-1 truncate text-[14px] font-semibold text-[var(--color-text-primary)]">
<Bot size={15} strokeWidth={1.75} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-[var(--color-text-primary)]">
{toolCalls.length === 1 ? t('toolGroup.agentOne') : t('toolGroup.agentMany', { count: toolCalls.length })}
</span>
{isAnyRunning && (
<Badge tone="warning" className="font-semibold">
{t('agentStatus.running')}
</Badge>
)}
{!isAnyRunning && errorPresent && (
<span className="material-symbols-outlined shrink-0 text-[17px] text-[var(--color-error)]">error</span>
)}
{!isAnyRunning && !errorPresent && allComplete && (
<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 shrink-0 text-[17px] text-[var(--color-text-tertiary)]">pending</span>
)}
{!isAnyRunning && !errorPresent && !allComplete && anyStopped && (
<span className="material-symbols-outlined shrink-0 text-[17px] text-[var(--color-text-tertiary)]">stop_circle</span>
)}
<AgentStatusBadge status={groupStatus} t={t} />
<ChevronRight
size={14}
strokeWidth={1.75}
aria-hidden="true"
className={`shrink-0 text-[var(--color-text-tertiary)] transition-transform duration-150 ${expanded ? 'rotate-90' : ''}`}
/>
</button>
{expanded && (
<div className="px-3.5 pb-2.5 pt-0.5">
<div className="ml-1.5 flex flex-col border-l border-[var(--color-border)] pl-4">
{toolCalls.map((toolCall) => (
<AgentCallCard
key={toolCall.id}
sessionId={sessionId}
onOpenAgentRun={onOpenAgentRun}
resolveAgentActivityTarget={resolveAgentActivityTarget}
toolCall={toolCall}
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
agentTaskNotification={agentTaskNotifications[toolCall.toolUseId]}
agentTaskStatus={agentTaskStatuses?.[toolCall.toolUseId]}
showOpenRun={showOpenRun}
/>
))}
</div>
<div className="divide-y divide-[var(--color-border)] border-t border-[var(--color-border)]">
{toolCalls.map((toolCall) => (
<AgentCallCard
key={toolCall.id}
sessionId={sessionId}
onOpenAgentRun={onOpenAgentRun}
resolveAgentActivityTarget={resolveAgentActivityTarget}
toolCall={toolCall}
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
agentTaskNotification={agentTaskNotifications[toolCall.toolUseId]}
agentTaskStatus={agentTaskStatuses?.[toolCall.toolUseId]}
showOpenRun={showOpenRun}
/>
))}
</div>
)}
</div>
)
}
/**
* The run's state as one pill: in progress is info blue, done green, failed
* red. Stopped and not-yet-started stay neutral — neither needs the reader.
*/
function AgentStatusBadge({
status,
t,
}: {
status: AgentStatus
t: (key: TranslationKey, params?: Record<string, string | number>) => string
}) {
const icon = status === 'running' || status === 'starting'
? <LoaderCircle size={11} strokeWidth={2} className="animate-spin" aria-hidden="true" />
: status === 'done'
? <Check size={11} strokeWidth={2} aria-hidden="true" />
: status === 'failed'
? <CircleX size={11} strokeWidth={2} aria-hidden="true" />
: <CircleStop size={11} strokeWidth={2} aria-hidden="true" />
return (
<Badge tone={getAgentStatusTone(status)} icon={icon} data-agent-status={status}>
{getAgentStatusLabel(status, t)}
</Badge>
)
}
function AgentCallCard({
sessionId,
onOpenAgentRun,
@@ -592,7 +646,6 @@ function AgentCallCard({
childCount: childToolCalls.length || (lazyActivity?.toolCalls.length ?? 0),
taskStatus: agentTaskNotification?.status ?? agentTaskStatus,
})
const statusTone = getAgentStatusTone(status)
const statusLabel = getAgentStatusLabel(status, t)
const taskSummary = agentTaskNotification?.summary?.trim() || ''
const taskResult = agentTaskNotification?.result?.trim() || ''
@@ -614,51 +667,80 @@ function AgentCallCard({
const openRunTitle = description.trim() || 'Agent'
const canOpenRun = showOpenRun && !!sessionId && !!toolCall.toolUseId
const subagentType = typeof input.subagent_type === 'string' && input.subagent_type.trim()
? input.subagent_type.trim()
: 'Agent'
const usage = agentTaskNotification?.usage
const toolUseCount = usage?.toolUses ?? (childToolCalls.length || undefined)
const elapsedMs = usage?.durationMs ?? (result && !isLaunchResult ? toolCallDurationMs(toolCall, result) : undefined)
const stats = [
typeof toolUseCount === 'number' ? t('agentStatus.toolUses', { count: toolUseCount }) : '',
typeof elapsedMs === 'number' ? formatDuration(elapsedMs) : '',
].filter(Boolean).join(' · ')
const AvatarIcon = status === 'running' || status === 'starting' ? LoaderCircle : Bot
return (
<div data-agent-call-layout="row">
<div className="-mx-2 flex w-[calc(100%+1rem)] items-center gap-3 rounded-[var(--radius-md)] px-2 py-1.5 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 data-agent-call-layout="row" data-agent-status={status}>
<div className="flex items-center gap-3 px-3 py-2.5">
{/* The avatar carries the row's state — blue while it works, green when
done, red on failure — so a group of five does not stack five pills
under the header's own. Its name is the state, for screen readers. */}
<span
role="img"
aria-label={statusLabel}
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-[var(--radius-md)] ${AGENT_AVATAR_TONE[status]}`}
>
<AvatarIcon
size={16}
strokeWidth={1.75}
aria-hidden="true"
className={status === 'running' || status === 'starting' ? 'animate-spin' : undefined}
/>
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-[13px] font-semibold text-[var(--color-text-primary)]">{t('subagentRun.agent')}</span>
{description && (
<span className="truncate text-[12px] text-[var(--color-text-secondary)]">
{description}
</span>
)}
<div className="flex min-w-0 items-baseline gap-2">
<span className="min-w-0 truncate text-[13px] font-medium text-[var(--color-text-primary)]">
{description || t('agentStatus.resultTitle')}
</span>
<span className="shrink-0 font-mono text-[11px] text-[var(--color-text-tertiary)]">{subagentType}</span>
</div>
{!expanded && outputSummary && (
<div className="mt-1 line-clamp-2 text-[11px] text-[var(--color-text-tertiary)]">
<div className="mt-0.5 truncate text-[12px] text-[var(--color-text-tertiary)]" title={outputSummary}>
{outputSummary}
</div>
)}
{!expanded && !outputSummary && recentToolCalls.length > 0 && (
<div className="mt-1 space-y-1">
<div className="mt-0.5 space-y-0.5">
{recentToolCalls.map((recentToolCall) => (
<div
key={recentToolCall.id}
className="truncate text-[11px] text-[var(--color-text-tertiary)]"
className="truncate text-[12px] text-[var(--color-text-tertiary)]"
>
{formatRecentToolUseSummary(recentToolCall, resultMap)}
{formatRecentToolUseSummary(recentToolCall, resultMap, t)}
</div>
))}
</div>
)}
{!expanded && !outputSummary && !recentToolCalls.length && errorText && (
<div className="mt-1 truncate text-[11px] text-[var(--color-error)]">
<div className="mt-0.5 truncate text-[12px] text-[var(--color-error)]">
{errorText}
</div>
)}
</div>
{stats ? (
<span className="hidden shrink-0 font-mono text-[11px] tabular-nums text-[var(--color-text-tertiary)] sm:inline">
{stats}
</span>
) : null}
{outputSummary && (
<Button
variant="ghost"
variant="secondary"
size="sm"
onClick={(event) => {
event.stopPropagation()
setPreviewOpen(true)
}}
className="shrink-0 border border-[var(--color-border)]"
className="shrink-0"
>
{t('agentStatus.viewResult')}
</Button>
@@ -680,38 +762,38 @@ function AgentCallCard({
}
useTabStore.getState().openSubagentTab(sessionId, toolCall.toolUseId, openRunTitle)
}}
className="shrink-0 border border-[var(--color-border)]"
className="shrink-0"
>
{t('toolGroup.openRun')}
</Button>
)}
<Badge tone={statusTone} className="font-semibold">
{statusLabel}
</Badge>
<IconButton
size="sm"
shape="circle"
tone="muted"
onClick={() => setExpanded((value) => !value)}
label={t(expanded ? 'toolGroup.collapseAgent' : 'toolGroup.expandAgent')}
showTooltip={false}
aria-expanded={expanded}
icon={(
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
{expanded ? 'expand_less' : 'expand_more'}
</span>
<ChevronDown
size={14}
strokeWidth={1.75}
aria-hidden="true"
className={`transition-transform duration-150 ${expanded ? 'rotate-180' : ''}`}
/>
)}
/>
</div>
{expanded && (
<div className="mb-2 ml-2 mt-1 border-l border-[var(--color-border)] py-1 pl-3">
<div className="pb-2.5 pl-[52px] pr-3">
{errorText && (
<div className="mb-2 bg-[var(--color-error-soft)] px-3 py-2 text-[11px] text-[var(--color-error)]">
<div className="mb-2 rounded-[var(--radius-md)] bg-[var(--color-error-container)] px-3 py-2 text-[12px] text-[var(--color-on-error-container)]">
{errorText}
</div>
)}
{childToolCalls.length > 0 ? (
<div className="space-y-0.5">
<ToolTimeline>
{childToolCalls.map((childToolCall) => (
<ToolCallTree
key={childToolCall.id}
@@ -722,18 +804,18 @@ function AgentCallCard({
chrome="row"
/>
))}
</div>
</ToolTimeline>
) : showActivityLoading ? (
<div
data-testid="agent-call-activity-loading"
className="px-2 py-1 text-[11px] text-[var(--color-text-tertiary)]"
className="py-1 text-[12px] text-[var(--color-text-tertiary)]"
>
{t('agentActivity.loading')}
</div>
) : activityState.status === 'error' ? (
<div
data-testid="agent-call-activity-error"
className="flex items-center gap-2 px-2 py-1 text-[11px] text-[var(--color-text-tertiary)]"
className="flex items-center gap-2 py-1 text-[12px] text-[var(--color-text-tertiary)]"
>
<span>{t('agentActivity.failed')}</span>
<Button variant="ghost" size="sm" onClick={retryActivity}>
@@ -741,29 +823,31 @@ function AgentCallCard({
</Button>
</div>
) : lazyActivity && lazyActivity.toolCalls.length > 0 ? (
<div className="space-y-0.5" data-testid="agent-call-activity">
{lazyActivity.toolCalls.map((childToolCall) => (
<ToolCallTree
key={childToolCall.id}
toolCall={childToolCall}
resultMap={lazyActivity.resultMap}
childToolCallsByParent={lazyActivity.childToolCallsByParent}
compact
chrome="row"
/>
))}
<div data-testid="agent-call-activity">
<ToolTimeline>
{lazyActivity.toolCalls.map((childToolCall) => (
<ToolCallTree
key={childToolCall.id}
toolCall={childToolCall}
resultMap={lazyActivity.resultMap}
childToolCallsByParent={lazyActivity.childToolCallsByParent}
compact
chrome="row"
/>
))}
</ToolTimeline>
{lazyActivity.truncated && (
<div className="px-2 py-1 text-[11px] text-[var(--color-text-tertiary)]">
<div className="py-1 pl-[30px] text-[12px] text-[var(--color-text-tertiary)]">
{t('agentActivity.truncated')}
</div>
)}
</div>
) : outputSummary ? (
<div className="px-2 py-1 text-[11px] text-[var(--color-text-tertiary)]">
<div className="py-1 text-[12px] text-[var(--color-text-tertiary)]">
{t('agentStatus.noActivity')}
</div>
) : (
<div className="px-2 py-1 text-[11px] text-[var(--color-text-tertiary)]">
<div className="py-1 text-[12px] text-[var(--color-text-tertiary)]">
{status === 'starting' ? t('agentStatus.starting') : t('agentStatus.noActivity')}
</div>
)}
@@ -783,6 +867,14 @@ function AgentCallCard({
)
}
const AGENT_AVATAR_TONE: Record<AgentStatus, string> = {
starting: 'bg-[var(--color-info-container)] text-[var(--color-on-info-container)]',
running: 'bg-[var(--color-info-container)] text-[var(--color-on-info-container)]',
done: 'bg-[var(--color-success-container)] text-[var(--color-on-success-container)]',
failed: 'bg-[var(--color-error-container)] text-[var(--color-on-error-container)]',
stopped: 'bg-[var(--color-surface-container)] text-[var(--color-text-secondary)]',
}
function ToolCallTree({
toolCall,
resultMap,
@@ -814,28 +906,33 @@ function ToolCallTree({
durationMs={toolCallDurationMs(toolCall, result)}
toolUseId={toolCall.parentToolUseId ? undefined : toolCall.toolUseId}
/>
{childToolCalls.length > 0 && (
<div className={
isRow
? 'ml-2 border-l border-[var(--color-border)] pl-3'
: 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={isRow ? 'space-y-0.5' : 'space-y-1'}>
{childToolCalls.map((childToolCall) => (
<ToolCallTree
key={childToolCall.id}
toolCall={childToolCall}
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
compact
chrome={chrome}
/>
))}
</div>
{childToolCalls.length > 0 && (isRow ? (
<ToolTimeline className="ml-[30px]">
{childToolCalls.map((childToolCall) => (
<ToolCallTree
key={childToolCall.id}
toolCall={childToolCall}
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
compact
chrome={chrome}
/>
))}
</ToolTimeline>
) : (
<div className={`${compact ? 'ml-4' : 'mb-2 ml-6'} space-y-1 border-l border-[var(--color-border)] pl-3`}>
{childToolCalls.map((childToolCall) => (
<ToolCallTree
key={childToolCall.id}
toolCall={childToolCall}
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
compact
chrome={chrome}
/>
))}
</div>
)}
))}
</div>
)
}
@@ -996,38 +1093,52 @@ function getAgentStatusTone(status: AgentStatus): Tone {
case 'done':
return 'success'
case 'running':
return 'warning'
case 'stopped':
case 'starting':
return 'info'
case 'stopped':
default:
return 'neutral'
}
}
/**
* One line per recent child call under a collapsed agent row: verb, target and
* state, in the interface language — `读取 · example.ts · 完成`.
*/
function formatRecentToolUseSummary(
toolCall: ToolCall,
resultMap: Map<string, ToolResult>,
t: (key: TranslationKey, params?: Record<string, string | number>) => string,
): string {
const input = toolCall.input && typeof toolCall.input === 'object'
? toolCall.input as Record<string, unknown>
: {}
const result = resultMap.get(toolCall.toolUseId)
const suffix = result?.isError ? ' • failed' : result ? ' • done' : ' • running'
const state = t(result?.isError ? 'agentStatus.failed' : result ? 'agentStatus.done' : 'agentStatus.running')
const str = (value: unknown) => (typeof value === 'string' ? value : '')
let target = ''
switch (toolCall.toolName) {
case 'Bash':
return `Bash · ${typeof input.command === 'string' ? input.command : ''}${suffix}`
case 'PowerShell':
target = str(input.command)
break
case 'Read':
return `Read · ${typeof input.file_path === 'string' ? input.file_path.split('/').pop() : 'file'}${suffix}`
case 'Edit':
case 'Write':
target = str(input.file_path).split(/[/\\]/).pop() || ''
break
case 'Glob':
return `Glob · ${typeof input.pattern === 'string' ? input.pattern : ''}${suffix}`
case 'Grep':
return `Grep · ${typeof input.pattern === 'string' ? input.pattern : ''}${suffix}`
target = str(input.pattern)
break
case 'Agent':
return `Agent · ${typeof input.description === 'string' ? input.description : ''}${suffix}`
target = str(input.description)
break
default:
return `${toolCall.toolName}${suffix}`
break
}
return [toolVerb(toolCall.toolName, t), target, state].filter(Boolean).join(' · ')
}
function getAgentErrorSummary(content: unknown): string {
+16 -23
View File
@@ -1,5 +1,7 @@
import { CodeViewer } from './CodeViewer'
import { memo, useMemo, useState } from 'react'
import { CircleAlert, CircleCheck } from 'lucide-react'
import { Badge } from '@/components/ui/Badge'
import { useTranslation } from '../../i18n'
import { getDisclosure, setDisclosure } from '../../lib/disclosureMemory'
import { extractToolResultImages } from '../../lib/toolResultContent'
@@ -35,11 +37,7 @@ export const ToolResultBlock = memo(function ToolResultBlock({ content, isError,
const hasImages = toolImages.images.length > 0 || toolImages.dropped > 0
return (
<div className={`overflow-hidden rounded-[var(--radius-lg)] border ${
isError
? 'border-[var(--color-error)]'
: 'border-[var(--color-border)]'
}`}>
<div className="overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]">
{/* Status header */}
<button
type="button"
@@ -48,25 +46,19 @@ export const ToolResultBlock = memo(function ToolResultBlock({ content, isError,
setLocalExpanded(next)
if (disclosureKey) setDisclosure(disclosureKey, next)
}}
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-on-error-container)]'
: 'bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)]'
}`}
className="flex min-h-9 w-full items-center gap-2 px-3 text-left text-[13px] transition-colors hover:bg-[var(--color-surface-hover)] focus:outline-none focus-visible:shadow-[var(--shadow-focus-ring)]"
>
<span className="flex items-center gap-1.5">
<span className="material-symbols-outlined text-[12px]">
{isError ? 'error' : 'check_circle'}
</span>
{isError ? (
<CircleAlert size={14} strokeWidth={1.75} aria-hidden="true" className="shrink-0 text-[var(--color-error)]" />
) : (
<CircleCheck size={14} strokeWidth={1.75} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
)}
<span className="min-w-0 flex-1 truncate font-medium text-[var(--color-text-primary)]">
{toolName ? t('tool.result', { toolName }) : t('tool.resultGeneric')}
</span>
<span className={`px-2 py-0.5 rounded-full text-[9px] ${
isError
? 'bg-[var(--color-error-container)] text-[var(--color-on-error-container)]'
: 'bg-[var(--color-diff-added-bg)] text-[var(--color-diff-added-text)]'
}`}>
<Badge tone={isError ? 'danger' : 'success'}>
{isError ? t('tool.error') : t('tool.success')}
</span>
</Badge>
</button>
{/* Pictures the tool returned as image blocks */}
@@ -85,7 +77,7 @@ export const ToolResultBlock = memo(function ToolResultBlock({ content, isError,
{/* Content. A result that is only pictures has no text to preview. */}
{!text && hasImages ? null : expanded ? (
isError ? (
<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)]">
<div className="whitespace-pre-wrap break-words border-t border-[var(--color-border)] bg-[var(--color-error-container)] px-3 py-2.5 font-mono text-[12px] leading-[1.6] text-[var(--color-on-error-container)]">
{text}
</div>
) : (
@@ -96,7 +88,7 @@ export const ToolResultBlock = memo(function ToolResultBlock({ content, isError,
/>
)
) : (
<div className="bg-[var(--color-surface-container-lowest)] px-3 py-2 font-mono text-[10px] leading-[1.35] text-[var(--color-text-tertiary)]">
<div className="border-t border-[var(--color-border)] bg-[var(--color-surface-container)] px-3 py-2 font-mono text-[12px] leading-[1.5] text-[var(--color-text-tertiary)]">
{preview}
{hasMore ? '…' : ''}
</div>
@@ -109,7 +101,8 @@ export const ToolResultBlock = memo(function ToolResultBlock({ content, isError,
setLocalExpanded(next)
if (disclosureKey) setDisclosure(disclosureKey, next)
}}
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)]"
type="button"
className="flex h-7 w-full items-center justify-center border-t border-[var(--color-border)] text-[12px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus:outline-none focus-visible:shadow-[var(--shadow-focus-ring)]"
>
{expanded ? t('tool.showLess') : t('tool.showMore', { count: text.length - 200 })}
</button>
@@ -25,14 +25,14 @@ export function TurnCompletionStamp({ completion }: Props) {
return (
<span
data-turn-completion
className="inline-flex min-w-0 items-center gap-2 whitespace-nowrap text-[11px] font-medium tabular-nums text-[var(--color-text-tertiary)]"
className="inline-flex min-w-0 items-center gap-1.5 whitespace-nowrap text-xs tabular-nums text-[var(--color-text-tertiary)]"
>
<span title={formatExactMessageTimestamp(completion.completedAt, locale) || clockLabel}>
{clockLabel}
</span>
{duration ? (
<>
<span aria-hidden="true">·</span>
<span aria-hidden="true" className="h-[3px] w-[3px] shrink-0 rounded-full bg-[var(--color-text-tertiary)]" />
<span data-turn-completion-duration>{t('chat.turnDuration', { duration })}</span>
</>
) : null}
@@ -45,6 +45,10 @@ describe('UserMessage', () => {
expect(actions?.className).toContain('opacity-0')
expect(actions?.className).toContain('group-hover:opacity-100')
expect(actions?.className).toContain('group-focus-within:opacity-100')
// Hung under the bubble, out of flow: revealing it never shifts the transcript.
expect(actions?.getAttribute('data-placement')).toBe('overlay')
expect(actions?.className).toContain('absolute')
expect(actions?.parentElement?.className).toContain('relative')
expect(container.querySelectorAll('[data-message-actions]')).toHaveLength(1)
expect(screen.getByRole('button', { name: 'Fork' }).closest('[data-message-actions]')).toBe(actions)
expect(button.textContent).toBe('')
+11 -9
View File
@@ -111,9 +111,9 @@ export const UserMessage = memo(function UserMessage({
<div
data-message-shell="teammate"
data-teammate-from={teammateFrom}
className="group flex min-w-0 max-w-[82%] flex-col items-start sm:max-w-[78%] lg:max-w-[680px]"
className="group relative flex min-w-0 max-w-[82%] flex-col items-start lg:max-w-[680px]"
>
<div className="mb-1 flex min-w-0 items-center gap-2 px-0.5 text-[11px] text-[var(--color-text-tertiary)]">
<div className="mb-1.5 flex min-w-0 items-center gap-2 px-0.5 text-xs text-[var(--color-text-tertiary)]">
{teammateAvatarSrc ? (
<span
data-testid="teammate-message-avatar"
@@ -133,9 +133,9 @@ export const UserMessage = memo(function UserMessage({
/>
</span>
) : (
<UsersRound size={12} strokeWidth={2.2} aria-hidden="true" className="shrink-0 text-[var(--color-brand)]" />
<UsersRound size={12} strokeWidth={2} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
)}
<span className="min-w-0 truncate font-mono font-bold text-[var(--color-text-secondary)]">
<span className="min-w-0 truncate font-mono font-medium text-[var(--color-text-secondary)]">
{teammateFrom}
</span>
<span className="shrink-0">{t('chat.teammateMessage')}</span>
@@ -148,13 +148,13 @@ export const UserMessage = memo(function UserMessage({
{hasText && (
<div
data-message-body="teammate"
className="min-w-0 max-w-full rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container)] px-[16px] py-[12px] chat-reading-text leading-relaxed text-[var(--color-text-primary)]"
className="min-w-0 max-w-full rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3.5 py-2.5 chat-reading-text text-[var(--color-text-primary)]"
style={{ overflowWrap: 'anywhere', wordBreak: 'break-word' }}
>
<MarkdownRenderer
content={content}
onLinkClick={sessionId ? handleLinkClick : undefined}
className="chat-reading-markdown [&>:first-child]:mt-0 [&>:last-child]:mb-0 [&_h1]:text-lg [&_h2]:text-base [&_h3]:text-sm [&_h4]:text-sm"
className="chat-reading-markdown [&>:first-child]:mt-0 [&>:last-child]:mb-0"
/>
</div>
)}
@@ -165,6 +165,7 @@ export const UserMessage = memo(function UserMessage({
copyText={content}
copyLabel={t('chat.copyPrompt')}
align="start"
placement="overlay"
timestamp={timestamp}
/>
)}
@@ -179,7 +180,7 @@ export const UserMessage = memo(function UserMessage({
<div
data-message-shell="user"
data-editing="true"
className="flex w-full min-w-0 max-w-[82%] flex-col items-stretch sm:max-w-[78%] lg:max-w-[640px]"
className="flex w-full min-w-0 max-w-[82%] flex-col items-stretch lg:max-w-[640px]"
>
<UserMessageEditor
initialDraft={editAction.getDraft()}
@@ -198,7 +199,7 @@ export const UserMessage = memo(function UserMessage({
<div className="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-[640px]"
className="group relative flex min-w-0 max-w-[82%] flex-col items-end lg:max-w-[640px]"
>
<div className="flex max-w-full flex-col items-end gap-2">
{collaboration ? <div className="px-0.5 text-[11px] text-[var(--color-text-tertiary)]">
@@ -214,7 +215,7 @@ export const UserMessage = memo(function UserMessage({
{hasText && (
<div
data-message-body="user"
className="min-w-0 max-w-full rounded-[var(--radius-lg)] bg-[var(--color-surface-user-msg)] px-[18px] py-[13px] chat-reading-text 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-3.5 py-2.5 chat-reading-text text-[var(--color-text-primary)] whitespace-pre-wrap break-words"
style={{
overflowWrap: 'anywhere',
wordBreak: 'break-word',
@@ -232,6 +233,7 @@ export const UserMessage = memo(function UserMessage({
branchAction={branchAction}
editAction={actionBarEditAction}
align="end"
placement="overlay"
timestamp={timestamp}
/>
)}
@@ -70,7 +70,7 @@ export function UserMessageEditor({
return (
<div
data-testid="user-message-editor"
className="flex w-full flex-col gap-2 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)] p-2.5"
className="flex w-full flex-col gap-2 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] p-2.5"
>
{draft.attachments.length > 0 ? (
<AttachmentGallery
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from 'react'
import { Network } from 'lucide-react'
import { workflowsApi } from '../../api/workflows'
import { useTranslation } from '../../i18n'
import { runsForSession, useWorkflowStore } from '../../stores/workflowStore'
@@ -139,7 +140,7 @@ export function WorkflowSavePanel({
>
{completedRuns.length === 0 ? (
<EmptyState
icon={<span className="material-symbols-outlined">account_tree</span>}
icon={<Network size={20} strokeWidth={1.75} aria-hidden="true" />}
description={t('slash.saveWorkflow.noCompletedRun')}
/>
) : (
@@ -191,12 +192,12 @@ export function WorkflowSavePanel({
]}
/>
{error && (
<p role="alert" className="text-sm text-[var(--color-error)]">
<p role="alert" className="text-[13px] text-[var(--color-error)]">
{t('slash.saveWorkflow.error', { detail: error })}
</p>
)}
{savedName && (
<p role="status" className="text-sm text-[var(--color-success)]">
<p role="status" className="text-[13px] text-[var(--color-success)]">
{t('slash.saveWorkflow.success', { name: savedName })}
</p>
)}
@@ -1,5 +1,5 @@
import { useState } from 'react'
import { ChevronRight } from 'lucide-react'
import { ChevronRight, FileDiff } from 'lucide-react'
import type { WorkspaceChangedFile } from '@/api/sessions'
import { Button } from '@/components/ui/Button'
import { FileTypeIcon } from '@/components/ui/FileTypeIcon'
@@ -23,15 +23,16 @@ export function WorkspaceChangesFallback({ sessionId, files }: WorkspaceChangesF
return (
<section
aria-label={t('chat.workspaceChangesFallbackLabel')}
className="mx-auto mb-5 mt-2 w-full max-w-[var(--chat-content-max-width)] overflow-hidden rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-[var(--shadow-card)]"
className="mx-auto mb-5 mt-2.5 w-full max-w-[var(--chat-content-max-width)] overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]"
>
<div className="space-y-1 bg-[var(--color-surface-container-low)] px-3 py-2">
<h3 className="text-sm font-semibold text-[var(--color-text-primary)]">
<div className="space-y-0.5 px-3 py-2.5">
<h3 className="flex items-center gap-2 text-[13px] font-medium text-[var(--color-text-primary)]">
<FileDiff size={15} strokeWidth={1.75} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
{files === null
? t('chat.workspaceChangesFallbackLabel')
: t('chat.workspaceChangesFallbackTitle', { count: files.length })}
</h3>
<p role={files === null ? 'alert' : undefined} className="text-xs leading-5 text-[var(--color-text-secondary)]">
<p role={files === null ? 'alert' : undefined} className="pl-[23px] text-xs leading-5 text-[var(--color-text-tertiary)]">
{t(files === null ? 'chat.workspaceChangesFallbackUnavailable' : 'chat.workspaceChangesFallbackExplanation')}
</p>
</div>
@@ -43,7 +44,7 @@ export function WorkspaceChangesFallback({ sessionId, files }: WorkspaceChangesF
) : null}
{visibleFiles?.length ? (
<ul className="divide-y divide-[var(--color-border)] border-t border-[var(--color-border)]">
<ul className="border-t border-[var(--color-border)] px-1.5 py-1">
{visibleFiles.map((file) => (
<li key={file.path}>
<button
@@ -51,11 +52,11 @@ export function WorkspaceChangesFallback({ sessionId, files }: WorkspaceChangesF
aria-label={t('chat.workspaceChangesFallbackOpenFile', { path: file.path })}
title={file.path}
onClick={() => workspaceOpen.file(sessionId, file.path)}
className="flex min-h-[44px] w-full min-w-0 items-center gap-3 rounded-[var(--radius-md)] px-3 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)]"
className="flex h-8 w-full min-w-0 items-center gap-2 rounded-[var(--radius-sm)] px-1.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)]"
>
<FileTypeIcon path={file.path} size={20} />
<span className="min-w-0 flex-1 truncate text-sm text-[var(--color-text-primary)]">{file.path}</span>
<ChevronRight size={16} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
<FileTypeIcon path={file.path} size={14} />
<span className="min-w-0 flex-1 truncate font-mono text-xs text-[var(--color-text-primary)]">{file.path}</span>
<ChevronRight size={14} strokeWidth={1.75} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
</button>
</li>
))}
@@ -63,7 +64,7 @@ export function WorkspaceChangesFallback({ sessionId, files }: WorkspaceChangesF
) : null}
{files && files.length > COLLAPSED_COUNT ? (
<div className="border-t border-[var(--color-border)] px-3 py-2">
<div className="border-t border-[var(--color-border)] px-1.5 py-1">
<Button
variant="ghost"
size="sm"
@@ -1,13 +1,15 @@
import {
Bot,
Box,
Brain,
FilePen,
FilePlus,
FileText,
Globe,
ListTodo,
MessageSquare,
Search,
Terminal,
SquareTerminal,
Wrench,
type LucideIcon,
} from 'lucide-react'
@@ -31,6 +33,7 @@ export type ActivityStep =
| { kind: 'tool'; toolCall: ToolCall }
export const THINKING_SEGMENT_KEY = '__thinking__'
const TASKS_FAMILY_KEY = '__tasks__'
export type ActivitySegment = {
key: string
@@ -63,11 +66,27 @@ const TOOL_VERBS: Record<string, (count: number, t: Translate) => string> = {
Agent: (n, t) => n === 1 ? t('toolGroup.agentOne') : t('toolGroup.agentMany', { count: n }),
WebSearch: (_n, t) => t('toolGroup.searchedWeb'),
WebFetch: (n, t) => n === 1 ? t('toolGroup.fetchedOne') : t('toolGroup.fetchedMany', { count: n }),
// A todo list is one artifact however many times it is rewritten, so the
// summary says what happened to it rather than counting the rewrites.
TodoWrite: (_n, t) => t('toolGroup.updatedTodos'),
[TASKS_FAMILY_KEY]: (_n, t) => t('toolGroup.managedTasks'),
}
/**
* Tools that share one clause in the summary. The task tools are four names for
* one activity — "TaskCreate, TaskUpdate (3), TaskList" read as an inventory of
* internals where "managed tasks" says what the reader needs.
*/
const TOOL_FAMILIES: Record<string, string> = {
TaskCreate: TASKS_FAMILY_KEY,
TaskUpdate: TASKS_FAMILY_KEY,
TaskList: TASKS_FAMILY_KEY,
TaskGet: TASKS_FAMILY_KEY,
}
const TOOL_SEGMENT_ICONS: Record<string, LucideIcon> = {
Bash: Terminal,
PowerShell: Terminal,
Bash: SquareTerminal,
PowerShell: SquareTerminal,
Read: FileText,
Write: FilePlus,
Edit: FilePen,
@@ -75,12 +94,22 @@ const TOOL_SEGMENT_ICONS: Record<string, LucideIcon> = {
NotebookEdit: FilePen,
Glob: Search,
Grep: Search,
ToolSearch: Search,
Agent: Bot,
WebSearch: Globe,
WebFetch: Globe,
TodoWrite: ListTodo,
TaskCreate: ListTodo,
TaskUpdate: ListTodo,
TaskList: ListTodo,
TaskGet: ListTodo,
[TASKS_FAMILY_KEY]: ListTodo,
Skill: Box,
ListSessions: MessageSquare,
ReadSession: MessageSquare,
CreateSession: MessageSquare,
SendSessionMessage: MessageSquare,
WaitSessions: MessageSquare,
}
export function activitySegmentIcon(toolName: string): LucideIcon {
@@ -98,7 +127,9 @@ export function buildActivitySegments(steps: ActivityStep[], t: Translate): Acti
const editedFilePaths = new Set<string>()
for (const step of steps) {
const key = step.kind === 'thinking' ? THINKING_SEGMENT_KEY : step.toolCall.toolName
const key = step.kind === 'thinking'
? THINKING_SEGMENT_KEY
: TOOL_FAMILIES[step.toolCall.toolName] ?? step.toolCall.toolName
if (!counts.has(key)) order.push(key)
// The Edit label counts files, not operations. Agents commonly refine one
@@ -149,6 +180,15 @@ export function buildActivitySegments(steps: ActivityStep[], t: Translate): Acti
})
}
/**
* The header sentence. The separator is the locale's own list mark — `、` in
* Chinese and Japanese, a comma elsewhere — because a Latin comma between
* Chinese clauses reads as a typo.
*/
export function formatActivitySummary(segments: ActivitySegment[], t: Translate): string {
return segments.map((segment) => segment.label).join(t('toolGroup.separator'))
}
export function toolCallHasError(
toolCall: ToolCall,
resultMap: Map<string, ToolResult>,
@@ -6,7 +6,7 @@ import {
Paperclip,
Plug,
Settings2,
Sparkles,
UserPlus,
Users,
Workflow,
type LucideIcon,
@@ -180,7 +180,7 @@ export function buildCapabilitySections(input: CapabilityMenuInput): CapabilityM
key: 'teams:create',
label: t('chat.capabilities.teamCreate'),
description: t('chat.capabilities.teamCreateDescription'),
icon: { kind: 'lucide', icon: Sparkles },
icon: { kind: 'lucide', icon: UserPlus },
action: { type: 'insertPromptSeed', text: input.teamCreatePrompt },
}]
for (const team of input.teams) {
@@ -420,7 +420,10 @@ describe('chat blocks', () => {
/>,
)
expect(container.textContent).toContain('Bash')
// A described command is named by its description; the raw tool name and
// the command stay in the tooltip.
expect(container.textContent).toContain('List files')
expect(screen.getByRole('button').getAttribute('title')).toBe('Bash · ls -la')
expect(container.textContent).not.toContain('file-a')
fireEvent.click(screen.getByRole('button'))
@@ -714,7 +717,8 @@ describe('chat blocks', () => {
/>,
)
expect(container.textContent).toContain('Write')
expect(container.textContent).toContain('Create')
expect(screen.getByRole('button').getAttribute('title')).toContain('Write')
expect(container.textContent).toContain('ai-code-novel.md')
expect(container.textContent).toContain('Generating content')
})
@@ -840,8 +844,9 @@ describe('chat blocks', () => {
/>,
)
expect(container.textContent).toContain('Bash')
expect(container.textContent).toContain('Show full diff of latest commit')
expect(container.textContent).toContain('fatal: unrecognized argument: --no-stat')
expect(container.querySelector('[data-tool-status]')?.getAttribute('data-tool-status')).toBe('error')
})
it('shows full bash error output when the tool block is expanded', () => {
@@ -864,7 +869,9 @@ describe('chat blocks', () => {
fireEvent.click(screen.getByRole('button'))
expect(container.textContent).toContain('Error Output')
// A failed command reads as failed in the terminal head — no exit code is
// recoverable from this payload, so the pill says so instead of guessing 1.
expect(container.querySelector('[data-shell-exit]')?.textContent).toBe('Failed')
expect(container.textContent).toContain('detail line 8')
expect(container.textContent).toContain('final remediation hint')
})
+40 -3
View File
@@ -13,6 +13,43 @@ import { findMentionRanges, mentionToken, tokenOccurrences, safeMentionIcon } fr
export const AT_MENTION_NODE = 'atMention'
const SVG_NS = 'http://www.w3.org/2000/svg'
/**
* Path data of the lucide glyphs the mention pill shows (MessageSquare, Puzzle,
* Box, Folder, File). The pill is a ProseMirror node rendered through `toDOM`,
* not React, so the icon is emitted as a plain SVG with the same 24px grid and
* stroke the rest of the composer's lucide icons use.
*/
const MENTION_ICON_PATHS: Record<'session' | 'plugin' | 'skill' | 'directory' | 'file', string[]> = {
session: ['M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z'],
plugin: ['M15.39 4.39a1 1 0 0 0 1.68-.474 2.5 2.5 0 1 1 3.014 3.015 1 1 0 0 0-.474 1.68l1.683 1.682a2.414 2.414 0 0 1 0 3.414L19.61 15.39a1 1 0 0 1-1.68-.474 2.5 2.5 0 1 0-3.014 3.015 1 1 0 0 1 .474 1.68l-1.683 1.682a2.414 2.414 0 0 1-3.414 0L8.61 19.61a1 1 0 0 0-1.68.474 2.5 2.5 0 1 1-3.014-3.015 1 1 0 0 0 .474-1.68l-1.683-1.682a2.414 2.414 0 0 1 0-3.414L4.39 8.61a1 1 0 0 1 1.68.474 2.5 2.5 0 1 0 3.014-3.015 1 1 0 0 1-.474-1.68l1.683-1.682a2.414 2.414 0 0 1 3.414 0z'],
skill: ['M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z', 'm3.3 7 8.7 5 8.7-5', 'M12 22V12'],
directory: ['M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z'],
file: ['M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z', 'M14 2v4a2 2 0 0 0 2 2h4'],
}
function mentionIconSpec(glyph: keyof typeof MENTION_ICON_PATHS): DOMOutputSpec {
return [
`${SVG_NS} svg`,
{
class: 'composer-mention-icon',
'data-mention-glyph': glyph,
viewBox: '0 0 24 24',
width: '14',
height: '14',
fill: 'none',
stroke: 'currentColor',
'stroke-width': '1.75',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
'aria-hidden': 'true',
focusable: 'false',
},
...MENTION_ICON_PATHS[glyph].map((d): DOMOutputSpec => [`${SVG_NS} path`, { d }]),
]
}
export const composerSchema = new Schema({
nodes: {
doc: { content: 'block+' },
@@ -55,15 +92,15 @@ export const composerSchema = new Schema({
}
const symbol: DOMOutputSpec = icon
? ['img', { class: 'composer-mention-brand-icon', src: `${import.meta.env.BASE_URL}${icon.slice(1)}`, alt: '', draggable: 'false' }]
: ['span', { class: 'material-symbols-outlined composer-mention-icon', 'aria-hidden': 'true' }, kind === 'session' ? 'chat' : kind === 'plugin' ? 'extension' : kind === 'skill' ? 'deployed_code' : node.attrs.isDirectory ? 'folder' : 'draft']
: mentionIconSpec(kind ?? (node.attrs.isDirectory ? 'directory' : 'file'))
return ['span', attrs, symbol, `@${node.attrs.label as string}`]
},
parseDOM: [
{
tag: 'span.composer-mention',
getAttrs: (dom) => ({
// The ligature icon text pollutes textContent, so the label
// travels in its own attribute.
// The pill's text is decorated (the leading `@`, the icon), so
// the label travels in its own attribute.
label: dom.getAttribute('data-mention-label') ?? '',
path: dom.getAttribute('data-mention-path') ?? '',
isDirectory: dom.classList.contains('composer-mention--directory'),
@@ -0,0 +1,32 @@
/**
* Class recipes shared by the composer's popovers (model, effort, permission
* mode, the + menu, slash commands, @ references) — the 「素」 menu pattern in
* one place, so the six surfaces cannot drift into six slightly different
* menus again.
*/
/** White sheet, hairline, card corner, dropdown shadow, 4px inset. */
export const COMPOSER_POPOVER =
'rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] p-1 shadow-[var(--shadow-dropdown)]'
/** A menu row: 32px minimum, the row corner, 13px. Hover doubles as highlight. */
export const COMPOSER_MENU_ITEM =
'flex min-h-8 w-full items-center gap-2.5 rounded-[var(--radius-sm)] px-2 py-1.5 text-left text-[13px] text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]'
/** The selected/highlighted row keeps the hover fill at rest. */
export const COMPOSER_MENU_ITEM_ACTIVE = 'bg-[var(--color-surface-hover)]'
/** Group heading: 11px semibold tertiary, sentence case. */
export const COMPOSER_MENU_SECTION =
'px-2 pb-1 pt-2 text-[11px] font-semibold text-[var(--color-text-tertiary)]'
/** Hairline between groups inside a menu. */
export const COMPOSER_MENU_SEPARATOR = 'mx-1 my-1 h-px bg-[var(--color-border)]'
/** Keyboard hint chip: mono 11px, 18px tall, chip corner, white ground. */
export const COMPOSER_KBD =
'inline-flex h-[18px] min-w-[18px] shrink-0 items-center justify-center rounded-[var(--radius-xs)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-1 font-mono text-[11px] text-[var(--color-text-tertiary)]'
/** A 28px toolbar control in the composer's bottom row. */
export const COMPOSER_TOOLBAR_CONTROL =
'inline-flex h-7 items-center gap-1.5 rounded-[var(--radius-sm)] px-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-[var(--color-border-focus)] disabled:cursor-not-allowed disabled:opacity-50'
@@ -11,11 +11,13 @@ import { describe, expect, it } from 'vitest'
* 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.
* Every popover above the composer row (「素」: model, effort, permission
* mode, the + menu, slash commands, references, context) is a menu sheet at
* the card corner, `--radius-lg`; only the composer card itself is `xl`. The
* shared recipe lives in `composerMenuStyles.ts`, so a component may either
* use `COMPOSER_POPOVER` or spell the panel inline — both are held here.
*/
const OVERLAY_RADIUS = 'rounded-[var(--radius-xl)]'
const OVERLAY_RADIUS = 'rounded-[var(--radius-lg)]'
function source(relativePath: string): string {
return readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), 'utf8')
@@ -29,6 +31,7 @@ const COMPOSERS = {
ComposerCapabilityMenu: source('./ComposerCapabilityMenu.tsx'),
}
const SLASH_COMMAND_MENU = source('./SlashCommandMenu.tsx')
const MENU_STYLES = source('./composerMenuStyles.ts')
/** Every className string that also carries a floating-panel background. */
function overlayPanelClassNames(code: string): string[] {
@@ -50,7 +53,8 @@ describe('composer overlay chrome', () => {
return
}
expect(panels.length).toBeGreaterThan(0)
const sharedRecipeUses = (code.match(/\bCOMPOSER_POPOVER\b/g) ?? []).length
expect(panels.length + sharedRecipeUses).toBeGreaterThan(0)
for (const panel of panels) {
expect(panel, `${name} renders a panel at a corner other than ${OVERLAY_RADIUS}`)
.toContain(OVERLAY_RADIUS)
@@ -58,6 +62,13 @@ describe('composer overlay chrome', () => {
})
}
it('holds the shared popover recipe to the same corner, edge and fill', () => {
const recipe = /export const COMPOSER_POPOVER =\s*'([^']+)'/.exec(MENU_STYLES)?.[1] ?? ''
for (const token of [OVERLAY_RADIUS, 'border-[var(--color-border)]', 'bg-[var(--color-surface-container-lowest)]', 'shadow-[var(--shadow-dropdown)]']) {
expect(recipe).toContain(token)
}
})
it('renders the same slash menu and capability menu in both composers', () => {
for (const [name, code] of Object.entries({
ChatInput: COMPOSERS.ChatInput,
@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest'
import { translate } from '../../i18n'
import {
countDiffLines,
countReadLines,
hasToolVerb,
parseSearchResult,
parseShellExitCode,
parseTodos,
toolVerb,
} from './toolCallPresentation'
const zh = (key: Parameters<typeof translate>[1], params?: Record<string, string | number>) => translate('zh', key, params)
describe('toolVerb', () => {
it('leads a row with a verb, never the raw tool name', () => {
expect(toolVerb('Grep', zh)).toBe('搜索')
expect(toolVerb('Read', zh)).toBe('读取')
expect(toolVerb('Bash', zh)).toBe('运行')
expect(toolVerb('TodoWrite', zh)).toBe('更新任务清单')
expect(toolVerb('TodoWrite', zh)).not.toContain('TodoWrite')
})
it('keeps an unknown tool its own name, since that is all that says what it is', () => {
expect(hasToolVerb('mcp__linear__create_issue')).toBe(false)
expect(toolVerb('mcp__linear__create_issue', zh)).toBe('mcp__linear__create_issue')
})
})
describe('countDiffLines', () => {
it('does not count a deletion for a brand-new file', () => {
// Write diffs against ''. Splitting '' yields [''], which used to report a
// phantom "−1" on every new file.
expect(countDiffLines('', 'a\nb\nc')).toEqual({ additions: 3, deletions: 0 })
})
it('counts changed lines on both sides', () => {
expect(countDiffLines('a\nb\nc', 'a\nB\nc\nd')).toEqual({ additions: 2, deletions: 1 })
})
})
describe('parseSearchResult', () => {
it('reads content-mode grep lines into file:line rows', () => {
const parsed = parseSearchResult('src/a.ts:12:const x = 1\nsrc/a.ts:30:x += 1\nsrc/b.ts:4:return x')
expect(parsed).toEqual({
fileCount: 2,
matchCount: 3,
rows: [
{ file: 'src/a.ts', line: 12, code: 'const x = 1' },
{ file: 'src/a.ts', line: 30, code: 'x += 1' },
{ file: 'src/b.ts', line: 4, code: 'return x' },
],
})
})
it('skips the separators between context groups', () => {
expect(parseSearchResult('a.ts:1:one\n--\nb.ts:9:two')?.matchCount).toBe(2)
})
it('reads a files-with-matches list under its banner', () => {
const parsed = parseSearchResult('Found 2 files\n/repo/src/a.ts\n/repo/src/b.ts')
expect(parsed?.fileCount).toBe(2)
expect(parsed?.matchCount).toBeUndefined()
expect(parsed?.rows.map((row) => row.file)).toEqual(['/repo/src/a.ts', '/repo/src/b.ts'])
})
it('reports an empty search as zero files', () => {
expect(parseSearchResult('No files found')).toEqual({ rows: [], fileCount: 0 })
})
it('gives up on text it does not recognise, so the raw output still shows', () => {
expect(parseSearchResult('error: regex parse error:\n (unclosed')).toBeNull()
})
})
describe('parseShellExitCode', () => {
it('reads the code the CLI reports for a failed command', () => {
expect(parseShellExitCode('fatal: bad revision\nExit code 128', true)).toBe(128)
})
it('treats a successful result as exit 0', () => {
expect(parseShellExitCode('ok', false)).toBe(0)
})
it('does not invent a code for a failure that carries none', () => {
expect(parseShellExitCode('InputValidationError', true)).toBeNull()
})
})
describe('parseTodos', () => {
it('keeps each item and its state, defaulting unknown states to pending', () => {
expect(parseTodos({
todos: [
{ content: 'Read the form', status: 'completed' },
{ content: 'Extract validators', status: 'in_progress' },
{ content: 'Add tests', status: 'whatever' },
{ content: '', status: 'pending' },
],
})).toEqual([
{ content: 'Read the form', status: 'completed' },
{ content: 'Extract validators', status: 'in_progress' },
{ content: 'Add tests', status: 'pending' },
])
})
it('returns null when the input carries no list', () => {
expect(parseTodos({})).toBeNull()
})
})
describe('countReadLines', () => {
it('ignores the reminders appended to a Read result', () => {
expect(countReadLines('1\tconst a = 1\n2\tconst b = 2\n<system-reminder>\nnote\n</system-reminder>\n')).toBe(2)
})
})
@@ -0,0 +1,152 @@
import type { TranslationKey } from '../../i18n'
type Translate = (key: TranslationKey, params?: Record<string, string | number>) => string
/**
* The verb a timeline row leads with. A row reads "读取 MessageList.tsx", not
* "Read MessageList.tsx": the raw tool name is an implementation detail the
* reader should not have to decode (`TodoWrite` least of all), so it moves to
* the row's tooltip and the trajectory view. Tools with no verb here keep their
* own name — an MCP tool's name is the only thing that says what it is.
*/
const TOOL_VERB_KEYS: Record<string, TranslationKey> = {
Grep: 'toolVerb.search',
Glob: 'toolVerb.search',
ToolSearch: 'toolVerb.search',
Read: 'toolVerb.read',
Edit: 'toolVerb.edit',
MultiEdit: 'toolVerb.edit',
NotebookEdit: 'toolVerb.edit',
Write: 'toolVerb.create',
Bash: 'toolVerb.run',
PowerShell: 'toolVerb.run',
TodoWrite: 'toolVerb.updateTodos',
TaskCreate: 'toolVerb.createTask',
TaskUpdate: 'toolVerb.updateTask',
TaskList: 'toolVerb.viewTasks',
TaskGet: 'toolVerb.viewTasks',
WebSearch: 'toolVerb.searchWeb',
WebFetch: 'toolVerb.fetchWeb',
Agent: 'toolVerb.agent',
Skill: 'toolVerb.skill',
}
export function toolVerb(toolName: string, t: Translate): string {
const key = TOOL_VERB_KEYS[toolName]
return key ? t(key) : toolName
}
/** Whether the row shows a translated verb rather than the tool's own name. */
export function hasToolVerb(toolName: string): boolean {
return toolName in TOOL_VERB_KEYS
}
export type DiffLineCounts = { additions: number; deletions: number }
/**
* Line-level change counts for an Edit/Write preview. Deliberately the same
* positional comparison the diff header has always used, so the `+N −N` on the
* row and the one on the diff block it opens can never disagree.
*/
export function countDiffLines(oldString: string, newString: string): DiffLineCounts {
const oldLines = oldString.length === 0 ? [] : oldString.split('\n')
const newLines = newString.split('\n')
return {
additions: newLines.filter((line, index) => line !== (oldLines[index] ?? null)).length,
deletions: oldLines.filter((line, index) => line !== (newLines[index] ?? null)).length,
}
}
export type GrepRow = { file: string; line?: number; code?: string }
export type GrepResult = {
rows: GrepRow[]
fileCount: number
matchCount?: number
}
const GREP_CONTENT_LINE = /^(.+?):(\d+)[:-](.*)$/
const FOUND_FILES_LINE = /^Found (\d+) files?\b/i
const NO_MATCHES_LINE = /^No (?:files|matches) found\b/i
/**
* Read a Grep/Glob result into rows the timeline can lay out as `file:line`
* plus code. Recognises the three shapes the CLI produces — content lines
* (`path:12:code`), a file list under a `Found N files` banner, and a bare path
* list from Glob. Anything else returns null and falls back to the plain output
* block, so an unexpected format still shows the model's exact text.
*/
export function parseSearchResult(text: string): GrepResult | null {
const lines = text.replace(/\r\n/g, '\n').split('\n').map((line) => line.trimEnd()).filter(Boolean)
if (lines.length === 0) return null
if (lines.length === 1 && NO_MATCHES_LINE.test(lines[0]!)) return { rows: [], fileCount: 0 }
// `--` separates context groups in `-C` output; it is not a row.
const contentLines = lines.filter((line) => line !== '--')
const contentRows: GrepRow[] = []
for (const line of contentLines) {
const match = GREP_CONTENT_LINE.exec(line)
if (!match) break
contentRows.push({ file: match[1]!, line: Number(match[2]), code: match[3] ?? '' })
}
if (contentRows.length > 0 && contentRows.length === contentLines.length) {
return {
rows: contentRows,
fileCount: new Set(contentRows.map((row) => row.file)).size,
matchCount: contentRows.length,
}
}
const banner = FOUND_FILES_LINE.exec(lines[0]!)
const pathLines = banner ? lines.slice(1) : lines
if (pathLines.length > 0 && pathLines.every(looksLikePath)) {
return {
rows: pathLines.map((file) => ({ file })),
fileCount: banner ? Number(banner[1]) : pathLines.length,
}
}
return null
}
function looksLikePath(line: string): boolean {
return !/\s{2,}/.test(line) && /[/\\.]/.test(line) && !line.startsWith('(')
}
/** `Exit code 2` heads a failed shell result; a success carries no code at all. */
export function parseShellExitCode(text: string, isError: boolean): number | null {
const match = /(?:^|\n)Exit code (\d+)\b/.exec(text)
if (match) return Number(match[1])
return isError ? null : 0
}
export type TodoItem = { content: string; status: 'pending' | 'in_progress' | 'completed' }
export function parseTodos(input: Record<string, unknown>): TodoItem[] | null {
if (!Array.isArray(input.todos)) return null
const todos: TodoItem[] = []
for (const entry of input.todos) {
if (!entry || typeof entry !== 'object') continue
const record = entry as Record<string, unknown>
const content = typeof record.content === 'string' ? record.content : ''
if (!content) continue
const status = record.status === 'completed' || record.status === 'in_progress' ? record.status : 'pending'
todos.push({ content, status })
}
return todos
}
/** Line count of a Read result, ignoring the reminders the CLI appends. */
export function countReadLines(text: string): number {
const body = text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trimEnd()
if (!body) return 0
return body.split('\n').length
}
export function formatCountLabel(
count: number,
singular: TranslationKey,
plural: TranslationKey,
t: Translate,
): string {
return t(count === 1 ? singular : plural, { count: new Intl.NumberFormat().format(count) })
}
@@ -144,10 +144,9 @@ describe('DirectoryPicker', () => {
const trigger = screen.getByRole('button')
expect(trigger).toHaveTextContent('project')
// 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)]')
// The workbar trigger is a form field in ProjectEditorModal, where it stands
// in for a secondary button: the same h-9 hairline-bordered 8px block.
expect(trigger.className).toContain('rounded-[var(--radius-md)]')
expect(trigger.className).toContain('border-[var(--color-border)]')
expect(trigger.className).not.toContain('rounded-full')
})
@@ -1,5 +1,17 @@
import { useState, useEffect, useId, useMemo, useRef, useCallback } from 'react'
import { createPortal } from 'react-dom'
import {
ArrowUp,
Check,
ChevronDown,
ChevronLeft,
Folder,
FolderGit2,
FolderOpen,
FolderPlus,
Plus,
Search,
} from 'lucide-react'
import { useDismissable } from '@/hooks/useDismissable'
import { sessionsApi, type RecentProject } from '../../api/sessions'
import { filesystemApi } from '../../api/filesystem'
@@ -81,22 +93,21 @@ function RecentProjectItem({
aria-selected={isSelected}
onMouseEnter={onHover}
onClick={() => onSelect(project.realPath)}
className={`flex w-full items-center gap-3 px-4 text-left transition-colors hover:bg-[var(--color-surface-hover)] ${
touch ? 'min-h-[72px] py-3.5' : 'py-3'
// Selected and keyboard-highlighted share the hover fill; the terracotta
// check on the right is what marks the current project.
className={`flex w-full items-center gap-2.5 rounded-[var(--radius-sm)] px-2 text-left transition-colors hover:bg-[var(--color-surface-hover)] ${
touch ? 'min-h-[56px] py-2.5' : 'min-h-8 py-1.5'
} ${
isSelected ? 'bg-[var(--color-surface-selected)]' : highlighted ? 'bg-[var(--color-surface-hover)]' : ''
isSelected || highlighted ? 'bg-[var(--color-surface-hover)]' : ''
}`}
>
{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="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>
<FolderGit2 size={16} strokeWidth={1.75} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
) : (
<span className="material-symbols-outlined w-5 flex-shrink-0 text-center text-[20px] text-[var(--color-text-secondary)]">folder</span>
<Folder size={16} strokeWidth={1.75} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
)}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-[var(--color-text-primary)]">
<div className="truncate text-[13px] font-medium text-[var(--color-text-primary)]">
{label}
</div>
<div className="truncate font-mono text-[11px] text-[var(--color-text-tertiary)]">
@@ -104,9 +115,7 @@ function RecentProjectItem({
</div>
</div>
{isSelected && (
<span className="material-symbols-outlined flex-shrink-0 text-[18px] text-[var(--color-brand)]" style={{ fontVariationSettings: "'FILL' 1" }}>
check
</span>
<Check size={14} strokeWidth={2} aria-hidden="true" className="shrink-0 text-[var(--color-brand)]" />
)}
</button>
)
@@ -301,34 +310,43 @@ export function RecentProjectsPanel({
}
}
const actionRowClassName = `flex w-full items-center gap-2.5 rounded-[var(--radius-sm)] px-2 text-left transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:bg-[var(--color-surface-hover)] focus-visible:outline-none ${
touch ? 'min-h-11 py-2' : 'min-h-8 py-1.5'
}`
if (mode === 'browse') {
return (
<>
<div className="flex flex-wrap items-center gap-1 border-b border-[var(--color-border)] px-3 py-2">
<Button variant="link" size="xs" className="mr-2" onClick={() => setMode('recent')}>
{'← ' + t('dirPicker.recent')}
</Button>
<button onClick={() => loadBrowseDir('/')} className="text-[10px] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)]">/</button>
<div className={`flex flex-wrap items-center gap-1 border-b border-[var(--color-border)] pb-1 ${touch ? 'px-3 pt-1' : 'px-1'}`}>
<button
type="button"
onClick={() => setMode('recent')}
className="mr-1 inline-flex h-7 items-center gap-1 rounded-[var(--radius-sm)] px-1.5 text-xs font-medium 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)]"
>
<ChevronLeft size={14} strokeWidth={1.75} aria-hidden="true" />
{t('dirPicker.recent')}
</button>
<button onClick={() => loadBrowseDir('/')} className="font-mono text-[11px] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)]">/</button>
{browsePath.split('/').filter(Boolean).map((seg, i, arr) => (
<span key={i} className="flex items-center gap-1">
<span className="text-[10px] text-[var(--color-text-tertiary)]">/</span>
<span className="font-mono text-[11px] text-[var(--color-text-tertiary)]">/</span>
<button
onClick={() => loadBrowseDir('/' + arr.slice(0, i + 1).join('/'))}
className="text-[10px] text-[var(--color-text-accent)] hover:underline"
className="font-mono text-[11px] text-[var(--color-text-accent)] hover:underline"
>{seg}</button>
</span>
))}
</div>
<div className={`${touch ? '' : 'max-h-[240px]'} overflow-y-auto`}>
<div className={`${touch ? 'px-1.5' : 'max-h-[240px]'} overflow-y-auto py-1`}>
{loading ? (
<LoadingState label={t('common.loading')} variant="block" size="sm" />
) : (
<>
{browseParent && browseParent !== browsePath && (
<button onClick={() => loadBrowseDir(browseParent)} className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-[var(--color-surface-hover)]">
<span className="material-symbols-outlined text-[16px] text-[var(--color-text-tertiary)]">arrow_upward</span>
<span className="text-xs text-[var(--color-text-secondary)]">..</span>
<button onClick={() => loadBrowseDir(browseParent)} className="flex min-h-[30px] w-full items-center gap-2 rounded-[var(--radius-sm)] px-2 text-left transition-colors hover:bg-[var(--color-surface-hover)]">
<ArrowUp size={14} strokeWidth={1.75} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
<span className="text-[13px] text-[var(--color-text-secondary)]">..</span>
</button>
)}
{browseEntries.length === 0 ? (
@@ -336,15 +354,15 @@ export function RecentProjectsPanel({
) : browseEntries.map((entry) => (
<div
key={entry.path}
className="flex w-full items-center gap-2 px-3 py-2 hover:bg-[var(--color-surface-hover)]"
className="flex min-h-[30px] w-full items-center gap-2 rounded-[var(--radius-sm)] px-2 transition-colors hover:bg-[var(--color-surface-hover)]"
>
<button
type="button"
onClick={() => loadBrowseDir(entry.path)}
className="flex min-w-0 flex-1 items-center gap-2 text-left"
>
<span className="material-symbols-outlined text-[16px] text-[var(--color-text-tertiary)]">folder</span>
<span className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-primary)]">{entry.name}</span>
<Folder size={14} strokeWidth={1.75} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
<span className="min-w-0 flex-1 truncate text-[13px] text-[var(--color-text-primary)]">{entry.name}</span>
</button>
<Button variant="link" size="xs" onClick={() => handleSelect(entry.path)}>
{t('common.select')}
@@ -355,9 +373,9 @@ export function RecentProjectsPanel({
)}
</div>
<div className="flex items-center justify-between border-t border-[var(--color-border)] px-3 py-2">
<span className="truncate font-mono text-[10px] text-[var(--color-text-tertiary)]">{browsePath}</span>
<Button size="base" onClick={() => handleSelect(browsePath)}>
<div className={`flex items-center justify-between gap-3 border-t border-[var(--color-border)] pt-1 ${touch ? 'px-3 pb-2' : 'px-1'}`}>
<span className="min-w-0 truncate font-mono text-[11px] text-[var(--color-text-tertiary)]">{browsePath}</span>
<Button size="sm" className="shrink-0" onClick={() => handleSelect(browsePath)}>
{t('dirPicker.useThisFolder')}
</Button>
</div>
@@ -368,13 +386,15 @@ export function RecentProjectsPanel({
return (
<>
{showRecentHeading && (
<div className="px-4 py-2 text-[10px] font-bold uppercase tracking-widest text-[var(--color-outline)]">
<div className="px-2 pb-1 pt-2 text-[11px] font-semibold text-[var(--color-text-tertiary)]">
{t('dirPicker.recent')}
</div>
)}
<div className="px-3 pb-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">
<span aria-hidden="true" className="material-symbols-outlined shrink-0 text-[16px] text-[var(--color-text-tertiary)]">search</span>
{/* The menu-search row: a bare field over a hairline, not a boxed input
inside a box. */}
<div className={`mb-1 border-b border-[var(--color-border)] ${touch ? 'px-3' : ''}`}>
<div className="flex h-8 items-center gap-2 px-2">
<Search size={14} strokeWidth={1.75} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
<input
id={searchInputId}
ref={searchRef}
@@ -386,7 +406,7 @@ export function RecentProjectsPanel({
placeholder={t('dirPicker.searchProjects')}
autoComplete="off"
spellCheck={false}
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-[13px] text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]"
/>
</div>
</div>
@@ -394,7 +414,7 @@ export function RecentProjectsPanel({
id={listboxId}
role="listbox"
aria-label={t('dirPicker.selectProject')}
className={`${touch ? '' : 'max-h-[300px]'} overflow-y-auto`}
className={`${touch ? 'px-1.5' : 'max-h-[300px]'} overflow-y-auto`}
>
{loading ? (
<LoadingState label={t('common.loading')} variant="block" size="sm" />
@@ -420,23 +440,23 @@ export function RecentProjectsPanel({
</div>
{/* Action rows stay out of the listbox: an `option` role would announce
them as projects. */}
<div className="border-t border-[var(--color-border)]">
<div className={`mt-1 border-t border-[var(--color-border)] pt-1 ${touch ? 'px-1.5 pb-1.5' : ''}`}>
{onCreateProject && (
<button
type="button"
onClick={onCreateProject}
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-[var(--color-surface-hover)]"
className={actionRowClassName}
>
<span className="material-symbols-outlined text-[20px] text-[var(--color-text-tertiary)]">add</span>
<span className="text-sm text-[var(--color-text-secondary)]">{t('sidebar.newProject')}</span>
<Plus size={16} strokeWidth={1.75} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
<span className="text-[13px] text-[var(--color-text-primary)]">{t('sidebar.newProject')}</span>
</button>
)}
<button
onClick={handleChooseFolder}
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-[var(--color-surface-hover)]"
className={actionRowClassName}
>
<span className="material-symbols-outlined text-[20px] text-[var(--color-text-tertiary)]">create_new_folder</span>
<span className="text-sm text-[var(--color-text-secondary)]">{t('dirPicker.chooseFolder')}</span>
<FolderPlus size={16} strokeWidth={1.75} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
<span className="text-[13px] text-[var(--color-text-primary)]">{t('dirPicker.chooseFolder')}</span>
</button>
</div>
</>
@@ -510,18 +530,22 @@ export function DirectoryPicker({ value, onChange, variant = 'chip', isGitProjec
const isWorkbar = variant === 'workbar'
const selectedLabel = selectedDisplayName || selectedProject?.repoName || selectedProject?.projectName || projectNameFromPath(value)
const showGitIcon = selectedProject?.isGit || isGitProject
// The workbar trigger is a form field (ProjectEditorModal's folder row), so it
// takes the geometry of the secondary button it alternates with there: h-9,
// 8px corner, a hairline that firms up on hover.
const workbarTriggerClassName = (isMobileBrowser ? 'min-h-11 ' : '') + 'group inline-flex h-9 min-w-0 items-center gap-2 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 text-sm 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 triggerClassName = isWorkbar
? '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)]'
? 'max-w-full ' + workbarTriggerClassName
: 'inline-flex h-7 max-w-full items-center gap-1.5 rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface)] px-2 text-xs text-[var(--color-text-secondary)] transition-colors 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)]'
const emptyTriggerClassName = isWorkbar
? (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'
? workbarTriggerClassName
: 'inline-flex h-7 items-center gap-1.5 rounded-[var(--radius-sm)] px-1.5 text-xs 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-[var(--color-border-focus)]'
const containerClassName = isWorkbar
? `relative min-w-0 ${isMobileBrowser ? 'flex-1' : 'max-w-[320px] shrink'}`
: 'relative'
const dropdownClassName = 'overflow-hidden rounded-[var(--radius-lg)] 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)] p-1 shadow-[var(--shadow-dropdown)]'
const dropdownStyle = {
position: 'fixed' as const,
left: dropdownPos?.left,
@@ -555,16 +579,14 @@ export function DirectoryPicker({ value, onChange, variant = 'chip', isGitProjec
title={value}
>
{showGitIcon ? (
<svg width="15" height="15" viewBox="0 0 16 16" fill="currentColor" className="shrink-0 text-[var(--color-text-secondary)]">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>
<FolderGit2 size={isWorkbar ? 16 : 14} strokeWidth={1.75} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
) : (
<span aria-hidden="true" className={`material-symbols-outlined shrink-0 ${isWorkbar ? 'text-[17px]' : 'text-[14px]'} text-[var(--color-text-secondary)]`}>folder</span>
<Folder size={isWorkbar ? 16 : 14} strokeWidth={1.75} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
)}
<span className="min-w-0 flex-1 truncate text-[var(--color-text-primary)]">
{selectedLabel}
</span>
<span className={`${isWorkbar ? 'text-[15px]' : 'text-[12px]'} material-symbols-outlined shrink-0 text-[var(--color-text-tertiary)]`}>expand_more</span>
<ChevronDown size={isWorkbar ? 16 : 14} strokeWidth={1.75} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
</button>
) : (
<button
@@ -573,7 +595,7 @@ export function DirectoryPicker({ value, onChange, variant = 'chip', isGitProjec
className={emptyTriggerClassName}
title={t('dirPicker.selectProject')}
>
<span aria-hidden="true" className={`material-symbols-outlined shrink-0 ${isWorkbar ? 'text-[17px]' : 'text-[14px]'}`}>folder_open</span>
<FolderOpen size={isWorkbar ? 16 : 14} strokeWidth={1.75} aria-hidden="true" className="shrink-0" />
<span className="min-w-0 truncate">{t('dirPicker.selectProject')}</span>
</button>
)}
@@ -17,11 +17,11 @@ type Props = {
}
function ItemIcon({ item }: { item: OpenWithItem }) {
if (item.target) return <TargetIcon target={item.target} size={20} />
if (item.icon === 'in-app-browser') return <Globe size={18} strokeWidth={1.9} />
if (item.icon === 'preview') return <FileText size={18} strokeWidth={1.9} />
if (item.icon === 'copy') return <Copy size={18} strokeWidth={1.9} />
return <ExternalLink size={18} strokeWidth={1.9} />
if (item.target) return <TargetIcon target={item.target} size={16} />
if (item.icon === 'in-app-browser') return <Globe size={16} strokeWidth={1.75} />
if (item.icon === 'preview') return <FileText size={16} strokeWidth={1.75} />
if (item.icon === 'copy') return <Copy size={16} strokeWidth={1.75} />
return <ExternalLink size={16} strokeWidth={1.75} />
}
export function OpenWithMenu({ items, anchor, onClose, triggerEl }: Props) {
@@ -61,21 +61,21 @@ export function OpenWithMenu({ items, anchor, onClose, triggerEl }: Props) {
<div
ref={ref}
role="menu"
className="min-w-[min(220px,calc(100vw-16px))] max-w-[min(300px,calc(100vw-16px))] overflow-y-auto overscroll-contain rounded-[12px] border border-[var(--color-border)] bg-[var(--color-surface)] py-1 shadow-[var(--shadow-dropdown)]"
className="min-w-[min(220px,calc(100vw-16px))] max-w-[min(300px,calc(100vw-16px))] overflow-y-auto overscroll-contain rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] p-1 shadow-[var(--shadow-dropdown)]"
style={{ ...positionStyle, zIndex: 'var(--z-dropdown)' }}
>
{items.map((item) => (
<Fragment key={item.id}>
{item.separatorBefore && (
<div className="my-1 border-t border-[var(--color-border)]" role="separator" />
<div className="mx-1 my-1 border-t border-[var(--color-border)]" role="separator" />
)}
<button
type="button"
role="menuitem"
onClick={() => { item.onSelect(); onClose() }}
className="flex w-full items-center gap-3 px-3 py-2.5 text-left text-sm font-medium text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)]"
className="flex min-h-8 w-full items-center gap-2.5 rounded-[var(--radius-sm)] px-2 py-1.5 text-left text-[13px] text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:bg-[var(--color-surface-hover)] focus-visible:outline-none"
>
<span className="flex h-6 w-6 items-center justify-center text-[var(--color-text-secondary)]"><ItemIcon item={item} /></span>
<span className="flex h-4 w-4 shrink-0 items-center justify-center text-[var(--color-text-tertiary)]"><ItemIcon item={item} /></span>
<span className="min-w-0 truncate">{item.label}</span>
</button>
</Fragment>
@@ -3,13 +3,13 @@ import { AppWindow, Code2, ExternalLink, FolderOpen } from 'lucide-react'
import { apiGetBlob } from '../../api/client'
import type { OpenTarget } from '../../stores/openTargetStore'
export function getFallbackIcon(kind: OpenTarget['kind'], size = 17) {
export function getFallbackIcon(kind: OpenTarget['kind'], size = 16) {
if (kind === 'file_manager') {
return <FolderOpen size={size} strokeWidth={1.9} />
return <FolderOpen size={size} strokeWidth={1.75} />
}
if (kind === 'application') return <AppWindow size={size} strokeWidth={1.9} />
if (kind === 'system_default') return <ExternalLink size={size} strokeWidth={1.9} />
return <Code2 size={size} strokeWidth={1.9} />
if (kind === 'application') return <AppWindow size={size} strokeWidth={1.75} />
if (kind === 'system_default') return <ExternalLink size={size} strokeWidth={1.75} />
return <Code2 size={size} strokeWidth={1.75} />
}
/**
@@ -87,5 +87,5 @@ export function TargetIcon({ target, size = 18 }: { target: OpenTarget; size?: n
)
}
return getFallbackIcon(target.kind, Math.max(16, size - 1))
return getFallbackIcon(target.kind, Math.min(18, Math.max(14, size)))
}
@@ -1,3 +1,4 @@
import { TriangleAlert } from 'lucide-react'
import { useTranslation } from '@/i18n'
import { ActionDialog } from '@/components/ui/ActionDialog'
@@ -26,10 +27,10 @@ export function ComputerUseEnableDialog({
width={500}
loading={loading}
body={(
<div className="space-y-4 text-sm leading-6 text-[var(--color-text-secondary)]">
<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 text-[var(--color-on-warning-container)]">
<span className="material-symbols-outlined mt-0.5 text-[20px] text-[var(--color-warning)]">warning</span>
<p className="font-semibold">{t('settings.computerUse.enableRiskSummary')}</p>
<div className="space-y-4 text-[13px] leading-[1.6] text-[var(--color-text-secondary)]">
<div className="flex items-start gap-2.5 rounded-[var(--radius-md)] bg-[var(--color-warning-container)] px-3 py-2.5 text-[var(--color-on-warning-container)]">
<TriangleAlert size={16} strokeWidth={1.75} aria-hidden="true" className="mt-0.5 shrink-0 text-[var(--color-warning)]" />
<p className="font-medium">{t('settings.computerUse.enableRiskSummary')}</p>
</div>
<ul className="list-disc space-y-1.5 pl-5">
<li>{t('settings.computerUse.enableRiskScreen')}</li>
@@ -38,7 +39,7 @@ export function ComputerUseEnableDialog({
<li>{t('settings.computerUse.enableRiskStop')}</li>
</ul>
{platform === 'darwin' && (
<p className="rounded-[var(--radius-lg)] bg-[var(--color-surface-container-low)] px-3 py-2 text-xs text-[var(--color-text-tertiary)]">
<p className="rounded-[var(--radius-md)] bg-[var(--color-surface-container)] px-3 py-2 text-xs leading-[1.5] text-[var(--color-text-tertiary)]">
{t('settings.computerUse.enableRiskMacos')}
</p>
)}
@@ -23,6 +23,13 @@ it('omits the badge container when no type label is needed', () => {
expect(container.querySelector('span.border')).toBeNull()
expect(screen.getByRole('button', { name: 'Service' })).toBeInTheDocument()
})
it('shows the status as a toned badge and hovers by deepening the edge, not lifting', () => {
const { container } = render(<ConnectorRow id="test" name="Service" description="Search documents" status="Ready" statusTone="success" actionLabel="Details" added onDetails={vi.fn()} onAction={vi.fn()} />)
expect(screen.getByText('Ready').className).toContain('bg-[var(--color-success-container)]')
const card = container.querySelector('article')!
expect(card.className).toContain('hover:border-[var(--color-outline)]')
expect(card.className).not.toMatch(/translate-y|hover:shadow/)
})
it('renders an explicit management action while preserving access to details', () => {
const details = vi.fn()
const remove = vi.fn()
@@ -1,30 +1,35 @@
import { useState, type ReactNode } from 'react'
import { ChevronRight, Plus } from 'lucide-react'
import { Badge, type Tone } from '@/components/ui/Badge'
import { IconButton } from '@/components/ui/IconButton'
export function ConnectorRow({ id, name, description, kind, status, actionLabel, added, onDetails, onAction, action }: {
export function ConnectorRow({ id, name, description, kind, status, statusTone = 'neutral', actionLabel, added, onDetails, onAction, action }: {
action?: ReactNode
id: string
name: string
description: string
kind?: string
status?: string
/** The app-wide status vocabulary: working = info, needs you = warning, usable = success. */
statusTone?: Tone
actionLabel: string
added: boolean
onDetails: () => void
onAction: () => void
}) {
const [failedIcon, setFailedIcon] = useState(false)
return <article className="flex min-h-24 items-center gap-3 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3 transition-colors hover:bg-[var(--color-surface-hover)]">
// White card on a hairline; hover only deepens the edge — no lift.
return <article className="flex min-h-[104px] items-start gap-3 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] p-4 transition-colors duration-150 hover:border-[var(--color-outline)]">
<button type="button" aria-label={name} onClick={onDetails} className="flex min-w-0 flex-1 items-start gap-3 rounded-[var(--radius-sm)] text-left outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]">
<span aria-hidden="true" className="flex h-10 w-10 shrink-0 items-center justify-center text-lg font-medium text-[var(--color-text-secondary)]">
{failedIcon ? name.slice(0, 1) : <img src={`${import.meta.env.BASE_URL}connectors/${id}.svg`} alt="" className="h-10 w-10 object-contain" onError={() => setFailedIcon(true)} />}
<span aria-hidden="true" className="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded-[var(--radius-md)] text-[15px] font-medium text-[var(--color-text-secondary)]">
{failedIcon ? <span className="flex h-full w-full items-center justify-center bg-[var(--color-surface-container)]">{name.slice(0, 1)}</span> : <img src={`${import.meta.env.BASE_URL}connectors/${id}.svg`} alt="" className="h-10 w-10 object-contain" onError={() => setFailedIcon(true)} />}
</span>
<span className="min-w-0 flex-1">
<span className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1"><span className="truncate text-sm font-semibold text-[var(--color-text-primary)]">{name}</span>{kind && <span className="rounded-[var(--radius-sm)] border border-[var(--color-border)] px-1 text-[10px] leading-4 text-[var(--color-text-tertiary)]">{kind}</span>}</span>
<span className="mt-1 block line-clamp-2 text-xs leading-5 text-[var(--color-text-secondary)]">{description}</span>
{status && <span className="mt-1 block text-[10px] leading-4 text-[var(--color-text-tertiary)]">{status}</span>}
<span className="flex min-w-0 items-center gap-2"><span className="truncate text-sm font-semibold text-[var(--color-text-primary)]">{name}</span>{kind && <Badge variant="outline" size="xs">{kind}</Badge>}</span>
<span className="mt-1 block line-clamp-2 text-xs leading-[18px] text-[var(--color-text-secondary)]">{description}</span>
{status && <Badge tone={statusTone} size="xs" className="mt-2">{status}</Badge>}
</span>
</button>
{action ?? <IconButton label={actionLabel} size="md" shape="circle" tone="secondary" bordered onClick={onAction} icon={<svg aria-hidden="true" viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">{added ? <path d="m7 5 5 5-5 5" /> : <path d="M10 4v12M4 10h12" />}</svg>} />}
{action ?? <IconButton label={actionLabel} size="md" tone="secondary" bordered onClick={onAction} icon={added ? <ChevronRight size={16} strokeWidth={1.75} aria-hidden="true" /> : <Plus size={16} strokeWidth={1.75} aria-hidden="true" />} />}
</article>
}
@@ -1,3 +1,4 @@
import { TriangleAlert } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { ActionDialog } from '@/components/ui/ActionDialog'
@@ -24,10 +25,10 @@ export function AutoModeOptInDialog({ open, loading = false, onClose, onConfirm
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-on-warning-container)]">
<p className="font-semibold">{t('permMode.enableAutoBody')}</p>
<div className="flex items-start gap-3 rounded-[var(--radius-md)] bg-[var(--color-warning-container)] px-3 py-3">
<TriangleAlert aria-hidden="true" size={16} strokeWidth={1.75} className="mt-0.5 shrink-0 text-[var(--color-warning)]" />
<div className="space-y-2 text-[13px] leading-6 text-[var(--color-on-warning-container)]">
<p className="font-medium">{t('permMode.enableAutoBody')}</p>
<p>{t('permMode.enableAutoDetail')}</p>
</div>
</div>
@@ -729,7 +729,10 @@ describe('ModelSelector', () => {
.getAllByRole('button', { name: /deepseek-v4-pro/i })
.find((button) => button.textContent?.includes('Sonnet Model'))
expect(configuredOption).toBeDefined()
expect(configuredOption?.className).toContain('border-[var(--color-model-option-selected-border)]')
// 「素」 menu selection: the hover fill at rest plus the terracotta check.
expect(configuredOption).toHaveAttribute('aria-current', 'true')
expect(configuredOption?.className).toContain('bg-[var(--color-surface-hover)]')
expect(configuredOption?.querySelector('svg.lucide-check')).toHaveClass('text-[var(--color-brand)]')
})
it('closes the focus ring on both halves of the segmented control', () => {
@@ -757,8 +760,8 @@ describe('ModelSelector', () => {
// 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)]')
expect(modelHalf).toHaveClass('rounded-l-[var(--radius-sm)]', 'focus-visible:rounded-[var(--radius-sm)]')
expect(effortHalf).toHaveClass('rounded-r-[var(--radius-sm)]', 'focus-visible:rounded-[var(--radius-sm)]')
})
// On the phone composer this control sits between two 44px buttons and opens
@@ -1,6 +1,12 @@
import { MODEL_SLOTS, type ModelSlot } from '@/lib/providerModelContext'
import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { ArrowRight, Check, ChevronDown } from 'lucide-react'
import {
COMPOSER_MENU_ITEM,
COMPOSER_MENU_ITEM_ACTIVE,
COMPOSER_MENU_SECTION,
} from '@/components/chat/composerMenuStyles'
import {
BUNDLED_PROVIDER_PRESETS,
getBundledPresetReasoningProviderKind,
@@ -515,6 +521,7 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
const runtimeEffortOptions = supportedRuntimeEfforts === undefined
? EFFORT_OPTIONS.filter((option) => option.value !== 'xhigh')
: EFFORT_OPTIONS.filter((option) => supportedRuntimeEfforts.includes(option.value))
const showEffortPill = canEditRuntimeEffort && !!selectedRuntimeEffort && runtimeEffortOptions.length > 0
const navigateToProviderSettings = useCallback(() => {
setOpen(false)
@@ -640,39 +647,39 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
through it (and above the panel edge). As a sibling above the
scrollport, the list is hard-clipped below the header instead. */}
{!isMobileBrowser && (
<div className="flex-none border-b border-[var(--color-border)] px-3.5 pb-2 pt-3">
<div className="mb-2 px-1 text-[10px] font-bold uppercase tracking-widest text-[var(--color-text-tertiary)]">
<div className="flex-none border-b border-[var(--color-border)] px-1 pb-1.5">
<div className={COMPOSER_MENU_SECTION}>
{t('model.configuration')}
</div>
{searchField}
</div>
)}
<div className={`overflow-y-auto ${isMobileBrowser ? 'p-1' : 'min-h-0 flex-1 p-1.5'}`}>
<div className={`overflow-y-auto ${isMobileBrowser ? 'p-1' : 'min-h-0 flex-1 p-1'}`}>
{!hasMatchingModels && (
<div
role="status"
className={`flex items-center justify-center px-4 text-center text-sm text-[var(--color-text-tertiary)] ${isMobileBrowser ? 'min-h-28' : 'min-h-24'}`}
className={`flex items-center justify-center px-4 text-center text-[13px] text-[var(--color-text-tertiary)] ${isMobileBrowser ? 'min-h-28' : 'min-h-24'}`}
>
{t('model.noMatches')}
</div>
)}
{isRuntimeScoped ? (
<div className="space-y-3">
<div className="space-y-1">
{filteredProviderChoices.map((choice) => (
<div key={choice.providerId ?? 'official'} className="space-y-1.5">
<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)]">
<div key={choice.providerId ?? 'official'}>
<div className={`flex items-center justify-between gap-2 ${COMPOSER_MENU_SECTION}`}>
<span className="truncate">
{choice.providerName}
</span>
{choice.isDefault && (
<span className="flex-shrink-0 text-[10px] font-medium text-[var(--color-text-tertiary)]">
<span className="flex-shrink-0 font-normal">
{t('settings.providers.default')}
</span>
)}
</div>
<div className="space-y-1">
<div className="space-y-px">
{choice.models.map((model) => {
const isSelected =
activeRuntimeSelection?.providerId === choice.providerId &&
@@ -722,33 +729,25 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
...(nextEffort ? { effortLevel: nextEffort } : {}),
})
}}
className={`
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)]'
}
`}
aria-current={isSelected || undefined}
className={`${COMPOSER_MENU_ITEM} ${isMobileBrowser ? 'min-h-[56px] py-3' : ''} ${isSelected ? COMPOSER_MENU_ITEM_ACTIVE : ''}`}
>
<div className="flex items-center gap-3">
<div className="min-w-0 flex-1">
{/* 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-[11px] text-[var(--color-text-tertiary)]">
{model.description}
</div>
)}
<div className="grid min-w-0 flex-1 gap-px">
{/* Model ids are identifiers, so they sit in the
mono face alongside paths and token counts. */}
<div className="truncate font-mono text-[13px] text-[var(--color-text-primary)]">
{model.name}
</div>
{isSelected && (
<span className="material-symbols-outlined flex-shrink-0 text-[16px] text-[var(--color-brand)]">check</span>
{model.description && (
<div className="truncate pr-[6px] text-xs text-[var(--color-text-tertiary)]">
{model.description}
</div>
)}
</div>
{isSelected && (
<Check aria-hidden="true" size={14} strokeWidth={2} className="flex-shrink-0 text-[var(--color-brand)]" />
)}
</button>
)
})}
@@ -757,7 +756,7 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
))}
</div>
) : (
<div className="space-y-1">
<div className="space-y-px">
{filteredAvailableModels.map((model) => {
const isSelected = model.id === selectedModel?.id
return (
@@ -771,29 +770,21 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
}
setOpen(false)
}}
className={`
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)]'
}
`}
aria-current={isSelected || undefined}
className={`${COMPOSER_MENU_ITEM} ${isMobileBrowser ? 'min-h-[56px] py-3' : ''} ${isSelected ? COMPOSER_MENU_ITEM_ACTIVE : ''}`}
>
<div className="flex items-center gap-3">
<div className="min-w-0 flex-1">
<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-[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 className="grid min-w-0 flex-1 gap-px">
<div className="truncate font-mono text-[13px] text-[var(--color-text-primary)]">{model.name}</div>
{model.description && (
<div className="truncate text-xs text-[var(--color-text-tertiary)]">
{model.description}
</div>
)}
</div>
{isSelected && (
<Check aria-hidden="true" size={14} strokeWidth={2} className="flex-shrink-0 text-[var(--color-brand)]" />
)}
</button>
)
})}
@@ -823,7 +814,7 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
<div
ref={dropdownRef}
data-testid="model-selector-dropdown"
className="fixed z-[var(--z-dropdown)] flex flex-col overflow-hidden rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-overlay)]"
className="fixed z-[var(--z-dropdown)] flex flex-col overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)]"
style={{
top: dropdownPosition.top,
bottom: dropdownPosition.bottom,
@@ -852,10 +843,10 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
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 ${
className={`flex min-w-0 items-stretch transition-colors ${
appearance === 'field'
? 'h-10 w-full border border-[var(--color-border)] bg-[var(--color-surface)] hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-container-low)]'
: 'hover:bg-[var(--color-surface-hover)]'
? 'h-10 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] hover:border-[var(--color-outline)]'
: `h-7 rounded-[var(--radius-sm)] hover:bg-[var(--color-surface-hover)] ${open || effortOpen ? 'bg-[var(--color-surface-hover)]' : ''}`
} ${isMobileBrowser ? 'min-h-11' : ''} ${fluid ? 'w-full' : ''} ${disabled ? 'opacity-50' : ''}`}
>
<button
@@ -875,13 +866,13 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
// 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 text-xs font-medium text-[var(--color-text-secondary)] outline-none transition-colors focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] disabled:cursor-not-allowed ${
className={`flex min-w-0 items-center gap-1.5 text-xs text-[var(--color-text-secondary)] outline-none transition-colors focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] disabled:cursor-not-allowed ${
appearance === 'field'
? 'h-full w-full rounded-[var(--radius-md)] px-3 text-left'
: `rounded-l-[var(--radius-md)] focus-visible:rounded-[var(--radius-md)] ${fluid ? 'flex-1' : ''} ${compact ? 'max-w-[112px] py-1.5 pl-2.5 pr-1' : 'max-w-[220px] py-1.5 pl-2.5 pr-1'}`
: `rounded-l-[var(--radius-sm)] focus-visible:rounded-[var(--radius-sm)] pl-2 ${fluid ? 'flex-1' : ''} ${compact ? 'max-w-[112px]' : 'max-w-[220px]'} ${showEffortPill ? 'pr-1' : 'rounded-r-[var(--radius-sm)] pr-1.5'}`
}`}
>
<span className={`${appearance === 'field' ? 'text-sm font-normal' : compact ? 'text-xs font-medium' : 'text-[13px] font-medium'} min-w-0 flex-1 truncate text-[var(--color-text-primary)]`}>
<span className={`${appearance === 'field' ? 'text-[13px]' : 'text-xs'} min-w-0 flex-1 truncate text-[var(--color-text-primary)]`}>
{buttonModelLabel}
</span>
{!canEditRuntimeEffort && !compact && buttonProviderLabel && (
@@ -889,12 +880,12 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
{buttonProviderLabel}
</span>
)}
<span className={`material-symbols-outlined flex-shrink-0 text-[var(--color-text-tertiary)] ${appearance === 'field' ? 'text-[16px]' : 'text-[12px]'}`}>
{needsProviderConfiguration ? 'arrow_forward' : 'expand_more'}
</span>
{needsProviderConfiguration
? <ArrowRight aria-hidden="true" size={appearance === 'field' ? 16 : 12} strokeWidth={2} className="flex-shrink-0 text-[var(--color-text-tertiary)]" />
: !showEffortPill && <ChevronDown aria-hidden="true" size={appearance === 'field' ? 16 : 12} strokeWidth={2} className="flex-shrink-0 text-[var(--color-text-tertiary)]" />}
</button>
{canEditRuntimeEffort && selectedRuntimeEffort && runtimeEffortOptions.length > 0 && (
{showEffortPill && selectedRuntimeEffort && (
<button
ref={effortButtonRef}
type="button"
@@ -906,9 +897,14 @@ export const ModelSelector = forwardRef<ModelSelectorHandle, Props>(function Mod
setOpen(false)
setEffortOpen(!effortOpen)
}}
className={`shrink-0 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-[12px]'}`}
// The effort rides on the model name as a small sunken pill; the
// chevron moves here so the pair reads "model · effort ⌄".
className={`flex shrink-0 items-center gap-1 rounded-r-[var(--radius-sm)] pl-0.5 pr-1.5 text-[var(--color-text-tertiary)] outline-none transition-colors hover:text-[var(--color-text-primary)] focus-visible:rounded-[var(--radius-sm)] focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] disabled:cursor-not-allowed`}
>
{effortLabels[selectedRuntimeEffort]}
<span className="inline-flex h-[18px] items-center rounded-full bg-[var(--color-surface-container)] px-1.5 text-[11px]">
{effortLabels[selectedRuntimeEffort]}
</span>
<ChevronDown aria-hidden="true" size={12} strokeWidth={2} className="flex-shrink-0" />
</button>
)}
</div>

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