mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
fix(native-chat): scope a tool row's hover reveal to that row (#21918)
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.
This commit is contained in:
@@ -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}
|
||||
>
|
||||
<VerbIcon kind={file.changeKind} />
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground group-hover:text-foreground/80">
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground group-hover/diff-card:text-foreground/80">
|
||||
{verbLabel(file)}
|
||||
</span>
|
||||
{hasBody ? (
|
||||
|
||||
@@ -208,7 +208,7 @@ export function NativeChatSubagentRun({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className="group flex min-h-6 w-full items-center gap-1.5 rounded-md py-0.5 text-left text-sm leading-relaxed text-muted-foreground hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
|
||||
className="group/subagent-run flex min-h-6 w-full items-center gap-1.5 rounded-md py-0.5 text-left text-sm leading-relaxed text-muted-foreground hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
|
||||
aria-expanded={open}
|
||||
aria-live="polite"
|
||||
>
|
||||
@@ -240,7 +240,7 @@ export function NativeChatSubagentRun({
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'size-3.5 shrink-0 text-muted-foreground transition-all',
|
||||
open ? 'rotate-90 opacity-100' : 'opacity-0 group-hover:opacity-100'
|
||||
open ? 'rotate-90 opacity-100' : 'opacity-0 group-hover/subagent-run:opacity-100'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
@@ -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. */
|
||||
<span aria-hidden className="size-4 shrink-0" />
|
||||
)}
|
||||
<code className="min-w-0 truncate font-mono text-xs font-semibold text-foreground/90 transition-colors group-hover:text-foreground">
|
||||
<code className="min-w-0 truncate font-mono text-xs font-semibold text-foreground/90 transition-colors group-hover/tool-line:text-foreground">
|
||||
{isCall ? <NativeChatToolName name={name} mcpIdentity={block.mcpIdentity} /> : name}
|
||||
</code>
|
||||
{preview ? (
|
||||
<span
|
||||
className="min-w-0 truncate font-mono text-[11px] text-muted-foreground transition-colors group-hover:text-foreground/70"
|
||||
className="min-w-0 truncate font-mono text-[11px] text-muted-foreground transition-colors group-hover/tool-line:text-foreground/70"
|
||||
title={preview}
|
||||
>
|
||||
{preview}
|
||||
@@ -102,11 +102,12 @@ export function NativeChatToolLine({
|
||||
) : null}
|
||||
{isCall ? <NativeChatCommandMetadata block={block} /> : 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.
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'size-3.5 shrink-0 text-muted-foreground transition-all',
|
||||
expanded ? 'rotate-90 opacity-100' : 'opacity-0 group-hover:opacity-100'
|
||||
expanded ? 'rotate-90 opacity-100' : 'opacity-0 group-hover/tool-line:opacity-100'
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -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(
|
||||
<NativeChatToolRun blocks={run} expandSignal={false} expandOverride />
|
||||
)
|
||||
|
||||
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(
|
||||
<NativeChatToolRun blocks={run} expandSignal={false} expandOverride />
|
||||
)
|
||||
|
||||
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(
|
||||
<NativeChatToolRun blocks={run} expandSignal={false} expandOverride />
|
||||
)
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -214,7 +214,7 @@ export function NativeChatToolRun({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="group flex min-h-6 w-full items-center gap-1.5 rounded-md py-0.5 text-left text-sm leading-relaxed text-muted-foreground hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
|
||||
className="group/tool-run flex min-h-6 w-full items-center gap-1.5 rounded-md py-0.5 text-left text-sm leading-relaxed text-muted-foreground hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
|
||||
aria-expanded={open}
|
||||
aria-live="polite"
|
||||
>
|
||||
@@ -232,13 +232,13 @@ export function NativeChatToolRun({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="group flex min-h-6 w-full items-center gap-1.5 py-0.5 text-left"
|
||||
className="group/tool-run flex min-h-6 w-full items-center gap-1.5 py-0.5 text-left"
|
||||
aria-expanded={open}
|
||||
>
|
||||
{structuredActivityUi && settledHeaderIcon ? (
|
||||
<NativeChatToolRunIcon iconName={settledHeaderIcon} className="text-muted-foreground" />
|
||||
) : null}
|
||||
<span className="shrink-0 font-mono text-[11px] font-bold text-muted-foreground transition-colors group-hover:text-foreground/80">
|
||||
<span className="shrink-0 font-mono text-[11px] font-bold text-muted-foreground transition-colors group-hover/tool-run:text-foreground/80">
|
||||
{callCount}×
|
||||
</span>
|
||||
{summaryMembers.length > 0 ? (
|
||||
@@ -248,7 +248,7 @@ export function NativeChatToolRun({
|
||||
`browser.open` and `tools/read`. The list stays one line and
|
||||
truncates as a whole rather than wrapping into a block — a
|
||||
header that grows to three rows stops reading as a header. */}
|
||||
<span className="min-w-0 truncate font-mono text-[11px] text-muted-foreground transition-colors group-hover:text-foreground/80">
|
||||
<span className="min-w-0 truncate font-mono text-[11px] text-muted-foreground transition-colors group-hover/tool-run:text-foreground/80">
|
||||
{keyedSummaryMembers.map((member, index) => (
|
||||
<Fragment key={member.key}>
|
||||
{/* A real space, not just the margin: a CSS gap is invisible to
|
||||
@@ -273,7 +273,7 @@ export function NativeChatToolRun({
|
||||
{hiddenCallCount > 0 ? (
|
||||
/* Outside the truncating span, so the count of what is not shown
|
||||
survives a list the pane is too narrow to print. */
|
||||
<span className="shrink-0 font-mono text-[11px] text-muted-foreground transition-colors group-hover:text-foreground/80">
|
||||
<span className="shrink-0 font-mono text-[11px] text-muted-foreground transition-colors group-hover/tool-run:text-foreground/80">
|
||||
{translate(
|
||||
'components.native-chat.tool.moreCalls',
|
||||
NATIVE_CHAT_TOOL_ACTIVITY_COPY.moreCalls,
|
||||
@@ -283,7 +283,7 @@ export function NativeChatToolRun({
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<span className="min-w-0 truncate font-mono text-[11px] text-muted-foreground transition-colors group-hover:text-foreground/80">
|
||||
<span className="min-w-0 truncate font-mono text-[11px] text-muted-foreground transition-colors group-hover/tool-run:text-foreground/80">
|
||||
{fallbackLabel}
|
||||
</span>
|
||||
)}
|
||||
@@ -299,7 +299,7 @@ export function NativeChatToolRun({
|
||||
NATIVE_CHAT_TOOL_ACTIVITY_COPY.failedCallsLabel,
|
||||
{ value0: failedCallCount }
|
||||
)}
|
||||
className="shrink-0 font-mono text-[11px] text-muted-foreground transition-colors group-hover:text-foreground/80"
|
||||
className="shrink-0 font-mono text-[11px] text-muted-foreground transition-colors group-hover/tool-run:text-foreground/80"
|
||||
>
|
||||
{translate(
|
||||
'components.native-chat.tool.failedCount',
|
||||
@@ -312,11 +312,12 @@ export function NativeChatToolRun({
|
||||
{structuredActivityUi && runSucceeded ? (
|
||||
<Check aria-hidden className="size-3 shrink-0 text-muted-foreground" />
|
||||
) : null}
|
||||
{/* Chevron is revealed on hover when collapsed and points down when open. */}
|
||||
{/* Revealed on hover of this header alone — see NativeChatToolLine on
|
||||
why the group is named — and points down when open. */}
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'size-3.5 shrink-0 text-muted-foreground transition-all',
|
||||
open ? 'rotate-90 opacity-100' : 'opacity-0 group-hover:opacity-100'
|
||||
open ? 'rotate-90 opacity-100' : 'opacity-0 group-hover/tool-run:opacity-100'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user