From 6b60c23e07de7b2602b796176db94d6430d725f9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:01:45 -0700 Subject: [PATCH] fix(native-chat): reveal message chrome on keyboard focus, not any focus (#19426) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(native-chat): reveal message chrome on keyboard focus, not any focus `:focus-within` cannot tell a mouse click from a keyboard tab, so clicking any control inside a message row — a tool run's disclosure, most visibly — parked focus there and left the row's copy, scroll, and timestamp chrome showing after the pointer had moved away. The row read as permanently hovered. `:focus-visible` is the distinction the browser already computes, and it is the keyboard focus this reveal was written for in #19218. Measured in the running app: after a real click on a run header, `document.activeElement` is that button and `:focus-visible` is false, while `:focus-within` is true. * refactor(native-chat): spell the keyboard-focus reveal with the first-class has variant Swaps the hand-rolled arbitrary variant `group-[&:has(:focus-visible)]:` for Tailwind's `group-has-[:focus-visible]:`. Every other `has-` variant in the renderer is already spelled in the bracket form, including the same hover-plus-keyboard-focus reveal on the project header actions. Measured in the running app: both spellings compile to an equivalent selector and are identical in every state (idle, mouse click on a control inside the row with the pointer moved away, and each keyboard tab stop). Also asserts the pointer-events half of both reveals, which governs whether the copy button is clickable and had no coverage. * fix(native-chat): preserve message controls on touch devices --------- Co-authored-by: Merge Sim --- ...ver-reveal-touch-action-visibility.test.ts | 1 + .../NativeChatMessageList.test.tsx | 2 +- .../native-chat/NativeChatMessageRow.test.tsx | 20 +++++++++++++------ .../native-chat/NativeChatMessageRow.tsx | 6 +++--- 4 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/renderer/src/components/hover-reveal-touch-action-visibility.test.ts b/src/renderer/src/components/hover-reveal-touch-action-visibility.test.ts index 30c7d53b17b..d47c278582a 100644 --- a/src/renderer/src/components/hover-reveal-touch-action-visibility.test.ts +++ b/src/renderer/src/components/hover-reveal-touch-action-visibility.test.ts @@ -17,6 +17,7 @@ const HOVER_REVEAL_FILES = [ resolve(__dirname, 'editor/DiffSectionHeader.tsx'), resolve(__dirname, 'github-project/ProjectPicker.tsx'), resolve(__dirname, 'github-project/ProjectRow.tsx'), + resolve(__dirname, 'native-chat/NativeChatMessageRow.tsx'), resolve(__dirname, 'right-sidebar/AiVaultSessionRow.tsx'), resolve(__dirname, 'right-sidebar/ChecksPanel.tsx'), resolve(__dirname, 'right-sidebar/local-port-row.tsx'), diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.test.tsx index bdfc80c5f77..144ce1e87ec 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.test.tsx @@ -50,7 +50,7 @@ describe('NativeChatMessageList assistant messages', () => { const controls = copyButton.parentElement expect(row).toHaveClass('select-text') - expect(controls).toHaveClass('select-none', 'pointer-events-none', 'mt-1') + expect(controls).toHaveClass('select-none', 'can-hover:pointer-events-none', 'mt-1') expect(controls).not.toHaveClass('absolute') expect(prose.compareDocumentPosition(controls!)).toBe(Node.DOCUMENT_POSITION_FOLLOWING) }) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx index e74db58dd3f..b2a55abf154 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx @@ -23,7 +23,7 @@ function renderMessage(role: NativeChatMessage['role'], timestamp: number | null ) } -describe('MessageRow hover timestamps', () => { +describe('MessageRow control visibility', () => { it('appends time to the existing agent controls and inherits their reveal', () => { renderMessage('assistant') const copy = screen.getByRole('button', { name: 'Copy message' }) @@ -31,24 +31,32 @@ describe('MessageRow hover timestamps', () => { const time = screen.getByRole('time') expect(Array.from(copy.parentElement!.children)).toEqual([copy, scroll, time]) expect(copy.parentElement).toHaveClass( - 'opacity-0', + 'can-hover:opacity-0', + 'can-hover:pointer-events-none', 'group-hover:opacity-100', - 'group-focus-within:opacity-100' + 'group-has-[:focus-visible]:opacity-100', + 'group-hover:pointer-events-auto', + 'group-has-[:focus-visible]:pointer-events-auto' ) + expect(copy.parentElement).not.toHaveClass('opacity-0', 'pointer-events-none') expect(time).not.toHaveAttribute('tabindex') copy.focus() expect(copy).toHaveFocus() }) - it('gives user bubbles only a hover/focus timestamp', () => { + it('gives user bubbles a timestamp that only hides on hover-capable devices', () => { renderMessage('user') const time = screen.getByRole('time') expect(screen.queryByRole('button')).toBeNull() expect(time).toHaveClass( - 'opacity-0', + 'can-hover:opacity-0', + 'can-hover:pointer-events-none', 'group-hover:opacity-100', - 'group-focus-within:opacity-100' + 'group-has-[:focus-visible]:opacity-100', + 'group-hover:pointer-events-auto', + 'group-has-[:focus-visible]:pointer-events-auto' ) + expect(time).not.toHaveClass('opacity-0', 'pointer-events-none') expect(time.parentElement).toHaveClass('group') time.focus() expect(time).toHaveFocus() diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx index e30b46e594d..8fe5f47261a 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx @@ -153,7 +153,7 @@ export const MessageRow = memo(function MessageRow({ {deliveryFailed ? (
@@ -168,7 +168,7 @@ export const MessageRow = memo(function MessageRow({ } // Plain assistant prose is the copyable unit; reasoning/system asides stay - // chrome-free. The controls reveal on hover (and on keyboard focus-within). + // chrome-free. Controls reveal on hover/keyboard focus and stay visible on touch. const showControls = !isReasoning && !isSystem && markdown.length > 0 return ( @@ -212,7 +212,7 @@ export const MessageRow = memo(function MessageRow({ markdown={markdown} timestamp={message.timestamp} onScrollToTop={scrollToTop} - className="pointer-events-none mt-1 -mb-5 w-fit select-none opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100" + className="mt-1 -mb-5 w-fit select-none transition-opacity can-hover:pointer-events-none can-hover:opacity-0 group-hover:pointer-events-auto group-hover:opacity-100 group-has-[:focus-visible]:pointer-events-auto group-has-[:focus-visible]:opacity-100" /> ) : null}