diff --git a/packages/core/src/inbox/reportMembership.ts b/packages/core/src/inbox/reportMembership.ts index edfca5f9e8..b6a38db5c4 100644 --- a/packages/core/src/inbox/reportMembership.ts +++ b/packages/core/src/inbox/reportMembership.ts @@ -124,6 +124,22 @@ export function isInboxDetailPath(pathname: string): boolean { return INBOX_DETAIL_PATH_RE.test(pathname); } +/** Which tab a list pathname belongs to; anything unrecognised reads as Pulls. */ +export function inboxTabFromPath(pathname: string): InboxTabKey { + if (pathname.startsWith(INBOX_TAB_LIST_ROUTE.reports)) return "reports"; + if (pathname.startsWith(INBOX_TAB_LIST_ROUTE.runs)) return "runs"; + if (pathname.startsWith(INBOX_TAB_LIST_ROUTE.dismissed)) return "dismissed"; + return "pulls"; +} + +/** + * Whether the reviewer-scope control means anything on this tab: Runs is + * unscoped and the Archive is a terminal list, so neither filters by reviewer. + */ +export function inboxScopeApplies(tab: InboxTabKey): boolean { + return tab !== "runs" && tab !== "dismissed"; +} + /** * PR tab membership: Responder shipped a draft PR and it is `ready` for review. * PRs that have already been merged/closed (`resolved`) or are still running diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index 83f6edc458..2da7a31ed6 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -918,7 +918,8 @@ export type ChannelActionType = | "view_activity" | "open_mention" | "canvas_mode_toggle" - | "activity_tab_change"; + | "activity_tab_change" + | "artifacts_view_change"; export interface ChannelActionProperties { action_type: ChannelActionType; @@ -939,6 +940,7 @@ export interface ChannelActionProperties { armed?: boolean; /** For activity_tab_change: the tab landed on. */ tab?: string; + view_mode?: "list" | "grid" | "masonry"; /** Whether the underlying mutation resolved successfully. */ success?: boolean; } @@ -947,6 +949,8 @@ export type DashboardActionType = | "open" | "create" | "delete" + /** The delete was undone inside its undo window, so nothing was removed. */ + | "delete_undo" | "rename" | "save" | "fork" diff --git a/packages/ui/src/features/canvas/components/ActivityView.tsx b/packages/ui/src/features/canvas/components/ActivityView.tsx index 136489b667..cb3e01eba2 100644 --- a/packages/ui/src/features/canvas/components/ActivityView.tsx +++ b/packages/ui/src/features/canvas/components/ActivityView.tsx @@ -31,6 +31,15 @@ import { useMarkTaskActivityRead } from "@posthog/ui/features/canvas/hooks/useMa import { useTaskActivity } from "@posthog/ui/features/canvas/hooks/useTaskActivity"; import { copyChannelLink } from "@posthog/ui/features/canvas/utils/copyChannelLink"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; +import { + PageHeader, + PageHeaderActions, + PageHeaderChip, + PageHeaderDescription, + PageHeaderHeading, + PageHeaderTitle, + PageHeaderTitleRow, +} from "@posthog/ui/primitives/PageHeader"; import { navigateToChannelTask, navigateToTaskDetail, @@ -38,7 +47,7 @@ import { import { track } from "@posthog/ui/shell/analytics"; import { Text } from "@radix-ui/themes"; import type { ReactNode } from "react"; -import { useCallback, useEffect, useMemo } from "react"; +import { memo, useCallback, useEffect, useMemo } from "react"; import { activityReadPayload, channelIdForName, @@ -278,8 +287,11 @@ export function ActivityView() { () => createChannelIdByName(folderChannels), [folderChannels], ); - const folderChannelIdFor = (channelName: string | null): string | null => - channelIdForName(folderIdByName, channelName); + const folderChannelIdFor = useCallback( + (channelName: string | null): string | null => + channelIdForName(folderIdByName, channelName), + [folderIdByName], + ); useEffect(() => { track(ANALYTICS_EVENTS.CHANNEL_ACTION, { action_type: "view_activity", @@ -287,77 +299,164 @@ export function ActivityView() { }); }, []); - return ( -
-
-
-
- - Activity - - - Tasks you're involved in across{" "} - {spacesLayout ? "spaces" : "channels"}. - -
- {unreadCount > 0 && ( - - )} -
-
- {isLoading && items.length === 0 ? ( -
- -
- ) : items.length === 0 ? ( - - - - - - No activity yet - - Tasks you create, get tagged in, or reply to across{" "} - {spacesLayout ? "spaces" : "channels"} land here. - - - - ) : ( -
- {items.map((item) => ( - - ))} - {hasNextPage && ( - - )} + const markAllReadButton = useMemo( + () => + unreadCount > 0 ? ( + + ) : null, + [unreadCount, unreadItems.length, isMarkingRead, markAllRead], + ); + + const feed = ( + + ); + + // The shared page header ships with the spaces layout; without it the page + // keeps the in-container title it has always had. Delete the legacy branch + // when the layout flag graduates. + if (!spacesLayout) { + return ( +
+
+
+
+ + Activity + + + Tasks you're involved in across{" "} + {spacesLayout ? "spaces" : "channels"}. +
- )} + {markAllReadButton} +
+
{feed}
+ ); + } + + return ( +
+ + + + Activity + {unreadCount > 0 && ( + }> + {unreadCount} unread + + )} + {markAllReadButton && ( + {markAllReadButton} + )} + + + Tasks you're involved in across{" "} + {spacesLayout ? "spaces" : "channels"}. + + + +
+
{feed}
+
); } + +/** + * The feed body. A memo'd child rather than JSX built in the parent: the parent + * picks between two page shells and returns early, and this way the branch it + * doesn't take costs nothing. + */ +const ActivityFeed = memo(function ActivityFeed({ + items, + isLoading, + spacesLayout, + folderChannelIdFor, + markRead, + currentUser, + hasNextPage, + isFetchingNextPage, + fetchNextPage, +}: { + items: TaskActivityItem[]; + isLoading: boolean; + spacesLayout: boolean; + folderChannelIdFor: (channelName: string | null) => string | null; + markRead: (item: TaskActivityItem) => void; + currentUser?: UserBasic | null; + hasNextPage: boolean; + isFetchingNextPage: boolean; + fetchNextPage: () => void; +}) { + if (isLoading && items.length === 0) { + return ( +
+ +
+ ); + } + + if (items.length === 0) { + return ( + + + + + + No activity yet + + Tasks you create, get tagged in, or reply to across{" "} + {spacesLayout ? "spaces" : "channels"} land here. + + + + ); + } + + return ( +
+ {items.map((item) => ( + + ))} + {hasNextPage && ( + + )} +
+ ); +}); diff --git a/packages/ui/src/features/canvas/components/ArtifactsViewToggle.tsx b/packages/ui/src/features/canvas/components/ArtifactsViewToggle.tsx new file mode 100644 index 0000000000..c2b91d3aae --- /dev/null +++ b/packages/ui/src/features/canvas/components/ArtifactsViewToggle.tsx @@ -0,0 +1,68 @@ +import { Kanban, ListIcon, SquaresFourIcon } from "@phosphor-icons/react"; +import { + ToggleGroup, + ToggleGroupItem, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@posthog/quill"; +import { + ARTIFACTS_VIEW_MODES, + type ArtifactsViewMode, + useArtifactsViewStore, +} from "@posthog/ui/features/canvas/stores/artifactsViewStore"; +import type { ComponentType } from "react"; + +const OPTIONS: { + mode: ArtifactsViewMode; + label: string; + Icon: ComponentType<{ size?: number; weight?: "bold" }>; +}[] = [ + { mode: "list", label: "List", Icon: ListIcon }, + { mode: "grid", label: "Grid", Icon: SquaresFourIcon }, + { mode: "masonry", label: "Masonry", Icon: Kanban }, +]; + +function isViewMode(value: string | undefined): value is ArtifactsViewMode { + return ARTIFACTS_VIEW_MODES.some((mode) => mode === value); +} + +// Layout switcher for the artifacts list. A quill ToggleGroup carries the +// pressed state itself, so there's no hand-rolled active styling here. +export function ArtifactsViewToggle({ channelId }: { channelId?: string }) { + const view = useArtifactsViewStore((s) => s.view); + const setView = useArtifactsViewStore((s) => s.setView); + + return ( + + { + // Pressing the active item would otherwise clear the group — a view + // is always on, so ignore the empty result. + const mode = next[0]; + if (isViewMode(mode)) setView(mode, channelId); + }} + > + {OPTIONS.map(({ mode, label, Icon }) => ( + + + + + } + /> + {label} + + ))} + + + ); +} diff --git a/packages/ui/src/features/canvas/components/ChannelBreadcrumb.test.tsx b/packages/ui/src/features/canvas/components/ChannelBreadcrumb.test.tsx index f7124726ee..0353b144bf 100644 --- a/packages/ui/src/features/canvas/components/ChannelBreadcrumb.test.tsx +++ b/packages/ui/src/features/canvas/components/ChannelBreadcrumb.test.tsx @@ -1,9 +1,21 @@ import { Theme } from "@radix-ui/themes"; import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Hoisted so the module factory below can read them, and each test can steer +// the current route / assert navigations. +const router = vi.hoisted(() => ({ + pathname: "/website/team/artifacts", + navigate: vi.fn(), +})); vi.mock("@tanstack/react-router", () => ({ - useNavigate: () => vi.fn(), + useNavigate: () => router.navigate, + useRouterState: ({ + select, + }: { + select: (state: { location: { pathname: string } }) => T; + }) => select({ location: { pathname: router.pathname } }), })); vi.mock("@posthog/ui/features/canvas/hooks/useChannelsLayout", () => ({ useChannelsLayout: () => true, @@ -12,6 +24,11 @@ vi.mock("@posthog/ui/features/canvas/hooks/useChannelsLayout", () => ({ import { ChannelBreadcrumb } from "./ChannelBreadcrumb"; describe("ChannelBreadcrumb", () => { + beforeEach(() => { + router.pathname = "/website/team/artifacts"; + router.navigate.mockClear(); + }); + it("closes title editing when the editable leaf changes", () => { const onRename = vi.fn(); const { rerender } = render( @@ -25,7 +42,14 @@ describe("ChannelBreadcrumb", () => { , ); - fireEvent.doubleClick(screen.getByText("Task A")); + // A renamable leaf stays a live control, so it isn't marked disabled. + expect(screen.getByRole("button", { name: "Task A" })).not.toHaveAttribute( + "aria-disabled", + ); + + // One click opens the editor — the leaf never navigates, so a click has + // nothing else to mean. + fireEvent.click(screen.getByRole("button", { name: "Task A" })); expect(screen.getByRole("textbox")).toHaveValue("Task A"); rerender( @@ -43,4 +67,65 @@ describe("ChannelBreadcrumb", () => { expect(screen.getByText("Task B")).toBeInTheDocument(); expect(onRename).not.toHaveBeenCalled(); }); + + it("navigates home from the root segment on a sub-page", () => { + render( + + + , + ); + + const root = screen.getByRole("button", { name: /Team/ }); + expect(root).not.toHaveAttribute("aria-disabled", "true"); + fireEvent.click(root); + expect(router.navigate).toHaveBeenCalledWith({ + to: "/website/$channelId", + params: { channelId: "team" }, + }); + }); + + it("links the middle segment to its section", () => { + const onMiddleClick = vi.fn(); + render( + + + , + ); + + // Every segment is a Button so they share padding and height; the leaf is + // the current page, so it's the disabled one. + expect(screen.getAllByRole("button")).toHaveLength(3); + fireEvent.click(screen.getByRole("button", { name: "Loops" })); + expect(onMiddleClick).toHaveBeenCalledTimes(1); + expect( + screen.getByRole("button", { name: "CI failure summary" }), + ).toHaveAttribute("aria-disabled", "true"); + }); + + it("disables the root segment on the space's own index", () => { + router.pathname = "/website/team"; + render( + + + , + ); + + const root = screen.getByRole("button", { name: /Team/ }); + expect(root).toHaveAttribute("aria-disabled", "true"); + fireEvent.click(root); + expect(router.navigate).not.toHaveBeenCalled(); + }); }); diff --git a/packages/ui/src/features/canvas/components/ChannelBreadcrumb.tsx b/packages/ui/src/features/canvas/components/ChannelBreadcrumb.tsx index e47614aa05..670f071518 100644 --- a/packages/ui/src/features/canvas/components/ChannelBreadcrumb.tsx +++ b/packages/ui/src/features/canvas/components/ChannelBreadcrumb.tsx @@ -1,5 +1,6 @@ import { Button, + cn, Tooltip, TooltipContent, TooltipTrigger, @@ -8,7 +9,7 @@ import { channelGlyph } from "@posthog/ui/features/canvas/components/channelGlyp import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { HeaderTitleEditor } from "@posthog/ui/features/task-detail/HeaderTitleEditor"; import { Flex, Text } from "@radix-ui/themes"; -import { useNavigate } from "@tanstack/react-router"; +import { useNavigate, useRouterState } from "@tanstack/react-router"; import { type ReactNode, useState } from "react"; interface ChannelBreadcrumbProps { @@ -19,14 +20,23 @@ interface ChannelBreadcrumbProps { * sidebar channel row and the channel-view header. */ channelId?: string; + /** + * An optional segment between the space and the leaf — the section a scene + * belongs to, e.g. "{space} / Loops / {loop}". `onClick` links it; without + * one it reads as a plain step. + */ + middle?: { icon?: ReactNode; label: string; onClick?: () => void }; /** Optional leading icon for the leaf segment (e.g. a canvas's tier icon). */ leafIcon?: ReactNode; - /** The trailing (current page) segment label. */ - leafLabel: string; + /** + * The trailing (current page) segment label. Omitted at a space's root, which + * renders the channel segment alone — same size and styling either way. + */ + leafLabel?: string; editScopeKey?: string; /** - * When provided, the leaf becomes inline-editable: double-click to rename, - * Enter or blur to submit, Escape to cancel. Receives the trimmed new value. + * When provided, the leaf becomes inline-editable: click to rename, Enter or + * blur to submit, Escape to cancel. Receives the trimmed new value. */ onRename?: (next: string) => void; /** Right-aligned slot pushed to the far end of the bar (e.g. an opener). */ @@ -35,11 +45,13 @@ interface ChannelBreadcrumbProps { // "# channel / leaf" header breadcrumb shared across channel scenes (CONTEXT.md, // new + existing tasks, canvases). The leaf can carry a tier icon and, when -// onRename is given, edits inline using the same editor as task titles. When -// channelId is given, the "# channel" segment links back to the channel home. +// onRename is given, edits inline on a single click using the same editor as +// task titles. When channelId is given, the "# channel" segment links back to +// the channel home. export function ChannelBreadcrumb({ channelName, channelId, + middle, leafIcon, leafLabel, editScopeKey, @@ -47,84 +59,164 @@ export function ChannelBreadcrumb({ trailing, }: ChannelBreadcrumbProps) { const spacesLayout = useChannelsLayout(); - const currentEditScope = editScopeKey ?? leafLabel; + // Only a leaf is renamable, so the scope key falls back to its label. + const currentEditScope = editScopeKey ?? leafLabel ?? ""; const [editingScope, setEditingScope] = useState(null); const editing = editingScope === currentEditScope; const navigate = useNavigate(); - - const channelSegment = ( - <> - {channelGlyph(channelName, { - size: 12, - space: spacesLayout, - className: "mt-px shrink-0 text-muted-foreground/80", - })} - - {channelName} - - - ); + const pathname = useRouterState({ select: (s) => s.location.pathname }); + const atChannelHome = channelId + ? pathname === `/website/${channelId}` + : false; return ( - - {channelId ? ( - - ) : ( -
{channelSegment}
- )} - / -
- {leafIcon && ( - {leafIcon} - )} - {editing && onRename ? ( - { - setEditingScope(null); - onRename(next); - }} - onCancel={() => setEditingScope(null)} + {/* flex-1 so the inline editor can stretch across the row; the trailing + slot still sits at the far end. */} + + + void navigate({ + to: "/website/$channelId", + params: { channelId }, + }) + : undefined + } + /> + {middle && ( + <> + + - ) : ( - - setEditingScope(currentEditScope) - : undefined - } + + )} + {leafLabel !== undefined && ( + <> + + {editing && onRename ? ( + // Matches the segment it replaces — same height, padding and type + // scale as a `size="sm"` button — so opening the editor doesn't + // jump the row. It takes the rest of the row, since a long name is + // exactly what you're most likely to be editing. + { + setEditingScope(null); + onRename(next); + }} + onCancel={() => setEditingScope(null)} + className="h-6 px-2 font-normal text-[13px]" + /> + ) : onRename ? ( + // Only a renamable leaf gets a tooltip: it carries a user-authored + // name that can be long enough to truncate. Fixed section labels + // never overflow, so a tooltip there is just noise. + + }> + {/* A renamable leaf is a live control — a click opens the + editor — so it reads as one: full-strength text, pointer + cursor, hover fill. */} + setEditingScope(currentEditScope)} /> - } - > - {leafLabel} - - {leafLabel} - - )} -
+ + {leafLabel} + + ) : ( + + )} + + )}
{trailing}
); } + +/** + * One segment of the breadcrumb. Always a Button, so every segment carries the + * same padding, height and icon gap whether or not it goes anywhere — the leaf + * used to be bare text, which left it visually adrift from its siblings. + * + * Without `onClick` the segment is genuinely inert: `aria-disabled` (so quill + * drops the hover fill and assistive tech reads it as unavailable) plus + * `pointer-events-none`, and out of the tab order. The disabled dimming is + * overridden — a breadcrumb has to stay readable. + */ +function BreadcrumbSegment({ + icon, + label, + strong, + muted, + onClick, + ...rest +}: { + icon?: ReactNode; + label: string; + /** The root segment carries the space name, which reads heavier. */ + strong?: boolean; + /** The leaf is the current page, so it sits back from the linked segments. */ + muted?: boolean; + /** Navigates, or (on a renamable leaf) opens the inline editor. */ + onClick?: () => void; +}) { + const interactive = Boolean(onClick); + + return ( + + ); +} + +function BreadcrumbSeparator() { + return ( + / + ); +} diff --git a/packages/ui/src/features/canvas/components/ChannelHeader.tsx b/packages/ui/src/features/canvas/components/ChannelHeader.tsx index 289cb5b713..40f65fb29e 100644 --- a/packages/ui/src/features/canvas/components/ChannelHeader.tsx +++ b/packages/ui/src/features/canvas/components/ChannelHeader.tsx @@ -1,25 +1,64 @@ import { Button, cn } from "@posthog/quill"; +import { ChannelBreadcrumb } from "@posthog/ui/features/canvas/components/ChannelBreadcrumb"; import { ChannelTabs } from "@posthog/ui/features/canvas/components/ChannelTabs"; import { channelGlyph } from "@posthog/ui/features/canvas/components/channelGlyph"; +import { + type ChannelPageKey, + channelPageIcon, + channelPageLabel, +} from "@posthog/ui/features/canvas/components/channelPages"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { useMarkChannelSeen } from "@posthog/ui/features/canvas/hooks/useMarkChannelSeen"; import { Text } from "@radix-ui/themes"; import { useNavigate, useRouterState } from "@tanstack/react-router"; -// The shared channel header. The new layout drops the section tab strip — the -// channel sidebar carries those entries — while flag off keeps it. Starring -// lives on the sidebar back row and the channel list, not here. -export function ChannelHeader({ channelId }: { channelId: string }) { - const navigate = useNavigate(); +// The shared channel header. Every space scene renders the same breadcrumb — +// the root segment is identical whether or not there's a leaf, so the space +// name doesn't change size between the space home and its sub-pages. The new +// layout drops the section tab strip (the channel sidebar carries those +// entries); flag off keeps it. Starring lives on the sidebar back row and the +// channel list, not here. +export function ChannelHeader({ + channelId, + page, +}: { + channelId: string; + /** + * Which space page this is — supplies the leaf's label and icon. Every space + * page names itself, the feed included ("{space} / Feed"); omitting it leaves + * the root segment alone, for scenes that carry no page of their own. + */ + page?: ChannelPageKey; +}) { const channelsLayout = useChannelsLayout(); const { channels } = useChannels(); const channelName = channels.find((c) => c.id === channelId)?.name; - const pathname = useRouterState({ select: (s) => s.location.pathname }); - const isHome = pathname === `/website/${channelId}`; // Every channel surface renders this header, so mark the channel read here. useMarkChannelSeen(channelName); + // Channels-layout off keeps the header it has always had: the channel pill + // plus the section tab strip, no breadcrumb. Delete this branch when the + // layout flag graduates. + if (!channelsLayout) return ; + + return ( + + ); +} + +function LegacyChannelHeader({ channelId }: { channelId: string }) { + const navigate = useNavigate(); + const { channels } = useChannels(); + const channelName = channels.find((c) => c.id === channelId)?.name; + const pathname = useRouterState({ select: (s) => s.location.pathname }); + const isHome = pathname === `/website/${channelId}`; + return (
- {!channelsLayout && } +
); } diff --git a/packages/ui/src/features/canvas/components/ChannelNav.tsx b/packages/ui/src/features/canvas/components/ChannelNav.tsx index 23f0a6fe81..9c324c7676 100644 --- a/packages/ui/src/features/canvas/components/ChannelNav.tsx +++ b/packages/ui/src/features/canvas/components/ChannelNav.tsx @@ -98,6 +98,10 @@ interface NavButtonProps extends ComponentPropsWithRef<"button"> { badge?: ReactNode; } +// Same quill Button as NavIcon above — this variant only exists because the +// Activity entry is a Popover trigger, so it needs to forward the trigger's +// props and ref. Hand-rolling the button here left it a size larger than its +// neighbours. function NavButton({ icon, label, @@ -109,23 +113,23 @@ function NavButton({ ...buttonProps }: NavButtonProps) { return ( - + ); } diff --git a/packages/ui/src/features/canvas/components/ChannelSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelSidebar.tsx index e1589e2a1f..c069e1ac7d 100644 --- a/packages/ui/src/features/canvas/components/ChannelSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ChannelSidebar.tsx @@ -1,10 +1,8 @@ import { - BookOpenTextIcon, ChatsCircleIcon, FunnelSimple as FunnelSimpleIcon, MagnifyingGlass, PackageIcon, - RepeatIcon, } from "@phosphor-icons/react"; import type { CreatedByFilter } from "@posthog/core/canvas/channelItems"; import { filterChannelItems } from "@posthog/core/canvas/channelItems"; @@ -32,6 +30,11 @@ import type { TaskRunStatus } from "@posthog/shared/domain-types"; import { ChannelBackRow } from "@posthog/ui/features/canvas/components/ChannelBackRow"; import { ChannelItemRow } from "@posthog/ui/features/canvas/components/ChannelItemRow"; import { ChannelsFab } from "@posthog/ui/features/canvas/components/ChannelsFab"; +import { + type ChannelPageKey, + channelPageIcon, + channelPageLabel, +} from "@posthog/ui/features/canvas/components/channelPages"; import { useChannelItems } from "@posthog/ui/features/canvas/hooks/useChannelItems"; import { useCommandCenterStore } from "@posthog/ui/features/command-center/commandCenterStore"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; @@ -42,7 +45,7 @@ import { useTasks } from "@posthog/ui/features/tasks/useTasks"; import { navigateToCommandCenter } from "@posthog/ui/router/navigationBridge"; import { logger } from "@posthog/ui/shell/logger"; import { useNavigate, useRouterState } from "@tanstack/react-router"; -import { type ReactNode, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; const CREATED_BY_OPTIONS: readonly { value: CreatedByFilter; label: string }[] = [ @@ -338,16 +341,17 @@ export function ChannelSidebar({ channelId }: { channelId: string }) { /> ); + // Label and icon come from the shared space-page table, so a sidebar row and + // the header breadcrumb for the same page can never disagree. const sectionRow = ( - label: string, - icon: ReactNode, + page: ChannelPageKey, to: string, onClick: () => void, ) => ( @@ -359,15 +363,13 @@ export function ChannelSidebar({ channelId }: { channelId: string }) {
{sectionRow( - "Feed", - , + "home", base, () => void navigate({ to: "/website/$channelId", params: { channelId } }), )} {sectionRow( - "Context", - , + "context", `${base}/context`, () => void navigate({ @@ -377,8 +379,7 @@ export function ChannelSidebar({ channelId }: { channelId: string }) { )} {loopsEnabled && sectionRow( - "Loops", - , + "loops", `${base}/loops`, () => void navigate({ @@ -387,8 +388,7 @@ export function ChannelSidebar({ channelId }: { channelId: string }) { }), )} {sectionRow( - "Artifacts", - , + "artifacts", `${base}/artifacts`, () => void navigate({ diff --git a/packages/ui/src/features/canvas/components/FreeformPreview.tsx b/packages/ui/src/features/canvas/components/FreeformPreview.tsx new file mode 100644 index 0000000000..37b1f62bfc --- /dev/null +++ b/packages/ui/src/features/canvas/components/FreeformPreview.tsx @@ -0,0 +1,137 @@ +import { ShapesIcon, WarningIcon } from "@phosphor-icons/react"; +import { cn, Skeleton, Text } from "@posthog/quill"; +import { FreeformCanvas } from "@posthog/ui/features/canvas/freeform/FreeformCanvas"; +import { handleFreeformDataRequest } from "@posthog/ui/features/canvas/freeform/freeformDataBridge"; +import { useInView } from "@posthog/ui/primitives/hooks/useInView"; +import { ErrorBoundary } from "@posthog/ui/shell/ErrorBoundary"; +import { Box, Flex } from "@radix-ui/themes"; +import { useQueryClient } from "@tanstack/react-query"; +import { type ReactNode, useCallback } from "react"; + +// Render each canvas's live app at 1/SCALE of the card width, then shrink so it +// fits inside the preview frame as a thumbnail. +const PREVIEW_SCALE = 0.4; + +// Mount a preview only while it's near the viewport, and UNMOUNT it once it +// scrolls away (once: false). This caps how many full preview trees / sandbox +// iframes are live at any time, so a channel with many large canvases doesn't +// accumulate pages of off-screen DOM. The margin pre-mounts a little early so +// scrolling doesn't flash an empty frame. The fixed-height frame keeps the +// layout stable across mount/unmount (no scroll jump). +const PREVIEW_VIEWPORT = { once: false, rootMargin: "400px 0px" } as const; + +// A freeform (React-in-iframe) canvas preview: the app rendered at PREVIEW_SCALE +// in a clipped frame. Deferred until near the viewport, and runs with NO +// analytics so it fires no events. +export function FreeformPreview({ + code, + height = 176, + className, +}: { + code?: string; + /** Frame height in px. Taller frames simply reveal more of the app. */ + height?: number; + className?: string; +}) { + const [ref, inView] = useInView(PREVIEW_VIEWPORT); + + // Preview data handler: swallow captures so a thumbnail never emits analytics + // events, but let reads through (cached, shared with the full view) so the + // preview shows real-ish content. (posthog-js itself is never booted — no + // `analytics` prop — so there's no autocapture/pageview/replay either.) + const queryClient = useQueryClient(); + const onDataRequest = useCallback( + (method: string, payload: unknown) => + method === "capture" + ? Promise.resolve({ ok: true }) + : handleFreeformDataRequest(method, payload, queryClient), + [queryClient], + ); + + return ( + + {code ? ( + inView ? ( + + } + label="Preview unavailable" + /> + } + > + + + + ) : ( + // Deferred, not broken: a shimmer reads as "coming", where a line of + // text reads as the final state. + + ) + ) : ( + } + label="Nothing built yet" + /> + )} + + ); +} + +function PreviewPlaceholder({ + icon, + label, +}: { + icon?: ReactNode; + label: string; +}) { + return ( + + {icon} + + {label} + + + ); +} + +/** Stand-in for a preview that hasn't mounted yet — the shape of a small app: + * a title bar, a chart block, a couple of rows. */ +function PreviewSkeleton() { + return ( +
+ + +
+ + +
+
+ ); +} diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx index 4ea4783e1c..d9c9569358 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx @@ -1,23 +1,52 @@ -import { CaretRightIcon } from "@phosphor-icons/react"; +import { CaretRightIcon, FilesIcon, TrashIcon } from "@phosphor-icons/react"; import type { ChannelTaskRecord } from "@posthog/core/canvas/channelTaskSchemas"; import type { DashboardSummary } from "@posthog/core/canvas/dashboardSchemas"; +import { + Badge, + Card, + CardContent, + cn, + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, + Text, +} from "@posthog/quill"; import { formatRelativeTimeShort } from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { useArchivedTaskIds } from "@posthog/ui/features/archive/useArchivedTaskIds"; +import { ArtifactsViewToggle } from "@posthog/ui/features/canvas/components/ArtifactsViewToggle"; import { ChannelHeader } from "@posthog/ui/features/canvas/components/ChannelHeader"; import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon"; +import { FreeformPreview } from "@posthog/ui/features/canvas/components/FreeformPreview"; import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { useChannelTasks } from "@posthog/ui/features/canvas/hooks/useChannelTasks"; import { useDashboards } from "@posthog/ui/features/canvas/hooks/useDashboards"; +import { useArtifactsViewStore } from "@posthog/ui/features/canvas/stores/artifactsViewStore"; +import { useIsCanvasPendingDelete } from "@posthog/ui/features/canvas/stores/pendingCanvasDeleteStore"; +import { masonryPreviewHeight } from "@posthog/ui/features/canvas/utils/masonryPreviewHeight"; import { usePrArtifact } from "@posthog/ui/features/git-interaction/usePrArtifact"; import { useTasks } from "@posthog/ui/features/tasks/useTasks"; import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; +import { + PageHeader, + PageHeaderActions, + PageHeaderChip, + PageHeaderDescription, + PageHeaderHeading, + PageHeaderTitle, + PageHeaderTitleRow, +} from "@posthog/ui/primitives/PageHeader"; import { track } from "@posthog/ui/shell/analytics"; import { openExternalUrl } from "@posthog/ui/shell/openExternal"; -import { Text } from "@radix-ui/themes"; import { useNavigate } from "@tanstack/react-router"; import { type ReactNode, useCallback, useEffect, useMemo } from "react"; +// Uniform media height for the grid: cards line up row to row, and a PR tile +// (which has nothing to preview) fills the same band as a canvas thumbnail. +const GRID_PREVIEW_HEIGHT = 176; + // Artifacts are the durable outputs of a channel's work. Canvases for now; PRs // are surfaced from each filed task's latest run output. More kinds (reports, // files, …) slot into this union later. @@ -29,6 +58,8 @@ type ArtifactItem = ts: number; templateId: string; dashboardId: string; + /** Live React source, along for the ride so cards preview without a get(). */ + code?: string; } | { kind: "pr"; @@ -40,10 +71,12 @@ type ArtifactItem = // A channel's artifacts: canvases and the pull requests produced by its tasks, // most recent first. Sibling of the History tab, but scoped to outputs rather -// than the full activity stream. +// than the full activity stream. The view toggle switches between a dense row +// list and card layouts that preview each canvas live. export function WebsiteChannelArtifacts({ channelId }: { channelId: string }) { const spacesLayout = useChannelsLayout(); const navigate = useNavigate(); + const view = useArtifactsViewStore((s) => s.view); useEffect(() => { track(ANALYTICS_EVENTS.CHANNEL_ACTION, { @@ -54,7 +87,10 @@ export function WebsiteChannelArtifacts({ channelId }: { channelId: string }) { }, [channelId]); useSetHeaderContent( - useMemo(() => , [channelId]), + useMemo( + () => , + [channelId], + ), ); const { dashboards } = useDashboards(channelId); @@ -71,6 +107,7 @@ export function WebsiteChannelArtifacts({ channelId }: { channelId: string }) { ts: d.updatedAt, templateId: d.templateId, dashboardId: d.id, + code: d.code, }), ); @@ -124,50 +161,212 @@ export function WebsiteChannelArtifacts({ channelId }: { channelId: string }) { ); return ( -
-
- {items.length === 0 ? ( -
- - No artifacts yet - - +
+ {/* Full-bleed header over a container-width body — the Inbox shape. Ships + behind the spaces layout like every other space page header; off, + the view switcher rides above the list instead. */} + {spacesLayout ? ( + + + + Artifacts + {items.length > 0 && ( + }> + {items.length} item{items.length === 1 ? "" : "s"} + + )} + + + + + Canvases and pull requests from this{" "} - {spacesLayout ? "space's" : "channel's"} tasks show up here. - -
- ) : ( -
- {items.map((item) => - item.kind === "canvas" ? ( - + + + ) : null} + +
+ {/* Full width, flush with the page header above it — every layout + here (rows and cards alike) is a scannable list, not prose, so a + measure cap just strands whitespace on wide windows. */} +
+ {!spacesLayout && ( +
+ + {items.length === 0 + ? "Artifacts" + : `${items.length} artifact${items.length === 1 ? "" : "s"}`} + + +
+ )} + {items.length === 0 ? ( + + + + + + No artifacts yet + + Canvases and pull requests from this{" "} + {spacesLayout ? "space's" : "channel's"} tasks show up here. + + + + ) : view === "list" ? ( +
+ {items.map((item) => ( + openCanvas(item.dashboardId)} + item={item} + onOpenCanvas={openCanvas} + onOpenPr={openPr} /> - ) : ( - + ) : view === "grid" ? ( + // items-stretch + a full-height card: a PR tile (no preview to + // show) matches the canvas cards in its row instead of ending + // short. +
+ {items.map((item) => ( + - ), - )} -
- )} + ))} +
+ ) : ( + // CSS columns rather than a JS masonry: cards are self-contained and + // never reflow into each other, so break-inside-avoid is enough. The + // trade-off is column-major order — newest runs down column one, not + // across the row — which is fine for a browse-y wall of previews. +
+ {items.map((item) => ( +
+ +
+ ))} +
+ )} +
); } +function ArtifactListItem({ + item, + onOpenCanvas, + onOpenPr, +}: { + item: ArtifactItem; + onOpenCanvas: (dashboardId: string) => void; + onOpenPr: (safeUrl: string) => void; +}) { + return item.kind === "canvas" ? ( + + ) : ( + + ); +} + +function ArtifactCard({ + item, + previewHeight, + fillHeight, + onOpenCanvas, + onOpenPr, +}: { + item: ArtifactItem; + previewHeight: number; + /** Grid only: stretch to the tallest card in the row. */ + fillHeight?: boolean; + onOpenCanvas: (dashboardId: string) => void; + onOpenPr: (safeUrl: string) => void; +}) { + return item.kind === "canvas" ? ( + + ) : ( + + ); +} + +// A canvas artifact row. While the canvas is inside its delete-undo window the +// row stays put — its template icon becomes a pulsing trash can and the row +// stops opening — so undoing puts it back exactly where it was. +function CanvasArtifactRow({ + dashboardId, + templateId, + title, + ts, + onClick, +}: { + dashboardId: string; + templateId: string; + title: string; + ts: number; + onClick: (dashboardId: string) => void; +}) { + const deleting = useIsCanvasPendingDelete(dashboardId); + + return ( + + ) : ( + iconForTemplate(templateId, { size: 15, className: "text-violet-9" }) + ) + } + title={title} + subtitle={ + deleting ? "Deleting…" : `Canvas · ${formatRelativeTimeShort(ts)}` + } + onClick={deleting ? undefined : () => onClick(dashboardId)} + /> + ); +} + // A PR artifact row. The PR's lifecycle state (open / draft / merged / closed) // comes from usePrArtifact, which also gates the URL — PR links come from run // output, so a row must not fetch from whatever host that names. @@ -225,7 +424,7 @@ function ArtifactRow({ type="button" onClick={onClick} disabled={!onClick} - className="group flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-left transition-colors enabled:hover:bg-gray-3" + className="group flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-left transition-colors enabled:hover:bg-fill-hover" > {title} - + {subtitle} ); } + +// The card form of a canvas artifact: a live preview of the canvas above its +// title. Same delete-undo behaviour as the row — the card stays in place, +// dimmed, until the undo window closes. +function CanvasArtifactCard({ + dashboardId, + templateId, + title, + ts, + code, + previewHeight, + fillHeight, + onClick, +}: { + dashboardId: string; + templateId: string; + title: string; + ts: number; + code?: string; + previewHeight: number; + fillHeight?: boolean; + onClick: (dashboardId: string) => void; +}) { + const deleting = useIsCanvasPendingDelete(dashboardId); + + return ( + + + {deleting && ( +
+ + + Deleting… + +
+ )} + + } + icon={iconForTemplate(templateId, { + size: 14, + className: "text-violet-9", + })} + title={title} + badge="Canvas" + subtitle={ + deleting ? "Deleting…" : `Updated ${formatRelativeTimeShort(ts)}` + } + dimmed={deleting} + fillHeight={fillHeight} + onClick={deleting ? undefined : () => onClick(dashboardId)} + /> + ); +} + +// The card form of a PR artifact. A PR has nothing to preview, so its media +// slot is a short tinted band carrying the lifecycle icon — which also keeps PR +// cards visibly shorter than canvas cards in the masonry layout. +function PrArtifactCard({ + title, + prUrl, + ts, + mediaHeight, + fillHeight, + onClick, +}: { + title: string; + prUrl: string; + ts: number; + /** Grid only: match the canvas thumbnails' band instead of a short strip. */ + mediaHeight?: number; + fillHeight?: boolean; + onClick: (safeUrl: string) => void; +}) { + const { + safeUrl, + title: prTitle, + stateLabel, + Icon, + iconColor, + accentColor, + } = usePrArtifact(prUrl); + + const subtitle = [prTitle, formatRelativeTimeShort(ts)] + .filter(Boolean) + .join(" · "); + + return ( + + +
+ } + icon={} + title={title} + badge={stateLabel || "Pull request"} + subtitle={subtitle} + fillHeight={fillHeight} + onClick={safeUrl ? () => onClick(safeUrl) : undefined} + /> + ); +} + +function ArtifactCardShell({ + media, + icon, + title, + badge, + subtitle, + dimmed, + fillHeight, + onClick, +}: { + media: ReactNode; + icon: ReactNode; + title: string; + badge: string; + subtitle: string; + dimmed?: boolean; + /** Grid only: fill the row so neighbouring cards end at the same line. */ + fillHeight?: boolean; + /** Absent for a card with nowhere safe to go — a non-github PR link. */ + onClick?: () => void; +}) { + return ( + + ); +} diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelHistory.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelHistory.tsx index 4780ce51b0..2dfe4a6106 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelHistory.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelHistory.tsx @@ -42,7 +42,10 @@ export function WebsiteChannelHistory({ channelId }: { channelId: string }) { }, [channelId]); useSetHeaderContent( - useMemo(() => , [channelId]), + useMemo( + () => , + [channelId], + ), ); const { dashboards } = useDashboards(channelId); diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx index bee6dd19a9..ba9c092d2c 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx @@ -96,7 +96,10 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { }, [backendChannel, feedMessages]); useSetHeaderContent( - useMemo(() => , [channelId]), + useMemo( + () => , + [channelId], + ), ); const composerRef = useRef(null); diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelLoops.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelLoops.tsx index 38231afa59..44efad88aa 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelLoops.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelLoops.tsx @@ -1,7 +1,17 @@ import { CloudIcon, PlusIcon } from "@phosphor-icons/react"; import { ChannelHeader } from "@posthog/ui/features/canvas/components/ChannelHeader"; +import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; import { Button } from "@posthog/ui/primitives/Button"; +import { + PageHeader, + PageHeaderActions, + PageHeaderChip, + PageHeaderDescription, + PageHeaderHeading, + PageHeaderTitle, + PageHeaderTitleRow, +} from "@posthog/ui/primitives/PageHeader"; import { navigateToNewLoop } from "@posthog/ui/router/navigationBridge"; import { Flex, Heading, Text } from "@radix-ui/themes"; import { useMemo } from "react"; @@ -47,6 +57,7 @@ function contextQuickStarts(name: string): { label: string; prompt: string }[] { * this context. `channelId` is the desktop folder id, matching `context_target.folder_id`. */ export function WebsiteChannelLoops({ channelId }: { channelId: string }) { const { data: loops, isLoading, isError } = useLoops(); + const spacesLayout = useChannelsLayout(); const limits = useLoopLimits(); const limitReason = limits?.atLimit === true @@ -58,7 +69,10 @@ export function WebsiteChannelLoops({ channelId }: { channelId: string }) { const isPersonal = contextName === PERSONAL_CHANNEL_NAME; useSetHeaderContent( - useMemo(() => , [channelId]), + useMemo( + () => , + [channelId], + ), ); const attachedLoops = useMemo( @@ -99,56 +113,78 @@ export function WebsiteChannelLoops({ channelId }: { channelId: string }) { navigateToNewLoop(); }; + const title = isPersonal ? "Loops" : `Automate #${contextName}`; + const description = + "Put your work on autopilot. Loops run on a schedule, on an API call, or when something happens on GitHub. You can finally close the laptop!"; + const createButton = ( + + ); + return ( + {/* The shared page header ships with the spaces layout; without it the + in-container title block below is used. Delete that branch when the + layout flag graduates. */} + {spacesLayout && ( + + + + {title} + }> + Runs entirely in the cloud + + {createButton} + + {description} + + + )}
-
- - - - {isPersonal ? "Loops" : `Automate #${contextName}`} - - - - - Runs entirely in the cloud - + {!spacesLayout && ( +
+ + + {title} + + + + Runs entirely in the cloud + + + + {description} + - - Put your work on autopilot. Loops run on a schedule, on an API - call, or when something happens on GitHub. You can finally close - the laptop! - - - -
+ {createButton} +
+ )} {isLoading ? ( diff --git a/packages/ui/src/features/canvas/components/WebsiteContext.tsx b/packages/ui/src/features/canvas/components/WebsiteContext.tsx index 3c815483b2..4b8f783b37 100644 --- a/packages/ui/src/features/canvas/components/WebsiteContext.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteContext.tsx @@ -13,6 +13,7 @@ import { import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { ChannelHeader } from "@posthog/ui/features/canvas/components/ChannelHeader"; import { CreateChannelModal } from "@posthog/ui/features/canvas/components/CreateChannelModal"; +import { channelPageIcon } from "@posthog/ui/features/canvas/components/channelPages"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { @@ -22,6 +23,14 @@ import { } from "@posthog/ui/features/canvas/hooks/useFolderInstructions"; import { MarkdownRenderer } from "@posthog/ui/features/editor/components/MarkdownRenderer"; import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; +import { + PageHeader, + PageHeaderChip, + PageHeaderDescription, + PageHeaderHeading, + PageHeaderTitle, + PageHeaderTitleRow, +} from "@posthog/ui/primitives/PageHeader"; import { track } from "@posthog/ui/shell/analytics"; import { Box, @@ -90,7 +99,7 @@ export function WebsiteContext({ channelId }: WebsiteContextProps) { }, [latest?.content, hasDraft]); const headerContent = useMemo( - () => , + () => , [channelId], ); useSetHeaderContent(headerContent); @@ -163,6 +172,27 @@ export function WebsiteContext({ channelId }: WebsiteContextProps) { return ( + {/* The shared page header ships with the spaces layout; without it the + page opens straight onto its mode toolbar as it always has. */} + {spacesLayout && ( + + + + Context + {latest?.version != null && ( + + v{latest.version} + + )} + + + Background every agent working in this{" "} + {spacesLayout ? "space" : "channel"} reads before it starts — what + lives here, who cares about it, and how to work on it. + + + + )} + - + + + {deleting && ( + + + + Deleting… + + + )} + @@ -155,64 +176,6 @@ const DashboardCard = memo(function DashboardCard({ ); }); -// A freeform (React-in-iframe) canvas preview: the app rendered at PREVIEW_SCALE -// in a clipped frame, the same shape as DashboardPreview. Deferred until near -// the viewport, and runs with NO analytics so it fires no events. -function FreeformPreview({ code }: { code?: string }) { - const [ref, inView] = useInView(PREVIEW_VIEWPORT); - - // Preview data handler: swallow captures so a thumbnail never emits analytics - // events, but let reads through (cached, shared with the full view) so the - // preview shows real-ish content. (posthog-js itself is never booted — no - // `analytics` prop — so there's no autocapture/pageview/replay either.) - const queryClient = useQueryClient(); - const onDataRequest = useCallback( - (method: string, payload: unknown) => - method === "capture" - ? Promise.resolve({ ok: true }) - : handleFreeformDataRequest(method, payload, queryClient), - [queryClient], - ); - - return ( - - {code ? ( - inView ? ( - - } - > - - - - ) : ( - - ) - ) : ( - - )} - - ); -} - function DashboardCardMenu({ id, name, @@ -223,31 +186,24 @@ function DashboardCardMenu({ channelId: string; }) { const [open, setOpen] = useState(false); - const { deleteDashboard, isDeleting } = useDashboardMutations(); + const spacesLayout = useChannelsLayout(); + const containerNoun = spacesLayout ? "space" : "channel"; + // "Delete…" opens a confirmation rather than deleting inline — the canvas and + // its version history go away for everyone in the space. + const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); + const { invalidateDashboards } = useDashboardMutations(); - const onDelete = () => { - deleteDashboard(id) - .then(() => { - track(ANALYTICS_EVENTS.DASHBOARD_ACTION, { - action_type: "delete", - surface: "dashboards_grid", - channel_id: channelId, - dashboard_id: id, - success: true, - }); - }) - .catch((error) => { - track(ANALYTICS_EVENTS.DASHBOARD_ACTION, { - action_type: "delete", - surface: "dashboards_grid", - channel_id: channelId, - dashboard_id: id, - success: false, - }); - toast.error("Couldn't delete canvas", { - description: error instanceof Error ? error.message : String(error), - }); - }); + // The card disappears immediately, but the delete isn't sent until the undo + // toast's timer runs out — Undo simply cancels it. + const confirmDelete = () => { + setConfirmDeleteOpen(false); + deleteCanvasWithUndo({ + dashboardId: id, + channelId, + name, + surface: "dashboards_grid", + invalidate: invalidateDashboards, + }); }; return ( @@ -280,28 +236,38 @@ function DashboardCardMenu({ setConfirmDeleteOpen(true)} > - Delete + Delete… + {/* Destructive confirm for "Delete…" — the canvas goes for everyone. */} + + + + Delete canvas + + Delete {name}? Its code and + version history go for everyone in the {containerNoun}. You get a + few seconds to undo, then it's permanent. + + + + + Cancel + + } + /> + + + + ); } - -function PreviewPlaceholder({ label }: { label: string }) { - return ( - - - {label} - - - ); -} diff --git a/packages/ui/src/features/canvas/components/WebsiteLayout.tsx b/packages/ui/src/features/canvas/components/WebsiteLayout.tsx index 70105c1e05..a98cd7256d 100644 --- a/packages/ui/src/features/canvas/components/WebsiteLayout.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteLayout.tsx @@ -5,9 +5,17 @@ import { LinkIcon, PencilSimpleIcon, PushPinIcon, + TrashIcon, XIcon, } from "@phosphor-icons/react"; import { + AlertDialog, + AlertDialogClose, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, Button, DropdownMenu, DropdownMenuContent, @@ -17,7 +25,12 @@ import { import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { ChannelBreadcrumb } from "@posthog/ui/features/canvas/components/ChannelBreadcrumb"; import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon"; +import { + channelPageIcon, + channelPageLabel, +} from "@posthog/ui/features/canvas/components/channelPages"; import { NewCanvasMenu } from "@posthog/ui/features/canvas/components/NewCanvasMenu"; +import { deleteCanvasWithUndo } from "@posthog/ui/features/canvas/deleteCanvasWithUndo"; import { CanvasFrameHost } from "@posthog/ui/features/canvas/freeform/CanvasFrameHost"; import { useCanvasFrameStore } from "@posthog/ui/features/canvas/freeform/canvasFrameStore"; import { CANVAS_QUERY_KEY } from "@posthog/ui/features/canvas/freeform/freeformDataBridge"; @@ -50,7 +63,7 @@ import { useParams, useRouterState, } from "@tanstack/react-router"; -import type { ReactNode } from "react"; +import { type ReactNode, useState } from "react"; function threadIdFor(dashboardId: string): string { return `dashboard:${dashboardId}`; @@ -69,11 +82,37 @@ function FreeformEditControls({ dashboardId: string; }) { const navigate = useNavigate(); + // Pinning is scoped to whatever holds the canvas; the new layout calls that a + // space, the old one a channel. + const spacesLayout = useChannelsLayout(); + const containerNoun = spacesLayout ? "space" : "channel"; const editing = useIsDashboardEditing(dashboardId); const setEditing = useDashboardEditStore((s) => s.setEditing); const { dashboard } = useDashboard(dashboardId); - const { forkFreeform, isCreating, setPinned } = useDashboardMutations(); + const { forkFreeform, isCreating, setPinned, invalidateDashboards } = + useDashboardMutations(); const isPinned = dashboard?.pinnedAt != null; + // "Delete…" opens a confirmation rather than deleting inline — the canvas and + // its version history go away for everyone in the space. + const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); + + // Once confirmed the canvas vanishes from every list and we leave for the + // space's artifacts list, but the delete isn't sent until the undo toast's + // timer runs out — Undo simply cancels it. + const confirmDelete = () => { + setConfirmDeleteOpen(false); + deleteCanvasWithUndo({ + dashboardId, + channelId, + name: dashboard?.name ?? "Canvas", + surface: "canvas", + invalidate: invalidateDashboards, + }); + void navigate({ + to: "/website/$channelId/artifacts", + params: { channelId }, + }); + }; const onTogglePin = () => { void setPinned(dashboardId, !isPinned) @@ -233,7 +272,14 @@ function FreeformEditControls({ } /> - + {/* Sized to its longest item — the default width clipped "Unpin from + space". Same treatment as the channel-list menus. */} + Refresh @@ -248,10 +294,46 @@ function FreeformEditControls({ - {isPinned ? "Unpin from channel" : "Pin to channel"} + {isPinned + ? `Unpin from ${containerNoun}` + : `Pin to ${containerNoun}`} + + setConfirmDeleteOpen(true)} + > + + Delete… + {/* Destructive confirm for "Delete…" — the canvas goes for everyone. */} + + + + Delete canvas + + Delete{" "} + {dashboard?.name ?? "Canvas"} + ? Its code and version history go for everyone in the{" "} + {containerNoun}. You get a few seconds to undo, then it's + permanent. + + + + + Cancel + + } + /> + + + + + ); + + // Only the loaded, non-empty list has tabs to show — the skeleton, the error + // notice and the empty state all render without them. + const hasTabs = !isLoading && !error && loops.length > 0; + + const body = ( + <>
-
- - - Loops - - - - Runs entirely in the cloud - + {!sharedPageHeader && ( +
+ + + Loops + + + + Runs entirely in the cloud + + + + Put your work on autopilot. Loops run on a schedule, on an + API call, or when something happens on GitHub. You can + finally close the laptop! + - - Put your work on autopilot. Loops run on a schedule, on an API - call, or when something happens on GitHub. You can finally - close the laptop! - - - -
+ {createButton} +
+ )} {isLoading ? ( @@ -268,14 +280,26 @@ export function LoopsListViewPresentation({ } /> ) : loops.length > 0 ? ( - + sharedPageHeader ? ( + // Triggers live in the page header; only the panels sit here. + + ) : ( + + ) ) : ( )} @@ -302,7 +326,50 @@ export function LoopsListViewPresentation({
-
+ + ); + + if (!sharedPageHeader) { + return ( + + {body} + + ); + } + + // One Tabs root spanning header and body: the trigger strip sits in the + // header's sub-nav, its panels stay down in the scrolling body. + return ( + + + + + Loops + }> + Runs entirely in the cloud + + {createButton} + + + Put your work on autopilot. Loops run on a schedule, on an API call, + or when something happens on GitHub. You can finally close the + laptop! + + + {hasTabs && ( + + + + )} + + {body} + ); } @@ -323,18 +390,64 @@ function LoopListTabs({ }) { return ( - - - - My loops ({personalLoops.length}) - - - - - Team loops ({teamLoops.length}) - - - + + + + ); +} + +/** The trigger strip. Rendered inside the page header when one is present. */ +function LoopTabsList({ + personalCount, + teamCount, +}: { + personalCount: number; + teamCount: number; +}) { + return ( + + + + My loops ({personalCount}) + + + + + Team loops ({teamCount}) + + + + ); +} + +/** The panels. Always in the scrolling body, wherever the triggers live. */ +function LoopTabPanels({ + personalLoops, + teamLoops, + members, + membersLoading, + membersError, + membersComplete, +}: { + personalLoops: LoopSchemas.Loop[]; + teamLoops: LoopSchemas.Loop[]; + members: UserBasic[]; + membersLoading: boolean; + membersError: boolean; + membersComplete: boolean; +}) { + return ( + <> {personalLoops.length > 0 ? ( )} - + ); } diff --git a/packages/ui/src/features/task-detail/HeaderTitleEditor.tsx b/packages/ui/src/features/task-detail/HeaderTitleEditor.tsx index 51d425fd8c..c5f15aba09 100644 --- a/packages/ui/src/features/task-detail/HeaderTitleEditor.tsx +++ b/packages/ui/src/features/task-detail/HeaderTitleEditor.tsx @@ -1,15 +1,23 @@ +import { cn } from "@posthog/quill"; import { useEffect, useRef, useState } from "react"; interface HeaderTitleEditorProps { initialTitle: string; onSubmit: (newTitle: string) => void; onCancel: () => void; + /** + * Extends the base styling — callers match the input to whatever it replaces + * (e.g. a breadcrumb segment's type scale and height) so opening the editor + * doesn't resize the row. + */ + className?: string; } export function HeaderTitleEditor({ initialTitle, onSubmit, onCancel, + className, }: HeaderTitleEditorProps) { const [editValue, setEditValue] = useState(initialTitle); const inputRef = useRef(null); @@ -53,7 +61,10 @@ export function HeaderTitleEditor({ onChange={(e) => setEditValue(e.target.value)} onKeyDown={handleKeyDown} onBlur={handleSubmit} - className="no-drag h-5 min-w-0 flex-1 rounded-sm border border-accent-8 bg-gray-2 px-1 font-medium text-[12px] text-gray-12 outline-none" + className={cn( + "no-drag h-5 min-w-0 flex-1 rounded-sm border border-accent-8 bg-gray-2 px-1 font-medium text-[12px] text-gray-12 outline-none", + className, + )} /> ); } diff --git a/packages/ui/src/primitives/PageHeader.stories.tsx b/packages/ui/src/primitives/PageHeader.stories.tsx new file mode 100644 index 0000000000..328d466b3e --- /dev/null +++ b/packages/ui/src/primitives/PageHeader.stories.tsx @@ -0,0 +1,108 @@ +import { CloudIcon, FilesIcon } from "@phosphor-icons/react"; +import { + Button, + ButtonGroup, + Tabs, + TabsList, + TabsTrigger, +} from "@posthog/quill"; +import { + PageHeader, + PageHeaderActions, + PageHeaderChip, + PageHeaderDescription, + PageHeaderFilters, + PageHeaderHeading, + PageHeaderNav, + PageHeaderTitle, + PageHeaderTitleRow, +} from "@posthog/ui/primitives/PageHeader"; +import type { Meta, StoryObj } from "@storybook/react-vite"; + +const meta = { + title: "Primitives/PageHeader", + component: PageHeader, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** The Inbox shape: title, description, tab strip with a filter on the right. */ +export const WithTabsAndFilters: Story = { + args: { + children: ( + <> + + + Inbox + + + Work done by your agents – pull requests, reports, and live runs. + + + + + + + Pull requests + + + Reports + + + + + + + + + ), + }, +}; + +/** The Artifacts shape: a count chip beside the title, view switcher on the right. */ +export const WithChipAndActions: Story = { + args: { + children: ( + + + Artifacts + }> + 12 items + + + + + + + + + + Canvases and pull requests from this space's tasks. + + + ), + }, +}; + +/** Title only — the minimum a page has to spend. */ +export const TitleOnly: Story = { + args: { + children: ( + + + Loops + }> + Runs entirely in the cloud + + + + ), + }, +}; diff --git a/packages/ui/src/primitives/PageHeader.tsx b/packages/ui/src/primitives/PageHeader.tsx new file mode 100644 index 0000000000..2580511ff1 --- /dev/null +++ b/packages/ui/src/primitives/PageHeader.tsx @@ -0,0 +1,189 @@ +import { cn } from "@posthog/quill"; +import type { ReactNode } from "react"; + +/** + * The shared page header section. Full-bleed (the page body below it keeps its + * own container), bordered off from the content, and composed from parts so + * each surface takes only what it needs: + * + * + * + * + * Inbox + * Runs in the cloud + * + * + * + * + * + * + * + * + * + * + * Layout base is the Inbox header (full width, title + description + tab bar); + * the chip comes from Loops. + */ +export function PageHeader({ + className, + children, +}: { + className?: string; + children: ReactNode; +}) { + return ( +
+ {children} +
+ ); +} + +/** Title row + description, tight against each other. */ +export function PageHeaderHeading({ + className, + children, +}: { + className?: string; + children: ReactNode; +}) { + return ( +
+ {children} +
+ ); +} + +/** The title line: title, any chips, and (pushed right) actions. */ +export function PageHeaderTitleRow({ + className, + children, +}: { + className?: string; + children: ReactNode; +}) { + return ( +
+ {children} +
+ ); +} + +export function PageHeaderTitle({ + className, + children, +}: { + className?: string; + children: ReactNode; +}) { + return ( +

+ {children} +

+ ); +} + +/** A pill next to the title — a count, a mode, "Runs entirely in the cloud". */ +export function PageHeaderChip({ + icon, + className, + children, +}: { + icon?: ReactNode; + className?: string; + children: ReactNode; +}) { + return ( + + {icon} + {children} + + ); +} + +export function PageHeaderDescription({ + className, + children, +}: { + className?: string; + children: ReactNode; +}) { + return ( +

+ {children} +

+ ); +} + +/** Trailing controls on the title line (create buttons, view switchers). */ +export function PageHeaderActions({ + className, + children, +}: { + className?: string; + children: ReactNode; +}) { + return ( +
+ {children} +
+ ); +} + +/** + * The sub-nav row: a tab strip, with filters pushed to the right. Cancels the + * header's bottom padding so an underlined tab strip sits on the header border + * the way the Inbox tabs do; the tabs' own padding keeps the breathing room. + */ +export function PageHeaderNav({ + className, + children, +}: { + className?: string; + children: ReactNode; +}) { + return ( +
+ {children} +
+ ); +} + +/** Filters/controls sitting to the right of the sub-nav. */ +export function PageHeaderFilters({ + className, + children, +}: { + className?: string; + children: ReactNode; +}) { + return ( +
+ {children} +
+ ); +} diff --git a/packages/ui/src/shell/ContentHeader.tsx b/packages/ui/src/shell/ContentHeader.tsx index 74f34c089d..741a785734 100644 --- a/packages/ui/src/shell/ContentHeader.tsx +++ b/packages/ui/src/shell/ContentHeader.tsx @@ -10,10 +10,17 @@ import { Flex } from "@radix-ui/themes"; // review-panel toggle, cloud/local handoff, skill buttons and task actions that // used to live in the Code header bar. // -// This breadcrumb row is now scoped to the task-detail view only: every other -// page drops it (the title bar search carries wayfinding instead). The /website -// (Channels) space keeps its own header (WebsiteLayout), so it's unaffected — -// this is mounted only outside it. +// This breadcrumb row is scoped to views that have somewhere to walk back to: +// task detail, and the loop scenes (list / detail / form), which live outside +// the space routes but can belong to a space. Every other page drops it (the +// title bar search carries wayfinding instead). The /website (Channels) space +// keeps its own header (WebsiteLayout), so it's unaffected — this is mounted +// only outside it. +// +// A loop with no space pushes null, so the row collapses for it too: what a +// view puts in the header store decides, this only says who may. +const BREADCRUMB_VIEWS = new Set(["task-detail", "loops"]); + export function ContentHeader() { const content = useHeaderStore((state) => state.content); const view = useAppView(); @@ -25,8 +32,7 @@ export function ContentHeader() { : undefined; const showTaskSection = view.type === "task-detail" && Boolean(activeTask); - // Only the task-detail view keeps the breadcrumb row. - if (view.type !== "task-detail") return null; + if (!BREADCRUMB_VIEWS.has(view.type)) return null; if (!content && !showTaskSection) return null;