From 90431a66f5278e88b8e1661b92553fdd4ed50c11 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Wed, 19 Aug 2026 11:41:44 +0100 Subject: [PATCH 1/7] fix(webapp): use native controls for inline actions --- apps/webapp/app/components/AskAI.tsx | 6 +- .../app/components/code/TSQLResultsTable.tsx | 59 +++++++++---------- .../components/primitives/ClipboardField.tsx | 9 +-- .../app/components/primitives/CopyButton.tsx | 6 +- .../components/primitives/CopyableText.tsx | 14 +++-- .../app/components/primitives/Table.tsx | 59 +++++++++---------- apps/webapp/app/components/runs/v3/RunTag.tsx | 20 ++++--- 7 files changed, 85 insertions(+), 88 deletions(-) diff --git a/apps/webapp/app/components/AskAI.tsx b/apps/webapp/app/components/AskAI.tsx index bc64a7e0c42..9404c2eff5d 100644 --- a/apps/webapp/app/components/AskAI.tsx +++ b/apps/webapp/app/components/AskAI.tsx @@ -544,7 +544,9 @@ function ChatInterface({ initialQuery }: { initialQuery?: string }) { {isGeneratingAnswer ? ( stopGeneration()} className="group relative z-10 flex size-10 min-w-10 cursor-pointer items-center justify-center" > @@ -553,7 +555,7 @@ function ChatInterface({ initialQuery }: { initialQuery?: string }) { className="absolute inset-0 animate-spin" hoverEffect /> - + } content="Stop generating" /> diff --git a/apps/webapp/app/components/code/TSQLResultsTable.tsx b/apps/webapp/app/components/code/TSQLResultsTable.tsx index 6da3caee0df..0f7afbe0ef9 100644 --- a/apps/webapp/app/components/code/TSQLResultsTable.tsx +++ b/apps/webapp/app/components/code/TSQLResultsTable.tsx @@ -856,37 +856,34 @@ function CopyableCell({ onMouseLeave={() => setIsHovered(false)} > {children} - {isHovered && ( - { - e.stopPropagation(); - e.preventDefault(); - copy(); - }} - className="absolute right-1 top-1/2 z-10 flex -translate-y-1/2 cursor-pointer" - > - - {copied ? ( - - ) : ( - - )} - - } - content={copied ? "Copied!" : "Copy"} - disableHoverableContent - /> - - )} + { + e.stopPropagation(); + e.preventDefault(); + copy(); + }} + className={cn( + "absolute right-1 top-1/2 z-10 flex size-6 -translate-y-1/2 items-center justify-center rounded border border-border-bright bg-background-hover transition-opacity focus:opacity-100", + isHovered ? "opacity-100" : "pointer-events-none opacity-0", + copied + ? "text-green-500" + : "text-text-dimmed hover:border-border-bright hover:bg-background-raised hover:text-text-bright" + )} + > + {copied ? ( + + ) : ( + + )} + + } + content={copied ? "Copied!" : "Copy"} + disableHoverableContent + /> ); } diff --git a/apps/webapp/app/components/primitives/ClipboardField.tsx b/apps/webapp/app/components/primitives/ClipboardField.tsx index 622c5bde430..24fdf4942b1 100644 --- a/apps/webapp/app/components/primitives/ClipboardField.tsx +++ b/apps/webapp/app/components/primitives/ClipboardField.tsx @@ -127,14 +127,7 @@ export function ClipboardField({ return ( - {icon && ( - inputIcon.current && inputIcon.current.focus()} - className="flex items-center pl-1" - > - {icon} - - )} + {icon && {icon}} )} - + ) : ( ); return ( @@ -72,13 +76,11 @@ export function CopyableText({ {value} e.stopPropagation()} className={cn( - "absolute top-0 z-10 size-6 font-sans", + "absolute top-0 z-10 flex size-6 font-sans transition-opacity focus-within:opacity-100", // Truncated values reserve a right gutter, so the button sits inside it truncate ? "right-0" : "-right-6", - isHovered ? "flex" : "hidden" + isHovered ? "opacity-100" : "pointer-events-none opacity-0" )} > {hideTooltip ? ( diff --git a/apps/webapp/app/components/primitives/Table.tsx b/apps/webapp/app/components/primitives/Table.tsx index 9ade6c34bc2..4763a504005 100644 --- a/apps/webapp/app/components/primitives/Table.tsx +++ b/apps/webapp/app/components/primitives/Table.tsx @@ -486,37 +486,34 @@ export const CopyableTableCell = forwardRef setIsHovered(false)} > {children} - {isHovered && ( - { - e.stopPropagation(); - e.preventDefault(); - copy(); - }} - className="absolute -right-2 top-1/2 z-10 flex -translate-y-1/2 cursor-pointer" - > - - {copied ? ( - - ) : ( - - )} - - } - content={copied ? "Copied!" : "Copy"} - disableHoverableContent - /> - - )} + { + e.stopPropagation(); + e.preventDefault(); + copy(); + }} + className={cn( + "absolute -right-2 top-1/2 z-10 flex size-6 -translate-y-1/2 items-center justify-center rounded border border-border-bright bg-background-hover transition-opacity focus:opacity-100", + isHovered ? "opacity-100" : "pointer-events-none opacity-0", + copied + ? "text-green-500" + : "text-text-dimmed hover:border-border-bright hover:bg-background-raised hover:text-text-bright" + )} + > + {copied ? ( + + ) : ( + + )} + + } + content={copied ? "Copied!" : "Copy"} + disableHoverableContent + /> ); diff --git a/apps/webapp/app/components/runs/v3/RunTag.tsx b/apps/webapp/app/components/runs/v3/RunTag.tsx index 16d2333632e..405e3b6a913 100644 --- a/apps/webapp/app/components/runs/v3/RunTag.tsx +++ b/apps/webapp/app/components/runs/v3/RunTag.tsx @@ -114,12 +114,14 @@ function CopyButton({ textToCopy, isHovered }: { textToCopy: string; isHovered: return ( e.stopPropagation()} className={cn( - "absolute -right-6 top-0 z-10 size-6 items-center justify-center rounded-r-sm border-y border-r border-border-bright bg-background-hover", - isHovered ? "flex" : "hidden", + "absolute -right-6 top-0 z-10 flex size-6 items-center justify-center rounded-r-sm border-y border-r border-border-bright bg-background-hover transition-opacity focus:opacity-100", + isHovered ? "opacity-100" : "pointer-events-none opacity-0", copied ? "text-green-500" : "text-text-dimmed hover:border-border-bright hover:bg-background-raised hover:text-text-bright" @@ -130,7 +132,7 @@ function CopyButton({ textToCopy, isHovered }: { textToCopy: string; isHovered: ) : ( )} - + } content={copied ? "Copied!" : "Copy tag"} disableHoverableContent @@ -159,17 +161,19 @@ function DeleteButton({ return ( e.stopPropagation()} className={cn( - "absolute -right-6 top-0 z-10 size-6 items-center justify-center rounded-r-sm border-y border-r border-border-bright bg-background-hover", - isHovered ? "flex" : "hidden", + "absolute -right-6 top-0 z-10 flex size-6 items-center justify-center rounded-r-sm border-y border-r border-border-bright bg-background-hover transition-opacity focus:opacity-100", + isHovered ? "opacity-100" : "pointer-events-none opacity-0", "text-text-dimmed hover:border-border-bright hover:bg-background-raised hover:text-rose-400" )} > - + } content="Remove tag" disableHoverableContent From 1cb13d2387a636babe0cb362770fe15feee92cf9 Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Wed, 19 Aug 2026 12:00:45 +0100 Subject: [PATCH 2/7] fix(webapp): preserve tooltip trigger semantics --- apps/webapp/app/components/AskAI.tsx | 1 + apps/webapp/app/components/code/TSQLResultsTable.tsx | 1 + .../components/dashboard-agent/tooltip-accessible-name.test.ts | 1 - apps/webapp/app/components/primitives/CopyButton.tsx | 1 + apps/webapp/app/components/primitives/Table.tsx | 1 + apps/webapp/app/components/runs/v3/RunTag.tsx | 2 ++ 6 files changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/components/AskAI.tsx b/apps/webapp/app/components/AskAI.tsx index 9404c2eff5d..0ac70cf2fb0 100644 --- a/apps/webapp/app/components/AskAI.tsx +++ b/apps/webapp/app/components/AskAI.tsx @@ -543,6 +543,7 @@ function ChatInterface({ initialQuery }: { initialQuery?: string }) { /> {isGeneratingAnswer ? ( {children} {children} Date: Wed, 19 Aug 2026 12:55:37 +0100 Subject: [PATCH 3/7] fix(webapp): keep tooltip controls keyboard accessible --- apps/webapp/app/components/AskAI.tsx | 1 + .../app/components/code/TSQLResultsTable.tsx | 1 + .../tooltip-accessible-name.test.ts | 12 +- .../app/components/primitives/CopyButton.tsx | 103 ++++++++++-------- .../components/primitives/CopyableText.tsx | 3 +- .../app/components/primitives/Table.tsx | 1 + apps/webapp/app/components/runs/v3/RunTag.tsx | 2 + 7 files changed, 73 insertions(+), 50 deletions(-) diff --git a/apps/webapp/app/components/AskAI.tsx b/apps/webapp/app/components/AskAI.tsx index 0ac70cf2fb0..96be9dd7075 100644 --- a/apps/webapp/app/components/AskAI.tsx +++ b/apps/webapp/app/components/AskAI.tsx @@ -544,6 +544,7 @@ function ChatInterface({ initialQuery }: { initialQuery?: string }) { {isGeneratingAnswer ? ( {children} ts.isJsxAttribute(p) && p.name.getText() === name); } +function hasStaticTrueAttribute(node: JsxNode, name: string): boolean { + const attribute = attrOf(node, name); + if (!attribute || !ts.isJsxAttribute(attribute)) return false; + if (!attribute.initializer) return true; + return ( + ts.isJsxExpression(attribute.initializer) && + attribute.initializer.expression?.kind === ts.SyntaxKind.TrueKeyword + ); +} + /** Text anywhere under the element, ignoring an expression that can render nothing. */ function hasText(node: TsNode): boolean { if (!ts.isJsxElement(node)) return false; @@ -178,7 +188,7 @@ function scanFile(file: string, relative: string): Violation[] { const initializer = buttonAttr && ts.isJsxAttribute(buttonAttr) ? buttonAttr.initializer : undefined; if (initializer && ts.isJsxExpression(initializer)) { - const asChild = !!attrOf(node, "asChild"); + const asChild = hasStaticTrueAttribute(node, "asChild"); for (const trigger of resolve(initializer.expression).flatMap(triggersIn)) { const named = !!attrOf(trigger, "aria-label") || diff --git a/apps/webapp/app/components/primitives/CopyButton.tsx b/apps/webapp/app/components/primitives/CopyButton.tsx index cb1b68178e8..2685f9ab6ed 100644 --- a/apps/webapp/app/components/primitives/CopyButton.tsx +++ b/apps/webapp/app/components/primitives/CopyButton.tsx @@ -44,61 +44,68 @@ export function CopyButton({ const { icon: iconSize, button: buttonSize } = sizes[size]; - const button = - variant === "icon" ? ( - - ) : ( - + if (variant === "button") { + return ( + + + ); + } - if (!showTooltip) return {button}; + const iconButton = ( + + ); + + if (!showTooltip) return {iconButton}; return ( )} diff --git a/apps/webapp/app/components/primitives/Table.tsx b/apps/webapp/app/components/primitives/Table.tsx index 88d790ffb20..f9136aacb0e 100644 --- a/apps/webapp/app/components/primitives/Table.tsx +++ b/apps/webapp/app/components/primitives/Table.tsx @@ -488,6 +488,7 @@ export const CopyableTableCell = forwardRef Date: Wed, 19 Aug 2026 13:38:09 +0100 Subject: [PATCH 4/7] fix(webapp): avoid mounting copy tooltips for every visible cell Only mount the tooltip subtree for a copyable cell while it is hovered; the plain button (with its aria-label) stays mounted at all times so keyboard users can always reach it. Also give the icon-only CopyButton an accessible name when it renders without children. --- .../app/components/code/TSQLResultsTable.tsx | 70 +++++++++++-------- .../app/components/primitives/CopyButton.tsx | 1 + .../app/components/primitives/Table.tsx | 69 ++++++++++-------- 3 files changed, 80 insertions(+), 60 deletions(-) diff --git a/apps/webapp/app/components/code/TSQLResultsTable.tsx b/apps/webapp/app/components/code/TSQLResultsTable.tsx index cea2cf90ecd..519177ebe8d 100644 --- a/apps/webapp/app/components/code/TSQLResultsTable.tsx +++ b/apps/webapp/app/components/code/TSQLResultsTable.tsx @@ -843,6 +843,35 @@ function CopyableCell({ const [isHovered, setIsHovered] = useState(false); const { copy, copied } = useCopy(value); + // The button (with its aria-label) stays mounted at all times so keyboard users can always + // reach it. The Radix tooltip subtree is comparatively expensive to keep alive for every + // visible cell of a virtualized grid, so it's only mounted while the cell is hovered - the + // tooltip is a hover affordance, not required for the button's accessible name. + const copyButton = ( + + ); + return (
setIsHovered(false)} > {children} - { - e.stopPropagation(); - e.preventDefault(); - copy(); - }} - className={cn( - "absolute right-1 top-1/2 z-10 flex size-6 -translate-y-1/2 items-center justify-center rounded border border-border-bright bg-background-hover transition-opacity focus:opacity-100", - isHovered ? "opacity-100" : "pointer-events-none opacity-0", - copied - ? "text-green-500" - : "text-text-dimmed hover:border-border-bright hover:bg-background-raised hover:text-text-bright" - )} - > - {copied ? ( - - ) : ( - - )} - - } - content={copied ? "Copied!" : "Copy"} - disableHoverableContent - /> + {isHovered ? ( + + ) : ( + copyButton + )}
); } diff --git a/apps/webapp/app/components/primitives/CopyButton.tsx b/apps/webapp/app/components/primitives/CopyButton.tsx index 2685f9ab6ed..88a663dfe27 100644 --- a/apps/webapp/app/components/primitives/CopyButton.tsx +++ b/apps/webapp/app/components/primitives/CopyButton.tsx @@ -52,6 +52,7 @@ export function CopyButton({ onClick={copy} className={cn("shrink-0", buttonClassName)} tooltip={showTooltip ? (copied ? "Copied!" : "Copy") : undefined} + aria-label={children ? undefined : copied ? "Copied" : "Copy"} LeadingIcon={ copied ? ( { + e.stopPropagation(); + e.preventDefault(); + copy(); + }} + className={cn( + "absolute -right-2 top-1/2 z-10 flex size-6 -translate-y-1/2 items-center justify-center rounded border border-border-bright bg-background-hover transition-opacity focus:opacity-100", + isHovered ? "opacity-100" : "pointer-events-none opacity-0", + copied + ? "text-green-500" + : "text-text-dimmed hover:border-border-bright hover:bg-background-raised hover:text-text-bright" + )} + > + {copied ? ( + + ) : ( + + )} + + ); + return (
setIsHovered(false)} > {children} - { - e.stopPropagation(); - e.preventDefault(); - copy(); - }} - className={cn( - "absolute -right-2 top-1/2 z-10 flex size-6 -translate-y-1/2 items-center justify-center rounded border border-border-bright bg-background-hover transition-opacity focus:opacity-100", - isHovered ? "opacity-100" : "pointer-events-none opacity-0", - copied - ? "text-green-500" - : "text-text-dimmed hover:border-border-bright hover:bg-background-raised hover:text-text-bright" - )} - > - {copied ? ( - - ) : ( - - )} - - } - content={copied ? "Copied!" : "Copy"} - disableHoverableContent - /> + {isHovered ? ( + + ) : ( + copyButton + )}
); From 5e1c6beaab1247a7f9f6c2113cf5edeabb6253ee Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Wed, 19 Aug 2026 14:07:14 +0100 Subject: [PATCH 5/7] fix(webapp): preserve focus across copy tooltips --- .../app/components/code/TSQLResultsTable.tsx | 28 +++++++++---------- .../components/primitives/ClipboardField.tsx | 12 +++++--- .../app/components/primitives/Table.tsx | 26 ++++++++--------- 3 files changed, 33 insertions(+), 33 deletions(-) diff --git a/apps/webapp/app/components/code/TSQLResultsTable.tsx b/apps/webapp/app/components/code/TSQLResultsTable.tsx index 519177ebe8d..9af69819309 100644 --- a/apps/webapp/app/components/code/TSQLResultsTable.tsx +++ b/apps/webapp/app/components/code/TSQLResultsTable.tsx @@ -843,10 +843,11 @@ function CopyableCell({ const [isHovered, setIsHovered] = useState(false); const { copy, copied } = useCopy(value); - // The button (with its aria-label) stays mounted at all times so keyboard users can always - // reach it. The Radix tooltip subtree is comparatively expensive to keep alive for every - // visible cell of a virtualized grid, so it's only mounted while the cell is hovered - the - // tooltip is a hover affordance, not required for the button's accessible name. + // The button (with its aria-label) always sits in the same position in the tree, wrapped by + // the same SimpleTooltip, so it is never unmounted/remounted on hover (which would drop + // keyboard focus). Only the tooltip's open state, not its mounted tree, follows hover; Radix + // still only renders the (comparatively expensive) tooltip content into the DOM of this + // virtualized grid while `open` is true. const copyButton = (