diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 3bab1315ee8b..9ca02389b192 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -59,6 +59,7 @@ import { useLinkedThreadPullRequest, } from "./ThreadStatusIndicators"; import { Button } from "./ui/button"; +import { ComposerControl } from "./chat/ComposerControl"; import { Switch } from "./ui/switch"; import { getVirtualizedScrollFadeClassName } from "./ui/scroll-area"; import { @@ -790,7 +791,7 @@ export function BranchToolbarBranchSelector({ data-composer-context-control > } badge={prBadge} number={prNumber} url={prUrl} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 437ae73a0ce5..060b7c86a7a9 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -221,7 +221,7 @@ import { } from "../providerInstances"; import { useThreadRunningTerminalIds } from "../state/terminalSessions"; import { stackedThreadToast, toastManager } from "./ui/toast"; -import { Button } from "./ui/button"; +import { Button, InlineButton } from "./ui/button"; import { Combobox, ComboboxEmpty, @@ -1517,7 +1517,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { const prBadge = prBadgeShape?.kind === "stack" || pr || currentLinkedPr ? ( } badge={prBadgeShape} number={pr?.number ?? currentLinkedPr?.number} url={pr?.url ?? currentLinkedPr?.url} diff --git a/apps/web/src/components/ThreadStatusIndicators.test.tsx b/apps/web/src/components/ThreadStatusIndicators.test.tsx index 2ad3ef64448e..48066bc97e80 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.test.tsx @@ -1,8 +1,16 @@ import { ThreadId, type ThreadPullRequestLink } from "@t3tools/contracts"; +import type { ReactElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vite-plus/test"; -import { ThreadWorktreeIndicator, linkedPullRequestSnapshotStatus } from "./ThreadStatusIndicators"; +import { + ThreadPullRequestBadgeControl, + ThreadWorktreeIndicator, + linkedPullRequestSnapshotStatus, + prStatusIndicator, +} from "./ThreadStatusIndicators"; +import { ComposerControl } from "./chat/ComposerControl"; +import { InlineButton } from "./ui/button"; describe("ThreadWorktreeIndicator", () => { it("renders the worktree folder and branch in an accessible label", () => { @@ -80,3 +88,62 @@ describe("linked pull request snapshots", () => { }); }); }); + +describe("ThreadPullRequestBadgeControl", () => { + const merged = prStatusIndicator( + { + number: 370, + url: "https://github.com/example/orchard/pull/370", + title: "Change", + state: "merged", + isDraft: false, + headRef: "feature", + baseRef: "main", + updatedAt: "2026-01-02T00:00:00Z", + }, + null, + ); + + function renderBadge(render: ReactElement) { + const html = renderToStaticMarkup( + {}} + onOpenPullRequest={() => {}} + />, + ); + const link = html.match(/]*class="([^"]*)"/)?.[1]?.split(" ") ?? []; + return { html, link }; + } + + it("sets the meta size and normal weight on the control itself, over the InlineButton's", () => { + const { link } = renderBadge(); + + expect(link).toContain("text-xs"); + expect(link).toContain("font-normal"); + expect(link).not.toContain("font-medium"); + }); + + it.each([ + ["an InlineButton", () => ], + ["a ComposerControl", () => ], + ])( + "keeps the state tone on the icon and number inside %s, whatever its hover colour", + (_, control) => { + const tone = merged!.colorClass; + const { html, link } = renderBadge(control()); + // The glyph and number sit in a wrapper that carries the tone and no hover or focus + // colour, so the control's own hover:text-* can never repaint them. + const wrapper = html.match(/]*>.*<\/svg>#?370<\/span>/); + }, + ); +}); diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 8b27b74fd0a5..6873640d07e2 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -15,8 +15,8 @@ import { type ThreadPullRequestBadge, } from "@t3tools/shared/threadPullRequests"; import { FolderGit2Icon, TerminalIcon } from "lucide-react"; -import { useMemo, type MouseEvent } from "react"; -import { Button, InlineButton } from "./ui/button"; +import { useRender } from "@base-ui/react/use-render"; +import { useMemo, type MouseEvent, type ReactElement } from "react"; import { cn } from "../lib/utils"; import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; @@ -191,9 +191,14 @@ export function resolveThreadPullRequestBadgePresentation({ }; } -/** The complete linked-PR control shared by the sidebar and composer footer. */ +/** + * The linked-PR badge shared by the sidebar and composer footer. The badge owns what it shows: + * the state glyph and number at the meta size, in the state's color. The caller owns the control + * it sits in through `render` (an inline link in a sidebar row, a toolbar control in the + * composer), and the badge fills in the link or stack button behavior. + */ export function ThreadPullRequestBadgeControl({ - variant, + render, badge, number, url, @@ -201,7 +206,7 @@ export function ThreadPullRequestBadgeControl({ onOpenStack, onOpenPullRequest, }: { - variant: "underline" | "ghost"; + render: ReactElement<{ render?: useRender.RenderProp }>; badge: ThreadPullRequestBadge | null; number?: number | undefined; url?: string | undefined; @@ -211,53 +216,70 @@ export function ThreadPullRequestBadgeControl({ }) { const presentation = resolveThreadPullRequestBadgePresentation({ badge, number, url, status }); if (presentation === null) return null; - const isStack = badge?.kind === "stack"; - const content = ( - <> - - {presentation.text} - + return ( + ); - const linkProps = isStack - ? { - onClick: (event: MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - onOpenStack(); - }, +} + +function PullRequestBadge({ + render, + presentation, + isStack, + url, + onOpenStack, + onOpenPullRequest, +}: { + render: ReactElement<{ render?: useRender.RenderProp }>; + presentation: NonNullable>; + isStack: boolean; + url: string | undefined; + onOpenStack: () => void; + onOpenPullRequest: (event: MouseEvent) => void; +}) { + const onClick = isStack + ? (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onOpenStack(); } - : { onClick: onOpenPullRequest }; + : onOpenPullRequest; const element = isStack ? ( ) : ( ); + // The caller's control (InlineButton, ComposerControl) renders as the link or stack button + // through its own render prop; useRender merges the badge's behavior into it. The badge + // owns its type: the control itself reads at the meta size (text-xs sets the size and its + // line height), so its line box, hover underline and baseline match the row's timestamp + // instead of the control's inherited body size and weight. The state tone also sits on an + // inner contents span (it adds no box), because a control's own hover or focus colour + // (ComposerControl's hover:text-foreground/80) would otherwise repaint the icon and number; + // the control keeps the tone too, so an InlineButton's hover underline takes it. + const control = useRender({ + render, + props: { + render: element, + className: cn("font-normal text-xs tabular-nums", presentation.toneClassName), + "aria-label": presentation.label, + onPointerDown: (event: MouseEvent) => event.stopPropagation(), + onClick, + }, + }); return ( - event.stopPropagation()} - {...linkProps} - /> - ) : ( - event.stopPropagation()} - {...linkProps} - /> - ) - } - > - {content} + + + + {presentation.text} + {presentation.label} diff --git a/scripts/lint-restyle-ceiling.ts b/scripts/lint-restyle-ceiling.ts index 06429c1b2e20..0c1028d44833 100644 --- a/scripts/lint-restyle-ceiling.ts +++ b/scripts/lint-restyle-ceiling.ts @@ -8,7 +8,7 @@ import * as NodeURL from "node:url"; // components/ui exports are migrated to variants (see vite.config.ts). This gate keeps the // count from growing: CI fails when findings exceed the ceiling. Lower the ceiling when you // migrate a file, and delete this script when the rule becomes an error. -export const RESTYLE_CEILING = 628; +export const RESTYLE_CEILING = 627; const RULE = "shadcn(no-restyle)";