mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 11:53:10 +08:00
fix(desktop): polish provider presets, composer menu, browser picker, token usage and Agent Teams header (#1472)
* fix(desktop): label the custom provider preset in the active locale The custom preset showed the catalog's bare English "Custom" in every language and prefilled it as the config name. It now reads "Custom API" (自定义模型 API, etc.) on the chip and in the prefilled name. * fix(desktop): open the composer + submenu level with its row The side panel was bottom-aligned to the root menu, so Skills and Connectors both opened at the bottom. It now lines its title up with the row that opened it and only moves to stay inside the window. * fix(desktop): show pressed sidebar toggles in the theme accent A pressed sidebar button used the hover fill, so the task-view bell looked the same on and hovered. It now takes the brand accent. Pressed buttons also stop emitting the tone's resting text color, which won over the pressed color by class order. * fix(desktop): place the browser edit bubble at the click and capture after it closes - Picking an element that fills the view (such as <body>) squeezed the bubble to its 160px minimum at the bottom-left. It now opens at full height beside the click. - The <webview> guest does not repaint before a native capture, so the selection screenshot still showed the closed bubble and no annotation. Hiding the page chrome now resolves after the next painted frame. * feat(desktop): order the token usage page usage, activity, then models The per-model table moves below the activity heatmap and insights under its own "Usage by model" heading. * fix(desktop): wrap the Agent Teams header in narrow panes The header had a fixed 1180px minimum that scrolled sideways and let flex squeeze button labels into vertical stacks. The controls now wrap onto their own row, and buttons and timings never wrap.
This commit is contained in:
@@ -2392,7 +2392,7 @@ describe('Settings > Providers tab', () => {
|
|||||||
fireEvent.click(addButton)
|
fireEvent.click(addButton)
|
||||||
|
|
||||||
const dialog = screen.getByRole('dialog')
|
const dialog = screen.getByRole('dialog')
|
||||||
expect(within(dialog).getByLabelText(/Name/i)).toHaveValue('Custom')
|
expect(within(dialog).getByLabelText(/Name/i)).toHaveValue('Custom API')
|
||||||
expect(within(dialog).getByRole('textbox', { name: /Base URL/i })).toBeEnabled()
|
expect(within(dialog).getByRole('textbox', { name: /Base URL/i })).toBeEnabled()
|
||||||
|
|
||||||
const baseUrlInfo = within(dialog).getByRole('button', { name: 'Base URL help' })
|
const baseUrlInfo = within(dialog).getByRole('button', { name: 'Base URL help' })
|
||||||
|
|||||||
@@ -122,9 +122,19 @@ describe('AgentTeamsWorkbench', () => {
|
|||||||
expect(screen.getByTestId('agent-teams-canvas-task-2').getAttribute('data-state')).toBe('completed')
|
expect(screen.getByTestId('agent-teams-canvas-task-2').getAttribute('data-state')).toBe('completed')
|
||||||
expect(screen.getByTestId('agent-teams-canvas-task-3').getAttribute('data-state')).toBe('running')
|
expect(screen.getByTestId('agent-teams-canvas-task-3').getAttribute('data-state')).toBe('running')
|
||||||
|
|
||||||
|
// A narrow pane wraps the controls onto their own row rather than forcing
|
||||||
|
// a fixed-width header that scrolls sideways and squeezes button labels.
|
||||||
|
const headerRow = screen.getByTestId('agent-teams-live-controls').parentElement!
|
||||||
|
expect(headerRow.className).toContain('flex-wrap')
|
||||||
|
expect(headerRow.className).not.toMatch(/\bmin-w-\[\d+px\]/)
|
||||||
|
expect(headerRow.parentElement!.className).not.toContain('overflow-x-auto')
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Review history' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Review history' }))
|
||||||
expect(screen.queryByTestId('agent-teams-live-controls')).toBeNull()
|
expect(screen.queryByTestId('agent-teams-live-controls')).toBeNull()
|
||||||
expect(screen.getByTestId('agent-teams-replay-controls')).toBeTruthy()
|
expect(screen.getByTestId('agent-teams-replay-controls')).toBeTruthy()
|
||||||
|
for (const name of [/^(Play|Pause|Replay)$/, 'Back to live']) {
|
||||||
|
expect(screen.getByRole('button', { name }).className).toMatch(/(?=.*\bshrink-0\b)(?=.*\bwhitespace-nowrap\b)/)
|
||||||
|
}
|
||||||
|
|
||||||
const timeline = screen.getByRole('slider', { name: 'Replay timeline · click to seek' }) as HTMLInputElement
|
const timeline = screen.getByRole('slider', { name: 'Replay timeline · click to seek' }) as HTMLInputElement
|
||||||
expect(timeline.value).toBe('1000')
|
expect(timeline.value).toBe('1000')
|
||||||
|
|||||||
@@ -321,9 +321,12 @@ export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) {
|
|||||||
aria-label={t('agentTeams.title')}
|
aria-label={t('agentTeams.title')}
|
||||||
className="relative flex h-full min-h-0 w-full flex-col overflow-hidden bg-[var(--color-surface)] text-[var(--color-text-primary)]"
|
className="relative flex h-full min-h-0 w-full flex-col overflow-hidden bg-[var(--color-surface)] text-[var(--color-text-primary)]"
|
||||||
>
|
>
|
||||||
<header className="shrink-0 overflow-x-auto border-b border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]">
|
{/* A narrow pane wraps the controls onto a row of their own instead of
|
||||||
<div className="flex min-w-[1180px] items-center gap-5 px-[18px] py-2.5">
|
scrolling the header sideways; the team block and counters stay on
|
||||||
<div className="min-w-[260px]">
|
the first row. Nothing in a control wraps, only hint text truncates. */}
|
||||||
|
<header className="shrink-0 border-b border-[var(--color-border)] bg-[var(--color-surface-container-lowest)]">
|
||||||
|
<div className="flex flex-wrap items-center gap-x-5 gap-y-2.5 px-[18px] py-2.5">
|
||||||
|
<div className="min-w-0 max-w-full">
|
||||||
<div className="text-[11px] font-semibold text-[var(--color-text-tertiary)]">
|
<div className="text-[11px] font-semibold text-[var(--color-text-tertiary)]">
|
||||||
Agent Teams · {t('agentTeams.sharedTaskList')}
|
Agent Teams · {t('agentTeams.sharedTaskList')}
|
||||||
</div>
|
</div>
|
||||||
@@ -340,7 +343,7 @@ export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-[18px] border-x border-[var(--color-border)] px-[18px]">
|
<div className="flex shrink-0 items-center gap-[18px] border-l border-[var(--color-border)] pl-[18px]">
|
||||||
<div className="min-w-[74px]">
|
<div className="min-w-[74px]">
|
||||||
<div className="text-[11px] font-semibold text-[var(--color-text-tertiary)]">
|
<div className="text-[11px] font-semibold text-[var(--color-text-tertiary)]">
|
||||||
{t('agentTeams.stats.phase')}
|
{t('agentTeams.stats.phase')}
|
||||||
@@ -359,7 +362,7 @@ export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{followingLive ? (
|
{followingLive ? (
|
||||||
<div data-testid="agent-teams-live-controls" className="flex min-w-0 flex-1 items-center gap-3.5">
|
<div data-testid="agent-teams-live-controls" className="flex min-w-0 flex-[1_1_480px] items-center gap-3.5">
|
||||||
<div className="flex shrink-0 items-center gap-1.5 rounded-full bg-[var(--color-info-container)] px-2.5 py-1">
|
<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="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)]">
|
<span className="whitespace-nowrap text-xs font-medium text-[var(--color-on-info-container)]">
|
||||||
@@ -369,7 +372,7 @@ export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) {
|
|||||||
<span className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-secondary)]">
|
<span className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-secondary)]">
|
||||||
{liveHint}
|
{liveHint}
|
||||||
</span>
|
</span>
|
||||||
<Button variant="secondary" size="sm" onClick={enterReplay}>
|
<Button variant="secondary" size="sm" className="shrink-0 whitespace-nowrap" onClick={enterReplay}>
|
||||||
{t('agentTeams.reviewHistory')}
|
{t('agentTeams.reviewHistory')}
|
||||||
</Button>
|
</Button>
|
||||||
<div className="shrink-0 border-l border-[var(--color-border)] pl-3.5">
|
<div className="shrink-0 border-l border-[var(--color-border)] pl-3.5">
|
||||||
@@ -380,11 +383,11 @@ export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div data-testid="agent-teams-replay-controls" className="flex min-w-0 flex-1 items-center gap-3.5">
|
<div data-testid="agent-teams-replay-controls" className="flex min-w-0 flex-[1_1_560px] flex-wrap items-center gap-x-3.5 gap-y-2">
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="base"
|
size="base"
|
||||||
className="w-[86px]"
|
className="w-[86px] shrink-0 whitespace-nowrap"
|
||||||
icon={playing ? <Pause size={13} aria-hidden="true" /> : <Play size={13} aria-hidden="true" />}
|
icon={playing ? <Pause size={13} aria-hidden="true" /> : <Play size={13} aria-hidden="true" />}
|
||||||
onClick={togglePlayback}
|
onClick={togglePlayback}
|
||||||
>
|
>
|
||||||
@@ -419,10 +422,10 @@ export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-[200px] flex-1">
|
<div className="min-w-[180px] flex-1">
|
||||||
<div className="mb-1 flex justify-between text-[11px] font-semibold text-[var(--color-text-tertiary)]">
|
<div className="mb-1 flex justify-between gap-2 text-[11px] font-semibold text-[var(--color-text-tertiary)]">
|
||||||
<span>{t('agentTeams.replay.timeline')}</span>
|
<span className="min-w-0 truncate">{t('agentTeams.replay.timeline')}</span>
|
||||||
<span className="font-mono tabular-nums">
|
<span className="shrink-0 whitespace-nowrap font-mono tabular-nums">
|
||||||
{formatDuration(cursorTime - startTime)} / {formatDuration(totalElapsed)}
|
{formatDuration(cursorTime - startTime)} / {formatDuration(totalElapsed)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -472,6 +475,7 @@ export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) {
|
|||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className="shrink-0 whitespace-nowrap"
|
||||||
icon={<Radio size={12} aria-hidden="true" />}
|
icon={<Radio size={12} aria-hidden="true" />}
|
||||||
onClick={returnToLive}
|
onClick={returnToLive}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -183,6 +183,43 @@ describe('ComposerCapabilityMenu', () => {
|
|||||||
expect(onAction).toHaveBeenCalledWith({ type: 'insertMention', reference: designSkill })
|
expect(onAction).toHaveBeenCalledWith({ type: 'insertMention', reference: designSkill })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('opens the side panel level with its row and keeps it inside the window', () => {
|
||||||
|
const rowTops: Record<string, number> = { Skills: 160, Connectors: 192 }
|
||||||
|
let flyoutHeight = 120
|
||||||
|
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||||
|
const label = this.getAttribute('role') === 'option' ? Object.keys(rowTops).find(key => this.querySelector(`#${this.id}-label`)?.textContent === key) : undefined
|
||||||
|
const top = label ? rowTops[label]! : this.className.includes('bottom-full') ? 100 : 0
|
||||||
|
return { top, height: label ? 32 : 0, bottom: top + (label ? 32 : 0), left: 0, right: 0, width: 0, x: 0, y: top, toJSON: () => ({}) } as DOMRect
|
||||||
|
})
|
||||||
|
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function (this: HTMLElement) {
|
||||||
|
return this.dataset.testid === 'capability-flyout' ? flyoutHeight : 0
|
||||||
|
})
|
||||||
|
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 800 })
|
||||||
|
try {
|
||||||
|
renderMenu()
|
||||||
|
// Row top relative to the menu, title bar centred on the 32px row.
|
||||||
|
fireEvent.mouseEnter(screen.getByRole('option', { name: /Skills/ }))
|
||||||
|
expect(screen.getByTestId('capability-flyout').style.top).toBe('56px')
|
||||||
|
fireEvent.mouseEnter(screen.getByRole('option', { name: /Connectors/ }))
|
||||||
|
expect(screen.getByTestId('capability-flyout').style.top).toBe('88px')
|
||||||
|
|
||||||
|
// Near the window bottom it lifts just enough to stay 8px clear.
|
||||||
|
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 300 })
|
||||||
|
fireEvent.mouseEnter(screen.getByRole('option', { name: /Skills/ }))
|
||||||
|
expect(screen.getByTestId('capability-flyout').style.top).toBe('56px')
|
||||||
|
fireEvent.mouseEnter(screen.getByRole('option', { name: /Connectors/ }))
|
||||||
|
expect(screen.getByTestId('capability-flyout').style.top).toBe('72px')
|
||||||
|
|
||||||
|
// A panel taller than the window keeps its top edge on screen.
|
||||||
|
flyoutHeight = 400
|
||||||
|
fireEvent.mouseEnter(screen.getByRole('option', { name: /Skills/ }))
|
||||||
|
expect(screen.getByTestId('capability-flyout').style.top).toBe('-92px')
|
||||||
|
} finally {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 768 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
it('walks into a category with the keyboard and steps back one panel per Escape', () => {
|
it('walks into a category with the keyboard and steps back one panel per Escape', () => {
|
||||||
const { onAction, onClose } = renderMenu()
|
const { onAction, onClose } = renderMenu()
|
||||||
const input = searchInput()
|
const input = searchInput()
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ type Props = {
|
|||||||
const ROOT_WIDTH = 288
|
const ROOT_WIDTH = 288
|
||||||
const FLYOUT_WIDTH = 320
|
const FLYOUT_WIDTH = 320
|
||||||
const PANEL_GAP = 4
|
const PANEL_GAP = 4
|
||||||
|
/** The side panel's title bar, centred on the row that opened it. */
|
||||||
|
const FLYOUT_HEADER_HEIGHT = 40
|
||||||
|
/** Space the side panel keeps from the window edges. */
|
||||||
|
const VIEWPORT_MARGIN = 8
|
||||||
/** The full skill list reuses the @ menu's reference browser. */
|
/** The full skill list reuses the @ menu's reference browser. */
|
||||||
const BROWSE_REFERENCES_KEY = 'skills:all'
|
const BROWSE_REFERENCES_KEY = 'skills:all'
|
||||||
const PANEL = 'overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)]'
|
const PANEL = 'overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)]'
|
||||||
@@ -98,6 +102,8 @@ export function ComposerCapabilityMenu({ id, sections, cwd = '', referencesLoadi
|
|||||||
// categories in place, as the sheet does.
|
// categories in place, as the sheet does.
|
||||||
const [narrow, setNarrow] = useState(false)
|
const [narrow, setNarrow] = useState(false)
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const flyoutRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [flyoutTop, setFlyoutTop] = useState(0)
|
||||||
const referenceRef = useRef<ComposerReferenceMenuHandle>(null)
|
const referenceRef = useRef<ComposerReferenceMenuHandle>(null)
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (sheet) return
|
if (sheet) return
|
||||||
@@ -136,6 +142,32 @@ export function ComposerCapabilityMenu({ id, sections, cwd = '', referencesLoadi
|
|||||||
? subIndex < 0 ? undefined : getCapabilitySubMenuOptionId(id, subIndex)
|
? subIndex < 0 ? undefined : getCapabilitySubMenuOptionId(id, subIndex)
|
||||||
: rootIndex < 0 ? undefined : getCapabilityMenuOptionId(id, rootIndex)
|
: rootIndex < 0 ? undefined : getCapabilityMenuOptionId(id, rootIndex)
|
||||||
const controlsId = showReferences ? referencesId : drillParent ? subListId : listId
|
const controlsId = showReferences ? referencesId : drillParent ? subListId : listId
|
||||||
|
const flyoutAnchorIndex = flyoutOpen ? rootItems.findIndex(item => item.key === path[0]) : -1
|
||||||
|
|
||||||
|
// Like a desktop submenu, the side panel opens level with its row and only
|
||||||
|
// moves off it when it would otherwise run past the window.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const container = containerRef.current
|
||||||
|
const flyout = flyoutRef.current
|
||||||
|
if (!container || !flyout || flyoutAnchorIndex < 0) return
|
||||||
|
const doc = container.ownerDocument
|
||||||
|
const place = () => {
|
||||||
|
const row = doc.getElementById(getCapabilityMenuOptionId(id, flyoutAnchorIndex))
|
||||||
|
if (!row) return
|
||||||
|
const containerTop = container.getBoundingClientRect().top
|
||||||
|
const rowRect = row.getBoundingClientRect()
|
||||||
|
const viewHeight = doc.defaultView?.innerHeight ?? Infinity
|
||||||
|
const level = rowRect.top - containerTop + (rowRect.height - FLYOUT_HEADER_HEIGHT) / 2
|
||||||
|
const lowest = viewHeight - VIEWPORT_MARGIN - flyout.offsetHeight - containerTop
|
||||||
|
setFlyoutTop(Math.max(VIEWPORT_MARGIN - containerTop, Math.min(level, lowest)))
|
||||||
|
}
|
||||||
|
place()
|
||||||
|
const View = doc.defaultView
|
||||||
|
if (!View?.ResizeObserver) return
|
||||||
|
const observer = new View.ResizeObserver(place)
|
||||||
|
observer.observe(flyout)
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [id, flyoutAnchorIndex, path.length, browseReferences])
|
||||||
|
|
||||||
const openCategory = (nextPath: string[]) => {
|
const openCategory = (nextPath: string[]) => {
|
||||||
setPath(nextPath)
|
setPath(nextPath)
|
||||||
@@ -308,12 +340,12 @@ export function ComposerCapabilityMenu({ id, sections, cwd = '', referencesLoadi
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div ref={containerRef} className="absolute bottom-full left-0 z-[var(--z-dropdown)] mb-2 flex items-end" style={{ gap: PANEL_GAP }} onMouseDown={event => event.preventDefault()}>
|
return <div ref={containerRef} className="absolute bottom-full left-0 z-[var(--z-dropdown)] mb-2" onMouseDown={event => event.preventDefault()}>
|
||||||
<div className={`${PANEL} shrink-0 ${searching ? 'w-[min(480px,calc(100vw-32px))]' : ''}`} style={searching ? undefined : { width: ROOT_WIDTH }}>
|
<div className={`${PANEL} ${searching ? 'w-[min(480px,calc(100vw-32px))]' : ''}`} style={searching ? undefined : { width: ROOT_WIDTH }}>
|
||||||
{searchRow}
|
{searchRow}
|
||||||
{searching ? referenceMenu : rootList}
|
{searching ? referenceMenu : rootList}
|
||||||
</div>
|
</div>
|
||||||
{flyoutOpen ? <div data-testid="capability-flyout" className={`${PANEL} shrink-0`} style={{ width: FLYOUT_WIDTH }}>
|
{flyoutOpen ? <div ref={flyoutRef} data-testid="capability-flyout" className={`${PANEL} absolute`} style={{ left: ROOT_WIDTH + PANEL_GAP, top: flyoutTop, width: FLYOUT_WIDTH }}>
|
||||||
<div className="flex h-10 items-center gap-1.5 border-b border-[var(--color-border)] px-2">
|
<div className="flex h-10 items-center gap-1.5 border-b border-[var(--color-border)] px-2">
|
||||||
{path.length > 1 ? <IconButton icon={<ChevronLeft size={14} strokeWidth={1.75} />} label={t('chat.capabilities.back')} size="xs" tone="muted" onClick={goBack} /> : null}
|
{path.length > 1 ? <IconButton icon={<ChevronLeft size={14} strokeWidth={1.75} />} label={t('chat.capabilities.back')} size="xs" tone="muted" onClick={goBack} /> : null}
|
||||||
<span className="min-w-0 flex-1 truncate px-1 text-[13px] font-semibold text-[var(--color-text-primary)]">{drillParent!.label}</span>
|
<span className="min-w-0 flex-1 truncate px-1 text-[13px] font-semibold text-[var(--color-text-primary)]">{drillParent!.label}</span>
|
||||||
|
|||||||
@@ -106,6 +106,15 @@ describe('IconButton', () => {
|
|||||||
expect(screen.getByRole('button', { name: 'Refresh' })).not.toHaveAttribute('aria-pressed')
|
expect(screen.getByRole('button', { name: 'Refresh' })).not.toHaveAttribute('aria-pressed')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('marks a pressed sidebar toggle with the theme accent, not the hover fill', () => {
|
||||||
|
const { container } = render(<IconButton icon={<span />} label="Tasks" tone="muted" surface="sidebar" pressed />)
|
||||||
|
const className = container.firstElementChild!.className
|
||||||
|
expect(className).toContain('bg-[var(--color-brand-soft)]')
|
||||||
|
expect(className).toContain('text-[var(--color-brand)]')
|
||||||
|
expect(className).not.toContain('bg-[var(--color-sidebar-item-hover)]')
|
||||||
|
expect(className).not.toContain('text-[var(--color-text-tertiary)]')
|
||||||
|
})
|
||||||
|
|
||||||
it('drops the tone hover while pressed so two fills cannot compete', () => {
|
it('drops the tone hover while pressed so two fills cannot compete', () => {
|
||||||
const { container } = render(<IconButton icon={<span />} label="Filter" pressed />)
|
const { container } = render(<IconButton icon={<span />} label="Filter" pressed />)
|
||||||
const className = container.firstElementChild!.className
|
const className = container.firstElementChild!.className
|
||||||
|
|||||||
@@ -224,7 +224,10 @@ const SOFT_CLASSES = 'bg-[var(--color-btn-soft-bg)] hover:bg-[var(--color-btn-so
|
|||||||
|
|
||||||
const PRESSED_CLASSES: Record<IconButtonSurface, string> = {
|
const PRESSED_CLASSES: Record<IconButtonSurface, string> = {
|
||||||
default: 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)]',
|
default: 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)]',
|
||||||
sidebar: 'bg-[var(--color-sidebar-item-hover)] text-[var(--color-text-primary)]',
|
// The sidebar's hover fill is the faintest step on its ground; a toggle that
|
||||||
|
// is on must not look like one the pointer is merely over, so it takes the
|
||||||
|
// theme accent.
|
||||||
|
sidebar: 'bg-[var(--color-brand-soft)] text-[var(--color-brand)]',
|
||||||
terminal: 'bg-[var(--color-terminal-selection)] text-[var(--color-terminal-fg)]',
|
terminal: 'bg-[var(--color-terminal-selection)] text-[var(--color-terminal-fg)]',
|
||||||
media: 'bg-[var(--color-media-selection)] text-[var(--color-media-fg)]',
|
media: 'bg-[var(--color-media-selection)] text-[var(--color-media-fg)]',
|
||||||
}
|
}
|
||||||
@@ -288,9 +291,10 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
|
|||||||
BASE_CLASSES,
|
BASE_CLASSES,
|
||||||
DISABLED_OPACITY[disabledStyle],
|
DISABLED_OPACITY[disabledStyle],
|
||||||
SIZE_CLASSES[size],
|
SIZE_CLASSES[size],
|
||||||
// `solid` supplies both fill and foreground, so the tone's resting text
|
// `solid` and `pressed` supply both fill and foreground, so the tone's
|
||||||
// color and hover fill are skipped rather than left to compete.
|
// resting text color is skipped rather than left to compete: two
|
||||||
solid ? SOLID_CLASSES[tone] : (SURFACE_REST_TEXT[surface] ?? REST_TEXT)[tone],
|
// `text-[…]` values resolve by alphabetical order, not by intent.
|
||||||
|
solid ? SOLID_CLASSES[tone] : !pressed && (SURFACE_REST_TEXT[surface] ?? REST_TEXT)[tone],
|
||||||
solid && 'hover:brightness-110',
|
solid && 'hover:brightness-110',
|
||||||
// A pressed or soft button carries its own fill and hover; skipping the
|
// A pressed or soft button carries its own fill and hover; skipping the
|
||||||
// tone's hover here keeps two `hover:bg-[…]` values from competing.
|
// tone's hover here keeps two `hover:bg-[…]` values from competing.
|
||||||
|
|||||||
@@ -1197,6 +1197,7 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
|
|||||||
'settings.activity.messages': 'Messages',
|
'settings.activity.messages': 'Messages',
|
||||||
'settings.activity.tools': 'Tools',
|
'settings.activity.tools': 'Tools',
|
||||||
'settings.activity.activityInsights': 'Activity insights',
|
'settings.activity.activityInsights': 'Activity insights',
|
||||||
|
'settings.activity.modelUsage': 'Usage by model',
|
||||||
'settings.activity.activeRate': 'Active rate',
|
'settings.activity.activeRate': 'Active rate',
|
||||||
'settings.activity.mostUsedModel': 'Most used model',
|
'settings.activity.mostUsedModel': 'Most used model',
|
||||||
'settings.activity.exploredSkills': 'Skills explored',
|
'settings.activity.exploredSkills': 'Skills explored',
|
||||||
@@ -1442,6 +1443,7 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
|
|||||||
'settings.providers.regionChina': 'China mainland',
|
'settings.providers.regionChina': 'China mainland',
|
||||||
'settings.providers.regionGlobal': 'Global',
|
'settings.providers.regionGlobal': 'Global',
|
||||||
'settings.providers.regionCustom': 'Custom',
|
'settings.providers.regionCustom': 'Custom',
|
||||||
|
'settings.providers.customPreset': 'Custom API',
|
||||||
'settings.providers.apiKey': 'API Key',
|
'settings.providers.apiKey': 'API Key',
|
||||||
'settings.providers.apiKeyKeep': 'API Key (leave blank to keep current)',
|
'settings.providers.apiKeyKeep': 'API Key (leave blank to keep current)',
|
||||||
'settings.providers.showApiKey': 'Show API Key',
|
'settings.providers.showApiKey': 'Show API Key',
|
||||||
|
|||||||
@@ -1198,6 +1198,7 @@ export const jp: Record<TranslationKey, string> = {
|
|||||||
'settings.activity.messages': 'メッセージ',
|
'settings.activity.messages': 'メッセージ',
|
||||||
'settings.activity.tools': 'ツール',
|
'settings.activity.tools': 'ツール',
|
||||||
'settings.activity.activityInsights': 'アクティビティ分析',
|
'settings.activity.activityInsights': 'アクティビティ分析',
|
||||||
|
'settings.activity.modelUsage': 'モデル別の使用量',
|
||||||
'settings.activity.activeRate': 'アクティブ率',
|
'settings.activity.activeRate': 'アクティブ率',
|
||||||
'settings.activity.mostUsedModel': '最も使ったモデル',
|
'settings.activity.mostUsedModel': '最も使ったモデル',
|
||||||
'settings.activity.exploredSkills': '使用したスキル',
|
'settings.activity.exploredSkills': '使用したスキル',
|
||||||
@@ -1443,6 +1444,7 @@ export const jp: Record<TranslationKey, string> = {
|
|||||||
'settings.providers.regionChina': '中国本土',
|
'settings.providers.regionChina': '中国本土',
|
||||||
'settings.providers.regionGlobal': 'グローバル',
|
'settings.providers.regionGlobal': 'グローバル',
|
||||||
'settings.providers.regionCustom': 'カスタム',
|
'settings.providers.regionCustom': 'カスタム',
|
||||||
|
'settings.providers.customPreset': 'カスタム API',
|
||||||
'settings.providers.apiKey': 'API キー',
|
'settings.providers.apiKey': 'API キー',
|
||||||
'settings.providers.apiKeyKeep': 'API キー(空欄のままにすると現在の値を保持)',
|
'settings.providers.apiKeyKeep': 'API キー(空欄のままにすると現在の値を保持)',
|
||||||
'settings.providers.showApiKey': 'API キーを表示',
|
'settings.providers.showApiKey': 'API キーを表示',
|
||||||
|
|||||||
@@ -1200,6 +1200,7 @@ export const kr: Record<TranslationKey, string> = {
|
|||||||
'settings.activity.messages': '메시지',
|
'settings.activity.messages': '메시지',
|
||||||
'settings.activity.tools': '도구',
|
'settings.activity.tools': '도구',
|
||||||
'settings.activity.activityInsights': '활동 인사이트',
|
'settings.activity.activityInsights': '활동 인사이트',
|
||||||
|
'settings.activity.modelUsage': '모델별 사용량',
|
||||||
'settings.activity.activeRate': '활성 비율',
|
'settings.activity.activeRate': '활성 비율',
|
||||||
'settings.activity.mostUsedModel': '가장 많이 쓴 모델',
|
'settings.activity.mostUsedModel': '가장 많이 쓴 모델',
|
||||||
'settings.activity.exploredSkills': '사용한 스킬',
|
'settings.activity.exploredSkills': '사용한 스킬',
|
||||||
@@ -1445,6 +1446,7 @@ export const kr: Record<TranslationKey, string> = {
|
|||||||
'settings.providers.regionChina': '중국 본토',
|
'settings.providers.regionChina': '중국 본토',
|
||||||
'settings.providers.regionGlobal': '글로벌',
|
'settings.providers.regionGlobal': '글로벌',
|
||||||
'settings.providers.regionCustom': '사용자 지정',
|
'settings.providers.regionCustom': '사용자 지정',
|
||||||
|
'settings.providers.customPreset': '사용자 지정 API',
|
||||||
'settings.providers.apiKey': 'API 키',
|
'settings.providers.apiKey': 'API 키',
|
||||||
'settings.providers.apiKeyKeep': 'API 키 (비워 두면 현재 값 유지)',
|
'settings.providers.apiKeyKeep': 'API 키 (비워 두면 현재 값 유지)',
|
||||||
'settings.providers.showApiKey': 'API 키 표시',
|
'settings.providers.showApiKey': 'API 키 표시',
|
||||||
|
|||||||
@@ -1197,6 +1197,7 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
'settings.activity.messages': '訊息',
|
'settings.activity.messages': '訊息',
|
||||||
'settings.activity.tools': '工具',
|
'settings.activity.tools': '工具',
|
||||||
'settings.activity.activityInsights': '活動洞察',
|
'settings.activity.activityInsights': '活動洞察',
|
||||||
|
'settings.activity.modelUsage': '模型用量',
|
||||||
'settings.activity.activeRate': '活躍率',
|
'settings.activity.activeRate': '活躍率',
|
||||||
'settings.activity.mostUsedModel': '最常用模型',
|
'settings.activity.mostUsedModel': '最常用模型',
|
||||||
'settings.activity.exploredSkills': '已使用的技能',
|
'settings.activity.exploredSkills': '已使用的技能',
|
||||||
@@ -1442,6 +1443,7 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
'settings.providers.regionChina': '中國大陸',
|
'settings.providers.regionChina': '中國大陸',
|
||||||
'settings.providers.regionGlobal': '海外',
|
'settings.providers.regionGlobal': '海外',
|
||||||
'settings.providers.regionCustom': '自訂',
|
'settings.providers.regionCustom': '自訂',
|
||||||
|
'settings.providers.customPreset': '自訂模型 API',
|
||||||
'settings.providers.apiKey': 'API 金鑰',
|
'settings.providers.apiKey': 'API 金鑰',
|
||||||
'settings.providers.apiKeyKeep': 'API 金鑰(留空保持不變)',
|
'settings.providers.apiKeyKeep': 'API 金鑰(留空保持不變)',
|
||||||
'settings.providers.showApiKey': '顯示 API 金鑰',
|
'settings.providers.showApiKey': '顯示 API 金鑰',
|
||||||
|
|||||||
@@ -1196,6 +1196,7 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
'settings.activity.messages': '消息',
|
'settings.activity.messages': '消息',
|
||||||
'settings.activity.tools': '工具',
|
'settings.activity.tools': '工具',
|
||||||
'settings.activity.activityInsights': '活动洞察',
|
'settings.activity.activityInsights': '活动洞察',
|
||||||
|
'settings.activity.modelUsage': '模型用量',
|
||||||
'settings.activity.activeRate': '活跃率',
|
'settings.activity.activeRate': '活跃率',
|
||||||
'settings.activity.mostUsedModel': '最常用模型',
|
'settings.activity.mostUsedModel': '最常用模型',
|
||||||
'settings.activity.exploredSkills': '已使用的技能',
|
'settings.activity.exploredSkills': '已使用的技能',
|
||||||
@@ -1441,6 +1442,7 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
'settings.providers.regionChina': '中国大陆',
|
'settings.providers.regionChina': '中国大陆',
|
||||||
'settings.providers.regionGlobal': '海外',
|
'settings.providers.regionGlobal': '海外',
|
||||||
'settings.providers.regionCustom': '自定义',
|
'settings.providers.regionCustom': '自定义',
|
||||||
|
'settings.providers.customPreset': '自定义模型 API',
|
||||||
'settings.providers.apiKey': 'API 密钥',
|
'settings.providers.apiKey': 'API 密钥',
|
||||||
'settings.providers.apiKeyKeep': 'API 密钥(留空保持不变)',
|
'settings.providers.apiKeyKeep': 'API 密钥(留空保持不变)',
|
||||||
'settings.providers.showApiKey': '显示 API 密钥',
|
'settings.providers.showApiKey': '显示 API 密钥',
|
||||||
|
|||||||
@@ -412,6 +412,22 @@ describe('ActivitySettings', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('orders the page usage, then activity, then the per-model breakdown', async () => {
|
||||||
|
render(<ActivitySettings />)
|
||||||
|
|
||||||
|
await flushActivityLoad()
|
||||||
|
|
||||||
|
const usage = screen.getByRole('list', { name: 'Token usage by day' })
|
||||||
|
const activity = screen.getByRole('heading', { name: 'Token Activity' })
|
||||||
|
const insights = screen.getByRole('heading', { name: 'Activity insights' })
|
||||||
|
const models = screen.getByRole('heading', { name: 'Usage by model' })
|
||||||
|
const follows = (a: Node, b: Node) => Boolean(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||||||
|
expect(follows(usage, activity)).toBe(true)
|
||||||
|
expect(follows(activity, insights)).toBe(true)
|
||||||
|
expect(follows(insights, models)).toBe(true)
|
||||||
|
expect(follows(models, screen.getByRole('table'))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
it('supports localized heatmap mode switches and persisted display name edits', async () => {
|
it('supports localized heatmap mode switches and persisted display name edits', async () => {
|
||||||
useSettingsStore.setState({ locale: 'zh' })
|
useSettingsStore.setState({ locale: 'zh' })
|
||||||
render(<ActivitySettings />)
|
render(<ActivitySettings />)
|
||||||
|
|||||||
@@ -1001,34 +1001,6 @@ export function ActivitySettings() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoading && !error && hasUsage && modelRows.length > 0 && (
|
|
||||||
<Card surface="lowest" padding="none" role="table" className="mt-3 overflow-hidden">
|
|
||||||
<div
|
|
||||||
role="row"
|
|
||||||
className="grid grid-cols-[minmax(0,1.6fr)_minmax(0,0.8fr)_minmax(0,0.8fr)_minmax(0,0.8fr)] gap-3 bg-[var(--color-surface-container)] px-4 py-2.5 text-xs font-semibold text-[var(--color-text-tertiary)]"
|
|
||||||
>
|
|
||||||
<span role="columnheader" className="min-w-0 truncate">{t('settings.agents.model')}</span>
|
|
||||||
<span role="columnheader" className="min-w-0 truncate text-right">{t('settings.activity.freshTokens')}</span>
|
|
||||||
<span role="columnheader" className="min-w-0 truncate text-right">{t('settings.activity.cachedTokens')}</span>
|
|
||||||
<span role="columnheader" className="min-w-0 truncate text-right">{t('settings.activity.estimatedCost')}</span>
|
|
||||||
</div>
|
|
||||||
{modelRows.map((row) => (
|
|
||||||
<div
|
|
||||||
key={row.model}
|
|
||||||
role="row"
|
|
||||||
className="grid grid-cols-[minmax(0,1.6fr)_minmax(0,0.8fr)_minmax(0,0.8fr)_minmax(0,0.8fr)] items-center gap-3 border-t border-[var(--color-border)] px-4 py-2.5 text-[13px] tabular-nums text-[var(--color-text-primary)]"
|
|
||||||
>
|
|
||||||
<span role="cell" className="min-w-0 truncate font-mono text-xs" title={row.model}>{row.model}</span>
|
|
||||||
<span role="cell" className="min-w-0 truncate text-right">{formatTokens(row.fresh)}</span>
|
|
||||||
<span role="cell" className="min-w-0 truncate text-right">{formatTokens(row.cached)}</span>
|
|
||||||
<span role="cell" className="min-w-0 truncate text-right">
|
|
||||||
{row.unpriced ? <span className="text-[var(--color-text-tertiary)]">—</span> : formatCostUSD(row.costUSD, locale)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
open={isEditingProfile}
|
open={isEditingProfile}
|
||||||
onClose={cancelProfileEdit}
|
onClose={cancelProfileEdit}
|
||||||
@@ -1265,6 +1237,36 @@ export function ActivitySettings() {
|
|||||||
</Card>
|
</Card>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
|
{modelRows.length > 0 && (
|
||||||
|
<SettingsSection title={t('settings.activity.modelUsage')}>
|
||||||
|
<Card surface="lowest" padding="none" role="table" className="overflow-hidden">
|
||||||
|
<div
|
||||||
|
role="row"
|
||||||
|
className="grid grid-cols-[minmax(0,1.6fr)_minmax(0,0.8fr)_minmax(0,0.8fr)_minmax(0,0.8fr)] gap-3 bg-[var(--color-surface-container)] px-4 py-2.5 text-xs font-semibold text-[var(--color-text-tertiary)]"
|
||||||
|
>
|
||||||
|
<span role="columnheader" className="min-w-0 truncate">{t('settings.agents.model')}</span>
|
||||||
|
<span role="columnheader" className="min-w-0 truncate text-right">{t('settings.activity.freshTokens')}</span>
|
||||||
|
<span role="columnheader" className="min-w-0 truncate text-right">{t('settings.activity.cachedTokens')}</span>
|
||||||
|
<span role="columnheader" className="min-w-0 truncate text-right">{t('settings.activity.estimatedCost')}</span>
|
||||||
|
</div>
|
||||||
|
{modelRows.map((row) => (
|
||||||
|
<div
|
||||||
|
key={row.model}
|
||||||
|
role="row"
|
||||||
|
className="grid grid-cols-[minmax(0,1.6fr)_minmax(0,0.8fr)_minmax(0,0.8fr)_minmax(0,0.8fr)] items-center gap-3 border-t border-[var(--color-border)] px-4 py-2.5 text-[13px] tabular-nums text-[var(--color-text-primary)]"
|
||||||
|
>
|
||||||
|
<span role="cell" className="min-w-0 truncate font-mono text-xs" title={row.model}>{row.model}</span>
|
||||||
|
<span role="cell" className="min-w-0 truncate text-right">{formatTokens(row.fresh)}</span>
|
||||||
|
<span role="cell" className="min-w-0 truncate text-right">{formatTokens(row.cached)}</span>
|
||||||
|
<span role="cell" className="min-w-0 truncate text-right">
|
||||||
|
{row.unpriced ? <span className="text-[var(--color-text-tertiary)]">—</span> : formatCostUSD(row.costUSD, locale)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
</SettingsSection>
|
||||||
|
)}
|
||||||
|
|
||||||
{topPluginItems.length > 0 && (
|
{topPluginItems.length > 0 && (
|
||||||
<SettingsSection title={t('settings.activity.mostUsedPluginsAndSkills')}>
|
<SettingsSection title={t('settings.activity.mostUsedPluginsAndSkills')}>
|
||||||
<Card surface="lowest" padding="none" className="overflow-hidden">
|
<Card surface="lowest" padding="none" className="overflow-hidden">
|
||||||
|
|||||||
@@ -237,7 +237,19 @@ describe('retired sponsor providers', () => {
|
|||||||
expect(dialog.queryByRole('button', { name: provider.name })).not.toBeInTheDocument()
|
expect(dialog.queryByRole('button', { name: provider.name })).not.toBeInTheDocument()
|
||||||
}
|
}
|
||||||
expect(dialog.getByRole('button', { name: 'Atlas Cloud' })).toBeInTheDocument()
|
expect(dialog.getByRole('button', { name: 'Atlas Cloud' })).toBeInTheDocument()
|
||||||
expect(dialog.getByRole('button', { name: 'Custom' })).toBeInTheDocument()
|
expect(dialog.getByRole('button', { name: 'Custom API' })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('labels the custom preset in the active locale and prefills that label as the name', async () => {
|
||||||
|
useSettingsStore.setState({ locale: 'zh' })
|
||||||
|
render(<ProviderSettings />)
|
||||||
|
await screen.findByTestId(`provider-${savedProviders[0]!.id}`)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /添加模型/ }))
|
||||||
|
const dialog = within(screen.getByRole('dialog'))
|
||||||
|
expect(dialog.queryByRole('button', { name: 'Custom' })).not.toBeInTheDocument()
|
||||||
|
fireEvent.click(dialog.getByRole('button', { name: '自定义模型 API' }))
|
||||||
|
expect(dialog.getByLabelText(/配置名称/)).toHaveValue('自定义模型 API')
|
||||||
})
|
})
|
||||||
|
|
||||||
it.each(savedProviders)('edits and saves an existing $presetId provider without losing its connection', async (provider) => {
|
it.each(savedProviders)('edits and saves an existing $presetId provider without losing its connection', async (provider) => {
|
||||||
|
|||||||
@@ -1173,6 +1173,10 @@ function ProviderFormModal({ open, onClose, mode, provider, presets, browserMode
|
|||||||
const initialPreset = provider
|
const initialPreset = provider
|
||||||
? availablePresets.find((p) => p.id === provider.presetId) ?? fallbackPreset
|
? availablePresets.find((p) => p.id === provider.presetId) ?? fallbackPreset
|
||||||
: selectablePresets[0] ?? fallbackPreset
|
: selectablePresets[0] ?? fallbackPreset
|
||||||
|
// The custom preset's catalog name is a bare English "Custom"; show a localized
|
||||||
|
// label that says what it is for.
|
||||||
|
const presetLabel = (preset: ProviderPreset) =>
|
||||||
|
preset.id === 'custom' ? t('settings.providers.customPreset') : preset.name
|
||||||
const initialModels = stripModel1mMarkers(provider?.models ?? initialPreset.defaultModels)
|
const initialModels = stripModel1mMarkers(provider?.models ?? initialPreset.defaultModels)
|
||||||
const initialImageGeneration = provider
|
const initialImageGeneration = provider
|
||||||
? provider.imageGeneration
|
? provider.imageGeneration
|
||||||
@@ -1187,7 +1191,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets, browserMode
|
|||||||
)
|
)
|
||||||
|
|
||||||
const [selectedPreset, setSelectedPreset] = useState<ProviderPreset>(initialPreset)
|
const [selectedPreset, setSelectedPreset] = useState<ProviderPreset>(initialPreset)
|
||||||
const [name, setName] = useState(provider?.name ?? initialPreset.name)
|
const [name, setName] = useState(provider?.name ?? presetLabel(initialPreset))
|
||||||
const [baseUrl, setBaseUrl] = useState(provider?.baseUrl ?? initialPreset.baseUrl)
|
const [baseUrl, setBaseUrl] = useState(provider?.baseUrl ?? initialPreset.baseUrl)
|
||||||
// A preset that decides the protocol per model owns this field: the picked value
|
// A preset that decides the protocol per model owns this field: the picked value
|
||||||
// is only the fallback for models no rule matches, so a record carrying a stale
|
// is only the fallback for models no rule matches, so a record carrying a stale
|
||||||
@@ -1349,7 +1353,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets, browserMode
|
|||||||
settingsJsonUserEditedRef.current = false
|
settingsJsonUserEditedRef.current = false
|
||||||
setSelectedPreset(preset)
|
setSelectedPreset(preset)
|
||||||
setCompatibility(compatibilityForm())
|
setCompatibility(compatibilityForm())
|
||||||
setName(preset.name)
|
setName(presetLabel(preset))
|
||||||
setBaseUrl(preset.baseUrl)
|
setBaseUrl(preset.baseUrl)
|
||||||
setImageGeneration({
|
setImageGeneration({
|
||||||
enabled: Boolean(preset.defaultImageGeneration),
|
enabled: Boolean(preset.defaultImageGeneration),
|
||||||
@@ -1687,14 +1691,14 @@ function ProviderFormModal({ open, onClose, mode, provider, presets, browserMode
|
|||||||
const renderPresetButton = (preset: ProviderPreset) => (
|
const renderPresetButton = (preset: ProviderPreset) => (
|
||||||
<SettingsPill
|
<SettingsPill
|
||||||
key={preset.id}
|
key={preset.id}
|
||||||
aria-label={preset.name}
|
aria-label={presetLabel(preset)}
|
||||||
className="relative"
|
className="relative"
|
||||||
tone="terracotta"
|
tone="terracotta"
|
||||||
selected={selectedPreset.id === preset.id}
|
selected={selectedPreset.id === preset.id}
|
||||||
onClick={() => handlePresetChange(preset)}
|
onClick={() => handlePresetChange(preset)}
|
||||||
>
|
>
|
||||||
{preset.id === 'aruhub' && <img src={aruhubLogo} alt="" className="size-4 rounded-[var(--radius-sm)] object-contain" />}
|
{preset.id === 'aruhub' && <img src={aruhubLogo} alt="" className="size-4 rounded-[var(--radius-sm)] object-contain" />}
|
||||||
{preset.name}
|
{presetLabel(preset)}
|
||||||
{preset.id === 'aruhub' && <Star size={12} className="fill-[var(--color-warning)] text-[var(--color-warning)]" aria-label={t('settings.providers.sponsor')} />}
|
{preset.id === 'aruhub' && <Star size={12} className="fill-[var(--color-warning)] text-[var(--color-warning)]" aria-label={t('settings.providers.sponsor')} />}
|
||||||
{preset.isNew && (
|
{preset.isNew && (
|
||||||
<Badge tone="warning" size="xs" className="absolute -right-1 -top-2">
|
<Badge tone="warning" size="xs" className="absolute -right-1 -top-2">
|
||||||
|
|||||||
@@ -78,6 +78,29 @@ describe('createEditBubble', () => {
|
|||||||
bubble.destroy()
|
bubble.destroy()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('opens at full height beside the click when the picked element fills the view', () => {
|
||||||
|
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1024 })
|
||||||
|
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 720 })
|
||||||
|
const el = document.getElementById('t')!
|
||||||
|
// A picked <body>: taller than the viewport, no room above or below.
|
||||||
|
vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({
|
||||||
|
x: 0, y: 0, top: 0, right: 1024, bottom: 1400, left: 0, width: 1024, height: 1400, toJSON: () => ({}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const atClick = createEditBubble(el, { onConfirm: vi.fn(), onCancel: vi.fn(), pointer: { x: 300, y: 200 } })
|
||||||
|
expect(atClick.host.style.top).toBe('208px')
|
||||||
|
expect(atClick.host.style.left).toBe('308px')
|
||||||
|
// Full estimated height (380), not squeezed to the 160px minimum.
|
||||||
|
expect((atClick.host.shadowRoot!.querySelector('.bubble') as HTMLElement).style.maxHeight).toBe('504px')
|
||||||
|
atClick.destroy()
|
||||||
|
|
||||||
|
// A click near the bottom right lifts and shifts it just enough to fit.
|
||||||
|
const nearEdge = createEditBubble(el, { onConfirm: vi.fn(), onCancel: vi.fn(), pointer: { x: 1000, y: 700 } })
|
||||||
|
expect(nearEdge.host.style.top).toBe('332px')
|
||||||
|
expect(nearEdge.host.style.left).toBe('676px')
|
||||||
|
nearEdge.destroy()
|
||||||
|
})
|
||||||
|
|
||||||
it('prefills the text field and live-applies edits to the element', () => {
|
it('prefills the text field and live-applies edits to the element', () => {
|
||||||
const el = document.getElementById('t')!
|
const el = document.getElementById('t')!
|
||||||
const bubble = createEditBubble(el, { onConfirm: vi.fn(), onCancel: vi.fn() })
|
const bubble = createEditBubble(el, { onConfirm: vi.fn(), onCancel: vi.fn() })
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ type Deps = {
|
|||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
mode?: 'single' | 'batch'
|
mode?: 'single' | 'batch'
|
||||||
copy?: EditBubbleCopy
|
copy?: EditBubbleCopy
|
||||||
|
/** Where the pick was clicked, in viewport coordinates. */
|
||||||
|
pointer?: BubblePointer
|
||||||
}
|
}
|
||||||
|
type BubblePointer = { x: number; y: number }
|
||||||
|
|
||||||
const DEFAULT_COPY: EditBubbleCopy = {
|
const DEFAULT_COPY: EditBubbleCopy = {
|
||||||
cancel: '取消',
|
cancel: '取消',
|
||||||
@@ -224,7 +227,11 @@ function buildPatch(key: keyof EditableSnapshot, value: string): EditInput {
|
|||||||
return patch
|
return patch
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeBubbleLayout(rect: DOMRect, contentHeight: number) {
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.max(min, Math.min(value, max))
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeBubbleLayout(rect: DOMRect, contentHeight: number, pointer?: BubblePointer) {
|
||||||
const viewportWidth = Math.max(window.innerWidth || 0, BUBBLE_WIDTH + VIEWPORT_MARGIN * 2)
|
const viewportWidth = Math.max(window.innerWidth || 0, BUBBLE_WIDTH + VIEWPORT_MARGIN * 2)
|
||||||
const viewportHeight = Math.max(window.innerHeight || 0, BUBBLE_MIN_HEIGHT + VIEWPORT_MARGIN * 2)
|
const viewportHeight = Math.max(window.innerHeight || 0, BUBBLE_MIN_HEIGHT + VIEWPORT_MARGIN * 2)
|
||||||
const desiredHeight = Math.min(
|
const desiredHeight = Math.min(
|
||||||
@@ -234,21 +241,26 @@ function computeBubbleLayout(rect: DOMRect, contentHeight: number) {
|
|||||||
const belowTop = rect.bottom + BUBBLE_GAP
|
const belowTop = rect.bottom + BUBBLE_GAP
|
||||||
const spaceBelow = viewportHeight - VIEWPORT_MARGIN - belowTop
|
const spaceBelow = viewportHeight - VIEWPORT_MARGIN - belowTop
|
||||||
const spaceAbove = rect.top - BUBBLE_GAP - VIEWPORT_MARGIN
|
const spaceAbove = rect.top - BUBBLE_GAP - VIEWPORT_MARGIN
|
||||||
|
const maxLeft = Math.max(VIEWPORT_MARGIN, viewportWidth - BUBBLE_WIDTH - VIEWPORT_MARGIN)
|
||||||
let top: number
|
let top: number
|
||||||
|
let left = rect.left
|
||||||
|
|
||||||
if (spaceBelow >= desiredHeight) {
|
if (spaceBelow >= desiredHeight) {
|
||||||
top = belowTop
|
top = belowTop
|
||||||
} else if (spaceAbove >= desiredHeight) {
|
} else if (spaceAbove >= desiredHeight) {
|
||||||
top = rect.top - BUBBLE_GAP - desiredHeight
|
top = rect.top - BUBBLE_GAP - desiredHeight
|
||||||
} else if (spaceAbove > spaceBelow) {
|
|
||||||
top = VIEWPORT_MARGIN
|
|
||||||
} else {
|
} else {
|
||||||
top = Math.min(Math.max(belowTop, VIEWPORT_MARGIN), viewportHeight - VIEWPORT_MARGIN - BUBBLE_MIN_HEIGHT)
|
// Neither side has room: the target fills the view (a picked <body>, a
|
||||||
|
// full-bleed section). The bubble has to cover part of it anyway, so it
|
||||||
|
// opens at full height where the pick was clicked rather than squeezing
|
||||||
|
// into a sliver at whichever edge had a little more space.
|
||||||
|
const anchor = pointer ?? { x: rect.left, y: rect.top }
|
||||||
|
top = clamp(anchor.y + BUBBLE_GAP, VIEWPORT_MARGIN, viewportHeight - VIEWPORT_MARGIN - desiredHeight)
|
||||||
|
left = anchor.x + BUBBLE_GAP
|
||||||
}
|
}
|
||||||
|
|
||||||
top = Math.max(VIEWPORT_MARGIN, Math.round(top))
|
top = Math.max(VIEWPORT_MARGIN, Math.round(top))
|
||||||
const maxLeft = Math.max(VIEWPORT_MARGIN, viewportWidth - BUBBLE_WIDTH - VIEWPORT_MARGIN)
|
left = clamp(Math.round(left), VIEWPORT_MARGIN, maxLeft)
|
||||||
const left = Math.max(VIEWPORT_MARGIN, Math.min(Math.round(rect.left), maxLeft))
|
|
||||||
const maxHeight = Math.max(BUBBLE_MIN_HEIGHT, viewportHeight - VIEWPORT_MARGIN - top)
|
const maxHeight = Math.max(BUBBLE_MIN_HEIGHT, viewportHeight - VIEWPORT_MARGIN - top)
|
||||||
return { top, left, maxHeight }
|
return { top, left, maxHeight }
|
||||||
}
|
}
|
||||||
@@ -523,7 +535,7 @@ export function createEditBubble(target: HTMLElement, deps: Deps): { host: HTMLE
|
|||||||
if (textArea) autoGrow(textArea, TEXT_AREA_MAX_HEIGHT)
|
if (textArea) autoGrow(textArea, TEXT_AREA_MAX_HEIGHT)
|
||||||
|
|
||||||
const measuredHeight = wrap.getBoundingClientRect().height || wrap.scrollHeight || BUBBLE_ESTIMATED_HEIGHT
|
const measuredHeight = wrap.getBoundingClientRect().height || wrap.scrollHeight || BUBBLE_ESTIMATED_HEIGHT
|
||||||
const layout = computeBubbleLayout(rect, measuredHeight)
|
const layout = computeBubbleLayout(rect, measuredHeight, deps.pointer)
|
||||||
host.style.setProperty('top', `${layout.top}px`)
|
host.style.setProperty('top', `${layout.top}px`)
|
||||||
host.style.setProperty('left', `${layout.left}px`)
|
host.style.setProperty('left', `${layout.left}px`)
|
||||||
host.style.setProperty('visibility', 'visible')
|
host.style.setProperty('visibility', 'visible')
|
||||||
|
|||||||
@@ -94,6 +94,33 @@ describe('preview agent picker flow', () => {
|
|||||||
if (mode === 'bubble') expect(bubbleButton('confirm').isConnected).toBe(true)
|
if (mode === 'bubble') expect(bubbleButton('confirm').isConnected).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('holds the native capture until the page has painted without the bubble', async () => {
|
||||||
|
const frames: FrameRequestCallback[] = []
|
||||||
|
const raf = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => frames.push(callback))
|
||||||
|
try {
|
||||||
|
const captureId = confirmSelection()
|
||||||
|
// By the time the host captures, the bubble is gone and the annotation is up…
|
||||||
|
expect(document.querySelector('[data-preview-selection-annotation]')).not.toBeNull()
|
||||||
|
expect(() => bubbleButton('confirm')).toThrow()
|
||||||
|
|
||||||
|
// …but the frame showing that only exists after the page paints again.
|
||||||
|
const hide = (window as unknown as { __PREVIEW_AGENT_SET_CHROME_HIDDEN__: (hidden: boolean) => Promise<void> | undefined })
|
||||||
|
.__PREVIEW_AGENT_SET_CHROME_HIDDEN__
|
||||||
|
let painted = false
|
||||||
|
const ready = hide(true)!.then(() => { painted = true })
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(painted).toBe(false)
|
||||||
|
frames.shift()!(0)
|
||||||
|
frames.shift()!(16)
|
||||||
|
await ready
|
||||||
|
expect(painted).toBe(true)
|
||||||
|
expect(hide(false)).toBeUndefined()
|
||||||
|
finishNativeCapture(captureId)
|
||||||
|
} finally {
|
||||||
|
raf.mockRestore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
it('does not let an older capture remove the newest annotation overlay', () => {
|
it('does not let an older capture remove the newest annotation overlay', () => {
|
||||||
const chrome = showZoomControls()
|
const chrome = showZoomControls()
|
||||||
const oldCapture = confirmSelection()
|
const oldCapture = confirmSelection()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createPicker } from './picker'
|
|||||||
import { buildElementMetadata } from './metadata'
|
import { buildElementMetadata } from './metadata'
|
||||||
import { createEditBubble, type EditBubbleCopy } from './editBubble'
|
import { createEditBubble, type EditBubbleCopy } from './editBubble'
|
||||||
import { createZoomControls } from './zoomControls'
|
import { createZoomControls } from './zoomControls'
|
||||||
|
import { afterNextPaint } from './paint'
|
||||||
|
|
||||||
;(() => {
|
;(() => {
|
||||||
;(window as unknown as { __PREVIEW_AGENT__?: boolean }).__PREVIEW_AGENT__ = true
|
;(window as unknown as { __PREVIEW_AGENT__?: boolean }).__PREVIEW_AGENT__ = true
|
||||||
@@ -24,7 +25,13 @@ import { createZoomControls } from './zoomControls'
|
|||||||
previewWindow.__PREVIEW_BRIDGE__ = bridge
|
previewWindow.__PREVIEW_BRIDGE__ = bridge
|
||||||
previewWindow.__PREVIEW_AGENT_CAPTURE__ = captureToDataUrl
|
previewWindow.__PREVIEW_AGENT_CAPTURE__ = captureToDataUrl
|
||||||
const zoomControls = createZoomControls(action => bridge.send({ type: 'browser-zoom', action }))
|
const zoomControls = createZoomControls(action => bridge.send({ type: 'browser-zoom', action }))
|
||||||
previewWindow.__PREVIEW_AGENT_SET_CHROME_HIDDEN__ = (hidden: boolean) => zoomControls.setCaptureSuppressed(hidden)
|
// The host awaits this before a native capture, so hiding resolves only once
|
||||||
|
// the page has painted without its chrome, the closed edit bubble and with
|
||||||
|
// the selection annotation in place.
|
||||||
|
previewWindow.__PREVIEW_AGENT_SET_CHROME_HIDDEN__ = (hidden: boolean) => {
|
||||||
|
zoomControls.setCaptureSuppressed(hidden)
|
||||||
|
return hidden ? afterNextPaint() : undefined
|
||||||
|
}
|
||||||
bridge.on('browser-controls', message => zoomControls.update(message))
|
bridge.on('browser-controls', message => zoomControls.update(message))
|
||||||
window.addEventListener('pagehide', () => zoomControls.destroy())
|
window.addEventListener('pagehide', () => zoomControls.destroy())
|
||||||
window.addEventListener('pageshow', () => zoomControls.restore())
|
window.addEventListener('pageshow', () => zoomControls.restore())
|
||||||
@@ -197,6 +204,7 @@ import { createZoomControls } from './zoomControls'
|
|||||||
},
|
},
|
||||||
mode: pickerMode,
|
mode: pickerMode,
|
||||||
copy: pickerCopy,
|
copy: pickerCopy,
|
||||||
|
pointer: { x: e.clientX, y: e.clientY },
|
||||||
})
|
})
|
||||||
updateChromeSuppression()
|
updateChromeSuppression()
|
||||||
}, true)
|
}, true)
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { afterNextPaint } from './paint'
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('afterNextPaint', () => {
|
||||||
|
it('resolves only after a second animation frame, once the first has been drawn', async () => {
|
||||||
|
const frames: FrameRequestCallback[] = []
|
||||||
|
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => frames.push(callback))
|
||||||
|
let settled = false
|
||||||
|
const done = afterNextPaint().then(() => { settled = true })
|
||||||
|
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(settled).toBe(false)
|
||||||
|
frames.shift()!(0)
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(settled).toBe(false)
|
||||||
|
frames.shift()!(16)
|
||||||
|
await done
|
||||||
|
expect(settled).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gives up after the timeout when the page never paints', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 0)
|
||||||
|
let settled = false
|
||||||
|
const done = afterNextPaint(250).then(() => { settled = true })
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(249)
|
||||||
|
expect(settled).toBe(false)
|
||||||
|
await vi.advanceTimersByTimeAsync(1)
|
||||||
|
await done
|
||||||
|
expect(settled).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/** Bounds the wait: a guest that is not on screen never runs animation frames. */
|
||||||
|
const PAINT_TIMEOUT_MS = 250
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves once the page has drawn a frame with its current DOM. The second
|
||||||
|
* animation frame only runs after the first has been produced, so a native
|
||||||
|
* `capturePage` that copies the guest's latest frame sees every change made
|
||||||
|
* before this call — a <webview> guest does not repaint on its own before
|
||||||
|
* being captured, and the copy would otherwise still show a closed edit bubble.
|
||||||
|
*/
|
||||||
|
export function afterNextPaint(timeoutMs = PAINT_TIMEOUT_MS): Promise<void> {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const timer = window.setTimeout(resolve, timeoutMs)
|
||||||
|
window.requestAnimationFrame(() => window.requestAnimationFrame(() => {
|
||||||
|
window.clearTimeout(timer)
|
||||||
|
resolve()
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user