mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 03:43:11 +08:00
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:
Binary file not shown.
@@ -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 () => {
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
|
||||
@@ -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)。
|
||||
|
||||
---
|
||||
|
||||
## 四、桌面端硬约束
|
||||
|
||||
@@ -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 })}`)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)')
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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('')
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user