diff --git a/packages/junior-dashboard/e2e/conversations.spec.ts b/packages/junior-dashboard/e2e/conversations.spec.ts index c8a0fcf129..032a6aa326 100644 --- a/packages/junior-dashboard/e2e/conversations.spec.ts +++ b/packages/junior-dashboard/e2e/conversations.spec.ts @@ -134,7 +134,8 @@ test("opens a conversation in the built dashboard", async ({ page }) => { .filter({ hasText: /^\$0\.03$/ }); await expect(costMetric).toHaveCount(1); await costMetric.focus(); - const costTooltip = page.getByRole("tooltip"); + // Scope past sidebar linked-work tooltips that can stay open on the selected row. + const costTooltip = page.getByRole("tooltip").filter({ hasText: /\$/ }); await expect(costTooltip).toBeVisible(); const tooltipId = await costTooltip.getAttribute("id"); expect(tooltipId).toBeTruthy(); diff --git a/packages/junior-dashboard/src/client/conversations/ConversationMeta.tsx b/packages/junior-dashboard/src/client/conversations/ConversationMeta.tsx index 8d2b8057e2..04c5bdd516 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationMeta.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationMeta.tsx @@ -8,6 +8,7 @@ import { LockKeyhole, TriangleAlert, } from "lucide-react"; +import { useSyncExternalStore } from "react"; import { Link } from "react-router"; import type { ConversationDetailReport } from "@sentry/junior/api/schema"; @@ -22,9 +23,12 @@ import { } from "../format"; import { Tooltip } from "../components/Tooltip"; import { MetricList, type MetricListItem } from "../components/Metric"; +import { cn } from "../styles"; import { CostMetric, DurationMetric, TokenMetric } from "./TelemetryMetrics"; import type { Conversation } from "../types"; +const MOBILE_MEDIA_QUERY = "(max-width: 767px)"; + /** Show a pending OAuth authorization call-to-action above the composer. */ export function PendingAuthorization(props: { authorization: { @@ -96,23 +100,233 @@ export function hasConversationAnnotations( ); } -/** Render annotations selected by plugins for a conversation row. */ +/** Render plugin annotations as a newest-first stack in a conversation row. */ export function ConversationSidebarAnnotations(props: { annotations: ConversationDetailReport["sidebarAnnotations"] | undefined; }) { - if (!props.annotations?.length) return null; - return props.annotations.map((summary) => ( + const annotations = props.annotations; + const isMobile = useIsMobileViewport(); + if (!annotations?.length) return null; + + const details = annotations.map((annotation) => + sidebarAnnotationDetail(annotation), + ); + // Collapse same-label scopes for the stack so one repo doesn't become two + // chips. The tooltip still lists every annotation. + const stack = collapseSidebarAnnotationStack(annotations); + + return ( + + {annotations.map((annotation, index) => ( +
  • + {annotation.icon ? ( + + ) : null} + {details[index]} +
  • + ))} + + } + label="Linked work" + triggerClassName="min-w-0" + > + + {isMobile ? ( + + ) : stack.length <= 2 ? ( + + {stack.map((annotation) => ( + + ))} + + ) : ( + + )} + +
    + ); +} + +/** Compact overlapping status chips used by mobile and overflow clusters. */ +function SidebarAnnotationIconFacepile(props: { + annotations: NonNullable; + /** + * CSS color for the avatar-stack cutout ring. Must match the surface under + * the facepile so lower chips read as clean silhouettes. + */ + cutoutColor: string; + className?: string; +}) { + return ( - {summary.icon ? : null} - - {summary.label} + {props.annotations.map((annotation, index) => ( + 0} + zIndex={index + 1} + /> + ))} + + ); +} + +/** One continuous stack: labeled chip, icon chips, then the count chip. */ +function SidebarAnnotationOverflowStack(props: { + annotations: NonNullable; +}) { + const [primary, ...overflow] = props.annotations; + if (!primary || overflow.length === 0) return null; + return ( + + + {overflow.map((annotation, index) => ( + + ))} + + +{overflow.length} + + + ); +} + +function SidebarAnnotationChip(props: { + annotation: NonNullable< + ConversationDetailReport["sidebarAnnotations"] + >[number]; +}) { + return ( + + {props.annotation.icon ? ( + + ) : null} + + {props.annotation.label} - )); + ); +} + +/** Icon-only chip that matches labeled-chip chrome and stacks like a facepile. */ +function SidebarAnnotationStatusChip(props: { + annotation: NonNullable< + ConversationDetailReport["sidebarAnnotations"] + >[number]; + cutoutColor: string; + stacked?: boolean; + zIndex: number; +}) { + const tone = props.annotation.icon + ? SIDEBAR_ICON_PRESENTATION[props.annotation.icon] + : undefined; + return ( + + ); +} + +function useIsMobileViewport(): boolean { + return useSyncExternalStore( + (onStoreChange) => { + if (typeof window === "undefined") return () => {}; + const media = window.matchMedia(MOBILE_MEDIA_QUERY); + media.addEventListener("change", onStoreChange); + return () => media.removeEventListener("change", onStoreChange); + }, + () => + typeof window !== "undefined" && + window.matchMedia(MOBILE_MEDIA_QUERY).matches, + () => false, + ); +} + +function collapseSidebarAnnotationStack( + annotations: NonNullable, +): NonNullable { + const seen = new Set(); + return annotations.filter((annotation) => { + if (seen.has(annotation.label)) return false; + seen.add(annotation.label); + return true; + }); +} + +function sidebarAnnotationDetail(annotation: { + key: string; + label: string; +}): string { + // Prefer the plugin key when it carries a fuller resource identity than the + // compact label (for example owner/repo#123 vs repo). + return annotation.key.includes("/") || annotation.key.includes("#") + ? annotation.key + : annotation.label; } type SidebarAnnotationIconName = NonNullable< @@ -146,16 +360,31 @@ const SIDEBAR_ICON_PRESENTATION = { function SidebarAnnotationIcon(props: { icon: SidebarAnnotationIconName; size?: number; + /** Hide accessible name when a parent already labels the control. */ + decorative?: boolean; }) { const presentation = SIDEBAR_ICON_PRESENTATION[props.icon]; + const size = props.size ?? 11; return ( - + ); } diff --git a/packages/junior-dashboard/src/client/conversations/ConversationSidebar.tsx b/packages/junior-dashboard/src/client/conversations/ConversationSidebar.tsx index 26cdf7f3de..1719fd7893 100644 --- a/packages/junior-dashboard/src/client/conversations/ConversationSidebar.tsx +++ b/packages/junior-dashboard/src/client/conversations/ConversationSidebar.tsx @@ -190,10 +190,14 @@ function ConversationSidebarRow(props: { includeId: false, }); const title = conversationDisplayTitle(props.conversation); + const hasAnnotations = Boolean(props.conversation.sidebarAnnotations?.length); + // Linked work is denser and more actionable than channel; hide channel when + // annotations own the meta row. + const showLocation = Boolean(location) && !hasAnnotations; const hasMeta = - Boolean(location) || + showLocation || props.conversation.visibility === "private" || - Boolean(props.conversation.sidebarAnnotations?.length); + hasAnnotations; return (
    ) : null} - {location ? {location} : null} - {location && props.conversation.sidebarAnnotations?.length ? ( - - ) : null} + {showLocation ? {location} : null} diff --git a/packages/junior-dashboard/src/mock-reporting/fixtures.ts b/packages/junior-dashboard/src/mock-reporting/fixtures.ts index 40ff7a1063..3fe9bbbc73 100644 --- a/packages/junior-dashboard/src/mock-reporting/fixtures.ts +++ b/packages/junior-dashboard/src/mock-reporting/fixtures.ts @@ -133,7 +133,13 @@ function activeConversation(nowMs: number): ConversationDetailReport { assignedWork: true, unfinishedWork: true, isPriority: true, - sidebarAnnotations: [{ icon: "git-pull-request", key: "github", label: "payments" }], + sidebarAnnotations: [ + { + icon: "git-pull-request", + key: "getsentry/payments#42", + label: "payments", + }, + ], annotations: [ { kind: "resource_link", @@ -331,7 +337,18 @@ function dashboardQaConversation(nowMs: number): ConversationDetailReport { assignedWork: true, unfinishedWork: true, isPriority: true, - sidebarAnnotations: [{ icon: "git-pull-request", key: "github", label: "junior" }], + sidebarAnnotations: [ + { + icon: "git-pull-request", + key: "getsentry/junior#1081", + label: "junior", + }, + { + icon: "circle-dot", + key: "getsentry/junior#1090", + label: "junior", + }, + ], annotations: [ { kind: "resource_link", @@ -763,7 +780,23 @@ function longConversation(nowMs: number): ConversationDetailReport { assignedWork: true, unfinishedWork: true, isPriority: true, - sidebarAnnotations: [{ icon: "git-pull-request", key: "github", label: "2 repos" }], + sidebarAnnotations: [ + { + icon: "circle-dashed", + key: "getsentry/junior#2201", + label: "junior", + }, + { + icon: "git-pull-request", + key: "getsentry/payments#91", + label: "payments", + }, + { + icon: "git-merge", + key: "getsentry/relay#44", + label: "relay", + }, + ], annotations: [ { kind: "resource_link", @@ -777,23 +810,23 @@ function longConversation(nowMs: number): ConversationDetailReport { }, { kind: "resource_link", - key: "getsentry/payments#88", - label: "getsentry/payments#88", + key: "getsentry/payments#91", + label: "getsentry/payments#91", plugin: "github", status: "open", - url: "https://github.com/getsentry/payments/issues/88", + url: "https://github.com/getsentry/payments/pull/91", createdAt: startedAt, - updatedAt: iso(Date.parse(startedAt), 70_000), + updatedAt: iso(Date.parse(startedAt), 75_000), }, { kind: "resource_link", - key: "getsentry/payments#91", - label: "getsentry/payments#91", + key: "getsentry/relay#44", + label: "getsentry/relay#44", plugin: "github", - status: "open", - url: "https://github.com/getsentry/payments/pull/91", + status: "merged", + url: "https://github.com/getsentry/relay/pull/44", createdAt: startedAt, - updatedAt: iso(Date.parse(startedAt), 75_000), + updatedAt: iso(Date.parse(startedAt), 70_000), }, ], cumulativeDurationMs: 552_761, @@ -839,7 +872,18 @@ function incidentConversation(nowMs: number): ConversationDetailReport { // Finished links show the final annotation state in the sidebar. assignedWork: true, finishedWorkAt: iso(nowMs, -42 * 60_000), - sidebarAnnotations: [{ icon: "git-merge", key: "github", label: "payments" }], + sidebarAnnotations: [ + { + icon: "git-merge", + key: "getsentry/payments#77", + label: "payments", + }, + { + icon: "circle-x", + key: "getsentry/payments#61", + label: "payments", + }, + ], annotations: [ { kind: "resource_link", @@ -1736,7 +1780,8 @@ export function readMockPeoplePluginReports( id: "pull-requests-created", type: "bar_chart", title: "Pull requests opened", - description: "Junior-owned pull requests opened for this person per day", + description: + "Junior-owned pull requests opened for this person per day", timeRangeDays: [7, 30, 90], series: [{ key: "created", label: "Opened" }], categories: days, diff --git a/packages/junior-dashboard/tests/dashboard-mock-routes.test.ts b/packages/junior-dashboard/tests/dashboard-mock-routes.test.ts index c7449b85d9..0d62b924b9 100644 --- a/packages/junior-dashboard/tests/dashboard-mock-routes.test.ts +++ b/packages/junior-dashboard/tests/dashboard-mock-routes.test.ts @@ -262,7 +262,16 @@ describe("dashboard canonical-event mock routes", () => { ); expect(dashboardQa.annotations).toHaveLength(2); expect(dashboardQa.sidebarAnnotations).toEqual([ - { icon: "git-pull-request", key: "github", label: "junior" }, + { + icon: "git-pull-request", + key: "getsentry/junior#1081", + label: "junior", + }, + { + icon: "circle-dot", + key: "getsentry/junior#1090", + label: "junior", + }, ]); expect(dashboardQa.unfinishedWork).toBe(true); diff --git a/packages/junior-dashboard/tests/telemetry-components.test.tsx b/packages/junior-dashboard/tests/telemetry-components.test.tsx index 6b8990da5b..9cc0790c82 100644 --- a/packages/junior-dashboard/tests/telemetry-components.test.tsx +++ b/packages/junior-dashboard/tests/telemetry-components.test.tsx @@ -586,19 +586,94 @@ describe("dashboard canonical-event components", () => { expect(html).toContain('title="Open pull request"'); }); - it("renders plugin-selected sidebar annotations", () => { - const html = renderToStaticMarkup( + it("labels one or two scopes and clusters the rest on desktop", () => { + const single = renderToStaticMarkup( + , + ); + expect(single).toContain(">junior<"); + expect(single).toContain("getsentry/junior#2"); + + const dual = renderToStaticMarkup( + , + ); + expect(dual).toContain( + 'aria-label="Linked work, newest first: getsentry/junior#2, getsentry/payments#1"', + ); + expect(dual.indexOf(">junior<")).toBeLessThan(dual.indexOf(">payments<")); + expect(dual).not.toContain(">+1<"); + // Chip icons are decorative; the parent aria-label carries the identity. + expect(dual).toContain("lucide-circle-dot"); + expect(dual).toContain("lucide-git-merge"); + + const stacked = renderToStaticMarkup( + , + ); + expect(stacked).toContain(">junior<"); + expect(stacked).toContain(">+2<"); + expect(stacked).not.toContain(">payments<"); + expect(stacked).not.toContain(">relay<"); + expect(stacked).toContain("getsentry/payments#2"); + expect(stacked).toContain("getsentry/relay#1"); + + const sameRepo = renderToStaticMarkup( , ); - expect(html).toContain("2 repos"); - expect(html).toContain("Merged"); - expect(html).toContain("Open pull request"); - expect(html).toContain("min-w-0 truncate whitespace-nowrap font-sans"); + expect(sameRepo).toContain(">junior<"); + expect(sameRepo.match(/>junior+1<"); + expect(sameRepo).toContain("getsentry/junior#1"); }); it("distinguishes initial detail failures from stale refresh failures", () => { diff --git a/packages/junior-github/src/annotations.ts b/packages/junior-github/src/annotations.ts index e610223305..d47e24ef47 100644 --- a/packages/junior-github/src/annotations.ts +++ b/packages/junior-github/src/annotations.ts @@ -3,14 +3,6 @@ import type { ConversationSidebarAnnotation, } from "@sentry/junior-plugin-api"; -const STATUS_RANK = { - warning: 5, - open: 4, - draft: 3, - merged: 2, - closed: 1, -} as const; - const STATUS_ICON = { warning: "triangle-alert", open: "circle-dot", @@ -19,18 +11,7 @@ const STATUS_ICON = { closed: "circle-x", } as const; -type GitHubAnnotationStatus = keyof typeof STATUS_RANK; - -function repositoryScope( - annotation: ConversationAnnotation, -): { key: string; label: string } | undefined { - try { - const [, owner, repo] = new URL(annotation.url).pathname.split("/"); - return owner && repo ? { key: `${owner}/${repo}`, label: repo } : undefined; - } catch { - return undefined; - } -} +type GitHubAnnotationStatus = keyof typeof STATUS_ICON; function isPullRequestUrl(url: string): boolean { try { @@ -42,38 +23,44 @@ function isPullRequestUrl(url: string): boolean { function sidebarIconForStatus( status: GitHubAnnotationStatus, - links: Array<{ isPullRequest: boolean; status: GitHubAnnotationStatus }>, + url: string, ): ConversationSidebarAnnotation["icon"] { - if ( - status === "open" && - links.some((link) => link.status === "open" && link.isPullRequest) - ) { - return "git-pull-request"; - } + if (status === "open" && isPullRequestUrl(url)) return "git-pull-request"; return STATUS_ICON[status]; } -/** Select the one GitHub annotation summary shown in a conversation row. */ -export function githubSidebarAnnotation( +function repositoryName( + annotation: ConversationAnnotation, +): string | undefined { + try { + const [, , repo] = new URL(annotation.url).pathname.split("/"); + return repo || undefined; + } catch { + return undefined; + } +} + +/** Return GitHub annotations for a conversation row, newest first. */ +export function githubSidebarAnnotations( annotations: ConversationAnnotation[], -): ConversationSidebarAnnotation | undefined { - const links = annotations.flatMap((annotation) => { - const repo = repositoryScope(annotation); - const status = annotation.status as GitHubAnnotationStatus | undefined; - return repo && status - ? [{ isPullRequest: isPullRequestUrl(annotation.url), repo, status }] - : []; - }); - if (links.length === 0) return undefined; - const repos = new Map(links.map((link) => [link.repo.key, link.repo.label])); - const status = links.reduce( - (current, link) => - STATUS_RANK[link.status] > STATUS_RANK[current] ? link.status : current, - "closed", - ); - return { - icon: sidebarIconForStatus(status, links), - key: "github", - label: repos.size === 1 ? [...repos.values()][0]! : `${repos.size} repos`, - }; +): ConversationSidebarAnnotation[] { + return annotations + .flatMap((annotation) => { + const status = annotation.status as GitHubAnnotationStatus | undefined; + const label = repositoryName(annotation); + return status && label + ? [ + { + annotation: { + icon: sidebarIconForStatus(status, annotation.url), + key: annotation.key, + label, + }, + updatedAt: annotation.updatedAt, + }, + ] + : []; + }) + .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)) + .map(({ annotation }) => annotation); } diff --git a/packages/junior-github/src/plugin.ts b/packages/junior-github/src/plugin.ts index 0af6b8071e..e27f78741b 100644 --- a/packages/junior-github/src/plugin.ts +++ b/packages/junior-github/src/plugin.ts @@ -40,7 +40,7 @@ import type { GitHubDb } from "./db/database.js"; import { buildGitHubProfileReport } from "./outcomes/profile-report.js"; import { buildGitHubOutcomeReport } from "./outcomes/report.js"; import { classifyGitHubPullRequestCommitComposition } from "./pull-request-outcomes/commit-composition.js"; -import { githubSidebarAnnotation } from "./annotations.js"; +import { githubSidebarAnnotations } from "./annotations.js"; import { listGitHubAssignedWork, listGitHubFinishedWork, @@ -742,10 +742,12 @@ export function githubPlugin( return { annotationsByConversationId: Object.fromEntries( ctx.conversationIds.flatMap((conversationId) => { - const annotation = githubSidebarAnnotation( + const annotations = githubSidebarAnnotations( ctx.annotationsByConversationId[conversationId] ?? [], ); - return annotation ? [[conversationId, [annotation]]] : []; + return annotations.length > 0 + ? [[conversationId, annotations]] + : []; }), ), }; diff --git a/packages/junior-github/tests/annotations.test.ts b/packages/junior-github/tests/annotations.test.ts index 9734fc0a53..08caef1f63 100644 --- a/packages/junior-github/tests/annotations.test.ts +++ b/packages/junior-github/tests/annotations.test.ts @@ -3,13 +3,14 @@ import { type ConversationAnnotation, } from "@sentry/junior-plugin-api"; import { describe, expect, it } from "vitest"; -import { githubSidebarAnnotation } from "../src/annotations"; +import { githubSidebarAnnotations } from "../src/annotations"; function annotation( repo: string, number: number, status: NonNullable, owner = "getsentry", + updatedAt = "2026-01-01T00:00:01.000Z", kind: "pull" | "issues" = "pull", ): ConversationAnnotation { return { @@ -19,73 +20,89 @@ function annotation( label: `${owner}/${repo}#${number}`, plugin: "github", status, - updatedAt: "2026-01-01T00:00:01.000Z", + updatedAt, url: `https://github.com/${owner}/${repo}/${kind}/${number}`, }; } describe("GitHub conversation sidebar", () => { - it("selects repository scope and final status", () => { - expect( - githubSidebarAnnotation([ - annotation("junior", 1, "merged"), - annotation("junior", 2, "closed"), - ]), - ).toEqual({ icon: "git-merge", key: "github", label: "junior" }); - expect( - githubSidebarAnnotation([ - annotation("junior", 1, "merged"), - annotation("payments", 2, "open"), - ]), - ).toEqual({ icon: "git-pull-request", key: "github", label: "2 repos" }); - expect( - githubSidebarAnnotation([ - annotation("junior", 1, "closed"), - annotation("junior", 2, "closed"), - ]), - ).toEqual({ icon: "circle-x", key: "github", label: "junior" }); + it("returns every annotation newest first", () => { + const sidebar = githubSidebarAnnotations([ + annotation( + "junior", + 1, + "merged", + "getsentry", + "2026-01-01T00:00:01.000Z", + ), + annotation( + "payments", + 2, + "open", + "getsentry", + "2026-01-01T00:00:02.000Z", + ), + annotation( + "junior", + 3, + "closed", + "getsentry", + "2026-01-01T00:00:03.000Z", + ), + ]); + + expect(sidebar).toEqual([ + { + icon: "circle-x", + key: "getsentry/junior#3", + label: "junior", + }, + { + icon: "git-pull-request", + key: "getsentry/payments#2", + label: "payments", + }, + { + icon: "git-merge", + key: "getsentry/junior#1", + label: "junior", + }, + ]); + expect(() => + conversationSidebarAnnotationSchema.array().parse(sidebar), + ).not.toThrow(); }); it("uses the pull request icon for open pull requests", () => { - expect(githubSidebarAnnotation([annotation("junior", 1, "open")])).toEqual({ - icon: "git-pull-request", - key: "github", - label: "junior", - }); + expect(githubSidebarAnnotations([annotation("junior", 1, "open")])).toEqual( + [ + { + icon: "git-pull-request", + key: "getsentry/junior#1", + label: "junior", + }, + ], + ); }); it("keeps the issue icon for open issues", () => { expect( - githubSidebarAnnotation([annotation("junior", 1, "open", "getsentry", "issues")]), - ).toEqual({ icon: "circle-dot", key: "github", label: "junior" }); - }); - - it("prefers the pull request icon when open issues and pull requests mix", () => { - expect( - githubSidebarAnnotation([ - annotation("junior", 1, "open", "getsentry", "issues"), - annotation("junior", 2, "open"), - ]), - ).toEqual({ icon: "git-pull-request", key: "github", label: "junior" }); - }); - - it("counts repositories with the same name under different owners", () => { - expect( - githubSidebarAnnotation([ - annotation("shared", 1, "merged", "getsentry"), - annotation("shared", 2, "open", "example"), + githubSidebarAnnotations([ + annotation( + "junior", + 1, + "open", + "getsentry", + "2026-01-01T00:00:01.000Z", + "issues", + ), ]), - ).toEqual({ icon: "git-pull-request", key: "github", label: "2 repos" }); - }); - - it("keeps valid 100-character GitHub repository names", () => { - const repo = "r".repeat(100); - const sidebar = githubSidebarAnnotation([annotation(repo, 1, "open")]); - expect(sidebar).toEqual({ - icon: "git-pull-request", - key: "github", - label: repo, - }); - expect(() => conversationSidebarAnnotationSchema.parse(sidebar)).not.toThrow(); + ).toEqual([ + { + icon: "circle-dot", + key: "getsentry/junior#1", + label: "junior", + }, + ]); }); }); diff --git a/packages/junior-plugin-api/src/annotations.ts b/packages/junior-plugin-api/src/annotations.ts index 3595c12220..904427a700 100644 --- a/packages/junior-plugin-api/src/annotations.ts +++ b/packages/junior-plugin-api/src/annotations.ts @@ -73,8 +73,6 @@ export interface ConversationSidebarHookContext extends PluginContext { } export interface ConversationSidebarResult { - annotationsByConversationId: Record< - string, - ConversationSidebarAnnotation[] - >; + /** Sidebar annotations in display order. Put the newest annotation first. */ + annotationsByConversationId: Record; }