Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/web/src/components/BranchToolbarBranchSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -790,7 +791,7 @@ export function BranchToolbarBranchSelector({
data-composer-context-control
>
<ThreadPullRequestBadgeControl
variant="ghost"
render={<ComposerControl size="xs" />}
badge={prBadge}
number={prNumber}
url={prUrl}
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1517,7 +1517,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
const prBadge =
prBadgeShape?.kind === "stack" || pr || currentLinkedPr ? (
<ThreadPullRequestBadgeControl
variant="underline"
render={<InlineButton />}
badge={prBadgeShape}
number={pr?.number ?? currentLinkedPr?.number}
url={pr?.url ?? currentLinkedPr?.url}
Expand Down
69 changes: 68 additions & 1 deletion apps/web/src/components/ThreadStatusIndicators.test.tsx
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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(
<ThreadPullRequestBadgeControl
render={render}
badge={null}
number={370}
url="https://github.com/example/orchard/pull/370"
status={merged}
onOpenStack={() => {}}
onOpenPullRequest={() => {}}
/>,
);
const link = html.match(/<a [^>]*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(<InlineButton />);

expect(link).toContain("text-xs");
expect(link).toContain("font-normal");
expect(link).not.toContain("font-medium");
});

it.each([
["an InlineButton", () => <InlineButton />],
["a ComposerControl", () => <ComposerControl size="xs" />],
])(
"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(/<span class="([^"]*)"><svg/)?.[1] ?? "";

expect(link.join(" ")).toContain("text-xs");
expect(wrapper).toContain(tone);
expect(wrapper).not.toMatch(/(hover|focus[a-z-]*):/);
expect(html).toMatch(/<svg[^>]*>.*<\/svg>#?370<\/span>/);
},
);
});
108 changes: 65 additions & 43 deletions apps/web/src/components/ThreadStatusIndicators.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -191,17 +191,22 @@ 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,
status,
onOpenStack,
onOpenPullRequest,
}: {
variant: "underline" | "ghost";
render: ReactElement<{ render?: useRender.RenderProp }>;
badge: ThreadPullRequestBadge | null;
number?: number | undefined;
url?: string | undefined;
Expand All @@ -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.Icon aria-hidden className="size-3 shrink-0" />
{presentation.text}
</>
return (
<PullRequestBadge
render={render}
presentation={presentation}
isStack={badge?.kind === "stack"}
url={url}
onOpenStack={onOpenStack}
onOpenPullRequest={onOpenPullRequest}
/>
);
const linkProps = isStack
? {
onClick: (event: MouseEvent<HTMLElement>) => {
event.preventDefault();
event.stopPropagation();
onOpenStack();
},
}

function PullRequestBadge({
render,
presentation,
isStack,
url,
onOpenStack,
onOpenPullRequest,
}: {
render: ReactElement<{ render?: useRender.RenderProp }>;
presentation: NonNullable<ReturnType<typeof resolveThreadPullRequestBadgePresentation>>;
isStack: boolean;
url: string | undefined;
onOpenStack: () => void;
onOpenPullRequest: (event: MouseEvent<HTMLElement>) => void;
}) {
const onClick = isStack
? (event: MouseEvent<HTMLElement>) => {
event.preventDefault();
event.stopPropagation();
onOpenStack();
}
: { onClick: onOpenPullRequest };
: onOpenPullRequest;
const element = isStack ? (
<button type="button" />
) : (
<a href={url} target="_blank" rel="noopener noreferrer" />
);
// 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<HTMLElement>) => event.stopPropagation(),
onClick,
},
});
return (
<Tooltip>
<TooltipTrigger
render={
variant === "ghost" ? (
<Button
render={element}
variant="ghost"
size="xs"
className={presentation.toneClassName}
aria-label={presentation.label}
onPointerDown={(event) => event.stopPropagation()}
{...linkProps}
/>
) : (
<InlineButton
render={element}
className={presentation.toneClassName}
aria-label={presentation.label}
onPointerDown={(event) => event.stopPropagation()}
{...linkProps}
/>
)
}
>
{content}
<TooltipTrigger render={control}>
<span className={cn("contents", presentation.toneClassName)}>
<presentation.Icon aria-hidden className="size-3 shrink-0" />
{presentation.text}
</span>
</TooltipTrigger>
<TooltipPopup side="top">{presentation.label}</TooltipPopup>
</Tooltip>
Expand Down
2 changes: 1 addition & 1 deletion scripts/lint-restyle-ceiling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)";

Expand Down
Loading