| ... | ... | @@ -15,8 +15,8 @@ import { |
| 15 | 15 | type ThreadPullRequestBadge, |
| 16 | 16 | } from "@t3tools/shared/threadPullRequests"; |
| 17 | 17 | import { FolderGit2Icon, TerminalIcon } from "lucide-react"; |
| 18 | | import { useMemo, type MouseEvent } from "react"; |
| 19 | | import { Button, InlineButton } from "./ui/button"; |
| 18 | import { useRender } from "@base-ui/react/use-render"; |
| 19 | import { useMemo, type MouseEvent, type ReactElement } from "react"; |
| 20 | 20 | import { cn } from "../lib/utils"; |
| 21 | 21 | import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; |
| 22 | 22 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; |
| ... | ... | @@ -191,9 +191,14 @@ export function resolveThreadPullRequestBadgePresentation({ |
| 191 | 191 | }; |
| 192 | 192 | } |
| 193 | 193 | |
| 194 | | /** The complete linked-PR control shared by the sidebar and composer footer. */ |
| 194 | /** |
| 195 | * The linked-PR badge shared by the sidebar and composer footer. The badge owns what it shows: |
| 196 | * the state glyph and number at the meta size, in the state's color. The caller owns the control |
| 197 | * it sits in through `render` (an inline link in a sidebar row, a toolbar control in the |
| 198 | * composer), and the badge fills in the link or stack button behavior. |
| 199 | */ |
| 195 | 200 | export function ThreadPullRequestBadgeControl({ |
| 196 | | variant, |
| 201 | render, |
| 197 | 202 | badge, |
| 198 | 203 | number, |
| 199 | 204 | url, |
| ... | ... | @@ -201,7 +206,7 @@ export function ThreadPullRequestBadgeControl({ |
| 201 | 206 | onOpenStack, |
| 202 | 207 | onOpenPullRequest, |
| 203 | 208 | }: { |
| 204 | | variant: "underline" | "ghost"; |
| 209 | render: ReactElement<{ render?: useRender.RenderProp }>; |
| 205 | 210 | badge: ThreadPullRequestBadge | null; |
| 206 | 211 | number?: number | undefined; |
| 207 | 212 | url?: string | undefined; |
| ... | ... | @@ -211,53 +216,65 @@ export function ThreadPullRequestBadgeControl({ |
| 211 | 216 | }) { |
| 212 | 217 | const presentation = resolveThreadPullRequestBadgePresentation({ badge, number, url, status }); |
| 213 | 218 | if (presentation === null) return null; |
| 214 | | const isStack = badge?.kind === "stack"; |
| 215 | | const content = ( |
| 216 | | <> |
| 217 | | <presentation.Icon aria-hidden className="size-3 shrink-0" /> |
| 218 | | {presentation.text} |
| 219 | | </> |
| 219 | return ( |
| 220 | <PullRequestBadge |
| 221 | render={render} |
| 222 | presentation={presentation} |
| 223 | isStack={badge?.kind === "stack"} |
| 224 | url={url} |
| 225 | onOpenStack={onOpenStack} |
| 226 | onOpenPullRequest={onOpenPullRequest} |
| 227 | /> |
| 220 | 228 | ); |
| 221 | | const linkProps = isStack |
| 222 | | ? { |
| 223 | | onClick: (event: MouseEvent<HTMLElement>) => { |
| 224 | | event.preventDefault(); |
| 225 | | event.stopPropagation(); |
| 226 | | onOpenStack(); |
| 227 | | }, |
| 229 | } |
| 230 | |
| 231 | function PullRequestBadge({ |
| 232 | render, |
| 233 | presentation, |
| 234 | isStack, |
| 235 | url, |
| 236 | onOpenStack, |
| 237 | onOpenPullRequest, |
| 238 | }: { |
| 239 | render: ReactElement<{ render?: useRender.RenderProp }>; |
| 240 | presentation: NonNullable<ReturnType<typeof resolveThreadPullRequestBadgePresentation>>; |
| 241 | isStack: boolean; |
| 242 | url: string | undefined; |
| 243 | onOpenStack: () => void; |
| 244 | onOpenPullRequest: (event: MouseEvent<HTMLElement>) => void; |
| 245 | }) { |
| 246 | const onClick = isStack |
| 247 | ? (event: MouseEvent<HTMLElement>) => { |
| 248 | event.preventDefault(); |
| 249 | event.stopPropagation(); |
| 250 | onOpenStack(); |
| 228 | 251 | } |
| 229 | | : { onClick: onOpenPullRequest }; |
| 252 | : onOpenPullRequest; |
| 230 | 253 | const element = isStack ? ( |
| 231 | 254 | <button type="button" /> |
| 232 | 255 | ) : ( |
| 233 | 256 | <a href={url} target="_blank" rel="noopener noreferrer" /> |
| 234 | 257 | ); |
| 258 | // The caller's control (InlineButton, ComposerControl) renders as the link or stack button |
| 259 | // through its own render prop; useRender merges the badge's behavior into it. |
| 260 | const control = useRender({ |
| 261 | render, |
| 262 | props: { |
| 263 | render: element, |
| 264 | "aria-label": presentation.label, |
| 265 | onPointerDown: (event: MouseEvent<HTMLElement>) => event.stopPropagation(), |
| 266 | onClick, |
| 267 | }, |
| 268 | }); |
| 235 | 269 | return ( |
| 236 | 270 | <Tooltip> |
| 237 | | <TooltipTrigger |
| 238 | | render={ |
| 239 | | variant === "ghost" ? ( |
| 240 | | <Button |
| 241 | | render={element} |
| 242 | | variant="ghost" |
| 243 | | size="xs" |
| 244 | | className={presentation.toneClassName} |
| 245 | | aria-label={presentation.label} |
| 246 | | onPointerDown={(event) => event.stopPropagation()} |
| 247 | | {...linkProps} |
| 248 | | /> |
| 249 | | ) : ( |
| 250 | | <InlineButton |
| 251 | | render={element} |
| 252 | | className={presentation.toneClassName} |
| 253 | | aria-label={presentation.label} |
| 254 | | onPointerDown={(event) => event.stopPropagation()} |
| 255 | | {...linkProps} |
| 256 | | /> |
| 257 | | ) |
| 258 | | } |
| 259 | | > |
| 260 | | {content} |
| 271 | <TooltipTrigger render={control}> |
| 272 | <span |
| 273 | className={cn("contents font-normal text-xs tabular-nums", presentation.toneClassName)} |
| 274 | > |
| 275 | <presentation.Icon aria-hidden className="size-3 shrink-0" /> |
| 276 | {presentation.text} |
| 277 | </span> |
| 261 | 278 | </TooltipTrigger> |
| 262 | 279 | <TooltipPopup side="top">{presentation.label}</TooltipPopup> |
| 263 | 280 | </Tooltip> |