From be051364623dc521c1a0c524ec9488ae6e6a8891 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:26:36 +0000 Subject: [PATCH] feat(inbox): make report detail sections collapsible Web Inbox lets users collapse the report Summary, Evidence, Runs, and Reviewers sections. Desktop rendered all of them permanently open, and mobile only collapsed Signals. Desktop: `DetailSection` and `RightColumnSection` take `collapsible` (plus `defaultCollapsed`), turning the header into an `aria-expanded` disclosure button that shares a caret with the other. `rightSlot` stays outside the button so the Evidence count and the Reviewers "Add" popover keep their own click targets. Summary, Evidence, Runs, and Reviewers opt in; the data hooks are untouched, so collapsing only hides rendered content. `pr-review`'s `PrSectionHeader` was a near-copy of that chrome, so `PrCommentsSection` now uses the shared primitive and the duplicate is gone. Mobile: new `ReportSection` disclosure carries the accessible expanded state, and Summary and Suggested reviewers join the existing Signals toggle. Signals keeps its expand analytics. Generated-By: PostHog Code Task-Id: 6694c9d5-a520-4499-9af3-b46a15ab27dd --- apps/mobile/src/app/inbox/[...id].tsx | 64 +++--- .../inbox/components/ReportSection.tsx | 70 +++++++ .../inbox/components/SuggestedReviewers.tsx | 183 +++++++++--------- packages/ui/src/features/inbox/CLAUDE.md | 2 +- .../inbox/components/DetailSection.test.tsx | 80 ++++++++ .../inbox/components/DetailSection.tsx | 68 +++++-- .../inbox/components/InboxDetailFrame.tsx | 7 +- .../inbox/components/ReportTasksSection.tsx | 2 +- .../inbox/components/RightColumnSection.tsx | 52 ++++- .../components/SuggestedReviewersSection.tsx | 1 + .../components/utils/SectionCollapseCaret.tsx | 26 +++ .../features/pr-review/PrCommentsSection.tsx | 55 +++--- .../features/pr-review/PrSectionHeader.tsx | 50 ----- 13 files changed, 432 insertions(+), 228 deletions(-) create mode 100644 apps/mobile/src/features/inbox/components/ReportSection.tsx create mode 100644 packages/ui/src/features/inbox/components/DetailSection.test.tsx create mode 100644 packages/ui/src/features/inbox/components/utils/SectionCollapseCaret.tsx delete mode 100644 packages/ui/src/features/pr-review/PrSectionHeader.tsx diff --git a/apps/mobile/src/app/inbox/[...id].tsx b/apps/mobile/src/app/inbox/[...id].tsx index b688222e82..abc2d2690e 100644 --- a/apps/mobile/src/app/inbox/[...id].tsx +++ b/apps/mobile/src/app/inbox/[...id].tsx @@ -16,7 +16,6 @@ import * as Haptics from "expo-haptics"; import { useLocalSearchParams, useRouter } from "expo-router"; import { CaretDown, - CaretRight, ChatCircle, Lightning, Play, @@ -47,6 +46,7 @@ import { } from "@/features/inbox/components/DismissReportSheet"; import { ReportActivity } from "@/features/inbox/components/ReportActivity"; import { ReportFeedbackFooter } from "@/features/inbox/components/ReportFeedbackFooter"; +import { ReportSection } from "@/features/inbox/components/ReportSection"; import { SignalCard } from "@/features/inbox/components/SignalCard"; import { type ReviewerActionExtra, @@ -156,6 +156,7 @@ export default function ReportDetailScreen() { const [dismissOpen, setDismissOpen] = useState(false); const [discussOpen, setDiscussOpen] = useState(false); const [createPrFeedbackOpen, setCreatePrFeedbackOpen] = useState(false); + const [summaryExpanded, setSummaryExpanded] = useState(true); const [signalsExpanded, setSignalsExpanded] = useState(false); const artefactsQuery = useInboxReportArtefacts(reportId ?? null); @@ -519,11 +520,17 @@ export default function ReportDetailScreen() { {/* Summary */} {report.summary && ( - - - + setSummaryExpanded((prev) => !prev)} + > + + + + )} {/* Suggested reviewers */} @@ -538,35 +545,22 @@ export default function ReportDetailScreen() { {/* Signals */} {signals.length > 0 && ( - - - {signalsExpanded ? ( - - ) : ( - - )} - - Signals ({signals.length}) - - - {signalsExpanded && ( - - {signals.map((signal) => ( - - ))} - - )} - + + + {signals.map((signal) => ( + + ))} + + )} {signalsQuery.isLoading && ( Loading signals… diff --git a/apps/mobile/src/features/inbox/components/ReportSection.tsx b/apps/mobile/src/features/inbox/components/ReportSection.tsx new file mode 100644 index 0000000000..9574f86e68 --- /dev/null +++ b/apps/mobile/src/features/inbox/components/ReportSection.tsx @@ -0,0 +1,70 @@ +import { Text } from "@components/text"; +import { CaretDown, CaretRight } from "phosphor-react-native"; +import type { ReactNode } from "react"; +import { Pressable, View } from "react-native"; +import { useThemeColors } from "@/lib/theme"; + +interface ReportSectionProps { + title: string; + /** Appended to the title as `(n)` – omit for sections without a count. */ + count?: number; + /** Icon rendered before the title (e.g. ``). */ + icon?: ReactNode; + expanded: boolean; + onToggle: () => void; + /** + * Controls rendered at the end of the header row, outside the disclosure so + * they stay independently tappable. + */ + rightSlot?: ReactNode; + children: ReactNode; +} + +/** + * Collapsible section on the report detail screen (Summary, Reviewers, + * Signals). Mirrors the desktop `DetailSection` disclosure so the same report + * can be scanned the same way on either host. Expansion state is owned by the + * caller – the Signals disclosure fires analytics when it opens. + */ +export function ReportSection({ + title, + count, + icon, + expanded, + onToggle, + rightSlot, + children, +}: ReportSectionProps) { + const themeColors = useThemeColors(); + + return ( + + + + {expanded ? ( + + ) : ( + + )} + {icon} + + {count === undefined ? title : `${title} (${count})`} + + + {rightSlot ? ( + <> + + {rightSlot} + > + ) : null} + + {expanded ? children : null} + + ); +} diff --git a/apps/mobile/src/features/inbox/components/SuggestedReviewers.tsx b/apps/mobile/src/features/inbox/components/SuggestedReviewers.tsx index ced7aac5ea..9ccf7b83fa 100644 --- a/apps/mobile/src/features/inbox/components/SuggestedReviewers.tsx +++ b/apps/mobile/src/features/inbox/components/SuggestedReviewers.tsx @@ -26,6 +26,7 @@ import { openExternalUrl } from "@/lib/openExternalUrl"; import { useThemeColors } from "@/lib/theme"; import { useUpdateSuggestedReviewers } from "../hooks/useInboxReports"; import { EditReviewersSheet } from "./EditReviewersSheet"; +import { ReportSection } from "./ReportSection"; export type ReviewerActionExtra = Pick< InboxReportActionProperties, @@ -50,6 +51,7 @@ export function SuggestedReviewers({ }: SuggestedReviewersProps) { const themeColors = useThemeColors(); const [editOpen, setEditOpen] = useState(false); + const [expanded, setExpanded] = useState(true); const { mutate: updateReviewers, isPending } = useUpdateSuggestedReviewers(reportId); @@ -108,96 +110,99 @@ export function SuggestedReviewers({ }; return ( - - - - Suggested reviewers - - {isPending && ( - - )} - - setEditOpen(true)} - disabled={isPending} - accessibilityLabel="Add suggested reviewer" - hitSlop={6} - className="flex-row items-center gap-1 rounded-full border border-gray-6 px-2.5 py-1 active:opacity-70 disabled:opacity-50" - > - - Add - - - - {displayReviewers.length === 0 ? ( - - No reviewers assigned. Use “Add” to suggest one. - - ) : ( - - {displayReviewers.map((reviewer) => { - const isMe = - !!reviewer.user?.uuid && - !!meUuid && - reviewer.user.uuid === meUuid; - const displayName = - reviewer.user?.first_name ?? - reviewer.github_name ?? - reviewer.github_login; - return ( - - { - fireAction("click_suggested_reviewer", { - suggested_reviewer_login: reviewer.github_login, - }); - openExternalUrl( - `https://github.com/${reviewer.github_login}`, - ); - }} - hitSlop={4} - className="flex-row items-center gap-2 active:opacity-70" + <> + setExpanded((prev) => !prev)} + rightSlot={ + + {isPending && ( + + )} + setEditOpen(true)} + disabled={isPending} + accessibilityLabel="Add suggested reviewer" + hitSlop={6} + className="flex-row items-center gap-1 rounded-full border border-gray-6 px-2.5 py-1 active:opacity-70 disabled:opacity-50" + > + + Add + + + } + > + {displayReviewers.length === 0 ? ( + + No reviewers assigned. Use “Add” to suggest one. + + ) : ( + + {displayReviewers.map((reviewer) => { + const isMe = + !!reviewer.user?.uuid && + !!meUuid && + reviewer.user.uuid === meUuid; + const displayName = + reviewer.user?.first_name ?? + reviewer.github_name ?? + reviewer.github_login; + return ( + - { + fireAction("click_suggested_reviewer", { + suggested_reviewer_login: reviewer.github_login, + }); + openExternalUrl( + `https://github.com/${reviewer.github_login}`, + ); }} - className="h-6 w-6 rounded-full bg-gray-4" - /> - - {displayName} - - {isMe && ( - - - - )} - - removeReviewer(reviewer)} - disabled={isPending} - accessibilityLabel={`Remove ${displayName}`} - hitSlop={6} - className="h-5 w-5 items-center justify-center rounded-full active:bg-gray-4 disabled:opacity-50" - > - - - - ); - })} - - )} + hitSlop={4} + className="flex-row items-center gap-2 active:opacity-70" + > + + + {displayName} + + {isMe && ( + + + + )} + + removeReviewer(reviewer)} + disabled={isPending} + accessibilityLabel={`Remove ${displayName}`} + hitSlop={6} + className="h-5 w-5 items-center justify-center rounded-full active:bg-gray-4 disabled:opacity-50" + > + + + + ); + })} + + )} + setEditOpen(false)} onToggle={toggleReviewer} /> - + > ); } diff --git a/packages/ui/src/features/inbox/CLAUDE.md b/packages/ui/src/features/inbox/CLAUDE.md index b8b8273d57..60fe7c6593 100644 --- a/packages/ui/src/features/inbox/CLAUDE.md +++ b/packages/ui/src/features/inbox/CLAUDE.md @@ -123,7 +123,7 @@ The current UI is single-column, route-based, and card/list oriented. Do not rei Shared primitives exist to keep the surfaces consistent: - `InboxDetailPageHeader` for detail headers. -- `DetailSection` for content sections inside detail screens. +- `DetailSection` for main-column content sections inside detail screens, and `RightColumnSection` for the slimmer supporting sections beside them. Both take `collapsible` (plus `defaultCollapsed`) to turn the header into a disclosure button; `rightSlot` stays outside that button so its own controls remain clickable. Mobile's equivalent is `apps/mobile/src/features/inbox/components/ReportSection.tsx`. - `SignalsList` and the existing detail `SignalCard` for contributing signals. - Badge and metadata helpers in `components/utils/` and `InboxMetaRow`. - `SOURCE_PRODUCT_META` for source-product labels and icons. diff --git a/packages/ui/src/features/inbox/components/DetailSection.test.tsx b/packages/ui/src/features/inbox/components/DetailSection.test.tsx new file mode 100644 index 0000000000..39bb62bcd6 --- /dev/null +++ b/packages/ui/src/features/inbox/components/DetailSection.test.tsx @@ -0,0 +1,80 @@ +import { FileTextIcon } from "@phosphor-icons/react"; +import { Theme } from "@radix-ui/themes"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { DetailSection } from "./DetailSection"; +import { RightColumnSection } from "./RightColumnSection"; + +// Both column styles share the same collapsible contract, so exercise them +// through one table – a regression in either primitive collapses (or fails to +// collapse) sections on the report detail. +const SECTIONS = [ + { name: "DetailSection", Section: DetailSection }, + { name: "RightColumnSection", Section: RightColumnSection }, +]; + +describe.each(SECTIONS)("$name", ({ Section }) => { + const renderSection = ( + props: Partial[0]> = {}, + ) => { + render( + + + body + + , + ); + }; + + it("renders the body with no toggle when not collapsible", () => { + renderSection(); + + expect(screen.getByText("body")).toBeInTheDocument(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + it("hides and restores the body from the header, reporting expanded state", async () => { + const user = userEvent.setup(); + renderSection({ collapsible: true }); + + const toggle = screen.getByRole("button", { name: /summary/i }); + expect(toggle).toHaveAttribute("aria-expanded", "true"); + + await user.click(toggle); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByText("body")).not.toBeInTheDocument(); + + await user.click(toggle); + expect(toggle).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByText("body")).toBeInTheDocument(); + }); + + it("starts collapsed with defaultCollapsed", () => { + renderSection({ collapsible: true, defaultCollapsed: true }); + + expect(screen.getByRole("button", { name: /summary/i })).toHaveAttribute( + "aria-expanded", + "false", + ); + expect(screen.queryByText("body")).not.toBeInTheDocument(); + }); + + it("leaves rightSlot controls outside the toggle so they stay clickable", async () => { + const user = userEvent.setup(); + const onAdd = vi.fn(); + renderSection({ + collapsible: true, + rightSlot: ( + + Add + + ), + }); + + await user.click(screen.getByRole("button", { name: "Add" })); + + expect(onAdd).toHaveBeenCalledOnce(); + expect(screen.getByText("body")).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/features/inbox/components/DetailSection.tsx b/packages/ui/src/features/inbox/components/DetailSection.tsx index c72d787816..357b43e84a 100644 --- a/packages/ui/src/features/inbox/components/DetailSection.tsx +++ b/packages/ui/src/features/inbox/components/DetailSection.tsx @@ -1,38 +1,78 @@ import type { IconProps } from "@phosphor-icons/react"; +import { SectionCollapseCaret } from "@posthog/ui/features/inbox/components/utils/SectionCollapseCaret"; import { Flex, Text } from "@radix-ui/themes"; import type { ComponentType, ReactNode } from "react"; +import { useState } from "react"; interface DetailSectionProps { Icon: ComponentType; title: string; + /** + * Controls rendered at the end of the header row. Kept outside the collapse + * toggle so its own click targets stay independently usable. + */ rightSlot?: ReactNode; + /** When set, the icon + title row toggles the body open and closed. */ + collapsible?: boolean; + /** Start collapsed. Only honoured together with `collapsible`. */ + defaultCollapsed?: boolean; children: ReactNode; } +/** + * Main-column content section: icon + title, a spanning divider, then the + * body. Pass `collapsible` to turn the header into a disclosure button – the + * divider and `rightSlot` keep their geometry either way, so a collapsible + * section sits on the same baseline as a static one. + */ export function DetailSection({ Icon, title, rightSlot, + collapsible = false, + defaultCollapsed = false, children, }: DetailSectionProps) { + const [collapsed, setCollapsed] = useState(defaultCollapsed); + const open = !collapsible || !collapsed; + + const header = ( + <> + + + + {title} + + + + {collapsible && } + > + ); + return ( - - - - - {title} - - - + + {collapsible ? ( + setCollapsed((prev) => !prev)} + className="flex min-w-0 flex-1 cursor-pointer items-center gap-3 border-0 bg-transparent p-0 text-left" + > + {header} + + ) : ( + + {header} + + )} {rightSlot && {rightSlot}} - {children} + {open && {children}} ); } diff --git a/packages/ui/src/features/inbox/components/InboxDetailFrame.tsx b/packages/ui/src/features/inbox/components/InboxDetailFrame.tsx index e122dcd0ae..e845a2ab22 100644 --- a/packages/ui/src/features/inbox/components/InboxDetailFrame.tsx +++ b/packages/ui/src/features/inbox/components/InboxDetailFrame.tsx @@ -178,7 +178,11 @@ export function InboxDetailFrame({ - + {evidenceCount} signal diff --git a/packages/ui/src/features/inbox/components/ReportTasksSection.tsx b/packages/ui/src/features/inbox/components/ReportTasksSection.tsx index 905d6bb3a4..5098de0338 100644 --- a/packages/ui/src/features/inbox/components/ReportTasksSection.tsx +++ b/packages/ui/src/features/inbox/components/ReportTasksSection.tsx @@ -21,7 +21,7 @@ export function ReportTasksSection({ report }: ReportTasksSectionProps) { if (!reportTasks || reportTasks.length === 0) return null; return ( - + {reportTasks.map(({ task, purposeLabel }) => ( ; title: string; + /** + * Controls rendered at the end of the caption row. Kept outside the collapse + * toggle so its own click targets stay independently usable. + */ rightSlot?: ReactNode; + /** When set, the caption toggles the body open and closed. */ + collapsible?: boolean; + /** Start collapsed. Only honoured together with `collapsible`. */ + defaultCollapsed?: boolean; children: ReactNode; } @@ -13,31 +23,55 @@ interface RightColumnSectionProps { * Slim caption header used by every section in the detail-view right column. * Smaller and lighter than `DetailSection` (no spanning divider) so the * side column reads as supporting detail rather than competing with the - * main Summary on the left. + * main Summary on the left. Pass `collapsible` to turn the caption into a + * disclosure button. */ export function RightColumnSection({ Icon, title, rightSlot, + collapsible = false, + defaultCollapsed = false, children, }: RightColumnSectionProps) { + const [collapsed, setCollapsed] = useState(defaultCollapsed); + const open = !collapsible || !collapsed; + + const caption = ( + <> + + + {title} + + {collapsible && } + > + ); + return ( - - - - {title} - - + {collapsible ? ( + setCollapsed((prev) => !prev)} + className="flex min-w-0 cursor-pointer items-center gap-2 border-0 bg-transparent p-0 text-left text-gray-10 transition-colors hover:text-gray-11" + > + {caption} + + ) : ( + + {caption} + + )} {rightSlot && {rightSlot}} - {children} + {open && {children}} ); } diff --git a/packages/ui/src/features/inbox/components/SuggestedReviewersSection.tsx b/packages/ui/src/features/inbox/components/SuggestedReviewersSection.tsx index 75e460435e..415898eddd 100644 --- a/packages/ui/src/features/inbox/components/SuggestedReviewersSection.tsx +++ b/packages/ui/src/features/inbox/components/SuggestedReviewersSection.tsx @@ -168,6 +168,7 @@ function SuggestedReviewersBody({ {isPending && } diff --git a/packages/ui/src/features/inbox/components/utils/SectionCollapseCaret.tsx b/packages/ui/src/features/inbox/components/utils/SectionCollapseCaret.tsx new file mode 100644 index 0000000000..6fe939e139 --- /dev/null +++ b/packages/ui/src/features/inbox/components/utils/SectionCollapseCaret.tsx @@ -0,0 +1,26 @@ +import { CaretDownIcon } from "@phosphor-icons/react"; +import { cn } from "@posthog/quill"; + +/** + * Disclosure caret for collapsible detail sections – points down when the + * section is open, right when it is collapsed. Shared by `DetailSection` and + * `RightColumnSection` so both column styles read the same way. + */ +export function SectionCollapseCaret({ + open, + size = 12, +}: { + open: boolean; + size?: number; +}) { + return ( + + ); +} diff --git a/packages/ui/src/features/pr-review/PrCommentsSection.tsx b/packages/ui/src/features/pr-review/PrCommentsSection.tsx index cf2d574367..a6202311d4 100644 --- a/packages/ui/src/features/pr-review/PrCommentsSection.tsx +++ b/packages/ui/src/features/pr-review/PrCommentsSection.tsx @@ -1,11 +1,11 @@ import { ArrowSquareOutIcon, ChatCircleIcon } from "@phosphor-icons/react"; import { Spinner } from "@posthog/quill"; import { MarkdownRenderer } from "@posthog/ui/features/editor/components/MarkdownRenderer"; +import { DetailSection } from "@posthog/ui/features/inbox/components/DetailSection"; import { NestedButton } from "@posthog/ui/primitives/NestedButton"; import { RelativeTimestamp } from "@posthog/ui/primitives/RelativeTimestamp"; -import { useMemo, useState } from "react"; +import { useMemo } from "react"; import { openExternalUrl } from "../../shell/openExternal"; -import { PrSectionHeader } from "./PrSectionHeader"; import { usePrComments } from "./usePrComments"; import { usePrReviewThreads } from "./usePrReviewThreads"; @@ -32,7 +32,6 @@ interface PrCommentsSectionProps { export function PrCommentsSection({ prUrl }: PrCommentsSectionProps) { const commentsQuery = usePrComments(prUrl); const threadsQuery = usePrReviewThreads(prUrl); - const [collapsed, setCollapsed] = useState(true); const items = useMemo((): CommentItem[] => { // Conversation items mix issue comments and review summaries, whose ids @@ -70,18 +69,21 @@ export function PrCommentsSection({ prUrl }: PrCommentsSectionProps) { if (commentsQuery.isLoading || threadsQuery.isLoading) { return ( - {}} - summary={ + collapsible + defaultCollapsed + rightSlot={ Loading… } - /> + > + {/* Header-only while loading – the section lands collapsed anyway. */} + {null} + ); } @@ -110,26 +112,23 @@ export function PrCommentsSection({ prUrl }: PrCommentsSectionProps) { } return ( - - setCollapsed(!collapsed)} - summary={ - - {items.length} comment{items.length === 1 ? "" : "s"} - - } - /> - {!collapsed && ( - - {items.map((item) => ( - - ))} - - )} - + + {items.length} comment{items.length === 1 ? "" : "s"} + + } + > + + {items.map((item) => ( + + ))} + + ); } diff --git a/packages/ui/src/features/pr-review/PrSectionHeader.tsx b/packages/ui/src/features/pr-review/PrSectionHeader.tsx deleted file mode 100644 index 6a3227a421..0000000000 --- a/packages/ui/src/features/pr-review/PrSectionHeader.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import type { IconProps } from "@phosphor-icons/react"; -import { CaretDownIcon } from "@phosphor-icons/react"; -import { Text } from "@radix-ui/themes"; -import type { ComponentType, ReactNode } from "react"; - -/** - * Clickable section header matching the DetailSection chrome, for the - * collapsible PR sections (checks, comments). The right slot stays visible - * while collapsed so it can carry a summary. - */ -export function PrSectionHeader({ - Icon, - title, - collapsed, - onToggle, - summary, -}: { - Icon: ComponentType; - title: string; - collapsed: boolean; - onToggle: () => void; - summary?: ReactNode; -}) { - return ( - - - - - {title} - - - - - {summary} - - - - ); -}
body