From 084d3462cf56c43e0f3abbe4ac40e0bff02f9b61 Mon Sep 17 00:00:00 2001 From: Junmo Kim Date: Sat, 1 Aug 2026 18:11:40 +0900 Subject: [PATCH] fix(web): keep line numbers clear when code wraps (#1260) * fix(web): reserve code gutter padding * fix(web): expose diff wrap controls * test(ci): run terminal wrap regression --- .github/workflows/test.yml | 2 + e2e/terminal-wrap-fidelity.spec.ts | 112 +++++++++++ web/e2e-fixtures/terminal-wrap-fixture.html | 18 ++ web/e2e-fixtures/terminal-wrap-fixture.tsx | 66 ++++++ web/src/components/CodeBlock.test.tsx | 23 +++ web/src/components/CodeBlock.tsx | 5 +- web/src/components/DiffView.test.tsx | 45 ++++- web/src/components/DiffView.tsx | 189 ++++++++++-------- web/src/components/ToolCard/ToolCard.test.ts | 4 + web/src/components/ToolCard/ToolCard.tsx | 25 ++- .../ToolCard/codexDiffInteraction.test.tsx | 30 +++ .../ToolCard/inlineDetailFocus.test.tsx | 32 +++ .../ToolCard/views/MultiEditView.tsx | 1 + 13 files changed, 462 insertions(+), 90 deletions(-) create mode 100644 e2e/terminal-wrap-fidelity.spec.ts create mode 100644 web/e2e-fixtures/terminal-wrap-fixture.html create mode 100644 web/e2e-fixtures/terminal-wrap-fixture.tsx create mode 100644 web/src/components/ToolCard/codexDiffInteraction.test.tsx create mode 100644 web/src/components/ToolCard/inlineDetailFocus.test.tsx diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8fac2755..b91a3a8f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,6 +14,8 @@ jobs: bun-version: 1.3.14 - run: bun install - run: bun typecheck + - run: bunx playwright install --with-deps chromium + - run: bun run test:e2e -- terminal-wrap-fidelity.spec.ts - name: Create integration test env run: | { diff --git a/e2e/terminal-wrap-fidelity.spec.ts b/e2e/terminal-wrap-fidelity.spec.ts new file mode 100644 index 00000000..1b14156e --- /dev/null +++ b/e2e/terminal-wrap-fidelity.spec.ts @@ -0,0 +1,112 @@ +import { writeFile } from 'node:fs/promises' +import { expect, test } from '@playwright/test' + +test.use({ viewport: { width: 412, height: 915 }, deviceScaleFactor: 1 }) + +const fixtureUrl = '/e2e-fixtures/terminal-wrap-fixture.html' + +test.describe('terminal wrap fidelity', () => { + test('wrap-on keeps 1-, 2-, and 3-digit gutter numbers left of code text on mobile', async ({ page }, testInfo) => { + await page.addInitScript(() => window.localStorage.setItem('hapi-code-wrap', '1')) + await page.goto(fixtureUrl) + + await page.getByRole('button', { name: /node scripts\/render-report/i }).click() + const dialog = page.getByRole('dialog') + await expect(dialog).toBeVisible() + + const metrics = await dialog.evaluate((element) => { + const body = element.querySelector('[data-hapi-code-body="true"]')! + const grid = element.querySelector('[data-hapi-code-grid="true"]')! + const cells = Array.from(element.querySelectorAll('[data-code-cell]')) + const gutters = Array.from(element.querySelectorAll('[data-line-number]')) + const measure = (needle: string) => { + const index = cells.findIndex((cell) => cell.textContent?.includes(needle)) + const codeCell = cells[index]! + const textRange = document.createRange() + textRange.selectNodeContents(codeCell) + const numberRange = document.createRange() + numberRange.selectNodeContents(gutters[index]!) + const textRects = Array.from(textRange.getClientRects()).filter((rect) => rect.width > 0) + const numberRects = Array.from(numberRange.getClientRects()).filter((rect) => rect.width > 0) + const gutterRect = gutters[index]!.getBoundingClientRect() + return { + line: gutters[index]!.textContent, + gutter: { left: gutterRect.left, right: gutterRect.right, width: gutterRect.width }, + numberRight: Math.max(...numberRects.map((rect) => rect.right)), + numberWidth: Math.max(...numberRects.map((rect) => rect.width)), + codeTextLeft: Math.min(...textRects.map((rect) => rect.left)), + } + } + return { + body: { clientWidth: body.clientWidth, scrollWidth: body.scrollWidth }, + grid: { clientWidth: grid.clientWidth, scrollWidth: grid.scrollWidth }, + dialog: { right: element.getBoundingClientRect().right, viewportWidth: window.innerWidth }, + rows: [measure('node scripts/render-report'), measure('row-001 | value'), measure('row-100 | value')], + } + }) + await writeFile(testInfo.outputPath('terminal-gutter-geometry.json'), JSON.stringify(metrics, null, 2)) + await page.screenshot({ path: testInfo.outputPath('terminal-gutter.png') }) + + expect(metrics.body.scrollWidth).toBe(metrics.body.clientWidth) + expect(metrics.grid.scrollWidth).toBe(metrics.grid.clientWidth) + expect(metrics.dialog.right).toBeLessThanOrEqual(metrics.dialog.viewportWidth) + expect(metrics.rows.map((row) => row.line?.length)).toEqual([1, 2, 3]) + for (const row of metrics.rows) { + expect(row.numberRight).toBeLessThan(row.codeTextLeft) + expect(row.codeTextLeft - row.gutter.left).toBeGreaterThanOrEqual(row.numberWidth + 24) + expect(row.gutter.width).toBeCloseTo(metrics.rows[0]!.gutter.width, 2) + } + }) + + test('exposes one visible preview trigger, share-safe toggles, and wrapping geometry in inline diff surfaces', async ({ page }) => { + await page.goto(fixtureUrl) + + const preview = page.getByTestId('diff-preview') + const wrapToggle = preview.locator('[data-hapi-code-wrap-toggle="true"]') + await expect(wrapToggle).toHaveCount(1) + await expect(preview.locator('button button')).toHaveCount(0) + await expect(preview.getByRole('button', { name: /open diff for src\/mobile-terminal\.ts/i })).toHaveCount(1) + await expect(wrapToggle).toHaveAttribute('data-hapi-share-export-exclude', 'true') + + await wrapToggle.click() + await expect(wrapToggle).toHaveAttribute('aria-pressed', 'true') + await expect(page.locator('[data-hapi-code-grid="true"]')).toHaveAttribute('style', /minmax\(0px, 1fr\)/) + + const inline = page.getByTestId('diff-inline') + await expect(inline.locator('.whitespace-pre-wrap')).toHaveCount(2) + const inlineGeometry = await inline.evaluate((element) => { + const wrapped = element.querySelector('.whitespace-pre-wrap')! + const range = document.createRange() + range.selectNodeContents(wrapped) + const root = element.getBoundingClientRect() + const row = wrapped.parentElement! + return { + row: { clientWidth: row.clientWidth, scrollWidth: row.scrollWidth }, + rootRight: root.right, + fragments: Array.from(range.getClientRects()).map((rect) => ({ right: rect.right, top: rect.top })), + } + }) + await expect(inline.locator('.overflow-x-auto')).toHaveCount(0) + expect(inlineGeometry.row.scrollWidth).toBe(inlineGeometry.row.clientWidth) + expect(inlineGeometry.fragments.length).toBeGreaterThan(1) + expect(Math.max(...inlineGeometry.fragments.map((fragment) => fragment.right))).toBeLessThanOrEqual(inlineGeometry.rootRight + 1) + + const codexDiff = page.getByTestId('toolcard-codex-diff') + await expect(codexDiff.locator('[role="button"] button')).toHaveCount(0) + const codexWrapToggle = codexDiff.locator('[data-hapi-code-wrap-toggle="true"]') + await expect(codexWrapToggle).toHaveCount(1) + await codexWrapToggle.click() + await expect(page.getByRole('dialog')).toHaveCount(0) + await codexWrapToggle.click() + await codexDiff.getByRole('button', { name: 'Open diff preview' }).click() + await expect(page.getByRole('dialog')).toBeVisible() + await page.getByRole('dialog').getByRole('button', { name: 'Close' }).click() + + const previewTrigger = preview.getByRole('button', { name: /open diff for src\/mobile-terminal\.ts/i }) + await previewTrigger.click() + const dialog = page.getByRole('dialog') + await expect(dialog.getByRole('button', { pressed: true })).toHaveCount(1) + await dialog.getByRole('button', { name: 'Close' }).click() + await expect(previewTrigger).toBeFocused() + }) +}) diff --git a/web/e2e-fixtures/terminal-wrap-fixture.html b/web/e2e-fixtures/terminal-wrap-fixture.html new file mode 100644 index 00000000..82548a25 --- /dev/null +++ b/web/e2e-fixtures/terminal-wrap-fixture.html @@ -0,0 +1,18 @@ + + + + + + HAPI terminal wrap fixture + + + +
+ + + diff --git a/web/e2e-fixtures/terminal-wrap-fixture.tsx b/web/e2e-fixtures/terminal-wrap-fixture.tsx new file mode 100644 index 00000000..c96cb584 --- /dev/null +++ b/web/e2e-fixtures/terminal-wrap-fixture.tsx @@ -0,0 +1,66 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import '../src/index.css' +import { CliOutputBlock } from '../src/components/CliOutputBlock' +import { DiffView } from '../src/components/DiffView' +import { ToolCard } from '../src/components/ToolCard/ToolCard' +import { I18nProvider } from '../src/lib/i18n-context' +import type { ApiClient } from '../src/api/client' +import type { ToolCallBlock } from '../src/chat/types' + +const terminalPayload = `node scripts/render-report --source ./fixtures/mobile-terminal-wrap-fidelity-with-a-deliberately-long-path-and-unbroken-identifier.json --destination ./artifacts/mobile-preview.md--format markdown +--include "한글 mixed-language summary" +--filter "status:active AND owner:platform" +--verbose| 항목 | 상태 | 설명 | +| --- | --- | --- | +| mobile-wrap | 성공 | 한글과 English text are both preserved | + +long stdout text wraps naturally at the mobile code surface without changing source whitespace +${Array.from({ length: 100 }, (_, index) => `row-${String(index + 1).padStart(3, '0')} | value`).join('\n')}` + +const codexDiffBlock: ToolCallBlock = { + kind: 'tool-call', id: 'fixture-codex-diff', localId: null, createdAt: 1_000, + tool: { + id: 'fixture-codex-diff', name: 'CodexDiff', state: 'completed', + input: { unified_diff: 'diff --git a/example.ts b/example.ts\n--- a/example.ts\n+++ b/example.ts\n@@ -1 +1 @@\n-before\n+after with a deliberately long value that must wrap in the CodexDiff ToolCard' }, + createdAt: 1_000, startedAt: 1_000, completedAt: 1_100, execStartedAt: null, execCompletedAt: null, description: null, + }, children: [], +} + +function TerminalWrapFixture() { + return ( +
+ +
+ +
+
+ +
+
+ {}} block={codexDiffBlock} /> +
+
+ ) +} + +const rootEl = document.getElementById('root') +if (rootEl) { + ReactDOM.createRoot(rootEl).render( + + + + + + ) +} diff --git a/web/src/components/CodeBlock.test.tsx b/web/src/components/CodeBlock.test.tsx index 5d24b64a..b2bc4609 100644 --- a/web/src/components/CodeBlock.test.tsx +++ b/web/src/components/CodeBlock.test.tsx @@ -104,6 +104,29 @@ describe('CodeBlock', () => { expect(screen.getByRole('button', { pressed: true })).toBeInTheDocument() }) + it.each([1, 12, 123])('reserves the gutter padding outside the %i-digit number track', (lineCount) => { + const { container } = render( + + `line ${index + 1}`).join('\n')} language="text" /> + + ) + + const grid = container.querySelector('[data-hapi-code-grid="true"]') as HTMLElement + expect(grid.style.gridTemplateColumns).toBe('calc(3ch + 1.5rem) max-content') + }) + + it('uses the natural pre-wrap layout without hiding or shifting leading whitespace', () => { + window.localStorage.setItem('hapi-code-wrap', '1') + const source = ' \t --format a-deliberately-long-terminal-argument' + const { container } = render() + const codeCell = container.querySelector('[data-code-cell]') as HTMLElement + + expect(codeCell.style.paddingLeft).toBe('') + expect(codeCell.style.tabSize).toBe('') + expect(codeCell.querySelector('[data-code-leading-indent]')).toBeNull() + expect(codeCell.textContent).toBe(source) + }) + it('renders the plain-text fallback as per-line rows when highlighting is unavailable', () => { const { container } = render( diff --git a/web/src/components/CodeBlock.tsx b/web/src/components/CodeBlock.tsx index 50d950ef..56d73482 100644 --- a/web/src/components/CodeBlock.tsx +++ b/web/src/components/CodeBlock.tsx @@ -1,4 +1,4 @@ -import type { CSSProperties, ReactNode } from 'react' +import { type CSSProperties, type ReactNode } from 'react' import { useCopyToClipboard } from '@/hooks/useCopyToClipboard' import { useCodeWrap } from '@/hooks/useCodeWrap' import { useShikiHighlightedLines, splitCodeLines } from '@/lib/shiki' @@ -9,6 +9,7 @@ const DEFAULT_COLLAPSE_LINE_THRESHOLD = 18 const DEFAULT_COLLAPSE_CHAR_THRESHOLD = 1800 const DEFAULT_COLLAPSED_HEIGHT = 260 const DEFAULT_SCROLL_HEIGHT = 420 +const GUTTER_HORIZONTAL_PADDING_REM = 1.5 function shouldCollapseCode(code: string, lineThreshold: number, charThreshold: number): boolean { if (code.length > charThreshold) return true @@ -75,7 +76,7 @@ export function CodeBlock(props: { // (minmax(0,1fr)) so long lines wrap instead of overflowing; unwrapped it // grows to its content (max-content) inside the horizontal-scroll body. const codeGridStyle = { - gridTemplateColumns: `${lineNumberWidth}ch ${codeWrap ? 'minmax(0, 1fr)' : 'max-content'}` + gridTemplateColumns: `calc(${lineNumberWidth}ch + ${GUTTER_HORIZONTAL_PADDING_REM}rem) ${codeWrap ? 'minmax(0, 1fr)' : 'max-content'}` } satisfies CSSProperties const codeCellStyle = codeWrap ? { whiteSpace: 'pre-wrap' as const, wordBreak: 'break-word' as const } diff --git a/web/src/components/DiffView.test.tsx b/web/src/components/DiffView.test.tsx index c12b29ba..7ec5d3be 100644 --- a/web/src/components/DiffView.test.tsx +++ b/web/src/components/DiffView.test.tsx @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { cleanup, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { I18nProvider } from '@/lib/i18n-context' import { DiffView } from '@/components/DiffView' @@ -73,7 +73,7 @@ describe('DiffView', () => { expect(row?.children[1]).toHaveClass('text-left') }) - it('comfortable rows default to whitespace-pre (wrap off, no toggle button on DiffView)', () => { + it('comfortable rows default to whitespace-pre and expose a shared wrap toggle', () => { const { container } = render( { ) expect(container.querySelector('.whitespace-pre:not(.whitespace-pre-wrap)')).not.toBeNull() - // DiffView consumes the global wrap value but exposes no toggle - // button; assert by aria-pressed absence rather than a localized title. - expect(screen.queryByRole('button', { pressed: false })).toBeNull() - expect(screen.queryByRole('button', { pressed: true })).toBeNull() + const wrapToggle = screen.getByRole('button', { pressed: false }) + fireEvent.click(wrapToggle) + expect(screen.getByRole('button', { pressed: true })).toBeInTheDocument() + expect(container.querySelector('.whitespace-pre-wrap')).not.toBeNull() + }) + + it('keeps the visible preview header trigger and wrap action as sibling buttons', () => { + const { container } = render( + + + + ) + + expect(container.querySelectorAll('[data-hapi-code-wrap-toggle="true"]')).toHaveLength(1) + expect(container.querySelectorAll('button button')).toHaveLength(0) + expect(container.querySelectorAll('button[aria-haspopup="dialog"]')).toHaveLength(1) + expect(screen.getByRole('button', { name: 'Open diff for example.ts' })).toContainElement(screen.getByText('View')) + const wrapToggle = container.querySelector('[data-hapi-code-wrap-toggle="true"]')! + expect(wrapToggle).toHaveAttribute('data-hapi-share-export-exclude', 'true') + expect(wrapToggle).toHaveAttribute('data-hapi-wrap-enable-label') + expect(wrapToggle).toHaveAttribute('data-hapi-wrap-disable-label') + }) + + it('renders the same toggle in the opened preview dialog and restores focus to its header trigger', async () => { + render( + + + + ) + + fireEvent.click(screen.getByRole('button', { name: 'Open diff for example.ts' })) + const dialog = screen.getByRole('dialog') + expect(within(dialog).getByRole('button', { pressed: false })).toBeInTheDocument() + fireEvent.click(within(dialog).getByRole('button', { name: 'Close' })) + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Open diff for example.ts' })).toHaveFocus() + }) }) it('compact rows also follow the global wrap preference (previously hard-coded to wrap)', () => { diff --git a/web/src/components/DiffView.tsx b/web/src/components/DiffView.tsx index 2a2533e8..c8927f44 100644 --- a/web/src/components/DiffView.tsx +++ b/web/src/components/DiffView.tsx @@ -4,6 +4,7 @@ import { useMemo } from 'react' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' import { usePointerFocusRing } from '@/hooks/usePointerFocusRing' import { useCodeWrap } from '@/hooks/useCodeWrap' +import { WrapIcon } from '@/components/icons' import { cn } from '@/lib/utils' import { useTranslation } from '@/lib/use-translation' @@ -42,6 +43,33 @@ function DiffStatBadge(props: { tone: 'added' | 'removed'; value: number }) { ) } +function DiffWrapToggle() { + const { t } = useTranslation() + const { codeWrap, setCodeWrap } = useCodeWrap() + + return ( + + ) +} + export function DiffView(props: { oldString: string newString: string @@ -88,20 +116,20 @@ export function DiffView(props: { return ( - - - + + + + +
+ +
+ {title} @@ -175,67 +204,69 @@ function DiffInlineView(props: { let oldLineNumber = 1 let newLineNumber = 1 + const body = ( +
+
+ {diff.map((part, i) => { + const lines = splitDiffLines(part.value) + + return ( +
+ {lines.map((line, j) => { + const prefix = part.added ? '+' : part.removed ? '-' : ' ' + const leftNumber = part.added ? '' : String(oldLineNumber++) + const rightNumber = part.removed ? '' : String(newLineNumber++) + const rowClass = cn( + 'grid min-w-full gap-3', + isComfortable ? 'px-4' : 'px-3', + isComfortable ? 'py-0' : 'py-1.5', + part.added && 'bg-[var(--app-diff-added-bg)] text-[var(--app-diff-added-text)]', + part.removed && 'bg-[var(--app-diff-removed-bg)] text-[var(--app-diff-removed-text)]' + ) + + return ( +
+
{leftNumber}
+
{rightNumber}
+
+ {prefix} + {line} +
+
+ ) + })} +
+ ) + })} +
+
+ ) + return (
{props.showHeader ? (
-
- {props.filePath ?? 'Diff'} -
+
{props.filePath ?? 'Diff'}
+
) : null} - -
-
- {diff.map((part, i) => { - const lines = splitDiffLines(part.value) - - return ( -
- {lines.map((line, j) => { - const prefix = part.added ? '+' : part.removed ? '-' : ' ' - const leftNumber = part.added ? '' : String(oldLineNumber++) - const rightNumber = part.removed ? '' : String(newLineNumber++) - const rowClass = cn( - 'grid min-w-full gap-3', - isComfortable ? 'px-4' : 'px-3', - isComfortable ? 'py-0' : 'py-1.5', - part.added && 'bg-[var(--app-diff-added-bg)] text-[var(--app-diff-added-text)]', - part.removed && 'bg-[var(--app-diff-removed-bg)] text-[var(--app-diff-removed-text)]' - ) - - return ( -
-
{leftNumber}
-
{rightNumber}
-
- {prefix} - {line} -
-
- ) - })} -
- ) - })} -
-
+ {body}
) } diff --git a/web/src/components/ToolCard/ToolCard.test.ts b/web/src/components/ToolCard/ToolCard.test.ts index 3aa2e833..4d04c447 100644 --- a/web/src/components/ToolCard/ToolCard.test.ts +++ b/web/src/components/ToolCard/ToolCard.test.ts @@ -61,6 +61,10 @@ describe('ToolCard terminal display mode helpers', () => { expect(shouldShowInlineToolCardBody('Task', false, 'detailed')).toBe(false) expect(shouldShowInlineToolCardBody('Agent', false, 'detailed')).toBe(false) expect(shouldShowInlineToolCardBody('Read', true, 'detailed')).toBe(false) + expect(shouldShowInlineToolCardBody('Edit', true, 'detailed')).toBe(false) + expect(shouldShowInlineToolCardBody('MultiEdit', true, 'detailed')).toBe(false) + expect(shouldShowInlineToolCardBody('Write', true, 'detailed')).toBe(false) + expect(shouldShowInlineToolCardBody('CodexDiff', true, 'detailed')).toBe(false) }) }) diff --git a/web/src/components/ToolCard/ToolCard.tsx b/web/src/components/ToolCard/ToolCard.tsx index 55ae9297..4aa550b7 100644 --- a/web/src/components/ToolCard/ToolCard.tsx +++ b/web/src/components/ToolCard/ToolCard.tsx @@ -1,7 +1,7 @@ import type { ChatBlock, ChatToolCall, ToolCallBlock } from '@/chat/types' import type { ApiClient } from '@/api/client' import type { SessionMetadataSummary } from '@/types/api' -import { memo, useEffect, useMemo, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' +import { memo, useEffect, useMemo, useRef, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' import { getClaudeModelLabel, isObject, safeStringify } from '@hapi/protocol' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { CodeBlock } from '@/components/CodeBlock' @@ -430,6 +430,7 @@ function ToolCardInner(props: ToolCardProps) { const useCompactTerminalCard = shouldUseCompactTerminalToolCard(toolName, props.terminalToolDisplayMode) const showInline = shouldShowInlineToolCardBody(toolName, presentation.minimal, props.terminalToolDisplayMode) const CompactToolView = showInline ? getToolViewComponent(toolName) : null + const compactViewOwnsInteractions = toolName === 'CodexDiff' const ResultToolView = getToolResultViewComponent(toolName) const permission = props.block.tool.permission const isAskUserQuestion = isAskUserQuestionToolName(toolName) @@ -447,15 +448,18 @@ function ToolCardInner(props: ToolCardProps) { : (subtitle ? 'mt-1' : 'mt-0') const stateColor = toolStatusColorClass(props.block.tool.state) const { suppressFocusRing, onTriggerPointerDown, onTriggerKeyDown, onTriggerBlur } = usePointerFocusRing() + const inlineDetailInvokerRef = useRef(null) const openDetails = () => setDetailsOpen(true) const openDetailsFromInlinePreview = (event: MouseEvent) => { if (isNestedInteractiveElement(event)) return + inlineDetailInvokerRef.current = event.currentTarget openDetails() } const openDetailsFromInlinePreviewKeyDown = (event: KeyboardEvent) => { if (isNestedInteractiveElement(event)) return if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() + inlineDetailInvokerRef.current = event.currentTarget openDetails() } } @@ -526,7 +530,18 @@ function ToolCardInner(props: ToolCardProps) { {header} - + { + const invoker = inlineDetailInvokerRef.current + if (!invoker?.isConnected) return + event.preventDefault() + invoker.focus() + inlineDetailInvokerRef.current = null + }} + > {toolTitle} @@ -545,7 +560,11 @@ function ToolCardInner(props: ToolCardProps) { {showInline ? ( CompactToolView ? ( -
+ +
+ ) :
{ + it('lets a non-minimal CodexDiff own its preview and wrap controls', () => { + const { container } = render( + {}} block={block} /> + ) + + expect(container.querySelectorAll('[role="button"] button')).toHaveLength(0) + const wrapToggle = container.querySelector('[data-hapi-code-wrap-toggle="true"]')! + fireEvent.click(wrapToggle) + expect(screen.queryByRole('dialog')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Open diff preview' })) + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) +}) diff --git a/web/src/components/ToolCard/inlineDetailFocus.test.tsx b/web/src/components/ToolCard/inlineDetailFocus.test.tsx new file mode 100644 index 00000000..bc340e95 --- /dev/null +++ b/web/src/components/ToolCard/inlineDetailFocus.test.tsx @@ -0,0 +1,32 @@ +import { fireEvent, render, screen, waitFor, 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' + +const block: ToolCallBlock = { + kind: 'tool-call', id: 'bash-1', localId: null, createdAt: 1_000, + tool: { + id: 'bash-1', name: 'Bash', state: 'completed', input: { command: 'echo preview' }, + createdAt: 1_000, startedAt: 1_000, completedAt: 1_100, execStartedAt: null, execCompletedAt: null, description: null, + }, children: [], +} + +describe('ToolCard inline detail focus', () => { + it('restores focus to both the header trigger and an inline preview invoker', async () => { + const { container } = render( + {}} block={block} /> + ) + + const headerTrigger = container.querySelector('button')! + fireEvent.click(headerTrigger) + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Close' })) + await waitFor(() => expect(headerTrigger).toHaveFocus()) + + const inlinePreview = container.querySelector('[role="button"]') as HTMLElement + fireEvent.click(inlinePreview) + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Close' })) + await waitFor(() => expect(inlinePreview).toHaveFocus()) + }) +}) diff --git a/web/src/components/ToolCard/views/MultiEditView.tsx b/web/src/components/ToolCard/views/MultiEditView.tsx index 90828d0a..bf883aea 100644 --- a/web/src/components/ToolCard/views/MultiEditView.tsx +++ b/web/src/components/ToolCard/views/MultiEditView.tsx @@ -28,6 +28,7 @@ export function MultiEditView(props: ToolViewProps) { key={idx} oldString={edit.old_string} newString={edit.new_string} + variant="inline" /> ))} {edits.length > MAX_COMPACT_EDITS ? (