Skip to content

Commit 3f1cc9f

Browse files
feat: Add PR badge to sidebar task list (#2422)
Co-authored-by: Charles Vien <charles.v@posthog.com>
1 parent e38d724 commit 3f1cc9f

5 files changed

Lines changed: 142 additions & 70 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { render, screen } from "@testing-library/react";
2+
import userEvent from "@testing-library/user-event";
3+
import { describe, expect, it, vi } from "vitest";
4+
import { NestedButton } from "./NestedButton";
5+
6+
describe("NestedButton", () => {
7+
it("renders an accessible button", () => {
8+
render(<NestedButton onActivate={() => {}}>x</NestedButton>);
9+
expect(screen.getByRole("button")).toBeTruthy();
10+
});
11+
12+
it("calls onActivate on click without bubbling to the parent", async () => {
13+
const onActivate = vi.fn();
14+
const onParentClick = vi.fn();
15+
render(
16+
// biome-ignore lint/a11y/useKeyWithClickEvents: test-only wrapper
17+
// biome-ignore lint/a11y/noStaticElementInteractions: test-only wrapper
18+
<div onClick={onParentClick}>
19+
<NestedButton onActivate={onActivate}>x</NestedButton>
20+
</div>,
21+
);
22+
await userEvent.click(screen.getByRole("button"));
23+
expect(onActivate).toHaveBeenCalledTimes(1);
24+
expect(onParentClick).not.toHaveBeenCalled();
25+
});
26+
27+
it.each(["{Enter}", " "])("activates with the %s key", async (key) => {
28+
const onActivate = vi.fn();
29+
render(<NestedButton onActivate={onActivate}>x</NestedButton>);
30+
screen.getByRole("button").focus();
31+
await userEvent.keyboard(key);
32+
expect(onActivate).toHaveBeenCalledTimes(1);
33+
});
34+
});
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type React from "react";
2+
import { forwardRef } from "react";
3+
4+
interface NestedButtonProps extends React.HTMLAttributes<HTMLSpanElement> {
5+
onActivate: () => void;
6+
}
7+
8+
/**
9+
* A button nested inside another button. Rows like SidebarItem render as a real
10+
* `<button>`, and HTML forbids nesting a `<button>` inside one, so this is a
11+
* `<span role="button">` with full keyboard support instead. Click, double
12+
* click and Enter/Space stop propagation so the parent button does not also
13+
* fire. Forwards its ref and composes any injected handlers so it works as a
14+
* Radix `asChild` trigger (e.g. wrapped in a Tooltip).
15+
*/
16+
export const NestedButton = forwardRef<HTMLSpanElement, NestedButtonProps>(
17+
function NestedButton(
18+
{ onActivate, onClick, onDoubleClick, onKeyDown, children, ...rest },
19+
ref,
20+
) {
21+
return (
22+
// biome-ignore lint/a11y/useSemanticElements: nested clickable inside a parent <button> (e.g. SidebarItem)
23+
<span
24+
{...rest}
25+
ref={ref}
26+
role="button"
27+
tabIndex={0}
28+
onClick={(e) => {
29+
e.stopPropagation();
30+
onClick?.(e);
31+
onActivate();
32+
}}
33+
onDoubleClick={(e) => {
34+
e.stopPropagation();
35+
onDoubleClick?.(e);
36+
}}
37+
onKeyDown={(e) => {
38+
onKeyDown?.(e);
39+
if (e.key === "Enter" || e.key === " ") {
40+
e.preventDefault();
41+
e.stopPropagation();
42+
onActivate();
43+
}
44+
}}
45+
>
46+
{children}
47+
</span>
48+
);
49+
},
50+
);

apps/code/src/renderer/features/sidebar/components/TaskListView.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ function TaskRow({
107107
slackThreadUrl={task.slackThreadUrl}
108108
prState={prState}
109109
hasDiff={hasDiff}
110+
prUrl={task.cloudPrUrl}
110111
timestamp={timestamp}
111112
onClick={onClick}
112113
onDoubleClick={onDoubleClick}

apps/code/src/renderer/features/sidebar/components/items/TaskIcon.tsx

Lines changed: 10 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { DotsCircleSpinner } from "@components/DotsCircleSpinner";
2+
import { NestedButton } from "@components/ui/NestedButton";
23
import { Tooltip } from "@components/ui/Tooltip";
34
import type { SidebarPrState } from "@features/sidebar/hooks/useTaskPrStatus";
45
import type { WorkspaceMode } from "@main/services/workspace/schemas";
@@ -14,8 +15,8 @@ import {
1415
PushPin,
1516
SlackLogo,
1617
} from "@phosphor-icons/react";
17-
import { trpcClient } from "@renderer/trpc/client";
1818
import { isTerminalStatus, type TaskRunStatus } from "@shared/types";
19+
import { openUrlInBrowser } from "@utils/browser";
1920

2021
export const ICON_SIZE = 12;
2122

@@ -39,14 +40,10 @@ function getOriginProductMeta(
3940
return originProduct ? ORIGIN_PRODUCT_META[originProduct] : undefined;
4041
}
4142

42-
// Renders the icon inside a span. When `link` is set the span becomes
43-
// clickable and opens the originating thread externally. SidebarItem renders
44-
// the row as a `<button>`, so a real `<a>` here would be invalid HTML — match
45-
// the inline role="button" pattern used by TaskHoverToolbar.
46-
//
47-
// Returned as a plain React element (not a component) so the span is the
48-
// direct child of Tooltip — Radix's `asChild` Slot needs a host element to
49-
// attach hover handlers to.
43+
// Renders the icon inside a span. When `link` is set the icon becomes a
44+
// clickable NestedButton that opens the originating thread externally.
45+
// SidebarItem renders the row as a `<button>`, so a real `<a>` or a nested
46+
// `<button>` here would be invalid HTML.
5047
function renderIconSpan({
5148
icon,
5249
link,
@@ -59,31 +56,16 @@ function renderIconSpan({
5956
if (!link) {
6057
return <span className="flex items-center justify-center">{icon}</span>;
6158
}
62-
const open = () => {
63-
void trpcClient.os.openExternal.mutate({ url: link });
64-
};
6559
return (
66-
// biome-ignore lint/a11y/useSemanticElements: nested clickable inside SidebarItem button
67-
<span
68-
role="button"
69-
tabIndex={0}
60+
<NestedButton
7061
aria-label={ariaLabel}
7162
className="flex cursor-pointer items-center justify-center rounded transition-opacity hover:opacity-70"
72-
onClick={(e) => {
73-
e.stopPropagation();
74-
open();
75-
}}
76-
onDoubleClick={(e) => e.stopPropagation()}
77-
onKeyDown={(e) => {
78-
if (e.key === "Enter" || e.key === " ") {
79-
e.preventDefault();
80-
e.stopPropagation();
81-
open();
82-
}
63+
onActivate={() => {
64+
void openUrlInBrowser(link);
8365
}}
8466
>
8567
{icon}
86-
</span>
68+
</NestedButton>
8769
);
8870
}
8971

apps/code/src/renderer/features/sidebar/components/items/TaskItem.tsx

Lines changed: 47 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,33 @@
1+
import { NestedButton } from "@components/ui/NestedButton";
12
import { Tooltip } from "@components/ui/Tooltip";
23
import type { SidebarPrState } from "@features/sidebar/hooks/useTaskPrStatus";
34
import type { WorkspaceMode } from "@main/services/workspace/schemas";
4-
import { Archive, PushPin } from "@phosphor-icons/react";
5+
import { Archive, GitPullRequest, PushPin } from "@phosphor-icons/react";
6+
import { parseGithubUrl } from "@posthog/git/utils";
57
import type { TaskRunStatus } from "@shared/types";
8+
import { openUrlInBrowser } from "@utils/browser";
69
import { formatRelativeTimeShort } from "@utils/time";
7-
import { useCallback, useEffect, useRef, useState } from "react";
10+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
811
import { SidebarItem } from "../SidebarItem";
912
import { TaskIcon } from "./TaskIcon";
1013

14+
function PrBadge({ url, number }: { url: string; number: number }) {
15+
return (
16+
<Tooltip content="Open pull request" side="top">
17+
<NestedButton
18+
aria-label={`Open pull request #${number}`}
19+
className="flex h-4 shrink-0 cursor-pointer items-center gap-0.5 rounded bg-gray-3 px-1 text-[11px] text-gray-11 transition-colors hover:bg-gray-4 hover:text-gray-12"
20+
onActivate={() => {
21+
void openUrlInBrowser(url);
22+
}}
23+
>
24+
<GitPullRequest size={10} weight="bold" />
25+
{`#${number}`}
26+
</NestedButton>
27+
</Tooltip>
28+
);
29+
}
30+
1131
interface TaskItemProps {
1232
depth?: number;
1333
taskId: string;
@@ -27,6 +47,7 @@ interface TaskItemProps {
2747
slackThreadUrl?: string;
2848
prState?: SidebarPrState;
2949
hasDiff?: boolean;
50+
prUrl?: string | null;
3051
timestamp?: number;
3152
isEditing?: boolean;
3253
onClick: (e: React.MouseEvent) => void;
@@ -53,50 +74,24 @@ function TaskHoverToolbar({
5374
<span className="hidden shrink-0 items-center gap-0.5 group-hover:flex">
5475
{onTogglePin && (
5576
<Tooltip content={isPinned ? "Unpin task" : "Pin task"} side="top">
56-
{/* biome-ignore lint/a11y/useSemanticElements: Cannot use button inside parent button (SidebarItem) */}
57-
<span
58-
role="button"
59-
tabIndex={0}
77+
<NestedButton
78+
aria-label={isPinned ? "Unpin task" : "Pin task"}
6079
className="flex h-5 w-5 cursor-pointer items-center justify-center rounded text-gray-10 transition-colors hover:bg-gray-4 hover:text-gray-12"
61-
onClick={(e) => {
62-
e.stopPropagation();
63-
onTogglePin();
64-
}}
65-
onDoubleClick={(e) => e.stopPropagation()}
66-
onKeyDown={(e) => {
67-
if (e.key === "Enter" || e.key === " ") {
68-
e.preventDefault();
69-
e.stopPropagation();
70-
onTogglePin();
71-
}
72-
}}
80+
onActivate={onTogglePin}
7381
>
7482
<PushPin size={12} weight={isPinned ? "fill" : "regular"} />
75-
</span>
83+
</NestedButton>
7684
</Tooltip>
7785
)}
7886
{onArchive && (
7987
<Tooltip content="Archive task" side="top">
80-
{/* biome-ignore lint/a11y/useSemanticElements: Cannot use button inside parent button (SidebarItem) */}
81-
<span
82-
role="button"
83-
tabIndex={0}
88+
<NestedButton
89+
aria-label="Archive task"
8490
className="flex h-5 w-5 cursor-pointer items-center justify-center rounded text-gray-10 transition-colors hover:bg-gray-4 hover:text-gray-12"
85-
onClick={(e) => {
86-
e.stopPropagation();
87-
onArchive();
88-
}}
89-
onDoubleClick={(e) => e.stopPropagation()}
90-
onKeyDown={(e) => {
91-
if (e.key === "Enter" || e.key === " ") {
92-
e.preventDefault();
93-
e.stopPropagation();
94-
onArchive();
95-
}
96-
}}
91+
onActivate={onArchive}
9792
>
9893
<Archive size={12} />
99-
</span>
94+
</NestedButton>
10095
</Tooltip>
10196
)}
10297
</span>
@@ -123,6 +118,7 @@ export function TaskItem({
123118
slackThreadUrl,
124119
prState,
125120
hasDiff,
121+
prUrl,
126122
timestamp,
127123
isEditing = false,
128124
onClick,
@@ -149,11 +145,19 @@ export function TaskItem({
149145
/>
150146
);
151147

152-
const timestampNode = timestamp ? (
153-
<span className="shrink-0 text-[11px] text-gray-11 group-hover:hidden">
154-
{formatRelativeTimeShort(timestamp)}
155-
</span>
156-
) : null;
148+
const prRef = useMemo(() => (prUrl ? parseGithubUrl(prUrl) : null), [prUrl]);
149+
const prBadge =
150+
prUrl && prRef?.kind === "pr" ? (
151+
<PrBadge url={prUrl} number={prRef.number} />
152+
) : null;
153+
154+
// The PR badge takes the timestamp's slot, so hide the timestamp when shown.
155+
const timestampNode =
156+
timestamp && !prBadge ? (
157+
<span className="shrink-0 text-[11px] text-gray-11 group-hover:hidden">
158+
{formatRelativeTimeShort(timestamp)}
159+
</span>
160+
) : null;
157161

158162
const toolbar =
159163
!hideHoverActions && (onArchive || onTogglePin) ? (
@@ -165,8 +169,9 @@ export function TaskItem({
165169
) : null;
166170

167171
const endContent =
168-
timestampNode || toolbar ? (
172+
prBadge || timestampNode || toolbar ? (
169173
<>
174+
{prBadge}
170175
{timestampNode}
171176
{toolbar}
172177
</>

0 commit comments

Comments
 (0)