History

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

Utkarsh Patil committed 2 months agov0.0.34-nightly.20260816.1112

Commit fee10de1 parent bab4b6f27 files · +730 −355Browse files

Unified · Split

27 files
  1. apps/web/src/browser/BrowserDeviceToolbar.tsx +29 −20
  2. apps/web/src/components/ChatView.tsx +0 −1
  3. apps/web/src/components/ConnectionStatusDot.tsx +0 −1
  4. apps/web/src/components/DiffPanel.tsx +25 −9
  5. apps/web/src/components/ServerUpdateAction.tsx +7 −3
  6. apps/web/src/components/Sidebar.tsx +50 −32
  7. apps/web/src/components/chat/ChangedFilesTree.tsx +20 −15
  8. apps/web/src/components/chat/ComposerPreviewAnnotationCards.test.tsx +3 −2
  9. apps/web/src/components/chat/ComposerPreviewAnnotationCards.tsx +13 −7
  10. apps/web/src/components/chat/DraftHeroHeadline.tsx +26 −10
  11. apps/web/src/components/files/FilePreviewPanel.tsx +19 −11
  12. apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx +57 −39
  13. apps/web/src/components/pullRequest/PullRequestChecksPopover.tsx +7 −3
  14. apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +14 −6
  15. apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +129 −78
  16. apps/web/src/components/pullRequest/PullRequestListFilters.tsx +38 −17
  17. apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx +19 −9
  18. apps/web/src/components/pullRequest/pullRequestPresentation.tsx +18 −3
  19. apps/web/src/components/search/ProjectContentSearchDialog.tsx +18 −11
  20. apps/web/src/components/settings/ConnectionsSettings.tsx +45 −20
  21. apps/web/src/components/settings/ProjectSettingsPanel.tsx +23 −16
  22. apps/web/src/components/settings/ThemeColorPicker.tsx +39 −26
  23. apps/web/src/routes/_chat.pull-requests.tsx +28 −16
  24. oxlint-plugin-t3code/index.ts +2 −0
  25. oxlint-plugin-t3code/rules/no-native-title-tooltip.test.ts +61 −0
  26. oxlint-plugin-t3code/rules/no-native-title-tooltip.ts +39 −0
  27. vite.config.ts +1 −0
