fix(web): align tool detail layout (#1204)

* fix(web): align inline tool detail spacing

* fix(web): polish tool detail alignment
This commit is contained in:
Ananovo
2026-07-28 17:29:43 +08:00
committed by GitHub
parent 25dfa638b3
commit 8a77d2f84d
7 changed files with 113 additions and 10 deletions
+12 -4
View File
@@ -435,6 +435,11 @@ function ToolCardInner(props: ToolCardProps) {
|| ((permission.status === 'denied' || permission.status === 'canceled') && Boolean(permission.reason))
))
const hasBody = showInline || taskSummary !== null || showsPermissionFooter
// Header/content padding already supplies 12-16px below timing; add only
// the remainder needed to match the detail dialog's 16px section gap.
const inlineBodySpacing = props.block.tool.state === 'pending'
? 'mt-3'
: (subtitle ? 'mt-1' : 'mt-0')
const stateColor = toolStatusColorClass(props.block.tool.state)
const { suppressFocusRing, onTriggerPointerDown, onTriggerKeyDown, onTriggerBlur } = usePointerFocusRing()
const openDetails = () => setDetailsOpen(true)
@@ -516,8 +521,8 @@ function ToolCardInner(props: ToolCardProps) {
{header}
</button>
</DialogTrigger>
<DialogContent className="max-w-2xl" aria-describedby={undefined}>
<DialogHeader>
<DialogContent className="max-w-2xl" closeButtonClassName="top-2" aria-describedby={undefined}>
<DialogHeader className="text-left">
<DialogTitle>{toolTitle}</DialogTitle>
</DialogHeader>
<ToolDetailDialogContent block={props.block} metadata={props.metadata} />
@@ -536,7 +541,10 @@ function ToolCardInner(props: ToolCardProps) {
{showInline ? (
CompactToolView ? (
<div
className="mt-3 cursor-pointer rounded-xl focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)]"
className={cn(
inlineBodySpacing,
'cursor-pointer rounded-xl focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)]'
)}
role="button"
tabIndex={0}
onClick={openDetailsFromInlinePreview}
@@ -545,7 +553,7 @@ function ToolCardInner(props: ToolCardProps) {
<CompactToolView block={props.block} metadata={props.metadata} surface="inline" />
</div>
) : (
<div className="mt-3 flex flex-col gap-3">
<div className={cn(inlineBodySpacing, 'flex flex-col gap-4')}>
<div
className="cursor-pointer rounded-xl focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)]"
role="button"
@@ -195,6 +195,8 @@ describe('ToolGroupCard', () => {
expect(screen.getAllByText('src/a.ts')[0]).toBeInTheDocument()
expect(within(dialog).getAllByText('Input').length).toBeGreaterThan(0)
expect(within(dialog).getAllByText('Result').length).toBeGreaterThan(0)
expect(within(dialog).getByRole('heading').parentElement).toHaveClass('text-left')
expect(within(dialog).getByRole('button', { name: 'Close' })).toHaveClass('top-2')
})
it('shows structured Codex exploration actions by default without a generic action count', () => {
@@ -440,10 +440,10 @@ export function ToolGroupCard(props: {
setSelectedToolId(null)
}
}}>
<DialogContent className="max-w-2xl" aria-describedby={undefined}>
<DialogContent className="max-w-2xl" closeButtonClassName="top-2" aria-describedby={undefined}>
{selectedTool && selectedPresentation ? (
<>
<DialogHeader>
<DialogHeader className="text-left">
<DialogTitle>{selectedPresentation.title}</DialogTitle>
</DialogHeader>
<ToolDetailDialogContent block={selectedTool} metadata={props.metadata} />
@@ -1,3 +1,4 @@
import { render } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { formatTerminalCommandTitle, getToolPresentation } from '@/components/ToolCard/knownTools'
@@ -127,6 +128,8 @@ describe('getToolPresentation — unknown tool semantic title + subtitle dedup',
expect(presentation.title).toBe('Tool')
expect(presentation.subtitle).toBe('Tool 1')
const icon = render(<>{presentation.icon}</>).container.querySelector('svg')
expect(icon).toHaveClass('translate-y-px')
})
it('returns null subtitle when no recognized input field is present', () => {
+1 -1
View File
@@ -675,7 +675,7 @@ export function getToolPresentation(
}
return {
icon: <WrenchIcon className={DEFAULT_ICON_CLASS} />,
icon: <WrenchIcon className={`${DEFAULT_ICON_CLASS} translate-y-px`} />,
title,
subtitle: subtitle && subtitle !== title ? truncate(subtitle, 80) : null,
minimal: true
@@ -0,0 +1,83 @@
import { fireEvent, render, screen, within } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import type { ApiClient } from '@/api/client'
import type { ToolCallBlock } from '@/chat/types'
import { ToolCard } from '@/components/ToolCard/ToolCard'
import { I18nProvider } from '@/lib/i18n-context'
function renderDetailedBash(command: string, state: 'pending' | 'completed' = 'completed') {
const completed = state === 'completed'
const block: ToolCallBlock = {
kind: 'tool-call',
id: 'tool-1',
localId: null,
createdAt: 1_000,
tool: {
id: 'tool-1',
name: 'Bash',
state,
input: { command },
createdAt: 1_000,
startedAt: completed ? 1_000 : null,
completedAt: completed ? 1_500 : null,
execStartedAt: null,
execCompletedAt: null,
description: null,
result: completed ? 'ok' : undefined,
},
children: [],
}
render(
<I18nProvider>
<ToolCard
api={{} as ApiClient}
sessionId="session-1"
metadata={null}
terminalToolDisplayMode="detailed"
disabled={false}
onDone={() => {}}
block={block}
/>
</I18nProvider>
)
return screen.getByText('Input').parentElement?.parentElement
}
describe('ToolCard spacing', () => {
it('matches the dialog gap when the timing header has a subtitle', () => {
const inlineBody = renderDetailedBash('echo hello && pwd')
expect(inlineBody).toHaveClass('mt-1')
expect(inlineBody).toHaveClass('gap-4')
expect(inlineBody).not.toHaveClass('mt-3')
expect(inlineBody).not.toHaveClass('gap-3')
})
it('matches the dialog gap when the timing header has no subtitle', () => {
const inlineBody = renderDetailedBash('pwd')
expect(inlineBody).toHaveClass('mt-0')
expect(inlineBody).not.toHaveClass('mt-3')
})
it('keeps the original body spacing when pending tools have no timing summary', () => {
const inlineBody = renderDetailedBash('pwd', 'pending')
expect(inlineBody).toHaveClass('mt-3')
})
})
describe('ToolCard detail dialog', () => {
it('keeps the tool detail title left-aligned on mobile', () => {
renderDetailedBash('pwd')
fireEvent.click(screen.getByRole('button', { expanded: false }))
const dialog = screen.getByRole('dialog')
const title = within(dialog).getByRole('heading')
expect(title.parentElement).toHaveClass('text-left')
expect(within(dialog).getByRole('button', { name: 'Close' })).toHaveClass('top-2')
})
})
+10 -3
View File
@@ -7,10 +7,14 @@ import { useTranslation } from '@/lib/use-translation'
export const Dialog = DialogPrimitive.Root
export const DialogTrigger = DialogPrimitive.Trigger
type DialogContentProps = React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
closeButtonClassName?: string
}
export const DialogContent = React.forwardRef<
HTMLDivElement,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => {
DialogContentProps
>(({ className, closeButtonClassName, children, ...props }, ref) => {
const { t } = useTranslation()
return (
<DialogPrimitive.Portal>
@@ -25,7 +29,10 @@ export const DialogContent = React.forwardRef<
>
{children}
<DialogPrimitive.Close
className="absolute right-3 top-3 flex h-8 w-8 items-center justify-center rounded-full text-[var(--app-hint)] transition-colors hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)]"
className={cn(
'absolute right-3 top-3 flex h-8 w-8 items-center justify-center rounded-full text-[var(--app-hint)] transition-colors hover:bg-[var(--app-subtle-bg)] hover:text-[var(--app-fg)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)]',
closeButtonClassName
)}
aria-label={t('button.close')}
>
<CloseIcon className="h-4 w-4" />