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";
1212
1313import { Button } from "~/components/ui/button";
1414import { Input } from "~/components/ui/input";
15import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
1516import {
1617 Select,
1718 SelectGroup,
......@@ -296,26 +297,34 @@ export function BrowserDeviceToolbar({
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-1
......@@ -6348,7 +6348,6 @@ function ChatViewContent(props: ChatViewProps) {
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-1
......@@ -59,7 +59,6 @@ export function ConnectionStatusDot({
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({
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({
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";
1111import { manualServerUpdateCommand } from "~/versionSkew";
1212import { Button } from "./ui/button";
1313import { toastManager } from "./ui/toast";
14import { 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({
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: {
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: {
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: {
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: {
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", () => {
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";
66import { formatElementContextLabel, normalizeElementContextSelection } from "~/lib/elementContext";
77import { cn } from "~/lib/utils";
88import { Button } from "../ui/button";
9import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
910
1011interface ComposerPreviewAnnotationCardsProps {
1112 annotations: ReadonlyArray<PreviewAnnotationPayload>;
......@@ -16,14 +17,19 @@ interface ComposerPreviewAnnotationCardsProps {
1617}
1718
1819function 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 {
2323 MenuSeparator,
2424 MenuTrigger,
2525} from "../ui/menu";
26import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
2627
2728interface DraftHeroHeadlineProps {
2829 readonly activeProjectRef: ScopedProjectRef | null;
......@@ -99,13 +100,23 @@ export function DraftHeroHeadline({
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({
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({
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";
88import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId";
99import { Button } from "~/components/ui/button";
1010import { toastManager } from "~/components/ui/toast";
11import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
1112import { useThreadPreviewState } from "~/previewStateStore";
1213import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore";
1314import { useRightPanelStore } from "~/rightPanelStore";
......@@ -254,45 +255,63 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props
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
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";
1111import { useEnvironmentQuery } from "~/state/query";
1212
1313import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
14import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
1415import {
1516 PullRequestCheckStatusIcon,
1617 pullRequestCheckStatusLabel,
......@@ -57,9 +58,12 @@ function ChecksBody({ checks }: { checks: ReadonlyArray<PullRequestCheck> }) {
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({
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({
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 {
8585} from "../ui/menu";
8686import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
8787import { toastManager } from "../ui/toast";
88import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
8889import { PullRequestDetailGhost, PullRequestTimelineGhost } from "./PullRequestGhosts";
8990import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState";
9091import { DiffPanelLoadingState } from "../DiffPanelShell";
......@@ -1086,22 +1087,31 @@ export function PullRequestDetailPanel({
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({
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({
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({
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({
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({
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({
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 {
3535 MenuSeparator,
3636 MenuTrigger,
3737} from "../ui/menu";
38import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
3839
3940export interface PullRequestFilterOption<Value extends string> {
4041 readonly value: Value;
......@@ -156,21 +157,32 @@ function PullRequestFilterRadioGroup<Value extends string>({
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({
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({
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({
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({
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({
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({
226226export 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";
1212import { CommandPaletteContent } from "../CommandPaletteContent";
1313import { ScrollArea } from "../ui/scroll-area";
1414import { Toggle } from "../ui/toggle";
15import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
1516import { HighlightedSearchLine } from "./HighlightedSearchLine";
1617
1718interface ProjectContentSearchDialogProps {
......@@ -59,17 +60,23 @@ function SearchOptionButton(props: {
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({
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({
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({
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() {
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 {
9797import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
9898import { SidebarInset } from "../ui/sidebar";
9999import { stackedThreadToast, toastManager } from "../ui/toast";
100import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
100101import {
101102 WorkspaceBreadcrumb,
102103 WorkspaceBreadcrumbItem,
......@@ -937,22 +938,28 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
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
44import { cn } from "../../lib/utils";
55import { Input } from "../ui/input";
66import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
7import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
78export function getThemeRoleLabel(role: ThemeColorRole): string {
89 const labels: Partial<Record<ThemeColorRole, string>> = {
910 canvas: "Background",
......@@ -434,23 +435,29 @@ function ThemeColorPicker({
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({
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";
7878import { Button } from "../components/ui/button";
7979import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../components/ui/menu";
8080import { SidebarInset } from "../components/ui/sidebar";
81import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip";
8182import { useLiveRefresh } from "../hooks/useLiveRefresh";
8283import {
8384 selectActiveRightPanelSurface,
......@@ -1639,22 +1640,33 @@ function CompactFilterMenu<Value extends string>({
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
......@@ -4,6 +4,7 @@ import namespaceNodeImports from "./rules/namespace-node-imports.ts";
44import noGlobalProcessRuntime from "./rules/no-global-process-runtime.ts";
55import noInlineSchemaCompile from "./rules/no-inline-schema-compile.ts";
66import noManualEffectRuntimeInTests from "./rules/no-manual-effect-runtime-in-tests.ts";
7import noNativeTitleTooltip from "./rules/no-native-title-tooltip.ts";
78
89export default definePlugin({
910 meta: {
......@@ -14,5 +15,6 @@ export default definePlugin({
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 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({
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: {