apps/web/src/browser/BrowserDeviceToolbar.tsx+29−20
@@ -12,6 +12,7 @@ import { useState } from "react"; · 11 unmodified lines
1212
1313 import { Button } from "~/components/ui/button";
1414 import { Input } from "~/components/ui/input";
15+import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
1516 import {
1617 Select,
1718 SelectGroup,
@@ -296,26 +297,34 @@ export function BrowserDeviceToolbar({ · 278 unmodified lines
296297 />
297298 </form>
298299
299− <Button
300− variant="ghost"
301− size="icon-xs"
302− type="button"
303− aria-label={
304− aspectRatio === null ? "Lock viewport aspect ratio" : "Unlock viewport aspect ratio"
305− }
306− aria-pressed={aspectRatio !== null}
307− title={aspectRatio === null ? "Lock aspect ratio" : "Unlock aspect ratio"}
308− className={cn(aspectRatio !== null && "bg-accent text-foreground")}
309− disabled={pending || !customValid}
310− onPointerDown={(event) => event.preventDefault()}
311− onClick={toggleAspectRatio}
312− >
313− {aspectRatio === null ? (
314− <Unlink2 className={cn(aspectRatio !== null && "text-foreground")} />
315− ) : (
316− <Link2 className={cn(aspectRatio !== null && "text-foreground")} />
317− )}
318− </Button>
300+ <Tooltip>
301+ <TooltipTrigger
302+ render={
303+ <Button
304+ variant="ghost"
305+ size="icon-xs"
306+ type="button"
307+ aria-label={
308+ aspectRatio === null ? "Lock viewport aspect ratio" : "Unlock viewport aspect ratio"
309+ }
310+ aria-pressed={aspectRatio !== null}
311+ className={cn(aspectRatio !== null && "bg-accent text-foreground")}
312+ disabled={pending || !customValid}
313+ onPointerDown={(event) => event.preventDefault()}
314+ onClick={toggleAspectRatio}
315+ />
316+ }
317+ >
318+ {aspectRatio === null ? (
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>
319328 <Button
320329 variant="ghost"
321330 size="icon-xs"
apps/web/src/components/ChatView.tsx+0−1
@@ -6348,7 +6348,6 @@ function ChatViewContent(props: ChatViewProps) { · 6347 unmodified lines
63486348 >
63496349 <Button
63506350 aria-label="Scroll to end"
6351− title="Scroll to end"
63526351 onClick={() => scrollToEnd(true)}
63536352 className="pointer-events-auto gap-1.5 rounded-full px-3 text-muted-foreground hover:text-foreground"
63546353 size="xs"
apps/web/src/components/ConnectionStatusDot.tsx+0−1
@@ -59,7 +59,6 @@ export function ConnectionStatusDot({ · 58 unmodified lines
5959 const dot = (
6060 <button
6161 type="button"
62− title={tooltipText}
6362 aria-label={tooltipText}
6463 className="relative flex size-3 shrink-0 cursor-help items-center justify-center rounded-full outline-hidden"
6564 >
apps/web/src/components/DiffPanel.tsx+25−9
@@ -583,11 +583,19 @@ export default function DiffPanel({ · 582 unmodified lines
583583 {selectedTurnId === null && selectedGitScope === "branch" && selectedGitSource?.baseRef && (
584584 <div
585585 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}`}
587586 aria-label={`Comparing ${selectedGitSource.headRef ?? "HEAD"} against ${selectedGitSource.baseRef}`}
588587 >
589− <span className="min-w-0 max-w-48 truncate">{selectedGitSource.headRef ?? "HEAD"}</span>
590− <ArrowRightIcon className="size-3.5 shrink-0 opacity-70" />
588+ <Tooltip>
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>
591599 <Combobox
592600 items={baseRefItems}
593601 filteredItems={filteredBaseRefItems}
@@ -677,12 +685,20 @@ export default function DiffPanel({ · 83 unmodified lines
677685 />
678686 </div>
679687 ) : choice.remote ? (
680− <span
681− className="flex justify-end text-muted-foreground"
682− title="Remote only"
683− >
684− <CheckIcon aria-hidden="true" className="size-3" />
685− </span>
688+ <Tooltip>
689+ <TooltipTrigger
690+ render={
691+ <span className="flex justify-end text-muted-foreground">
692+ <CheckIcon
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>
686702 ) : null}
687703 </div>
688704 </ComboboxItem>
apps/web/src/components/ServerUpdateAction.tsx+7−3
@@ -11,6 +11,7 @@ import { useAtomCommand } from "~/state/use-atom-command"; · 10 unmodified lines
1111 import { manualServerUpdateCommand } from "~/versionSkew";
1212 import { Button } from "./ui/button";
1313 import { toastManager } from "./ui/toast";
14+import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
1415
1516 // The wire "installing" stage is a sub-second launcher handoff, so the UI
1617 // folds it into the download phase; everything after the handoff is the
@@ -45,9 +46,12 @@ export function ServerUpdateProgress({ · 28 unmodified lines
4546 return (
4647 <div className="mt-1 flex min-w-0 items-center gap-2 text-xs text-destructive" role="alert">
4748 <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− {state.message}
50− </span>
49+ <Tooltip>
50+ <TooltipTrigger render={<span className="min-w-0 truncate">{state.message}</span>} />
51+ <TooltipPopup side="top" className="max-w-80">
52+ {state.message}
53+ </TooltipPopup>
54+ </Tooltip>
5155 </div>
5256 );
5357 }
apps/web/src/components/Sidebar.tsx+50−32
@@ -537,15 +537,21 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { · 536 unmodified lines
537537 {props.projectTitle}
538538 </span>
539539 <span className="ml-auto flex h-5 min-w-5 shrink-0 items-center justify-end">
540− <button
541− type="button"
542− aria-label="Discard draft"
543− title="Discard draft"
544− onClick={handleDiscard}
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"
546− >
547− <XIcon className="size-3" />
548− </button>
540+ <Tooltip>
541+ <TooltipTrigger
542+ render={
543+ <button
544+ type="button"
545+ aria-label="Discard draft"
546+ onClick={handleDiscard}
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+ >
549+ <XIcon className="size-3" />
550+ </button>
551+ }
552+ />
553+ <TooltipPopup side="top">Discard draft</TooltipPopup>
554+ </Tooltip>
549555 </span>
550556 </div>
551557 <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: { · 695 unmodified lines
12471253 ) : isWoke ? (
12481254 // A wake can land straight in the settled tail (e.g. PR
12491255 // merged while snoozed); the signal must survive the trip.
1250− <button
1251− type="button"
1252− aria-label="Dismiss Woke notification"
1253− title="Dismiss Woke notification"
1254− onClick={handleAcknowledgeWokeClick}
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"
1256− >
1257− <AlarmClockIcon aria-hidden className="size-3" />
1258− <span role="status">Woke</span>
1259− </button>
1256+ <Tooltip>
1257+ <TooltipTrigger
1258+ render={
1259+ <button
1260+ type="button"
1261+ aria-label="Dismiss Woke notification"
1262+ onClick={handleAcknowledgeWokeClick}
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"
1264+ >
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>
12601272 ) : (
12611273 <span className="text-xs">
12621274 {variantAction === "unsettle"
@@ -1414,19 +1426,25 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { · 151 unmodified lines
14141426 >
14151427 {topStatus ? (
14161428 isWokeStatus ? (
1417− <button
1418− type="button"
1419− aria-label="Dismiss Woke notification"
1420− title="Dismiss Woke notification"
1421− onClick={handleAcknowledgeWokeClick}
1422− className={cn(
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",
1424− topStatus.className,
1425− )}
1426− >
1427− <AlarmClockIcon aria-hidden className="size-4 shrink-0" />
1428− <span role="status">{topStatus.label}</span>
1429− </button>
1429+ <Tooltip>
1430+ <TooltipTrigger
1431+ render={
1432+ <button
1433+ type="button"
1434+ aria-label="Dismiss Woke notification"
1435+ onClick={handleAcknowledgeWokeClick}
1436+ className={cn(
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",
1438+ topStatus.className,
1439+ )}
1440+ >
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>
14301448 ) : (
14311449 <span
14321450 className={cn(
apps/web/src/components/chat/ChangedFilesTree.tsx+20−15
@@ -174,21 +174,26 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { · 173 unmodified lines
174174 </p>
175175 <div className="mt-2 flex flex-wrap items-center gap-1.5">
176176 {previewFiles.map((file) => (
177− <button
178− key={file.path}
179− type="button"
180− title={file.path}
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"
182− onClick={() => onOpenTurnDiff(turnId, file.path)}
183− >
184− <PierreEntryIcon
185− pathValue={file.path}
186− kind="file"
187− theme={resolvedTheme}
188− className="size-3 shrink-0 text-muted-foreground/70"
189− />
190− <span className="truncate">{changedFileName(file.path)}</span>
191− </button>
177+ <Tooltip key={file.path}>
178+ <TooltipTrigger
179+ render={
180+ <button
181+ type="button"
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+ onClick={() => onOpenTurnDiff(turnId, file.path)}
184+ />
185+ }
186+ >
187+ <PierreEntryIcon
188+ pathValue={file.path}
189+ kind="file"
190+ theme={resolvedTheme}
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>
192197 ))}
193198 <button
194199 type="button"
apps/web/src/components/chat/ComposerPreviewAnnotationCards.test.tsx+3−2
@@ -37,8 +37,9 @@ describe("ComposerPreviewAnnotationCards", () => { · 36 unmodified lines
3737 );
3838
3939 expect(markup).toContain("Make this headline feel intentional.");
40− expect(markup).toContain('title="1 region"');
41− expect(markup).toContain('title="1 style change"');
40+ expect(markup.match(/data-slot="tooltip-trigger"/g)).toHaveLength(2);
41+ expect(markup).not.toContain('title="1 region"');
42+ expect(markup).not.toContain('title="1 style change"');
4243 expect(markup).not.toContain("Welcome");
4344 expect(markup).not.toContain("localhost:3000");
4445 expect(markup).not.toContain("Preview annotation");
apps/web/src/components/chat/ComposerPreviewAnnotationCards.tsx+13−7
@@ -6,6 +6,7 @@ import type { ComposerImageAttachment } from "~/composerDraftStore"; · 5 unmodified lines
66 import { formatElementContextLabel, normalizeElementContextSelection } from "~/lib/elementContext";
77 import { cn } from "~/lib/utils";
88 import { Button } from "../ui/button";
9+import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
910
1011 interface ComposerPreviewAnnotationCardsProps {
1112 annotations: ReadonlyArray<PreviewAnnotationPayload>;
@@ -16,14 +17,19 @@ interface ComposerPreviewAnnotationCardsProps { · 4 unmodified lines
1617 }
1718
1819 function TargetStat(props: { icon: ReactNode; count: number; label: string }) {
20+ const tooltipText = `${props.count} ${props.label}${props.count === 1 ? "" : "s"}`;
1921 return (
20− <span
21− className="inline-flex items-center gap-1 text-[10px] font-medium text-muted-foreground"
22− title={`${props.count} ${props.label}${props.count === 1 ? "" : "s"}`}
23− >
24− {props.icon}
25− {props.count}
26− </span>
22+ <Tooltip>
23+ <TooltipTrigger
24+ render={
25+ <span className="inline-flex items-center gap-1 text-[10px] font-medium text-muted-foreground">
26+ {props.icon}
27+ {props.count}
28+ </span>
29+ }
30+ />
31+ <TooltipPopup side="top">{tooltipText}</TooltipPopup>
32+ </Tooltip>
2733 );
2834 }
2935
apps/web/src/components/chat/DraftHeroHeadline.tsx+26−10
@@ -23,6 +23,7 @@ import { · 22 unmodified lines
2323 MenuSeparator,
2424 MenuTrigger,
2525 } from "../ui/menu";
26+import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
2627
2728 interface DraftHeroHeadlineProps {
2829 readonly activeProjectRef: ScopedProjectRef | null;
@@ -99,13 +100,23 @@ export function DraftHeroHeadline({ · 70 unmodified lines
99100
100101 const projectSelector = shouldShowProjectMenu ? (
101102 <Menu>
102− <MenuTrigger
103− aria-label={hasResolvedProject ? "Change project" : "Choose a project"}
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− title={activeProjectDisplayName ?? undefined}
106− >
107− {activeProjectDisplayName ?? "Choose a project"}
108− </MenuTrigger>
103+ <Tooltip>
104+ <TooltipTrigger
105+ render={
106+ <MenuTrigger
107+ aria-label={hasResolvedProject ? "Change project" : "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"
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>
109120 <MenuPopup align="center" className="max-h-80 min-w-40! w-max max-w-64 overflow-y-auto">
110121 <MenuRadioGroup
111122 value={activeProjectKey}
@@ -126,9 +137,14 @@ export function DraftHeroHeadline({ · 14 unmodified lines
126137 {projectPickerEntries.map(({ group }) => {
127138 return (
128139 <MenuRadioItem key={group.projectKey} value={group.projectKey} closeOnClick>
129− <span className="block min-w-0 truncate" title={group.displayName}>
130− {group.displayName}
131− </span>
140+ <Tooltip>
141+ <TooltipTrigger render={<span className="block min-w-0 truncate" />}>
142+ {group.displayName}
143+ </TooltipTrigger>
144+ <TooltipPopup side="top" className="max-w-80">
145+ {group.displayName}
146+ </TooltipPopup>
147+ </Tooltip>
132148 </MenuRadioItem>
133149 );
134150 })}
apps/web/src/components/files/FilePreviewPanel.tsx+19−11
@@ -880,17 +880,25 @@ export default function FilePreviewPanel({ · 879 unmodified lines
880880 {index > 0 ? (
881881 <ChevronRight className="mx-1 size-3.5 shrink-0 text-muted-foreground/60" />
882882 ) : null}
883− <span
884− className={cn(
885− "max-w-40 truncate",
886− crumb.kind === "file"
887− ? "font-medium text-foreground"
888− : "text-muted-foreground",
889− )}
890− title={crumb.path || projectName}
891− >
892− {crumb.label}
893− </span>
883+ <Tooltip>
884+ <TooltipTrigger
885+ render={
886+ <span
887+ className={cn(
888+ "max-w-40 truncate",
889+ crumb.kind === "file"
890+ ? "font-medium text-foreground"
891+ : "text-muted-foreground",
892+ )}
893+ />
894+ }
895+ >
896+ {crumb.label}
897+ </TooltipTrigger>
898+ <TooltipPopup side="top" className="max-w-80">
899+ {crumb.path || projectName}
900+ </TooltipPopup>
901+ </Tooltip>
894902 </div>
895903 ))}
896904 </div>
apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx+57−39
@@ -8,6 +8,7 @@ import { BrowserSurfaceSlot } from "~/browser/BrowserSurfaceSlot"; · 7 unmodified lines
88 import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";
99 import { Button } from "~/components/ui/button";
1010 import { toastManager } from "~/components/ui/toast";
11+import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
1112 import { useThreadPreviewState } from "~/previewStateStore";
1213 import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore";
1314 import { useRightPanelStore } from "~/rightPanelStore";
@@ -254,45 +255,63 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props · 240 unmodified lines
254255 onPointerUp={endDrag}
255256 onPointerCancel={endDrag}
256257 >
257− <Button
258− variant="ghost"
259− size="icon-xs"
260− aria-label="Open preview in right panel"
261− title="Open in right panel"
262− onPointerDown={(event) => event.stopPropagation()}
263− onClick={openInPanel}
264− >
265− <PanelRightIcon />
266− </Button>
267− <Button
268− variant={desktopOverlay?.pictureInPicture ? "secondary" : "ghost"}
269− size="icon-xs"
270− aria-label={
271− desktopOverlay?.pictureInPicture
272− ? "Close popped-out preview"
273− : "Pop preview into separate window"
274− }
275− title={
276− desktopOverlay?.pictureInPicture
258+ <Tooltip>
259+ <TooltipTrigger
260+ render={
261+ <Button
262+ variant="ghost"
263+ size="icon-xs"
264+ aria-label="Open preview in right panel"
265+ onPointerDown={(event) => event.stopPropagation()}
266+ onClick={openInPanel}
267+ />
268+ }
269+ >
270+ <PanelRightIcon />
271+ </TooltipTrigger>
272+ <TooltipPopup side="top">Open in right panel</TooltipPopup>
273+ </Tooltip>
274+ <Tooltip>
275+ <TooltipTrigger
276+ render={
277+ <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
277295 ? "Close separate window"
278− : "Pop into separate window"
279− }
280− disabled={!desktopOverlay?.hasWebContents}
281− onPointerDown={(event) => event.stopPropagation()}
282− onClick={toggleNativePictureInPicture}
283− >
284− <PictureInPicture2 />
285− </Button>
286− <Button
287− variant="ghost"
288− size="icon-xs"
289− aria-label="Close floating preview"
290− title="Close floating preview"
291− onPointerDown={(event) => event.stopPropagation()}
292− onClick={close}
293− >
294− <XIcon />
295− </Button>
296+ : "Pop into separate window"}
297+ </TooltipPopup>
298+ </Tooltip>
299+ <Tooltip>
300+ <TooltipTrigger
301+ render={
302+ <Button
303+ variant="ghost"
304+ size="icon-xs"
305+ aria-label="Close floating preview"
306+ onPointerDown={(event) => event.stopPropagation()}
307+ onClick={close}
308+ />
309+ }
310+ >
311+ <XIcon />
312+ </TooltipTrigger>
313+ <TooltipPopup side="top">Close floating preview</TooltipPopup>
314+ </Tooltip>
296315 </div>
297316 </div>
298317
@@ -319,7 +338,6 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props · 20 unmodified lines
319338 <button
320339 type="button"
321340 aria-label="Resize floating preview"
322− title="Resize floating preview"
323341 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"
324342 onPointerDown={handleResizePointerDown}
325343 onPointerMove={handleResizePointerMove}
apps/web/src/components/pullRequest/PullRequestChecksPopover.tsx+7−3
@@ -11,6 +11,7 @@ import { pullRequestEnvironment } from "~/state/pullRequests"; · 10 unmodified lines
1111 import { useEnvironmentQuery } from "~/state/query";
1212
1313 import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
14+import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
1415 import {
1516 PullRequestCheckStatusIcon,
1617 pullRequestCheckStatusLabel,
@@ -57,9 +58,12 @@ function ChecksBody({ checks }: { checks: ReadonlyArray<PullRequestCheck> }) { · 40 unmodified lines
5758 {checks.map((check, index) => (
5859 <li key={`${index}:${check.name}`} className="flex items-center gap-2 text-xs">
5960 <PullRequestCheckStatusIcon status={check.status} />
60− <span className="min-w-0 flex-1 truncate" title={check.description ?? check.name}>
61− {check.name}
62− </span>
61+ <Tooltip>
62+ <TooltipTrigger
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>
6367 <span className="shrink-0 text-muted-foreground">
6468 {pullRequestCheckStatusLabel(check.status)}
6569 </span>
apps/web/src/components/pullRequest/PullRequestCodeTab.tsx+14−6
@@ -1006,9 +1006,12 @@ export function PullRequestCodeTab({ · 1005 unmodified lines
10061006 >
10071007 {/* Headlines run long, and the abbreviated oid after one is what a reader
10081008 matches against the commit list on the host. */}
1009− <span className="min-w-0 truncate" title={entry.messageHeadline}>
1010− {entry.messageHeadline}
1011− </span>
1009+ <Tooltip>
1010+ <TooltipTrigger
1011+ render={<span className="min-w-0 truncate">{entry.messageHeadline}</span>}
1012+ />
1013+ <TooltipPopup side="top">{entry.messageHeadline}</TooltipPopup>
1014+ </Tooltip>
10121015 <span className="ml-auto shrink-0 font-mono text-xs text-muted-foreground">
10131016 {entry.oid.slice(0, 7)}
10141017 </span>
@@ -1264,9 +1267,14 @@ export function PullRequestCodeTab({ · 249 unmodified lines
12641267 <div className="max-h-64 space-y-3 overflow-auto px-4 pb-3">
12651268 {[...orphanFiles].map(([path, threads]) => (
12661269 <div key={path}>
1267− <p className="truncate px-3 text-xs text-muted-foreground" title={path}>
1268− {path}
1269− </p>
1270+ <Tooltip>
1271+ <TooltipTrigger
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>
12701278 <div className="mt-1 space-y-2">
12711279 {threads.map((thread) => (
12721280 <div key={thread.id}>
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+129−78
@@ -85,6 +85,7 @@ import { · 84 unmodified lines
8585 } from "../ui/menu";
8686 import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
8787 import { toastManager } from "../ui/toast";
88+import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
8889 import { PullRequestDetailGhost, PullRequestTimelineGhost } from "./PullRequestGhosts";
8990 import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState";
9091 import { DiffPanelLoadingState } from "../DiffPanelShell";
@@ -1086,22 +1087,31 @@ export function PullRequestDetailPanel({ · 995 unmodified lines
10861087 >
10871088 {detail && statePresentation ? (
10881089 <>
1089− <span className="min-w-0 truncate" title={detail.repository}>
1090− {detail.repository}
1091− </span>
1092− <button
1093− type="button"
1094− onClick={() => void readLocalApi()?.shell.openExternal(detail.url)}
1095− onContextMenu={(event) => openNumberContextMenu(event, detail)}
1096− className={cn(
1097− "shrink-0 font-medium underline-offset-2 hover:underline",
1098− statePresentation.toneClassName,
1099− )}
1100− title={openOnHostLabel(detail.provider)}
1101− aria-label={`Open pull request #${detail.number} on host`}
1102− >
1103− #{detail.number}
1104− </button>
1090+ <Tooltip>
1091+ <TooltipTrigger
1092+ render={<span className="min-w-0 truncate">{detail.repository}</span>}
1093+ />
1094+ <TooltipPopup side="top">{detail.repository}</TooltipPopup>
1095+ </Tooltip>
1096+ <Tooltip>
1097+ <TooltipTrigger
1098+ render={
1099+ <button
1100+ type="button"
1101+ onClick={() => void readLocalApi()?.shell.openExternal(detail.url)}
1102+ onContextMenu={(event) => openNumberContextMenu(event, detail)}
1103+ className={cn(
1104+ "shrink-0 font-medium underline-offset-2 hover:underline",
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>
11051115 </>
11061116 ) : null}
11071117 </div>
@@ -1117,23 +1127,36 @@ export function PullRequestDetailPanel({ · 9 unmodified lines
11171127 >
11181128 {detail && statePresentation ? (
11191129 <>
1120− <button
1121− type="button"
1122− tabIndex={condensed ? 0 : -1}
1123− onClick={() => void readLocalApi()?.shell.openExternal(detail.url)}
1124− onContextMenu={(event) => openNumberContextMenu(event, detail)}
1125− className={cn(
1126− "shrink-0 font-medium underline-offset-2 hover:underline",
1127− statePresentation.toneClassName,
1128− )}
1129− title={openOnHostLabel(detail.provider)}
1130− aria-label={`Open pull request #${detail.number} on host`}
1131− >
1132− #{detail.number}
1133− </button>
1134− <span className="min-w-0 truncate font-medium text-foreground" title={detail.title}>
1135− {detail.title}
1136− </span>
1130+ <Tooltip>
1131+ <TooltipTrigger
1132+ render={
1133+ <button
1134+ type="button"
1135+ tabIndex={condensed ? 0 : -1}
1136+ onClick={() => void readLocalApi()?.shell.openExternal(detail.url)}
1137+ onContextMenu={(event) => openNumberContextMenu(event, detail)}
1138+ className={cn(
1139+ "shrink-0 font-medium underline-offset-2 hover:underline",
1140+ statePresentation.toneClassName,
1141+ )}
1142+ aria-label={`Open pull request #${detail.number} on host`}
1143+ >
1144+ #{detail.number}
1145+ </button>
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>
11371160 {conflicting ? (
11381161 <Badge
11391162 variant="error"
@@ -1385,14 +1408,22 @@ export function PullRequestDetailPanel({ · 245 unmodified lines
13851408 {/* Said where the Merge button is, because it is the answer to why nobody has
13861409 pressed it: the merge is already asked for, and the host is holding it. */}
13871410 {autoMergeArmed ? (
1388− <Badge
1389− variant="info"
1390− className="h-5 shrink-0 gap-1 rounded px-1.5 text-[10px]"
1391− title="The host will merge this on its own once its requirements are met"
1392− >
1393− <GitMergeIcon aria-hidden className="size-3" />
1394− Auto-merge
1395− </Badge>
1411+ <Tooltip>
1412+ <TooltipTrigger
1413+ render={
1414+ <Badge
1415+ variant="info"
1416+ className="h-5 shrink-0 gap-1 rounded px-1.5 text-[10px]"
1417+ >
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>
13961427 ) : null}
13971428 {primaryAction === "ready" ? (
13981429 <Button size="xs" disabled={actionPending} onClick={() => void perform("ready")}>
@@ -1465,11 +1496,13 @@ export function PullRequestDetailPanel({ · 66 unmodified lines
14651496 </button>
14661497 ))}
14671498 </nav>
1468− <span
1469− className="ml-auto inline-flex min-w-0 shrink items-center gap-1 font-mono text-[11px] text-muted-foreground"
1470− title={`${detail.baseBranch} ← ${detail.headBranch}`}
1471− >
1472− <span className="truncate">{detail.baseBranch}</span>
1499+ <span className="ml-auto inline-flex min-w-0 shrink items-center gap-1 font-mono text-[11px] text-muted-foreground">
1500+ <Tooltip>
1501+ <TooltipTrigger render={<span className="truncate" />}>
1502+ {detail.baseBranch}
1503+ </TooltipTrigger>
1504+ <TooltipPopup side="top">{`${detail.baseBranch} ← ${detail.headBranch}`}</TooltipPopup>
1505+ </Tooltip>
14731506 {freshness ? (
14741507 <PullRequestBaseFreshnessWarning
14751508 baseBranch={detail.baseBranch}
@@ -1480,7 +1513,12 @@ export function PullRequestDetailPanel({ · 4 unmodified lines
14801513 />
14811514 ) : null}
14821515 <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>
14841522 </span>
14851523 <span className="ml-2 inline-flex shrink-0 items-center gap-2 text-[11px] text-muted-foreground">
14861524 <span className="inline-flex items-center gap-1 tabular-nums">
@@ -1594,12 +1632,16 @@ export function PullRequestDetailPanel({ · 107 unmodified lines
15941632 </PullRequestMetaLine>
15951633
15961634 <div className="mt-4 flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
1597− <code
1598− className="min-w-0 max-w-48 shrink truncate rounded-md bg-muted px-2 py-1 font-mono text-xs text-foreground"
1599− title={detail.baseBranch}
1600− >
1601− {detail.baseBranch}
1602− </code>
1635+ <Tooltip>
1636+ <TooltipTrigger
1637+ render={
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">
1639+ {detail.baseBranch}
1640+ </code>
1641+ }
1642+ />
1643+ <TooltipPopup side="top">{detail.baseBranch}</TooltipPopup>
1644+ </Tooltip>
16031645 {freshness ? (
16041646 <PullRequestBaseFreshnessWarning
16051647 baseBranch={detail.baseBranch}
@@ -1609,32 +1651,41 @@ export function PullRequestDetailPanel({ · 3 unmodified lines
16091651 />
16101652 ) : null}
16111653 <ArrowLeftIcon aria-label="receives changes from" className="size-4 shrink-0" />
1612− <button
1613− type="button"
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"
1615− aria-label={isBranchCopied ? "Branch name copied" : "Copy pull request branch"}
1616− title={isBranchCopied ? "Copied" : "Copy pull request branch"}
1617− onClick={() => copyBranchToClipboard(detail.headBranch)}
1618− >
1619− <code
1620− className={cn(
1621− "col-start-1 row-start-1 min-w-0 truncate transition-opacity duration-150 motion-reduce:transition-none",
1622− isBranchCopied ? "opacity-0" : "opacity-100",
1623− )}
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− )}
1654+ <Tooltip>
1655+ <TooltipTrigger
1656+ render={
1657+ <button
1658+ type="button"
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"
1660+ aria-label={
1661+ isBranchCopied ? "Branch name copied" : "Copy pull request branch"
1662+ }
1663+ onClick={() => copyBranchToClipboard(detail.headBranch)}
1664+ />
1665+ }
16341666 >
1635− Copied
1636− </span>
1637− </button>
1667+ <code
1668+ className={cn(
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>
16381689 <span className="ml-auto inline-flex shrink-0 items-center justify-end gap-2">
16391690 <span className="inline-flex items-center gap-1.5 tabular-nums">
16401691 <FileDiffIcon className="size-3.5" />
apps/web/src/components/pullRequest/PullRequestListFilters.tsx+38−17
@@ -35,6 +35,7 @@ import { · 34 unmodified lines
3535 MenuSeparator,
3636 MenuTrigger,
3737 } from "../ui/menu";
38+import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
3839
3940 export interface PullRequestFilterOption<Value extends string> {
4041 readonly value: Value;
@@ -156,21 +157,32 @@ function PullRequestFilterRadioGroup<Value extends string>({ · 115 unmodified lines
156157 }}
157158 >
158159 <MenuGroupLabel>{label}</MenuGroupLabel>
159− {options.map((option) => (
160− <MenuRadioItem
161− key={option.value}
162− value={option.value}
163− // A host the server has already said it cannot read is not a choice here: offering
164− // it would answer the press by replacing a working list with that failure.
165− disabled={option.unavailable !== undefined}
166− title={option.unavailable}
167− >
168− <span className="flex min-w-0 items-center gap-2">
169− <option.Icon aria-hidden className="size-3.5" />
170− {option.label}
171− </span>
172− </MenuRadioItem>
173− ))}
160+ {options.map((option) => {
161+ // A host the server has already said it cannot read is not a choice here: offering
162+ // it would answer the press by replacing a working list with that failure.
163+ const item = (
164+ <MenuRadioItem
165+ key={option.value}
166+ value={option.value}
167+ className={option.unavailable ? "data-disabled:pointer-events-auto" : undefined}
168+ disabled={option.unavailable !== undefined}
169+ >
170+ <span className="flex min-w-0 items-center gap-2">
171+ <option.Icon aria-hidden className="size-3.5" />
172+ {option.label}
173+ </span>
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+ })}
174186 </MenuRadioGroup>
175187 );
176188 }
@@ -375,12 +387,12 @@ export function PullRequestFiltersMenu({ · 198 unmodified lines
375387 )
376388 .map((project) => {
377389 const reason = unavailable.get(pullRequestProjectKey(project));
378− return (
390+ const item = (
379391 <MenuRadioItem
380392 key={pullRequestProjectKey(project)}
381393 value={pullRequestProjectKey(project)}
394+ className={reason !== undefined ? "data-disabled:pointer-events-auto" : undefined}
382395 disabled={reason !== undefined}
383− title={reason}
384396 >
385397 <span className="flex min-w-0 flex-1 items-center gap-2">
386398 <ProjectFavicon
@@ -398,6 +410,15 @@ export function PullRequestFiltersMenu({ · 11 unmodified lines
398410 </span>
399411 </MenuRadioItem>
400412 );
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+ );
401422 })}
402423 </MenuRadioGroup>
403424 </MenuPopup>
apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx+19−9
@@ -172,9 +172,14 @@ function CollapsedComment({ · 171 unmodified lines
172172 {open ? (
173173 <div className="px-3 pb-3">
174174 {comment.path ? (
175− <p className="truncate text-xs text-muted-foreground" title={comment.path}>
176− {comment.path}
177− </p>
175+ <Tooltip>
176+ <TooltipTrigger
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>
178183 ) : null}
179184 <CommentBody className="mt-2" comment={comment} editing={editing} />
180185 {reactionBar}
@@ -475,6 +480,7 @@ export function PullRequestSummaryTab({ · 294 unmodified lines
475480 >
476481 <PullRequestActorLabel
477482 actor={actor}
483+ tooltip={false}
478484 className="gap-0 [&>img]:ring-2 [&>img]:ring-background [&>span:first-child]:ring-2 [&>span:first-child]:ring-background [&>span:last-child]:sr-only"
479485 />
480486 </TooltipTrigger>
@@ -754,12 +760,16 @@ export function PullRequestSummaryTab({ · 273 unmodified lines
754760 ) : null}
755761 </div>
756762 {comment.path ? (
757− <p
758− className="mt-1 truncate text-xs text-muted-foreground"
759− title={comment.path}
760− >
761− {comment.path}
762− </p>
763+ <Tooltip>
764+ <TooltipTrigger
765+ render={
766+ <p className="mt-1 truncate text-xs text-muted-foreground">
767+ {comment.path}
768+ </p>
769+ }
770+ />
771+ <TooltipPopup side="top">{comment.path}</TooltipPopup>
772+ </Tooltip>
763773 ) : null}
764774 <CommentBody className="mt-2" comment={comment} editing={commentEditing} />
765775 <PullRequestReactionBar
apps/web/src/components/pullRequest/pullRequestPresentation.tsx+18−3
@@ -226,16 +226,31 @@ export function PullRequestActorAvatar({ · 225 unmodified lines
226226 export function PullRequestActorLabel({
227227 actor,
228228 className,
229+ tooltip = true,
229230 }: {
230231 actor: PullRequestActor | null;
231232 className?: string;
233+ tooltip?: boolean;
232234 }) {
233235 const login = actor?.login ?? "ghost";
234− return (
235− <span className={cn("flex min-w-0 items-center gap-1.5", className)} title={login}>
236+ const label = (
237+ <>
236238 <PullRequestActorAvatar actor={actor} />
237239 <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>
239254 );
240255 }
241256
apps/web/src/components/search/ProjectContentSearchDialog.tsx+18−11
@@ -12,6 +12,7 @@ import { PierreEntryIcon } from "../chat/PierreEntryIcon"; · 11 unmodified lines
1212 import { CommandPaletteContent } from "../CommandPaletteContent";
1313 import { ScrollArea } from "../ui/scroll-area";
1414 import { Toggle } from "../ui/toggle";
15+import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
1516 import { HighlightedSearchLine } from "./HighlightedSearchLine";
1617
1718 interface ProjectContentSearchDialogProps {
@@ -59,17 +60,23 @@ function SearchOptionButton(props: { · 41 unmodified lines
5960 readonly children: ReactNode;
6061 }) {
6162 return (
62− <Toggle
63− aria-label={props.label}
64− pressed={props.active}
65− title={props.label}
66− className="size-8 rounded-[5px] font-mono text-muted-foreground data-pressed:text-foreground sm:size-7"
67− size="compact"
68− variant="ghost"
69− onClick={props.onClick}
70− >
71− {props.children}
72− </Toggle>
63+ <Tooltip>
64+ <TooltipTrigger
65+ render={
66+ <Toggle
67+ aria-label={props.label}
68+ pressed={props.active}
69+ className="size-8 rounded-[5px] font-mono text-muted-foreground data-pressed:text-foreground sm:size-7"
70+ size="compact"
71+ variant="ghost"
72+ onClick={props.onClick}
73+ />
74+ }
75+ >
76+ {props.children}
77+ </TooltipTrigger>
78+ <TooltipPopup side="top">{props.label}</TooltipPopup>
79+ </Tooltip>
7380 );
7481 }
7582
apps/web/src/components/settings/ConnectionsSettings.tsx+45−20
@@ -692,8 +692,13 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ · 691 unmodified lines
692692 />
693693 <h3 className="text-sm font-medium text-foreground">{primaryLabel}</h3>
694694 </div>
695− <p className="text-xs text-muted-foreground" title={expiresAbsolute}>
696− {formatExpiresInLabel(pairingLink.expiresAt, nowMs)}
695+ <p className="text-xs text-muted-foreground">
696+ <Tooltip>
697+ <TooltipTrigger render={<span />}>
698+ {formatExpiresInLabel(pairingLink.expiresAt, nowMs)}
699+ </TooltipTrigger>
700+ <TooltipPopup side="top">{expiresAbsolute}</TooltipPopup>
701+ </Tooltip>
697702 <span aria-hidden> · </span>
698703 <AccessScopeSummary scopes={pairingLink.scopes} label="Pairing link scopes" />
699704 </p>
@@ -840,12 +845,18 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ · 140 unmodified lines
840845 </div>
841846 ) : null}
842847 <div className="flex items-center gap-2 rounded-lg border border-border/60 bg-muted/30 px-2.5 py-1.5">
843− <code
844− title={qrPairingUrl}
845− className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground"
846− >
847− {qrPairingUrl}
848− </code>
848+ <Tooltip>
849+ <TooltipTrigger
850+ render={
851+ <code className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground">
852+ {qrPairingUrl}
853+ </code>
854+ }
855+ />
856+ <TooltipPopup side="top" className="max-w-80 break-all">
857+ {qrPairingUrl}
858+ </TooltipPopup>
859+ </Tooltip>
849860 <Button
850861 size="xs"
851862 variant="ghost"
@@ -1234,12 +1245,18 @@ const AdvertisedEndpointListRow = memo(function AdvertisedEndpointListRow({ · 382 unmodified lines
12341245 {endpoint.label}
12351246 </h3>
12361247 {shouldShowEndpointUrl ? (
1237− <p
1238− className="min-w-0 truncate text-xs leading-5 text-muted-foreground"
1239− title={endpoint.httpBaseUrl}
1240− >
1241− {endpoint.httpBaseUrl}
1242− </p>
1248+ <Tooltip>
1249+ <TooltipTrigger
1250+ render={
1251+ <p className="min-w-0 truncate text-xs leading-5 text-muted-foreground">
1252+ {endpoint.httpBaseUrl}
1253+ </p>
1254+ }
1255+ />
1256+ <TooltipPopup side="top" className="max-w-80">
1257+ {endpoint.httpBaseUrl}
1258+ </TooltipPopup>
1259+ </Tooltip>
12431260 ) : null}
12441261 {!isAvailable ? (
12451262 <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() { · 2067 unmodified lines
33133330 ) : null}
33143331 <div className="rounded-md border border-border/70 bg-muted/20 px-3 py-2">
33153332 <p className="text-xs font-medium text-muted-foreground">HTTPS endpoint</p>
3316− <p
3317− className="mt-1 truncate text-sm text-foreground"
3318− title={pendingTailscaleServeBaseUrl ?? undefined}
3319− >
3320− {pendingTailscaleServeBaseUrl ?? "Pending MagicDNS endpoint"}
3321− </p>
3333+ <Tooltip>
3334+ <TooltipTrigger
3335+ render={
3336+ <p className="mt-1 truncate text-sm text-foreground">
3337+ {pendingTailscaleServeBaseUrl ?? "Pending MagicDNS endpoint"}
3338+ </p>
3339+ }
3340+ />
3341+ {pendingTailscaleServeBaseUrl ? (
3342+ <TooltipPopup side="top" className="max-w-80">
3343+ {pendingTailscaleServeBaseUrl}
3344+ </TooltipPopup>
3345+ ) : null}
3346+ </Tooltip>
33223347 </div>
33233348 </DialogPanel>
33243349 <DialogFooter>
apps/web/src/components/settings/ProjectSettingsPanel.tsx+23−16
@@ -97,6 +97,7 @@ import { · 96 unmodified lines
9797 import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
9898 import { SidebarInset } from "../ui/sidebar";
9999 import { stackedThreadToast, toastManager } from "../ui/toast";
100+import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
100101 import {
101102 WorkspaceBreadcrumb,
102103 WorkspaceBreadcrumbItem,
@@ -937,22 +938,28 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { · 834 unmodified lines
937938 >
938939 <div className="px-3 py-2 sm:px-4">
939940 <div className="flex min-w-0 items-center rounded-lg bg-muted/30 p-1 text-base text-muted-foreground sm:text-sm">
940− <button
941− aria-label="Copy checkout path"
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− title="Copy path"
944− type="button"
945− onClick={() =>
946− copyPathToClipboard(selectedCheckout.workspaceRoot, {
947− path: selectedCheckout.workspaceRoot,
948− })
949− }
950− >
951− <code className="min-w-0 flex-1 truncate font-mono">
952− {selectedCheckout.workspaceRoot}
953− </code>
954− <CopyIcon className="size-4 shrink-0 opacity-60 group-hover:opacity-100" />
955− </button>
941+ <Tooltip>
942+ <TooltipTrigger
943+ render={
944+ <button
945+ aria-label="Copy checkout path"
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"
947+ type="button"
948+ onClick={() =>
949+ copyPathToClipboard(selectedCheckout.workspaceRoot, {
950+ path: selectedCheckout.workspaceRoot,
951+ })
952+ }
953+ >
954+ <code className="min-w-0 flex-1 truncate font-mono">
955+ {selectedCheckout.workspaceRoot}
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>
956963 <div className="shrink-0 border-l border-border/60 px-2 tabular-nums">
957964 {selectedCheckoutThreadCount === 1
958965 ? "1 thread"
apps/web/src/components/settings/ThemeColorPicker.tsx+39−26
@@ -4,6 +4,7 @@ import { isThemeColor, themeColorToHex, type ThemeColorRole } from "../../themeP · 3 unmodified lines
44 import { cn } from "../../lib/utils";
55 import { Input } from "../ui/input";
66 import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
7+import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
78 export function getThemeRoleLabel(role: ThemeColorRole): string {
89 const labels: Partial<Record<ThemeColorRole, string>> = {
910 canvas: "Background",
@@ -434,23 +435,29 @@ function ThemeColorPicker({ · 424 unmodified lines
434435 }) {
435436 return (
436437 <Popover>
437− <PopoverTrigger
438− render={
439− <button
440− aria-label={`Choose ${label} color`}
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− onFocus={onInteract}
443− onPointerDown={onInteract}
444− title={`Choose ${label} color`}
445− type="button"
446− >
447− <span
448− className="absolute inset-0 rounded-full shadow-sm"
449− style={{ backgroundColor: value }}
438+ <Tooltip>
439+ <TooltipTrigger
440+ render={
441+ <PopoverTrigger
442+ render={
443+ <button
444+ aria-label={`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"
446+ onFocus={onInteract}
447+ onPointerDown={onInteract}
448+ type="button"
449+ >
450+ <span
451+ className="absolute inset-0 rounded-full shadow-sm"
452+ style={{ backgroundColor: value }}
453+ />
454+ </button>
455+ }
450456 />
451− </button>
452− }
453− />
457+ }
458+ />
459+ <TooltipPopup side="top">{`Choose ${label} color`}</TooltipPopup>
460+ </Tooltip>
454461 <PopoverPopup
455462 align="end"
456463 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({ · 39 unmodified lines
496503 )}
497504 data-theme-color-role={role}
498505 >
499− <button
500− aria-label={`${selected ? "Hide" : "Show"} ${label} usage`}
501− aria-pressed={selected}
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"
503− onClick={() => onToggleSelected?.(role)}
504− title={`${selected ? "Hide" : "Show"} where ${label} is used`}
505− type="button"
506− >
507− <span className="min-w-0 flex-1 truncate">{label}</span>
508− </button>
506+ <Tooltip>
507+ <TooltipTrigger
508+ render={
509+ <button
510+ aria-label={`${selected ? "Hide" : "Show"} ${label} usage`}
511+ aria-pressed={selected}
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"
513+ onClick={() => onToggleSelected?.(role)}
514+ type="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>
509522 <div className="ml-auto flex shrink-0 items-center gap-2">
510523 <ThemeColorPicker
511524 label={label}
apps/web/src/routes/_chat.pull-requests.tsx+28−16
@@ -78,6 +78,7 @@ import { PanelLayoutControls } from "../components/chat/PanelLayoutControls"; · 77 unmodified lines
7878 import { Button } from "../components/ui/button";
7979 import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../components/ui/menu";
8080 import { SidebarInset } from "../components/ui/sidebar";
81+import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip";
8182 import { useLiveRefresh } from "../hooks/useLiveRefresh";
8283 import {
8384 selectActiveRightPanelSurface,
@@ -1639,22 +1640,33 @@ function CompactFilterMenu<Value extends string>({ · 1555 unmodified lines
16391640 </MenuTrigger>
16401641 <MenuPopup align="start" side="bottom" className="min-w-40">
16411642 <MenuRadioGroup value={value} onValueChange={(next) => onChange(next as Value)}>
1642− {options.map((option) => (
1643− <MenuRadioItem
1644− key={option.value}
1645− value={option.value}
1646− // A host the server has already said it cannot read is not a choice here either.
1647− // The pills disable it; a menu that offers it would answer the press by replacing
1648− // a working list with the same failure the pill row exists to explain.
1649− disabled={option.unavailable !== undefined}
1650− title={option.unavailable}
1651− >
1652− <span className="flex min-w-0 items-center gap-2">
1653− <option.Icon aria-hidden className="size-3.5" />
1654− {option.label}
1655− </span>
1656− </MenuRadioItem>
1657− ))}
1643+ {options.map((option) => {
1644+ // A host the server has already said it cannot read is not a choice here either.
1645+ // The pills disable it; a menu that offers it would answer the press by replacing
1646+ // a working list with the same failure the pill row exists to explain.
1647+ const item = (
1648+ <MenuRadioItem
1649+ key={option.value}
1650+ value={option.value}
1651+ className={option.unavailable ? "data-disabled:pointer-events-auto" : undefined}
1652+ disabled={option.unavailable !== undefined}
1653+ >
1654+ <span className="flex min-w-0 items-center gap-2">
1655+ <option.Icon aria-hidden className="size-3.5" />
1656+ {option.label}
1657+ </span>
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+ })}
16581670 </MenuRadioGroup>
16591671 </MenuPopup>
16601672 </Menu>
oxlint-plugin-t3code/index.ts+2−0
@@ -4,6 +4,7 @@ import namespaceNodeImports from "./rules/namespace-node-imports.ts"; · 3 unmodified lines
44 import noGlobalProcessRuntime from "./rules/no-global-process-runtime.ts";
55 import noInlineSchemaCompile from "./rules/no-inline-schema-compile.ts";
66 import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-tests.ts";
7+import noNativeTitleTooltip from "./rules/no-native-title-tooltip.ts";
78
89 export default definePlugin({
910 meta: {
@@ -14,5 +15,6 @@ export default definePlugin({ · 4 unmodified lines
1415 "no-global-process-runtime": noGlobalProcessRuntime,
1516 "no-inline-schema-compile": noInlineSchemaCompile,
1617 "no-manual-effect-runtime-in-tests": noManualEffectRuntimeInTests,
18+ "no-native-title-tooltip": noNativeTitleTooltip,
1719 },
1820 });
oxlint-plugin-t3code/rules/no-native-title-tooltip.test.ts+61−0
@@ -0,0 +1,61 @@
1+import { assert, describe } from "@effect/vitest";
2+
3+import { createOxlintRuleHarness } from "../test/utils.ts";
4+
5+const rule = createOxlintRuleHarness("t3code/no-native-title-tooltip", {
6+ filename: "fixture.tsx",
7+});
8+
9+describe("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+39−0
@@ -0,0 +1,39 @@
1+import { defineRule } from "@oxlint/plugins";
2+
3+const 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.
8+const TITLE_IS_AN_ACCESSIBLE_NAME = new Set(["embed", "frame", "iframe", "math", "object"]);
9+
10+export 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−0
@@ -121,6 +121,7 @@ export default defineConfig({ · 120 unmodified lines
121121 "t3code/no-global-process-runtime": "error",
122122 "t3code/no-inline-schema-compile": "warn",
123123 "t3code/no-manual-effect-runtime-in-tests": "error",
124+ "t3code/no-native-title-tooltip": "error",
124125 "t3code/namespace-node-imports": "error",
125126 },
126127 options: {