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