From 4da3a95d50f7285aeb2882ab0cc7d9aeb5040c29 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Mon, 21 Sep 2026 02:26:29 -0400
Subject: [PATCH] fix(native-chat): scope a tool row's hover reveal to that row
(#21918)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Hovering one tool call in a chat turn revealed the expand chevron on every
other row in the same message at once, so the whole message lit up and nothing
said which row the click would open.
The rows were reading a hover they do not own. Tailwind's unnamed
`group-hover:` is not nearest-ancestor scoped — it compiles to
`:is(:where(.group):hover *)`, which matches a hover on ANY `.group` ancestor.
`NativeChatMessageRow` wraps the whole assistant message in a bare `group` for
its own copy/timestamp reveal, so every collapsible row nested inside it
answered to that wrapper as well as to itself.
Each row now names its own group — `group/tool-line`, `group/tool-run`,
`group/subagent-run`, `group/diff-card` — which compiles to
`:is(:where(.group\/tool-line):hover *)` and reaches that row alone. The
message-row reveal is left bare on purpose: its copy button and timestamp are
meant to answer to a hover anywhere in the message.
`NativeChatDiffCard` is included for the same defect, not as extra scope: its
verb label was brightening on any hover in the message.
---
.../native-chat/NativeChatDiffCard.tsx | 4 +-
.../native-chat/NativeChatSubagentRun.tsx | 4 +-
.../native-chat/NativeChatToolLine.tsx | 11 ++--
.../native-chat/NativeChatToolRun.test.tsx | 58 +++++++++++++++++++
.../native-chat/NativeChatToolRun.tsx | 19 +++---
5 files changed, 78 insertions(+), 18 deletions(-)
diff --git a/src/renderer/src/components/native-chat/NativeChatDiffCard.tsx b/src/renderer/src/components/native-chat/NativeChatDiffCard.tsx
index 91d113f15e0..bc0f47f2260 100644
--- a/src/renderer/src/components/native-chat/NativeChatDiffCard.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatDiffCard.tsx
@@ -152,13 +152,13 @@ export function NativeChatDiffCard({
type="button"
onClick={() => hasBody && setExpanded(!expanded)}
className={cn(
- 'group flex w-full items-center gap-1.5 px-2 py-1 text-left',
+ 'group/diff-card flex w-full items-center gap-1.5 px-2 py-1 text-left',
hasBody ? 'cursor-pointer hover:bg-accent/30' : 'cursor-default'
)}
aria-expanded={hasBody ? expanded : undefined}
>
-
+
{verbLabel(file)}
{hasBody ? (
diff --git a/src/renderer/src/components/native-chat/NativeChatSubagentRun.tsx b/src/renderer/src/components/native-chat/NativeChatSubagentRun.tsx
index cbb25e624cd..1ea4852ab15 100644
--- a/src/renderer/src/components/native-chat/NativeChatSubagentRun.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatSubagentRun.tsx
@@ -208,7 +208,7 @@ export function NativeChatSubagentRun({
diff --git a/src/renderer/src/components/native-chat/NativeChatToolLine.tsx b/src/renderer/src/components/native-chat/NativeChatToolLine.tsx
index 883244d8f91..4984853268d 100644
--- a/src/renderer/src/components/native-chat/NativeChatToolLine.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatToolLine.tsx
@@ -72,7 +72,7 @@ export function NativeChatToolLine({
type="button"
onClick={() => hasDetail && setExpanded(!expanded)}
className={cn(
- 'group flex w-full items-center gap-1.5 py-0.5 text-left',
+ 'group/tool-line flex w-full items-center gap-1.5 py-0.5 text-left',
hasDetail ? 'cursor-pointer' : 'cursor-default'
)}
aria-expanded={hasDetail ? expanded : undefined}
@@ -89,12 +89,12 @@ export function NativeChatToolLine({
category to read from it. The empty slot keeps rows aligned. */
)}
-
+
{isCall ? : name}
{preview ? (
{preview}
@@ -102,11 +102,12 @@ export function NativeChatToolLine({
) : null}
{isCall ? : null}
{hasDetail ? (
- // Chevron stays hidden until this row is expanded.
+ // Hover reveal is keyed to this row's own named group: a bare `group`
+ // also answers to the message row's, lighting every chevron at once.
) : null}
diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx
index 6c043b92374..aee0d2a94e4 100644
--- a/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx
@@ -774,6 +774,64 @@ describe('NativeChatToolRun', () => {
expect(container.querySelector('.lucide-folder')).toBeInTheDocument()
expect(screen.getByTitle('ls')).toHaveTextContent('ls')
})
+
+ // A bare `group-hover:` matches a hover on ANY ancestor carrying `.group`, and
+ // the assistant message row around this run is one. Hovering a single tool line
+ // therefore revealed every chevron in the message at once.
+ describe('hover reveal', () => {
+ const run: NativeChatBlock[] = [
+ { type: 'tool-call', name: 'Bash', input: { command: 'ls -la' }, state: 'completed' },
+ { type: 'tool-result', output: 'a\nb' },
+ { type: 'tool-call', name: 'Bash', input: { command: 'pwd' }, state: 'completed' },
+ { type: 'tool-result', output: '/tmp' }
+ ]
+
+ /** Every node's class list, read through the attribute because an SVG's
+ * `className` is an `SVGAnimatedString` rather than a string. */
+ function hoverRevealed(container: HTMLElement): { element: Element; classes: string }[] {
+ return [...container.querySelectorAll('[class*="group-hover"]')].map((element) => ({
+ element,
+ classes: element.getAttribute('class') ?? ''
+ }))
+ }
+
+ it('scopes a row’s reveal to that row rather than the whole message', () => {
+ // Open run, collapsed children — the state the turn caret leaves behind.
+ const { container } = render(
+
+ )
+
+ const revealed = hoverRevealed(container)
+ expect(revealed.length).toBeGreaterThan(0)
+ revealed.forEach(({ classes }) => {
+ expect(classes).not.toMatch(/(^|\s)group-hover:/)
+ })
+ })
+
+ it('puts every reveal inside the row button that governs it', () => {
+ const { container } = render(
+
+ )
+
+ hoverRevealed(container).forEach(({ element, classes }) => {
+ const scope = /group-hover\/([a-z-]+):/.exec(classes)?.[1]
+ expect(scope).toBeDefined()
+ expect(element.closest(`.group\\/${scope}`)).not.toBeNull()
+ })
+ })
+
+ it('hides a collapsed chevron on every row until its own row is hovered', () => {
+ const { container } = render(
+
+ )
+
+ const chevrons = [...container.querySelectorAll('.pl-4 button svg.lucide-chevron-right')]
+ expect(chevrons.length).toBeGreaterThan(1)
+ chevrons.forEach((chevron) => {
+ expect(chevron.getAttribute('class')).toContain('group-hover/tool-line:opacity-100')
+ })
+ })
+ })
})
describe('NativeChatToolRun task lists', () => {
diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx
index 2f8eb76b8cf..6a6e26ebd37 100644
--- a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx
+++ b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx
@@ -214,7 +214,7 @@ export function NativeChatToolRun({