authorgravatar for 73941998+UtkarshUsername@users.noreply.github.comUtkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> 2026-08-17 00:35:18+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-16 21:05:18+02:00
logfee10def1afb63cecd9a626aeba2bb063828cf22
tree71fd7f06e4fc7d25267bbd4541558d2646668c57
parentbab4b6f02b8bdaf15fd32636a97f69ff657cec50
signature Signed by PGP key B5690EEEBB952194

feat(lint): ban native title tooltips and migrate to styled Tooltip (#7209)


27 files changed, 730 insertions(+), 355 deletions(-)

apps/web/src/browser/BrowserDeviceToolbar.tsx+29-20
...@@ -12,6 +12,7 @@ import { useState } from "react";...@@ -12,6 +12,7 @@ import { useState } from "react";
1212
13import { Button } from "~/components/ui/button";13import { Button } from "~/components/ui/button";
14import { Input } from "~/components/ui/input";14import { Input } from "~/components/ui/input";
15import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
15import {16import {
16 Select,17 Select,
17 SelectGroup,18 SelectGroup,
...@@ -296,26 +297,34 @@ export function BrowserDeviceToolbar({...@@ -296,26 +297,34 @@ export function BrowserDeviceToolbar({
296 />297 />
297 </form>298 </form>
298299
299 <Button300 <Tooltip>
300 variant="ghost"301 <TooltipTrigger
301 size="icon-xs"302 render={
302 type="button"303 <Button
303 aria-label={304 variant="ghost"
304 aspectRatio === null ? "Lock viewport aspect ratio" : "Unlock viewport aspect ratio"305 size="icon-xs"
305 }306 type="button"
306 aria-pressed={aspectRatio !== null}307 aria-label={
307 title={aspectRatio === null ? "Lock aspect ratio" : "Unlock aspect ratio"}308 aspectRatio === null ? "Lock viewport aspect ratio" : "Unlock viewport aspect ratio"
308 className={cn(aspectRatio !== null && "bg-accent text-foreground")}309 }
309 disabled={pending || !customValid}310 aria-pressed={aspectRatio !== null}
310 onPointerDown={(event) => event.preventDefault()}311 className={cn(aspectRatio !== null && "bg-accent text-foreground")}
311 onClick={toggleAspectRatio}312 disabled={pending || !customValid}
312 >313 onPointerDown={(event) => event.preventDefault()}
313 {aspectRatio === null ? (314 onClick={toggleAspectRatio}
314 <Unlink2 className={cn(aspectRatio !== null && "text-foreground")} />315 />
315 ) : (316 }
316 <Link2 className={cn(aspectRatio !== null && "text-foreground")} />317 >
317 )}318 {aspectRatio === null ? (
318 </Button>319 <Unlink2 className={cn(aspectRatio !== null && "text-foreground")} />
320 ) : (
321 <Link2 className={cn(aspectRatio !== null && "text-foreground")} />
322 )}
323 </TooltipTrigger>
324 <TooltipPopup side="top">
325 {aspectRatio === null ? "Lock aspect ratio" : "Unlock aspect ratio"}
326 </TooltipPopup>
327 </Tooltip>
319 <Button328 <Button
320 variant="ghost"329 variant="ghost"
321 size="icon-xs"330 size="icon-xs"
apps/web/src/components/ChatView.tsx-1
...@@ -6348,7 +6348,6 @@ function ChatViewContent(props: ChatViewProps) {...@@ -6348,7 +6348,6 @@ function ChatViewContent(props: ChatViewProps) {
6348 >6348 >
6349 <Button6349 <Button
6350 aria-label="Scroll to end"6350 aria-label="Scroll to end"
6351 title="Scroll to end"
6352 onClick={() => scrollToEnd(true)}6351 onClick={() => scrollToEnd(true)}
6353 className="pointer-events-auto gap-1.5 rounded-full px-3 text-muted-foreground hover:text-foreground"6352 className="pointer-events-auto gap-1.5 rounded-full px-3 text-muted-foreground hover:text-foreground"
6354 size="xs"6353 size="xs"
apps/web/src/components/ConnectionStatusDot.tsx-1
...@@ -59,7 +59,6 @@ export function ConnectionStatusDot({...@@ -59,7 +59,6 @@ export function ConnectionStatusDot({
59 const dot = (59 const dot = (
60 <button60 <button
61 type="button"61 type="button"
62 title={tooltipText}
63 aria-label={tooltipText}62 aria-label={tooltipText}
64 className="relative flex size-3 shrink-0 cursor-help items-center justify-center rounded-full outline-hidden"63 className="relative flex size-3 shrink-0 cursor-help items-center justify-center rounded-full outline-hidden"
65 >64 >
apps/web/src/components/DiffPanel.tsx+25-9
...@@ -583,11 +583,19 @@ export default function DiffPanel({...@@ -583,11 +583,19 @@ export default function DiffPanel({
583 {selectedTurnId === null && selectedGitScope === "branch" && selectedGitSource?.baseRef && (583 {selectedTurnId === null && selectedGitScope === "branch" && selectedGitSource?.baseRef && (
584 <div584 <div
585 className="flex min-w-0 max-w-full items-center gap-2 overflow-hidden text-xs text-muted-foreground"585 className="flex min-w-0 max-w-full items-center gap-2 overflow-hidden text-xs text-muted-foreground"
586 title={`${selectedGitSource.headRef ?? "HEAD"} → ${selectedGitSource.baseRef}`}
587 aria-label={`Comparing ${selectedGitSource.headRef ?? "HEAD"} against ${selectedGitSource.baseRef}`}586 aria-label={`Comparing ${selectedGitSource.headRef ?? "HEAD"} against ${selectedGitSource.baseRef}`}
588 >587 >
589 <span className="min-w-0 max-w-48 truncate">{selectedGitSource.headRef ?? "HEAD"}</span>588 <Tooltip>
590 <ArrowRightIcon className="size-3.5 shrink-0 opacity-70" />589 <TooltipTrigger render={<span className="flex min-w-0 items-center gap-2" />}>
590 <span className="min-w-0 max-w-48 truncate">
591 {selectedGitSource.headRef ?? "HEAD"}
592 </span>
593 <ArrowRightIcon className="size-3.5 shrink-0 opacity-70" />
594 </TooltipTrigger>
595 <TooltipPopup side="top">
596 {`${selectedGitSource.headRef ?? "HEAD"} → ${selectedGitSource.baseRef}`}
597 </TooltipPopup>
598 </Tooltip>
591 <Combobox599 <Combobox
592 items={baseRefItems}600 items={baseRefItems}
593 filteredItems={filteredBaseRefItems}601 filteredItems={filteredBaseRefItems}
...@@ -677,12 +685,20 @@ export default function DiffPanel({...@@ -677,12 +685,20 @@ export default function DiffPanel({
677 />685 />
678 </div>686 </div>
679 ) : choice.remote ? (687 ) : choice.remote ? (
680 <span688 <Tooltip>
681 className="flex justify-end text-muted-foreground"689 <TooltipTrigger
682 title="Remote only"690 render={
683 >691 <span className="flex justify-end text-muted-foreground">
684 <CheckIcon aria-hidden="true" className="size-3" />692 <CheckIcon
685 </span>693 role="img"
694 aria-label="Remote only"
695 className="size-3"
696 />
697 </span>
698 }
699 />
700 <TooltipPopup side="top">Remote only</TooltipPopup>
701 </Tooltip>
686 ) : null}702 ) : null}
687 </div>703 </div>
688 </ComboboxItem>704 </ComboboxItem>
apps/web/src/components/ServerUpdateAction.tsx+7-3
...@@ -11,6 +11,7 @@ import { useAtomCommand } from "~/state/use-atom-command";...@@ -11,6 +11,7 @@ import { useAtomCommand } from "~/state/use-atom-command";
11import { manualServerUpdateCommand } from "~/versionSkew";11import { manualServerUpdateCommand } from "~/versionSkew";
12import { Button } from "./ui/button";12import { Button } from "./ui/button";
13import { toastManager } from "./ui/toast";13import { toastManager } from "./ui/toast";
14import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
1415
15// The wire "installing" stage is a sub-second launcher handoff, so the UI16// The wire "installing" stage is a sub-second launcher handoff, so the UI
16// folds it into the download phase; everything after the handoff is the17// folds it into the download phase; everything after the handoff is the
...@@ -45,9 +46,12 @@ export function ServerUpdateProgress({...@@ -45,9 +46,12 @@ export function ServerUpdateProgress({
45 return (46 return (
46 <div className="mt-1 flex min-w-0 items-center gap-2 text-xs text-destructive" role="alert">47 <div className="mt-1 flex min-w-0 items-center gap-2 text-xs text-destructive" role="alert">
47 <span className="size-1.5 shrink-0 rounded-full bg-destructive" aria-hidden="true" />48 <span className="size-1.5 shrink-0 rounded-full bg-destructive" aria-hidden="true" />
48 <span className="min-w-0 truncate" title={state.message}>49 <Tooltip>
49 {state.message}50 <TooltipTrigger render={<span className="min-w-0 truncate">{state.message}</span>} />
50 </span>51 <TooltipPopup side="top" className="max-w-80">
52 {state.message}
53 </TooltipPopup>
54 </Tooltip>
51 </div>55 </div>
52 );56 );
53 }57 }
apps/web/src/components/Sidebar.tsx+50-32
...@@ -537,15 +537,21 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {...@@ -537,15 +537,21 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
537 {props.projectTitle}537 {props.projectTitle}
538 </span>538 </span>
539 <span className="ml-auto flex h-5 min-w-5 shrink-0 items-center justify-end">539 <span className="ml-auto flex h-5 min-w-5 shrink-0 items-center justify-end">
540 <button540 <Tooltip>
541 type="button"541 <TooltipTrigger
542 aria-label="Discard draft"542 render={
543 title="Discard draft"543 <button
544 onClick={handleDiscard}544 type="button"
545 className="pointer-events-none inline-flex cursor-pointer items-center rounded-md bg-transparent px-1 text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/sidebar-row:pointer-events-auto group-hover/sidebar-row:opacity-100"545 aria-label="Discard draft"
546 >546 onClick={handleDiscard}
547 <XIcon className="size-3" />547 className="pointer-events-none inline-flex cursor-pointer items-center rounded-md bg-transparent px-1 text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/sidebar-row:pointer-events-auto group-hover/sidebar-row:opacity-100"
548 </button>548 >
549 <XIcon className="size-3" />
550 </button>
551 }
552 />
553 <TooltipPopup side="top">Discard draft</TooltipPopup>
554 </Tooltip>
549 </span>555 </span>
550 </div>556 </div>
551 <div className="mt-0.5 truncate text-sm font-medium text-foreground/90">{preview}</div>557 <div className="mt-0.5 truncate text-sm font-medium text-foreground/90">{preview}</div>
...@@ -1247,16 +1253,22 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1247,16 +1253,22 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1247 ) : isWoke ? (1253 ) : isWoke ? (
1248 // A wake can land straight in the settled tail (e.g. PR1254 // A wake can land straight in the settled tail (e.g. PR
1249 // merged while snoozed); the signal must survive the trip.1255 // merged while snoozed); the signal must survive the trip.
1250 <button1256 <Tooltip>
1251 type="button"1257 <TooltipTrigger
1252 aria-label="Dismiss Woke notification"1258 render={
1253 title="Dismiss Woke notification"1259 <button
1254 onClick={handleAcknowledgeWokeClick}1260 type="button"
1255 className="inline-flex cursor-pointer items-center gap-1 rounded-sm text-xs font-medium text-amber-700 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring dark:text-amber-300"1261 aria-label="Dismiss Woke notification"
1256 >1262 onClick={handleAcknowledgeWokeClick}
1257 <AlarmClockIcon aria-hidden className="size-3" />1263 className="inline-flex cursor-pointer items-center gap-1 rounded-sm text-xs font-medium text-amber-700 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring dark:text-amber-300"
1258 <span role="status">Woke</span>1264 >
1259 </button>1265 <AlarmClockIcon aria-hidden className="size-3" />
1266 <span role="status">Woke</span>
1267 </button>
1268 }
1269 />
1270 <TooltipPopup side="top">Dismiss Woke notification</TooltipPopup>
1271 </Tooltip>
1260 ) : (1272 ) : (
1261 <span className="text-xs">1273 <span className="text-xs">
1262 {variantAction === "unsettle"1274 {variantAction === "unsettle"
...@@ -1414,19 +1426,25 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {...@@ -1414,19 +1426,25 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
1414 >1426 >
1415 {topStatus ? (1427 {topStatus ? (
1416 isWokeStatus ? (1428 isWokeStatus ? (
1417 <button1429 <Tooltip>
1418 type="button"1430 <TooltipTrigger
1419 aria-label="Dismiss Woke notification"1431 render={
1420 title="Dismiss Woke notification"1432 <button
1421 onClick={handleAcknowledgeWokeClick}1433 type="button"
1422 className={cn(1434 aria-label="Dismiss Woke notification"
1423 "inline-flex cursor-pointer items-center gap-1 rounded-sm font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring",1435 onClick={handleAcknowledgeWokeClick}
1424 topStatus.className,1436 className={cn(
1425 )}1437 "inline-flex cursor-pointer items-center gap-1 rounded-sm font-medium outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring",
1426 >1438 topStatus.className,
1427 <AlarmClockIcon aria-hidden className="size-4 shrink-0" />1439 )}
1428 <span role="status">{topStatus.label}</span>1440 >
1429 </button>1441 <AlarmClockIcon aria-hidden className="size-4 shrink-0" />
1442 <span role="status">{topStatus.label}</span>
1443 </button>
1444 }
1445 />
1446 <TooltipPopup side="top">Dismiss Woke notification</TooltipPopup>
1447 </Tooltip>
1430 ) : (1448 ) : (
1431 <span1449 <span
1432 className={cn(1450 className={cn(
apps/web/src/components/chat/ChangedFilesTree.tsx+20-15
...@@ -174,21 +174,26 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {...@@ -174,21 +174,26 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
174 </p>174 </p>
175 <div className="mt-2 flex flex-wrap items-center gap-1.5">175 <div className="mt-2 flex flex-wrap items-center gap-1.5">
176 {previewFiles.map((file) => (176 {previewFiles.map((file) => (
177 <button177 <Tooltip key={file.path}>
178 key={file.path}178 <TooltipTrigger
179 type="button"179 render={
180 title={file.path}180 <button
181 className="inline-flex max-w-48 items-center gap-1 rounded-md border border-border/70 bg-background/45 px-1.5 py-1 font-mono text-[10px] text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"181 type="button"
182 onClick={() => onOpenTurnDiff(turnId, file.path)}182 className="inline-flex max-w-48 items-center gap-1 rounded-md border border-border/70 bg-background/45 px-1.5 py-1 font-mono text-[10px] text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
183 >183 onClick={() => onOpenTurnDiff(turnId, file.path)}
184 <PierreEntryIcon184 />
185 pathValue={file.path}185 }
186 kind="file"186 >
187 theme={resolvedTheme}187 <PierreEntryIcon
188 className="size-3 shrink-0 text-muted-foreground/70"188 pathValue={file.path}
189 />189 kind="file"
190 <span className="truncate">{changedFileName(file.path)}</span>190 theme={resolvedTheme}
191 </button>191 className="size-3 shrink-0 text-muted-foreground/70"
192 />
193 <span className="truncate">{changedFileName(file.path)}</span>
194 </TooltipTrigger>
195 <TooltipPopup side="top">{file.path}</TooltipPopup>
196 </Tooltip>
192 ))}197 ))}
193 <button198 <button
194 type="button"199 type="button"
apps/web/src/components/chat/ComposerPreviewAnnotationCards.test.tsx+3-2
...@@ -37,8 +37,9 @@ describe("ComposerPreviewAnnotationCards", () => {...@@ -37,8 +37,9 @@ describe("ComposerPreviewAnnotationCards", () => {
37 );37 );
3838
39 expect(markup).toContain("Make this headline feel intentional.");39 expect(markup).toContain("Make this headline feel intentional.");
40 expect(markup).toContain('title="1 region"');40 expect(markup.match(/data-slot="tooltip-trigger"/g)).toHaveLength(2);
41 expect(markup).toContain('title="1 style change"');41 expect(markup).not.toContain('title="1 region"');
42 expect(markup).not.toContain('title="1 style change"');
42 expect(markup).not.toContain("Welcome");43 expect(markup).not.toContain("Welcome");
43 expect(markup).not.toContain("localhost:3000");44 expect(markup).not.toContain("localhost:3000");
44 expect(markup).not.toContain("Preview annotation");45 expect(markup).not.toContain("Preview annotation");
apps/web/src/components/chat/ComposerPreviewAnnotationCards.tsx+13-7
...@@ -6,6 +6,7 @@ import type { ComposerImageAttachment } from "~/composerDraftStore";...@@ -6,6 +6,7 @@ import type { ComposerImageAttachment } from "~/composerDraftStore";
6import { formatElementContextLabel, normalizeElementContextSelection } from "~/lib/elementContext";6import { formatElementContextLabel, normalizeElementContextSelection } from "~/lib/elementContext";
7import { cn } from "~/lib/utils";7import { cn } from "~/lib/utils";
8import { Button } from "../ui/button";8import { Button } from "../ui/button";
9import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
910
10interface ComposerPreviewAnnotationCardsProps {11interface ComposerPreviewAnnotationCardsProps {
11 annotations: ReadonlyArray<PreviewAnnotationPayload>;12 annotations: ReadonlyArray<PreviewAnnotationPayload>;
...@@ -16,14 +17,19 @@ interface ComposerPreviewAnnotationCardsProps {...@@ -16,14 +17,19 @@ interface ComposerPreviewAnnotationCardsProps {
16}17}
1718
18function TargetStat(props: { icon: ReactNode; count: number; label: string }) {19function TargetStat(props: { icon: ReactNode; count: number; label: string }) {
20 const tooltipText = `${props.count} ${props.label}${props.count === 1 ? "" : "s"}`;
19 return (21 return (
20 <span22 <Tooltip>
21 className="inline-flex items-center gap-1 text-[10px] font-medium text-muted-foreground"23 <TooltipTrigger
22 title={`${props.count} ${props.label}${props.count === 1 ? "" : "s"}`}24 render={
23 >25 <span className="inline-flex items-center gap-1 text-[10px] font-medium text-muted-foreground">
24 {props.icon}26 {props.icon}
25 {props.count}27 {props.count}
26 </span>28 </span>
29 }
30 />
31 <TooltipPopup side="top">{tooltipText}</TooltipPopup>
32 </Tooltip>
27 );33 );
28}34}
2935
apps/web/src/components/chat/DraftHeroHeadline.tsx+26-10
...@@ -23,6 +23,7 @@ import {...@@ -23,6 +23,7 @@ import {
23 MenuSeparator,23 MenuSeparator,
24 MenuTrigger,24 MenuTrigger,
25} from "../ui/menu";25} from "../ui/menu";
26import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
2627
27interface DraftHeroHeadlineProps {28interface DraftHeroHeadlineProps {
28 readonly activeProjectRef: ScopedProjectRef | null;29 readonly activeProjectRef: ScopedProjectRef | null;
...@@ -99,13 +100,23 @@ export function DraftHeroHeadline({...@@ -99,13 +100,23 @@ export function DraftHeroHeadline({
99100
100 const projectSelector = shouldShowProjectMenu ? (101 const projectSelector = shouldShowProjectMenu ? (
101 <Menu>102 <Menu>
102 <MenuTrigger103 <Tooltip>
103 aria-label={hasResolvedProject ? "Change project" : "Choose a project"}104 <TooltipTrigger
104 className="pointer-events-auto inline-block max-w-64 truncate border-foreground/60 border-b border-dotted align-baseline text-foreground transition-colors hover:border-foreground/80 focus-visible:rounded-sm focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"105 render={
105 title={activeProjectDisplayName ?? undefined}106 <MenuTrigger
106 >107 aria-label={hasResolvedProject ? "Change project" : "Choose a project"}
107 {activeProjectDisplayName ?? "Choose a project"}108 className="pointer-events-auto inline-block max-w-64 truncate border-foreground/60 border-b border-dotted align-baseline text-foreground transition-colors hover:border-foreground/80 focus-visible:rounded-sm focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
108 </MenuTrigger>109 />
110 }
111 >
112 {activeProjectDisplayName ?? "Choose a project"}
113 </TooltipTrigger>
114 {activeProjectDisplayName ? (
115 <TooltipPopup side="top" className="max-w-80">
116 {activeProjectDisplayName}
117 </TooltipPopup>
118 ) : null}
119 </Tooltip>
109 <MenuPopup align="center" className="max-h-80 min-w-40! w-max max-w-64 overflow-y-auto">120 <MenuPopup align="center" className="max-h-80 min-w-40! w-max max-w-64 overflow-y-auto">
110 <MenuRadioGroup121 <MenuRadioGroup
111 value={activeProjectKey}122 value={activeProjectKey}
...@@ -126,9 +137,14 @@ export function DraftHeroHeadline({...@@ -126,9 +137,14 @@ export function DraftHeroHeadline({
126 {projectPickerEntries.map(({ group }) => {137 {projectPickerEntries.map(({ group }) => {
127 return (138 return (
128 <MenuRadioItem key={group.projectKey} value={group.projectKey} closeOnClick>139 <MenuRadioItem key={group.projectKey} value={group.projectKey} closeOnClick>
129 <span className="block min-w-0 truncate" title={group.displayName}>140 <Tooltip>
130 {group.displayName}141 <TooltipTrigger render={<span className="block min-w-0 truncate" />}>
131 </span>142 {group.displayName}
143 </TooltipTrigger>
144 <TooltipPopup side="top" className="max-w-80">
145 {group.displayName}
146 </TooltipPopup>
147 </Tooltip>
132 </MenuRadioItem>148 </MenuRadioItem>
133 );149 );
134 })}150 })}
apps/web/src/components/files/FilePreviewPanel.tsx+19-11
...@@ -880,17 +880,25 @@ export default function FilePreviewPanel({...@@ -880,17 +880,25 @@ export default function FilePreviewPanel({
880 {index > 0 ? (880 {index > 0 ? (
881 <ChevronRight className="mx-1 size-3.5 shrink-0 text-muted-foreground/60" />881 <ChevronRight className="mx-1 size-3.5 shrink-0 text-muted-foreground/60" />
882 ) : null}882 ) : null}
883 <span883 <Tooltip>
884 className={cn(884 <TooltipTrigger
885 "max-w-40 truncate",885 render={
886 crumb.kind === "file"886 <span
887 ? "font-medium text-foreground"887 className={cn(
888 : "text-muted-foreground",888 "max-w-40 truncate",
889 )}889 crumb.kind === "file"
890 title={crumb.path || projectName}890 ? "font-medium text-foreground"
891 >891 : "text-muted-foreground",
892 {crumb.label}892 )}
893 </span>893 />
894 }
895 >
896 {crumb.label}
897 </TooltipTrigger>
898 <TooltipPopup side="top" className="max-w-80">
899 {crumb.path || projectName}
900 </TooltipPopup>
901 </Tooltip>
894 </div>902 </div>
895 ))}903 ))}
896 </div>904 </div>
apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx+57-39
...@@ -8,6 +8,7 @@ import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot";...@@ -8,6 +8,7 @@ import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot";
8import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";8import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";
9import { Button } from "~/components/ui/button";9import { Button } from "~/components/ui/button";
10import { toastManager } from "~/components/ui/toast";10import { toastManager } from "~/components/ui/toast";
11import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
11import { useThreadPreviewState } from "~/previewStateStore";12import { useThreadPreviewState } from "~/previewStateStore";
12import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore";13import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore";
13import { useRightPanelStore } from "~/rightPanelStore";14import { useRightPanelStore } from "~/rightPanelStore";
...@@ -254,45 +255,63 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props...@@ -254,45 +255,63 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props
254 onPointerUp={endDrag}255 onPointerUp={endDrag}
255 onPointerCancel={endDrag}256 onPointerCancel={endDrag}
256 >257 >
257 <Button258 <Tooltip>
258 variant="ghost"259 <TooltipTrigger
259 size="icon-xs"260 render={
260 aria-label="Open preview in right panel"261 <Button
261 title="Open in right panel"262 variant="ghost"
262 onPointerDown={(event) => event.stopPropagation()}263 size="icon-xs"
263 onClick={openInPanel}264 aria-label="Open preview in right panel"
264 >265 onPointerDown={(event) => event.stopPropagation()}
265 <PanelRightIcon />266 onClick={openInPanel}
266 </Button>267 />
267 <Button268 }
268 variant={desktopOverlay?.pictureInPicture ? "secondary" : "ghost"}269 >
269 size="icon-xs"270 <PanelRightIcon />
270 aria-label={271 </TooltipTrigger>
271 desktopOverlay?.pictureInPicture272 <TooltipPopup side="top">Open in right panel</TooltipPopup>
272 ? "Close popped-out preview"273 </Tooltip>
273 : "Pop preview into separate window"274 <Tooltip>
274 }275 <TooltipTrigger
275 title={276 render={
276 desktopOverlay?.pictureInPicture277 <Button
278 variant={desktopOverlay?.pictureInPicture ? "secondary" : "ghost"}
279 size="icon-xs"
280 aria-label={
281 desktopOverlay?.pictureInPicture
282 ? "Close popped-out preview"
283 : "Pop preview into separate window"
284 }
285 disabled={!desktopOverlay?.hasWebContents}
286 onPointerDown={(event) => event.stopPropagation()}
287 onClick={toggleNativePictureInPicture}
288 />
289 }
290 >
291 <PictureInPicture2 />
292 </TooltipTrigger>
293 <TooltipPopup side="top">
294 {desktopOverlay?.pictureInPicture
277 ? "Close separate window"295 ? "Close separate window"
278 : "Pop into separate window"296 : "Pop into separate window"}
279 }297 </TooltipPopup>
280 disabled={!desktopOverlay?.hasWebContents}298 </Tooltip>
281 onPointerDown={(event) => event.stopPropagation()}299 <Tooltip>
282 onClick={toggleNativePictureInPicture}300 <TooltipTrigger
283 >301 render={
284 <PictureInPicture2 />302 <Button
285 </Button>303 variant="ghost"
286 <Button304 size="icon-xs"
287 variant="ghost"305 aria-label="Close floating preview"
288 size="icon-xs"306 onPointerDown={(event) => event.stopPropagation()}
289 aria-label="Close floating preview"307 onClick={close}
290 title="Close floating preview"308 />
291 onPointerDown={(event) => event.stopPropagation()}309 }
292 onClick={close}310 >
293 >311 <XIcon />
294 <XIcon />312 </TooltipTrigger>
295 </Button>313 <TooltipPopup side="top">Close floating preview</TooltipPopup>
314 </Tooltip>
296 </div>315 </div>
297 </div>316 </div>
298317
...@@ -319,7 +338,6 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props...@@ -319,7 +338,6 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props
319 <button338 <button
320 type="button"339 type="button"
321 aria-label="Resize floating preview"340 aria-label="Resize floating preview"
322 title="Resize floating preview"
323 className="pointer-events-auto absolute bottom-0 right-0 z-[33] size-5 cursor-nwse-resize rounded-br-xl after:absolute after:bottom-1 after:right-1 after:size-2 after:border-b after:border-r after:border-foreground/45"341 className="pointer-events-auto absolute bottom-0 right-0 z-[33] size-5 cursor-nwse-resize rounded-br-xl after:absolute after:bottom-1 after:right-1 after:size-2 after:border-b after:border-r after:border-foreground/45"
324 onPointerDown={handleResizePointerDown}342 onPointerDown={handleResizePointerDown}
325 onPointerMove={handleResizePointerMove}343 onPointerMove={handleResizePointerMove}
apps/web/src/components/pullRequest/PullRequestChecksPopover.tsx+7-3
...@@ -11,6 +11,7 @@ import { pullRequestEnvironment } from "~/state/pullRequests";...@@ -11,6 +11,7 @@ import { pullRequestEnvironment } from "~/state/pullRequests";
11import { useEnvironmentQuery } from "~/state/query";11import { useEnvironmentQuery } from "~/state/query";
1212
13import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";13import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
14import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
14import {15import {
15 PullRequestCheckStatusIcon,16 PullRequestCheckStatusIcon,
16 pullRequestCheckStatusLabel,17 pullRequestCheckStatusLabel,
...@@ -57,9 +58,12 @@ function ChecksBody({ checks }: { checks: ReadonlyArray<PullRequestCheck> }) {...@@ -57,9 +58,12 @@ function ChecksBody({ checks }: { checks: ReadonlyArray<PullRequestCheck> }) {
57 {checks.map((check, index) => (58 {checks.map((check, index) => (
58 <li key={`${index}:${check.name}`} className="flex items-center gap-2 text-xs">59 <li key={`${index}:${check.name}`} className="flex items-center gap-2 text-xs">
59 <PullRequestCheckStatusIcon status={check.status} />60 <PullRequestCheckStatusIcon status={check.status} />
60 <span className="min-w-0 flex-1 truncate" title={check.description ?? check.name}>61 <Tooltip>
61 {check.name}62 <TooltipTrigger
62 </span>63 render={<span className="min-w-0 flex-1 truncate">{check.name}</span>}
64 />
65 <TooltipPopup side="top">{check.description ?? check.name}</TooltipPopup>
66 </Tooltip>
63 <span className="shrink-0 text-muted-foreground">67 <span className="shrink-0 text-muted-foreground">
64 {pullRequestCheckStatusLabel(check.status)}68 {pullRequestCheckStatusLabel(check.status)}
65 </span>69 </span>
apps/web/src/components/pullRequest/PullRequestCodeTab.tsx+14-6
...@@ -1006,9 +1006,12 @@ export function PullRequestCodeTab({...@@ -1006,9 +1006,12 @@ export function PullRequestCodeTab({
1006 >1006 >
1007 {/* Headlines run long, and the abbreviated oid after one is what a reader1007 {/* Headlines run long, and the abbreviated oid after one is what a reader
1008 matches against the commit list on the host. */}1008 matches against the commit list on the host. */}
1009 <span className="min-w-0 truncate" title={entry.messageHeadline}>1009 <Tooltip>
1010 {entry.messageHeadline}1010 <TooltipTrigger
1011 </span>1011 render={<span className="min-w-0 truncate">{entry.messageHeadline}</span>}
1012 />
1013 <TooltipPopup side="top">{entry.messageHeadline}</TooltipPopup>
1014 </Tooltip>
1012 <span className="ml-auto shrink-0 font-mono text-xs text-muted-foreground">1015 <span className="ml-auto shrink-0 font-mono text-xs text-muted-foreground">
1013 {entry.oid.slice(0, 7)}1016 {entry.oid.slice(0, 7)}
1014 </span>1017 </span>
...@@ -1264,9 +1267,14 @@ export function PullRequestCodeTab({...@@ -1264,9 +1267,14 @@ export function PullRequestCodeTab({
1264 <div className="max-h-64 space-y-3 overflow-auto px-4 pb-3">1267 <div className="max-h-64 space-y-3 overflow-auto px-4 pb-3">
1265 {[...orphanFiles].map(([path, threads]) => (1268 {[...orphanFiles].map(([path, threads]) => (
1266 <div key={path}>1269 <div key={path}>
1267 <p className="truncate px-3 text-xs text-muted-foreground" title={path}>1270 <Tooltip>
1268 {path}1271 <TooltipTrigger
1269 </p>1272 render={
1273 <p className="truncate px-3 text-xs text-muted-foreground">{path}</p>
1274 }
1275 />
1276 <TooltipPopup side="top">{path}</TooltipPopup>
1277 </Tooltip>
1270 <div className="mt-1 space-y-2">1278 <div className="mt-1 space-y-2">
1271 {threads.map((thread) => (1279 {threads.map((thread) => (
1272 <div key={thread.id}>1280 <div key={thread.id}>
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+129-78
...@@ -85,6 +85,7 @@ import {...@@ -85,6 +85,7 @@ import {
85} from "../ui/menu";85} from "../ui/menu";
86import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";86import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
87import { toastManager } from "../ui/toast";87import { toastManager } from "../ui/toast";
88import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
88import { PullRequestDetailGhost, PullRequestTimelineGhost } from "./PullRequestGhosts";89import { PullRequestDetailGhost, PullRequestTimelineGhost } from "./PullRequestGhosts";
89import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState";90import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState";
90import { DiffPanelLoadingState } from "../DiffPanelShell";91import { DiffPanelLoadingState } from "../DiffPanelShell";
...@@ -1086,22 +1087,31 @@ export function PullRequestDetailPanel({...@@ -1086,22 +1087,31 @@ export function PullRequestDetailPanel({
1086 >1087 >
1087 {detail && statePresentation ? (1088 {detail && statePresentation ? (
1088 <>1089 <>
1089 <span className="min-w-0 truncate" title={detail.repository}>1090 <Tooltip>
1090 {detail.repository}1091 <TooltipTrigger
1091 </span>1092 render={<span className="min-w-0 truncate">{detail.repository}</span>}
1092 <button1093 />
1093 type="button"1094 <TooltipPopup side="top">{detail.repository}</TooltipPopup>
1094 onClick={() => void readLocalApi()?.shell.openExternal(detail.url)}1095 </Tooltip>
1095 onContextMenu={(event) => openNumberContextMenu(event, detail)}1096 <Tooltip>
1096 className={cn(1097 <TooltipTrigger
1097 "shrink-0 font-medium underline-offset-2 hover:underline",1098 render={
1098 statePresentation.toneClassName,1099 <button
1099 )}1100 type="button"
1100 title={openOnHostLabel(detail.provider)}1101 onClick={() => void readLocalApi()?.shell.openExternal(detail.url)}
1101 aria-label={`Open pull request #${detail.number} on host`}1102 onContextMenu={(event) => openNumberContextMenu(event, detail)}
1102 >1103 className={cn(
1103 #{detail.number}1104 "shrink-0 font-medium underline-offset-2 hover:underline",
1104 </button>1105 statePresentation.toneClassName,
1106 )}
1107 aria-label={`Open pull request #${detail.number} on host`}
1108 >
1109 #{detail.number}
1110 </button>
1111 }
1112 />
1113 <TooltipPopup side="top">{openOnHostLabel(detail.provider)}</TooltipPopup>
1114 </Tooltip>
1105 </>1115 </>
1106 ) : null}1116 ) : null}
1107 </div>1117 </div>
...@@ -1117,23 +1127,36 @@ export function PullRequestDetailPanel({...@@ -1117,23 +1127,36 @@ export function PullRequestDetailPanel({
1117 >1127 >
1118 {detail && statePresentation ? (1128 {detail && statePresentation ? (
1119 <>1129 <>
1120 <button1130 <Tooltip>
1121 type="button"1131 <TooltipTrigger
1122 tabIndex={condensed ? 0 : -1}1132 render={
1123 onClick={() => void readLocalApi()?.shell.openExternal(detail.url)}1133 <button
1124 onContextMenu={(event) => openNumberContextMenu(event, detail)}1134 type="button"
1125 className={cn(1135 tabIndex={condensed ? 0 : -1}
1126 "shrink-0 font-medium underline-offset-2 hover:underline",1136 onClick={() => void readLocalApi()?.shell.openExternal(detail.url)}
1127 statePresentation.toneClassName,1137 onContextMenu={(event) => openNumberContextMenu(event, detail)}
1128 )}1138 className={cn(
1129 title={openOnHostLabel(detail.provider)}1139 "shrink-0 font-medium underline-offset-2 hover:underline",
1130 aria-label={`Open pull request #${detail.number} on host`}1140 statePresentation.toneClassName,
1131 >1141 )}
1132 #{detail.number}1142 aria-label={`Open pull request #${detail.number} on host`}
1133 </button>1143 >
1134 <span className="min-w-0 truncate font-medium text-foreground" title={detail.title}>1144 #{detail.number}
1135 {detail.title}1145 </button>
1136 </span>1146 }
1147 />
1148 <TooltipPopup side="top">{openOnHostLabel(detail.provider)}</TooltipPopup>
1149 </Tooltip>
1150 <Tooltip>
1151 <TooltipTrigger
1152 render={
1153 <span className="min-w-0 truncate font-medium text-foreground">
1154 {detail.title}
1155 </span>
1156 }
1157 />
1158 <TooltipPopup side="top">{detail.title}</TooltipPopup>
1159 </Tooltip>
1137 {conflicting ? (1160 {conflicting ? (
1138 <Badge1161 <Badge
1139 variant="error"1162 variant="error"
...@@ -1385,14 +1408,22 @@ export function PullRequestDetailPanel({...@@ -1385,14 +1408,22 @@ export function PullRequestDetailPanel({
1385 {/* Said where the Merge button is, because it is the answer to why nobody has1408 {/* Said where the Merge button is, because it is the answer to why nobody has
1386 pressed it: the merge is already asked for, and the host is holding it. */}1409 pressed it: the merge is already asked for, and the host is holding it. */}
1387 {autoMergeArmed ? (1410 {autoMergeArmed ? (
1388 <Badge1411 <Tooltip>
1389 variant="info"1412 <TooltipTrigger
1390 className="h-5 shrink-0 gap-1 rounded px-1.5 text-[10px]"1413 render={
1391 title="The host will merge this on its own once its requirements are met"1414 <Badge
1392 >1415 variant="info"
1393 <GitMergeIcon aria-hidden className="size-3" />1416 className="h-5 shrink-0 gap-1 rounded px-1.5 text-[10px]"
1394 Auto-merge1417 >
1395 </Badge>1418 <GitMergeIcon aria-hidden className="size-3" />
1419 Auto-merge
1420 </Badge>
1421 }
1422 />
1423 <TooltipPopup side="top">
1424 The host will merge this on its own once its requirements are met
1425 </TooltipPopup>
1426 </Tooltip>
1396 ) : null}1427 ) : null}
1397 {primaryAction === "ready" ? (1428 {primaryAction === "ready" ? (
1398 <Button size="xs" disabled={actionPending} onClick={() => void perform("ready")}>1429 <Button size="xs" disabled={actionPending} onClick={() => void perform("ready")}>
...@@ -1465,11 +1496,13 @@ export function PullRequestDetailPanel({...@@ -1465,11 +1496,13 @@ export function PullRequestDetailPanel({
1465 </button>1496 </button>
1466 ))}1497 ))}
1467 </nav>1498 </nav>
1468 <span1499 <span className="ml-auto inline-flex min-w-0 shrink items-center gap-1 font-mono text-[11px] text-muted-foreground">
1469 className="ml-auto inline-flex min-w-0 shrink items-center gap-1 font-mono text-[11px] text-muted-foreground"1500 <Tooltip>
1470 title={`${detail.baseBranch} ← ${detail.headBranch}`}1501 <TooltipTrigger render={<span className="truncate" />}>
1471 >1502 {detail.baseBranch}
1472 <span className="truncate">{detail.baseBranch}</span>1503 </TooltipTrigger>
1504 <TooltipPopup side="top">{`${detail.baseBranch} ← ${detail.headBranch}`}</TooltipPopup>
1505 </Tooltip>
1473 {freshness ? (1506 {freshness ? (
1474 <PullRequestBaseFreshnessWarning1507 <PullRequestBaseFreshnessWarning
1475 baseBranch={detail.baseBranch}1508 baseBranch={detail.baseBranch}
...@@ -1480,7 +1513,12 @@ export function PullRequestDetailPanel({...@@ -1480,7 +1513,12 @@ export function PullRequestDetailPanel({
1480 />1513 />
1481 ) : null}1514 ) : null}
1482 <ArrowLeftIcon aria-label="receives changes from" className="size-3 shrink-0" />1515 <ArrowLeftIcon aria-label="receives changes from" className="size-3 shrink-0" />
1483 <span className="truncate">{detail.headBranch}</span>1516 <Tooltip>
1517 <TooltipTrigger render={<span className="truncate" />}>
1518 {detail.headBranch}
1519 </TooltipTrigger>
1520 <TooltipPopup side="top">{`${detail.baseBranch} ← ${detail.headBranch}`}</TooltipPopup>
1521 </Tooltip>
1484 </span>1522 </span>
1485 <span className="ml-2 inline-flex shrink-0 items-center gap-2 text-[11px] text-muted-foreground">1523 <span className="ml-2 inline-flex shrink-0 items-center gap-2 text-[11px] text-muted-foreground">
1486 <span className="inline-flex items-center gap-1 tabular-nums">1524 <span className="inline-flex items-center gap-1 tabular-nums">
...@@ -1594,12 +1632,16 @@ export function PullRequestDetailPanel({...@@ -1594,12 +1632,16 @@ export function PullRequestDetailPanel({
1594 </PullRequestMetaLine>1632 </PullRequestMetaLine>
15951633
1596 <div className="mt-4 flex min-w-0 items-center gap-2 text-xs text-muted-foreground">1634 <div className="mt-4 flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
1597 <code1635 <Tooltip>
1598 className="min-w-0 max-w-48 shrink truncate rounded-md bg-muted px-2 py-1 font-mono text-xs text-foreground"1636 <TooltipTrigger
1599 title={detail.baseBranch}1637 render={
1600 >1638 <code className="min-w-0 max-w-48 shrink truncate rounded-md bg-muted px-2 py-1 font-mono text-xs text-foreground">
1601 {detail.baseBranch}1639 {detail.baseBranch}
1602 </code>1640 </code>
1641 }
1642 />
1643 <TooltipPopup side="top">{detail.baseBranch}</TooltipPopup>
1644 </Tooltip>
1603 {freshness ? (1645 {freshness ? (
1604 <PullRequestBaseFreshnessWarning1646 <PullRequestBaseFreshnessWarning
1605 baseBranch={detail.baseBranch}1647 baseBranch={detail.baseBranch}
...@@ -1609,32 +1651,41 @@ export function PullRequestDetailPanel({...@@ -1609,32 +1651,41 @@ export function PullRequestDetailPanel({
1609 />1651 />
1610 ) : null}1652 ) : null}
1611 <ArrowLeftIcon aria-label="receives changes from" className="size-4 shrink-0" />1653 <ArrowLeftIcon aria-label="receives changes from" className="size-4 shrink-0" />
1612 <button1654 <Tooltip>
1613 type="button"1655 <TooltipTrigger
1614 className="grid min-w-0 max-w-64 shrink cursor-pointer rounded-md bg-muted px-2 py-1 font-mono text-xs text-foreground outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background"1656 render={
1615 aria-label={isBranchCopied ? "Branch name copied" : "Copy pull request branch"}1657 <button
1616 title={isBranchCopied ? "Copied" : "Copy pull request branch"}1658 type="button"
1617 onClick={() => copyBranchToClipboard(detail.headBranch)}1659 className="grid min-w-0 max-w-64 shrink cursor-pointer rounded-md bg-muted px-2 py-1 font-mono text-xs text-foreground outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background"
1618 >1660 aria-label={
1619 <code1661 isBranchCopied ? "Branch name copied" : "Copy pull request branch"
1620 className={cn(1662 }
1621 "col-start-1 row-start-1 min-w-0 truncate transition-opacity duration-150 motion-reduce:transition-none",1663 onClick={() => copyBranchToClipboard(detail.headBranch)}
1622 isBranchCopied ? "opacity-0" : "opacity-100",1664 />
1623 )}1665 }
1624 title={detail.headBranch}
1625 >
1626 {detail.headBranch}
1627 </code>
1628 <span
1629 aria-hidden="true"
1630 className={cn(
1631 "col-start-1 row-start-1 truncate text-center transition-opacity duration-150 motion-reduce:transition-none",
1632 isBranchCopied ? "opacity-100" : "opacity-0",
1633 )}
1634 >1666 >
1635 Copied1667 <code
1636 </span>1668 className={cn(
1637 </button>1669 "col-start-1 row-start-1 min-w-0 truncate transition-opacity duration-150 motion-reduce:transition-none",
1670 isBranchCopied ? "opacity-0" : "opacity-100",
1671 )}
1672 >
1673 {detail.headBranch}
1674 </code>
1675 <span
1676 aria-hidden="true"
1677 className={cn(
1678 "col-start-1 row-start-1 truncate text-center transition-opacity duration-150 motion-reduce:transition-none",
1679 isBranchCopied ? "opacity-100" : "opacity-0",
1680 )}
1681 >
1682 Copied
1683 </span>
1684 </TooltipTrigger>
1685 <TooltipPopup side="top">
1686 {`${isBranchCopied ? "Copied" : "Copy pull request branch"}: ${detail.headBranch}`}
1687 </TooltipPopup>
1688 </Tooltip>
1638 <span className="ml-auto inline-flex shrink-0 items-center justify-end gap-2">1689 <span className="ml-auto inline-flex shrink-0 items-center justify-end gap-2">
1639 <span className="inline-flex items-center gap-1.5 tabular-nums">1690 <span className="inline-flex items-center gap-1.5 tabular-nums">
1640 <FileDiffIcon className="size-3.5" />1691 <FileDiffIcon className="size-3.5" />
apps/web/src/components/pullRequest/PullRequestListFilters.tsx+38-17
...@@ -35,6 +35,7 @@ import {...@@ -35,6 +35,7 @@ import {
35 MenuSeparator,35 MenuSeparator,
36 MenuTrigger,36 MenuTrigger,
37} from "../ui/menu";37} from "../ui/menu";
38import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
3839
39export interface PullRequestFilterOption<Value extends string> {40export interface PullRequestFilterOption<Value extends string> {
40 readonly value: Value;41 readonly value: Value;
...@@ -156,21 +157,32 @@ function PullRequestFilterRadioGroup<Value extends string>({...@@ -156,21 +157,32 @@ function PullRequestFilterRadioGroup<Value extends string>({
156 }}157 }}
157 >158 >
158 <MenuGroupLabel>{label}</MenuGroupLabel>159 <MenuGroupLabel>{label}</MenuGroupLabel>
159 {options.map((option) => (160 {options.map((option) => {
160 <MenuRadioItem161 // A host the server has already said it cannot read is not a choice here: offering
161 key={option.value}162 // it would answer the press by replacing a working list with that failure.
162 value={option.value}163 const item = (
163 // A host the server has already said it cannot read is not a choice here: offering164 <MenuRadioItem
164 // it would answer the press by replacing a working list with that failure.165 key={option.value}
165 disabled={option.unavailable !== undefined}166 value={option.value}
166 title={option.unavailable}167 className={option.unavailable ? "data-disabled:pointer-events-auto" : undefined}
167 >168 disabled={option.unavailable !== undefined}
168 <span className="flex min-w-0 items-center gap-2">169 >
169 <option.Icon aria-hidden className="size-3.5" />170 <span className="flex min-w-0 items-center gap-2">
170 {option.label}171 <option.Icon aria-hidden className="size-3.5" />
171 </span>172 {option.label}
172 </MenuRadioItem>173 </span>
173 ))}174 </MenuRadioItem>
175 );
176 if (!option.unavailable) return item;
177 return (
178 <Tooltip key={option.value}>
179 <TooltipTrigger render={item} />
180 <TooltipPopup side="top" className="max-w-80">
181 {option.unavailable}
182 </TooltipPopup>
183 </Tooltip>
184 );
185 })}
174 </MenuRadioGroup>186 </MenuRadioGroup>
175 );187 );
176}188}
...@@ -375,12 +387,12 @@ export function PullRequestFiltersMenu({...@@ -375,12 +387,12 @@ export function PullRequestFiltersMenu({
375 )387 )
376 .map((project) => {388 .map((project) => {
377 const reason = unavailable.get(pullRequestProjectKey(project));389 const reason = unavailable.get(pullRequestProjectKey(project));
378 return (390 const item = (
379 <MenuRadioItem391 <MenuRadioItem
380 key={pullRequestProjectKey(project)}392 key={pullRequestProjectKey(project)}
381 value={pullRequestProjectKey(project)}393 value={pullRequestProjectKey(project)}
394 className={reason !== undefined ? "data-disabled:pointer-events-auto" : undefined}
382 disabled={reason !== undefined}395 disabled={reason !== undefined}
383 title={reason}
384 >396 >
385 <span className="flex min-w-0 flex-1 items-center gap-2">397 <span className="flex min-w-0 flex-1 items-center gap-2">
386 <ProjectFavicon398 <ProjectFavicon
...@@ -398,6 +410,15 @@ export function PullRequestFiltersMenu({...@@ -398,6 +410,15 @@ export function PullRequestFiltersMenu({
398 </span>410 </span>
399 </MenuRadioItem>411 </MenuRadioItem>
400 );412 );
413 if (reason === undefined) return item;
414 return (
415 <Tooltip key={pullRequestProjectKey(project)}>
416 <TooltipTrigger render={item} />
417 <TooltipPopup side="top" className="max-w-80">
418 {reason}
419 </TooltipPopup>
420 </Tooltip>
421 );
401 })}422 })}
402 </MenuRadioGroup>423 </MenuRadioGroup>
403 </MenuPopup>424 </MenuPopup>
apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx+19-9
...@@ -172,9 +172,14 @@ function CollapsedComment({...@@ -172,9 +172,14 @@ function CollapsedComment({
172 {open ? (172 {open ? (
173 <div className="px-3 pb-3">173 <div className="px-3 pb-3">
174 {comment.path ? (174 {comment.path ? (
175 <p className="truncate text-xs text-muted-foreground" title={comment.path}>175 <Tooltip>
176 {comment.path}176 <TooltipTrigger
177 </p>177 render={
178 <p className="truncate text-xs text-muted-foreground">{comment.path}</p>
179 }
180 />
181 <TooltipPopup side="top">{comment.path}</TooltipPopup>
182 </Tooltip>
178 ) : null}183 ) : null}
179 <CommentBody className="mt-2" comment={comment} editing={editing} />184 <CommentBody className="mt-2" comment={comment} editing={editing} />
180 {reactionBar}185 {reactionBar}
...@@ -475,6 +480,7 @@ export function PullRequestSummaryTab({...@@ -475,6 +480,7 @@ export function PullRequestSummaryTab({
475 >480 >
476 <PullRequestActorLabel481 <PullRequestActorLabel
477 actor={actor}482 actor={actor}
483 tooltip={false}
478 className="gap-0 [&>img]:ring-2 [&>img]:ring-background [&>span:first-child]:ring-2 [&>span:first-child]:ring-background [&>span:last-child]:sr-only"484 className="gap-0 [&>img]:ring-2 [&>img]:ring-background [&>span:first-child]:ring-2 [&>span:first-child]:ring-background [&>span:last-child]:sr-only"
479 />485 />
480 </TooltipTrigger>486 </TooltipTrigger>
...@@ -754,12 +760,16 @@ export function PullRequestSummaryTab({...@@ -754,12 +760,16 @@ export function PullRequestSummaryTab({
754 ) : null}760 ) : null}
755 </div>761 </div>
756 {comment.path ? (762 {comment.path ? (
757 <p763 <Tooltip>
758 className="mt-1 truncate text-xs text-muted-foreground"764 <TooltipTrigger
759 title={comment.path}765 render={
760 >766 <p className="mt-1 truncate text-xs text-muted-foreground">
761 {comment.path}767 {comment.path}
762 </p>768 </p>
769 }
770 />
771 <TooltipPopup side="top">{comment.path}</TooltipPopup>
772 </Tooltip>
763 ) : null}773 ) : null}
764 <CommentBody className="mt-2" comment={comment} editing={commentEditing} />774 <CommentBody className="mt-2" comment={comment} editing={commentEditing} />
765 <PullRequestReactionBar775 <PullRequestReactionBar
apps/web/src/components/pullRequest/pullRequestPresentation.tsx+18-3
...@@ -226,16 +226,31 @@ export function PullRequestActorAvatar({...@@ -226,16 +226,31 @@ export function PullRequestActorAvatar({
226export function PullRequestActorLabel({226export function PullRequestActorLabel({
227 actor,227 actor,
228 className,228 className,
229 tooltip = true,
229}: {230}: {
230 actor: PullRequestActor | null;231 actor: PullRequestActor | null;
231 className?: string;232 className?: string;
233 tooltip?: boolean;
232}) {234}) {
233 const login = actor?.login ?? "ghost";235 const login = actor?.login ?? "ghost";
234 return (236 const label = (
235 <span className={cn("flex min-w-0 items-center gap-1.5", className)} title={login}>237 <>
236 <PullRequestActorAvatar actor={actor} />238 <PullRequestActorAvatar actor={actor} />
237 <span className="truncate">{login}</span>239 <span className="truncate">{login}</span>
238 </span>240 </>
241 );
242 if (!tooltip) {
243 return <span className={cn("flex min-w-0 items-center gap-1.5", className)}>{label}</span>;
244 }
245 return (
246 <Tooltip>
247 <TooltipTrigger
248 render={<span className={cn("flex min-w-0 items-center gap-1.5", className)} />}
249 >
250 {label}
251 </TooltipTrigger>
252 <TooltipPopup side="top">{login}</TooltipPopup>
253 </Tooltip>
239 );254 );
240}255}
241256
apps/web/src/components/search/ProjectContentSearchDialog.tsx+18-11
...@@ -12,6 +12,7 @@ import { PierreEntryIcon } from "../chat/PierreEntryIcon";...@@ -12,6 +12,7 @@ import { PierreEntryIcon } from "../chat/PierreEntryIcon";
12import { CommandPaletteContent } from "../CommandPaletteContent";12import { CommandPaletteContent } from "../CommandPaletteContent";
13import { ScrollArea } from "../ui/scroll-area";13import { ScrollArea } from "../ui/scroll-area";
14import { Toggle } from "../ui/toggle";14import { Toggle } from "../ui/toggle";
15import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
15import { HighlightedSearchLine } from "./HighlightedSearchLine";16import { HighlightedSearchLine } from "./HighlightedSearchLine";
1617
17interface ProjectContentSearchDialogProps {18interface ProjectContentSearchDialogProps {
...@@ -59,17 +60,23 @@ function SearchOptionButton(props: {...@@ -59,17 +60,23 @@ function SearchOptionButton(props: {
59 readonly children: ReactNode;60 readonly children: ReactNode;
60}) {61}) {
61 return (62 return (
62 <Toggle63 <Tooltip>
63 aria-label={props.label}64 <TooltipTrigger
64 pressed={props.active}65 render={
65 title={props.label}66 <Toggle
66 className="size-8 rounded-[5px] font-mono text-muted-foreground data-pressed:text-foreground sm:size-7"67 aria-label={props.label}
67 size="compact"68 pressed={props.active}
68 variant="ghost"69 className="size-8 rounded-[5px] font-mono text-muted-foreground data-pressed:text-foreground sm:size-7"
69 onClick={props.onClick}70 size="compact"
70 >71 variant="ghost"
71 {props.children}72 onClick={props.onClick}
72 </Toggle>73 />
74 }
75 >
76 {props.children}
77 </TooltipTrigger>
78 <TooltipPopup side="top">{props.label}</TooltipPopup>
79 </Tooltip>
73 );80 );
74}81}
7582
apps/web/src/components/settings/ConnectionsSettings.tsx+45-20
...@@ -692,8 +692,13 @@ const PairingLinkListRow = memo(function PairingLinkListRow({...@@ -692,8 +692,13 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
692 />692 />
693 <h3 className="text-sm font-medium text-foreground">{primaryLabel}</h3>693 <h3 className="text-sm font-medium text-foreground">{primaryLabel}</h3>
694 </div>694 </div>
695 <p className="text-xs text-muted-foreground" title={expiresAbsolute}>695 <p className="text-xs text-muted-foreground">
696 {formatExpiresInLabel(pairingLink.expiresAt, nowMs)}696 <Tooltip>
697 <TooltipTrigger render={<span />}>
698 {formatExpiresInLabel(pairingLink.expiresAt, nowMs)}
699 </TooltipTrigger>
700 <TooltipPopup side="top">{expiresAbsolute}</TooltipPopup>
701 </Tooltip>
697 <span aria-hidden> · </span>702 <span aria-hidden> · </span>
698 <AccessScopeSummary scopes={pairingLink.scopes} label="Pairing link scopes" />703 <AccessScopeSummary scopes={pairingLink.scopes} label="Pairing link scopes" />
699 </p>704 </p>
...@@ -840,12 +845,18 @@ const PairingLinkListRow = memo(function PairingLinkListRow({...@@ -840,12 +845,18 @@ const PairingLinkListRow = memo(function PairingLinkListRow({
840 </div>845 </div>
841 ) : null}846 ) : null}
842 <div className="flex items-center gap-2 rounded-lg border border-border/60 bg-muted/30 px-2.5 py-1.5">847 <div className="flex items-center gap-2 rounded-lg border border-border/60 bg-muted/30 px-2.5 py-1.5">
843 <code848 <Tooltip>
844 title={qrPairingUrl}849 <TooltipTrigger
845 className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground"850 render={
846 >851 <code className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground">
847 {qrPairingUrl}852 {qrPairingUrl}
848 </code>853 </code>
854 }
855 />
856 <TooltipPopup side="top" className="max-w-80 break-all">
857 {qrPairingUrl}
858 </TooltipPopup>
859 </Tooltip>
849 <Button860 <Button
850 size="xs"861 size="xs"
851 variant="ghost"862 variant="ghost"
...@@ -1234,12 +1245,18 @@ const AdvertisedEndpointListRow = memo(function AdvertisedEndpointListRow({...@@ -1234,12 +1245,18 @@ const AdvertisedEndpointListRow = memo(function AdvertisedEndpointListRow({
1234 {endpoint.label}1245 {endpoint.label}
1235 </h3>1246 </h3>
1236 {shouldShowEndpointUrl ? (1247 {shouldShowEndpointUrl ? (
1237 <p1248 <Tooltip>
1238 className="min-w-0 truncate text-xs leading-5 text-muted-foreground"1249 <TooltipTrigger
1239 title={endpoint.httpBaseUrl}1250 render={
1240 >1251 <p className="min-w-0 truncate text-xs leading-5 text-muted-foreground">
1241 {endpoint.httpBaseUrl}1252 {endpoint.httpBaseUrl}
1242 </p>1253 </p>
1254 }
1255 />
1256 <TooltipPopup side="top" className="max-w-80">
1257 {endpoint.httpBaseUrl}
1258 </TooltipPopup>
1259 </Tooltip>
1243 ) : null}1260 ) : null}
1244 {!isAvailable ? (1261 {!isAvailable ? (
1245 <span className="shrink-0 rounded-md border border-border/70 px-1 py-0.5 text-[10px] text-muted-foreground">1262 <span className="shrink-0 rounded-md border border-border/70 px-1 py-0.5 text-[10px] text-muted-foreground">
...@@ -3313,12 +3330,20 @@ export function ConnectionsSettings() {...@@ -3313,12 +3330,20 @@ export function ConnectionsSettings() {
3313 ) : null}3330 ) : null}
3314 <div className="rounded-md border border-border/70 bg-muted/20 px-3 py-2">3331 <div className="rounded-md border border-border/70 bg-muted/20 px-3 py-2">
3315 <p className="text-xs font-medium text-muted-foreground">HTTPS endpoint</p>3332 <p className="text-xs font-medium text-muted-foreground">HTTPS endpoint</p>
3316 <p3333 <Tooltip>
3317 className="mt-1 truncate text-sm text-foreground"3334 <TooltipTrigger
3318 title={pendingTailscaleServeBaseUrl ?? undefined}3335 render={
3319 >3336 <p className="mt-1 truncate text-sm text-foreground">
3320 {pendingTailscaleServeBaseUrl ?? "Pending MagicDNS endpoint"}3337 {pendingTailscaleServeBaseUrl ?? "Pending MagicDNS endpoint"}
3321 </p>3338 </p>
3339 }
3340 />
3341 {pendingTailscaleServeBaseUrl ? (
3342 <TooltipPopup side="top" className="max-w-80">
3343 {pendingTailscaleServeBaseUrl}
3344 </TooltipPopup>
3345 ) : null}
3346 </Tooltip>
3322 </div>3347 </div>
3323 </DialogPanel>3348 </DialogPanel>
3324 <DialogFooter>3349 <DialogFooter>
apps/web/src/components/settings/ProjectSettingsPanel.tsx+23-16
...@@ -97,6 +97,7 @@ import {...@@ -97,6 +97,7 @@ import {
97import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";97import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
98import { SidebarInset } from "../ui/sidebar";98import { SidebarInset } from "../ui/sidebar";
99import { stackedThreadToast, toastManager } from "../ui/toast";99import { stackedThreadToast, toastManager } from "../ui/toast";
100import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
100import {101import {
101 WorkspaceBreadcrumb,102 WorkspaceBreadcrumb,
102 WorkspaceBreadcrumbItem,103 WorkspaceBreadcrumbItem,
...@@ -937,22 +938,28 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {...@@ -937,22 +938,28 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
937 >938 >
938 <div className="px-3 py-2 sm:px-4">939 <div className="px-3 py-2 sm:px-4">
939 <div className="flex min-w-0 items-center rounded-lg bg-muted/30 p-1 text-base text-muted-foreground sm:text-sm">940 <div className="flex min-w-0 items-center rounded-lg bg-muted/30 p-1 text-base text-muted-foreground sm:text-sm">
940 <button941 <Tooltip>
941 aria-label="Copy checkout path"942 <TooltipTrigger
942 className="group flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-md px-2 py-1 text-left outline-none hover:bg-accent/60 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"943 render={
943 title="Copy path"944 <button
944 type="button"945 aria-label="Copy checkout path"
945 onClick={() =>946 className="group flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-md px-2 py-1 text-left outline-none hover:bg-accent/60 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
946 copyPathToClipboard(selectedCheckout.workspaceRoot, {947 type="button"
947 path: selectedCheckout.workspaceRoot,948 onClick={() =>
948 })949 copyPathToClipboard(selectedCheckout.workspaceRoot, {
949 }950 path: selectedCheckout.workspaceRoot,
950 >951 })
951 <code className="min-w-0 flex-1 truncate font-mono">952 }
952 {selectedCheckout.workspaceRoot}953 >
953 </code>954 <code className="min-w-0 flex-1 truncate font-mono">
954 <CopyIcon className="size-4 shrink-0 opacity-60 group-hover:opacity-100" />955 {selectedCheckout.workspaceRoot}
955 </button>956 </code>
957 <CopyIcon className="size-4 shrink-0 opacity-60 group-hover:opacity-100" />
958 </button>
959 }
960 />
961 <TooltipPopup side="top">Copy path</TooltipPopup>
962 </Tooltip>
956 <div className="shrink-0 border-l border-border/60 px-2 tabular-nums">963 <div className="shrink-0 border-l border-border/60 px-2 tabular-nums">
957 {selectedCheckoutThreadCount === 1964 {selectedCheckoutThreadCount === 1
958 ? "1 thread"965 ? "1 thread"
apps/web/src/components/settings/ThemeColorPicker.tsx+39-26
...@@ -4,6 +4,7 @@ import { isThemeColor, themeColorToHex, type ThemeColorRole } from "../../themeP...@@ -4,6 +4,7 @@ import { isThemeColor, themeColorToHex, type ThemeColorRole } from "../../themeP
4import { cn } from "../../lib/utils";4import { cn } from "../../lib/utils";
5import { Input } from "../ui/input";5import { Input } from "../ui/input";
6import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";6import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
7import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
7export function getThemeRoleLabel(role: ThemeColorRole): string {8export function getThemeRoleLabel(role: ThemeColorRole): string {
8 const labels: Partial<Record<ThemeColorRole, string>> = {9 const labels: Partial<Record<ThemeColorRole, string>> = {
9 canvas: "Background",10 canvas: "Background",
...@@ -434,23 +435,29 @@ function ThemeColorPicker({...@@ -434,23 +435,29 @@ function ThemeColorPicker({
434}) {435}) {
435 return (436 return (
436 <Popover>437 <Popover>
437 <PopoverTrigger438 <Tooltip>
438 render={439 <TooltipTrigger
439 <button440 render={
440 aria-label={`Choose ${label} color`}441 <PopoverTrigger
441 className="relative flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-full border border-foreground/30 transition-transform hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"442 render={
442 onFocus={onInteract}443 <button
443 onPointerDown={onInteract}444 aria-label={`Choose ${label} color`}
444 title={`Choose ${label} color`}445 className="relative flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-full border border-foreground/30 transition-transform hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
445 type="button"446 onFocus={onInteract}
446 >447 onPointerDown={onInteract}
447 <span448 type="button"
448 className="absolute inset-0 rounded-full shadow-sm"449 >
449 style={{ backgroundColor: value }}450 <span
451 className="absolute inset-0 rounded-full shadow-sm"
452 style={{ backgroundColor: value }}
453 />
454 </button>
455 }
450 />456 />
451 </button>457 }
452 }458 />
453 />459 <TooltipPopup side="top">{`Choose ${label} color`}</TooltipPopup>
460 </Tooltip>
454 <PopoverPopup461 <PopoverPopup
455 align="end"462 align="end"
456 className="overflow-hidden rounded-2xl border border-border/70 p-0 shadow-2xl [--viewport-inline-padding:0px] [&_[data-slot=popover-viewport]]:p-0"463 className="overflow-hidden rounded-2xl border border-border/70 p-0 shadow-2xl [--viewport-inline-padding:0px] [&_[data-slot=popover-viewport]]:p-0"
...@@ -496,16 +503,22 @@ export const ThemeColorField = memo(function ThemeColorField({...@@ -496,16 +503,22 @@ export const ThemeColorField = memo(function ThemeColorField({
496 )}503 )}
497 data-theme-color-role={role}504 data-theme-color-role={role}
498 >505 >
499 <button506 <Tooltip>
500 aria-label={`${selected ? "Hide" : "Show"} ${label} usage`}507 <TooltipTrigger
501 aria-pressed={selected}508 render={
502 className="flex min-w-0 flex-1 cursor-pointer items-center rounded-md text-left text-sm text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"509 <button
503 onClick={() => onToggleSelected?.(role)}510 aria-label={`${selected ? "Hide" : "Show"} ${label} usage`}
504 title={`${selected ? "Hide" : "Show"} where ${label} is used`}511 aria-pressed={selected}
505 type="button"512 className="flex min-w-0 flex-1 cursor-pointer items-center rounded-md text-left text-sm text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
506 >513 onClick={() => onToggleSelected?.(role)}
507 <span className="min-w-0 flex-1 truncate">{label}</span>514 type="button"
508 </button>515 >
516 <span className="min-w-0 flex-1 truncate">{label}</span>
517 </button>
518 }
519 />
520 <TooltipPopup side="top">{`${selected ? "Hide" : "Show"} where ${label} is used`}</TooltipPopup>
521 </Tooltip>
509 <div className="ml-auto flex shrink-0 items-center gap-2">522 <div className="ml-auto flex shrink-0 items-center gap-2">
510 <ThemeColorPicker523 <ThemeColorPicker
511 label={label}524 label={label}
apps/web/src/routes/_chat.pull-requests.tsx+28-16
...@@ -78,6 +78,7 @@ import { PanelLayoutControls } from "../components/chat/PanelLayoutControls";...@@ -78,6 +78,7 @@ import { PanelLayoutControls } from "../components/chat/PanelLayoutControls";
78import { Button } from "../components/ui/button";78import { Button } from "../components/ui/button";
79import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../components/ui/menu";79import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../components/ui/menu";
80import { SidebarInset } from "../components/ui/sidebar";80import { SidebarInset } from "../components/ui/sidebar";
81import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip";
81import { useLiveRefresh } from "../hooks/useLiveRefresh";82import { useLiveRefresh } from "../hooks/useLiveRefresh";
82import {83import {
83 selectActiveRightPanelSurface,84 selectActiveRightPanelSurface,
...@@ -1639,22 +1640,33 @@ function CompactFilterMenu<Value extends string>({...@@ -1639,22 +1640,33 @@ function CompactFilterMenu<Value extends string>({
1639 </MenuTrigger>1640 </MenuTrigger>
1640 <MenuPopup align="start" side="bottom" className="min-w-40">1641 <MenuPopup align="start" side="bottom" className="min-w-40">
1641 <MenuRadioGroup value={value} onValueChange={(next) => onChange(next as Value)}>1642 <MenuRadioGroup value={value} onValueChange={(next) => onChange(next as Value)}>
1642 {options.map((option) => (1643 {options.map((option) => {
1643 <MenuRadioItem1644 // A host the server has already said it cannot read is not a choice here either.
1644 key={option.value}1645 // The pills disable it; a menu that offers it would answer the press by replacing
1645 value={option.value}1646 // a working list with the same failure the pill row exists to explain.
1646 // A host the server has already said it cannot read is not a choice here either.1647 const item = (
1647 // The pills disable it; a menu that offers it would answer the press by replacing1648 <MenuRadioItem
1648 // a working list with the same failure the pill row exists to explain.1649 key={option.value}
1649 disabled={option.unavailable !== undefined}1650 value={option.value}
1650 title={option.unavailable}1651 className={option.unavailable ? "data-disabled:pointer-events-auto" : undefined}
1651 >1652 disabled={option.unavailable !== undefined}
1652 <span className="flex min-w-0 items-center gap-2">1653 >
1653 <option.Icon aria-hidden className="size-3.5" />1654 <span className="flex min-w-0 items-center gap-2">
1654 {option.label}1655 <option.Icon aria-hidden className="size-3.5" />
1655 </span>1656 {option.label}
1656 </MenuRadioItem>1657 </span>
1657 ))}1658 </MenuRadioItem>
1659 );
1660 if (!option.unavailable) return item;
1661 return (
1662 <Tooltip key={option.value}>
1663 <TooltipTrigger render={item} />
1664 <TooltipPopup side="top" className="max-w-80">
1665 {option.unavailable}
1666 </TooltipPopup>
1667 </Tooltip>
1668 );
1669 })}
1658 </MenuRadioGroup>1670 </MenuRadioGroup>
1659 </MenuPopup>1671 </MenuPopup>
1660 </Menu>1672 </Menu>
oxlint-plugin-t3code/index.ts+2
...@@ -4,6 +4,7 @@ import namespaceNodeImports from "./rules/namespace-node-imports.ts";...@@ -4,6 +4,7 @@ import namespaceNodeImports from "./rules/namespace-node-imports.ts";
4import noGlobalProcessRuntime from "./rules/no-global-process-runtime.ts";4import noGlobalProcessRuntime from "./rules/no-global-process-runtime.ts";
5import noInlineSchemaCompile from "./rules/no-inline-schema-compile.ts";5import noInlineSchemaCompile from "./rules/no-inline-schema-compile.ts";
6import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-tests.ts";6import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-tests.ts";
7import noNativeTitleTooltip from "./rules/no-native-title-tooltip.ts";
78
8export default definePlugin({9export default definePlugin({
9 meta: {10 meta: {
...@@ -14,5 +15,6 @@ export default definePlugin({...@@ -14,5 +15,6 @@ export default definePlugin({
14 "no-global-process-runtime": noGlobalProcessRuntime,15 "no-global-process-runtime": noGlobalProcessRuntime,
15 "no-inline-schema-compile": noInlineSchemaCompile,16 "no-inline-schema-compile": noInlineSchemaCompile,
16 "no-manual-effect-runtime-in-tests": noManualEffectRuntimeInTests,17 "no-manual-effect-runtime-in-tests": noManualEffectRuntimeInTests,
18 "no-native-title-tooltip": noNativeTitleTooltip,
17 },19 },
18});20});
oxlint-plugin-t3code/rules/no-native-title-tooltip.test.ts created+61
...@@ -0,0 +1,61 @@
1import { assert, describe } from "@effect/vitest";
2
3import { createOxlintRuleHarness } from "../test/utils.ts";
4
5const rule = createOxlintRuleHarness("t3code/no-native-title-tooltip", {
6 filename: "fixture.tsx",
7});
8
9describe("t3code/no-native-title-tooltip", () => {
10 rule.valid(
11 "allows intrinsic elements without a title attribute",
12 `const el = <span className="truncate">Truncated text</span>;`,
13 );
14
15 rule.valid(
16 "allows title props on custom components",
17 `const el = <SettingsRow id="word-wrap" title="Word wrap" description="Wrap long lines." />;`,
18 );
19
20 rule.valid(
21 "allows title on member expression components",
22 `const el = <Foo.Bar title="Not a native tooltip" />;`,
23 );
24
25 rule.valid(
26 "allows title as an accessible name on embedded content",
27 `const el = <iframe title="Embedded widget" src="https://example.com" />;`,
28 );
29
30 rule.valid("allows the svg title child element", `const el = <svg><title>QR code</title></svg>;`);
31
32 rule.valid("allows document.title assignments", `document.title = "Thread · T3 Code";`);
33
34 rule.invalid(
35 "reports title on a truncating span",
36 `const el = <span className="min-w-0 truncate" title="Full repository name">Repo</span>;`,
37 (output) => {
38 assert.match(output, /native title attribute/);
39 },
40 );
41
42 rule.invalid(
43 "reports title on a button",
44 `const el = <button type="button" aria-label="Scroll to end" title="Scroll to end" />;`,
45 (output) => {
46 assert.match(output, /Tooltip \+ TooltipTrigger \+ TooltipPopup/);
47 },
48 );
49
50 rule.invalid(
51 "reports title on a paragraph",
52 `const el = <p className="truncate text-xs" title={path}>src/main.ts</p>;`,
53 );
54
55 rule.invalid(
56 "reports title on a code element",
57 `const el = <code className="truncate" title={pairingUrl}>abc</code>;`,
58 );
59
60 rule.invalid("reports title on an anchor", `const el = <a href="#" title="Learn more">More</a>;`);
61});
oxlint-plugin-t3code/rules/no-native-title-tooltip.ts created+39
...@@ -0,0 +1,39 @@
1import { defineRule } from "@oxlint/plugins";
2
3const INTRINSIC_ELEMENT_PATTERN = /^[a-z]/u;
4
5// On these elements the title attribute names the embedded content for
6// accessibility (and does not reliably produce a hover tooltip), so the styled
7// Tooltip component is not a drop-in replacement.
8const TITLE_IS_AN_ACCESSIBLE_NAME = new Set(["embed", "frame", "iframe", "math", "object"]);
9
10export default defineRule({
11 meta: {
12 type: "problem",
13 docs: {
14 description:
15 "Disallow the native title attribute as a tooltip on intrinsic elements; use the styled Tooltip component.",
16 },
17 },
18 create(context) {
19 return {
20 JSXOpeningElement(node) {
21 if (node.name.type !== "JSXIdentifier") return;
22 if (!INTRINSIC_ELEMENT_PATTERN.test(node.name.name)) return;
23 if (TITLE_IS_AN_ACCESSIBLE_NAME.has(node.name.name)) return;
24
25 for (const attribute of node.attributes) {
26 if (attribute.type !== "JSXAttribute") continue;
27 if (attribute.name.type !== "JSXIdentifier") continue;
28 if (attribute.name.name !== "title") continue;
29
30 context.report({
31 node: attribute,
32 message:
33 "Do not use the native title attribute as a tooltip. Use Tooltip + TooltipTrigger + TooltipPopup from components/ui/tooltip.",
34 });
35 }
36 },
37 };
38 },
39});
vite.config.ts+1
...@@ -121,6 +121,7 @@ export default defineConfig({...@@ -121,6 +121,7 @@ export default defineConfig({
121 "t3code/no-global-process-runtime": "error",121 "t3code/no-global-process-runtime": "error",
122 "t3code/no-inline-schema-compile": "warn",122 "t3code/no-inline-schema-compile": "warn",
123 "t3code/no-manual-effect-runtime-in-tests": "error",123 "t3code/no-manual-effect-runtime-in-tests": "error",
124 "t3code/no-native-title-tooltip": "error",
124 "t3code/namespace-node-imports": "error",125 "t3code/namespace-node-imports": "error",
125 },126 },
126 options: {127 options: {