diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntry.keyboard.test.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntry.keyboard.test.tsx index 23dd2a595..3135f8f50 100644 --- a/src/renderer/src/components/tab-bar/TabBarCreateEntry.keyboard.test.tsx +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntry.keyboard.test.tsx @@ -5,6 +5,7 @@ import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { TabEntryOption } from './tab-create-entry-action' import type { TabAgentLaunchOption } from './tab-agent-launch-options' +import { TooltipProvider } from '@/components/ui/tooltip' // Why: the real entry-action module pulls in runtime IPC + the app store; the // keyboard behavior under test only needs a controllable option list. @@ -37,7 +38,8 @@ let root: Root function mount(node: React.JSX.Element): void { act(() => { - root.render(node) + // Result rows carry a path tooltip, which Radix requires a provider for. + root.render({node}) }) } diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.test.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.test.tsx new file mode 100644 index 000000000..ce79ccea1 --- /dev/null +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.test.tsx @@ -0,0 +1,118 @@ +// @vitest-environment happy-dom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TooltipProvider } from '@/components/ui/tooltip' +import type { ActiveOption } from './tab-create-entry-active-option' +import { cursorTooltipOffsets, EntryActionRow } from './TabBarCreateEntryRow' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +const filePath = 'app/src/components/SecondaryNav.tsx' + +function makeFileOption(path: string): ActiveOption { + return { + kind: 'entry', + option: { + id: `existing-file:${path}`, + classification: { + kind: 'existing-file', + matchKind: 'fuzzy', + relativePath: path + } + } + } +} + +// The tooltip itself is asserted in tests/e2e/tab-create-entry-file-paths.spec.ts, +// where it actually opens; here we only need the row's own text layout. +function renderRow(option: ActiveOption): HTMLButtonElement { + act(() => { + root.render( + createElement( + TooltipProvider, + null, + createElement(EntryActionRow, { + id: 'file-result', + onClick: vi.fn(), + option, + selected: false + }) + ) + ) + }) + + const button = container.querySelector('button') + if (!button) { + throw new Error('row did not render a button') + } + return button +} + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +describe('EntryActionRow', () => { + it('puts the filename before the truncated parent path', () => { + const text = renderRow(makeFileOption(filePath)).textContent ?? '' + + expect(text.indexOf('SecondaryNav.tsx')).toBeLessThan(text.indexOf('app/src/components/')) + }) + + it('does not duplicate the root separator for absolute root-level files', () => { + const text = renderRow(makeFileOption('/foo')).textContent ?? '' + + expect(text).toContain('foo/') + expect(text).not.toContain('foo//') + }) + + it('keeps Windows separators intact', () => { + const text = renderRow(makeFileOption('C:\\repo\\src\\SecondaryNav.tsx')).textContent ?? '' + + expect(text.indexOf('SecondaryNav.tsx')).toBeLessThan(text.indexOf('C:\\repo\\src\\')) + }) + + it('renders a bare filename without a directory fragment', () => { + expect(renderRow(makeFileOption('README.md')).textContent).toContain('README.md') + expect(container.textContent).not.toContain('/') + }) +}) + +describe('cursorTooltipOffsets', () => { + const row = { bottom: 120, left: 400 } + + it('places the tooltip under the cursor rather than the row', () => { + // Row-anchored placement would be align 0; the cursor is 64px into the row. + expect(cursorTooltipOffsets({ x: 464, y: 108 }, row)).toEqual({ align: 64, side: 6 }) + }) + + it('tracks the cursor across the row', () => { + const left = cursorTooltipOffsets({ x: 410, y: 108 }, row) + const right = cursorTooltipOffsets({ x: 610, y: 108 }, row) + + expect(right.align - left.align).toBe(200) + expect(right.side).toBe(left.side) + }) + + it('stays anchored to the cursor when the row moves under it', () => { + // The dropdown reflows while results stream in; re-measuring the row must + // keep the tooltip on the cursor, not drag it along with the row. + const before = cursorTooltipOffsets({ x: 464, y: 108 }, row) + const after = cursorTooltipOffsets({ x: 464, y: 108 }, { bottom: 148, left: 576 }) + + expect(row.left + before.align).toBe(576 + after.align) + expect(row.bottom + before.side).toBe(148 + after.side) + }) +}) diff --git a/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx b/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx index 7aa57731c..c6d0f66ed 100644 --- a/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx +++ b/src/renderer/src/components/tab-bar/TabBarCreateEntryRow.tsx @@ -2,11 +2,29 @@ import React from 'react' import { FilePlus, FileText, Globe, Loader2, Smartphone, TerminalSquare } from 'lucide-react' import { AgentIcon } from '@/lib/agent-catalog' import { cn } from '@/lib/utils' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' import type { ActiveOption } from './tab-create-entry-active-option' export const RESULT_LISTBOX_ID = 'tab-create-entry-results' +// Clears the macOS pointer without putting the label under the cursor's own hotspot. +const CURSOR_TOOLTIP_GAP = 18 + +/** + * Radix positions the tooltip against the row, so a cursor position has to be + * restated as offsets from the row's bottom-left corner to land under the pointer. + */ +export function cursorTooltipOffsets( + pointer: { x: number; y: number }, + row: { bottom: number; left: number } +): { align: number; side: number } { + return { + align: pointer.x - row.left, + side: pointer.y + CURSOR_TOOLTIP_GAP - row.bottom + } +} + // Index-based (not the option id, which may contain spaces/slashes from file // paths) so it is always a valid aria-activedescendant IDREF. export function resultOptionDomId(index: number): string { @@ -40,13 +58,43 @@ export function EntryActionRow({ selected: boolean }): React.JSX.Element { const presentation = getActionPresentation(option) + const rowRef = React.useRef(null) + const pointerRef = React.useRef<{ x: number; y: number } | null>(null) + const [tooltipOpen, setTooltipOpen] = React.useState(false) + const [pointerOffset, setPointerOffset] = React.useState({ align: 0, side: 0 }) - return ( + // Why: Radix positions against the row, so the offsets are only valid for the + // row's position at the moment it positions. Results stream in while the open + // delay runs, so re-measure on open rather than trusting the pointer-move rect. + React.useLayoutEffect(() => { + const rect = rowRef.current?.getBoundingClientRect() + const pointer = pointerRef.current + if (!tooltipOpen || !rect || !pointer) { + return + } + const next = cursorTooltipOffsets(pointer, rect) + setPointerOffset((current) => + current.align === next.align && current.side === next.side ? current : next + ) + }, [tooltipOpen]) + + const row = ( ) + + // Only the filename-first rows hide information. Every other row already shows + // its detail in full, and STYLEGUIDE.md:162 rules out labelling those. + if (!presentation.prioritizeFilename) { + return row + } + + return ( + + {row} + {/* Anchored under the cursor the way a system tooltip is, rather than to + the row. Radix anchors to the trigger, so the cursor is expressed as + offsets off the row's bottom-left corner; collision flipping still + applies near the viewport edge. */} + + {presentation.detail} + + + ) +} + +// Why: keeps the separator attached to the directory, so `/foo` renders as +// `foo` + `/` rather than re-deriving a separator that may not match the path. +function splitTrailingSegment(path: string): { directory: string; filename: string } { + const separatorIndex = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + + return separatorIndex === -1 + ? { directory: '', filename: path } + : { directory: path.slice(0, separatorIndex + 1), filename: path.slice(separatorIndex + 1) } +} + +function FilenameFirstPath({ path }: { path: string }): React.JSX.Element { + const { directory, filename } = splitTrailingSegment(path) + + return ( + + {/* shrink-0 + max-w-full: the directory gives up all of its width before + the filename loses a character. */} + {filename} + {directory ? ( + {directory} + ) : null} + + ) } function getActionPresentation(option: ActiveOption): { detail: string icon: React.ReactNode label: string + prioritizeFilename?: boolean showDetail: boolean } { if (option.kind === 'menu') { @@ -122,6 +226,7 @@ function getActionPresentation(option: ActiveOption): { : classification.relativePath, icon: