From fc5334ec8ffe716a7edc3cce19ddb810bc0350bb Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Tue, 28 Jul 2026 19:42:56 +0100 Subject: [PATCH 01/12] feat(canvas): delete a canvas with a confirm and an undo window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The canvas "…" menu had no way to delete; the canvases grid had one that deleted on click with no confirmation. Both now open an alert dialog, and confirming doesn't delete straight away: the canvas is marked pending and the host isn't told until the "Deleted artifact" toast's timer expires, so Undo simply cancels the timer rather than recreating anything. Pending canvases stay in their lists — the artifacts row swaps its template icon for a pulsing trash can and stops opening, the grid card dims behind the same icon — so undoing restores them in place. The commit runs outside React (module-level timer + hostClient) because deleting from inside a canvas navigates away immediately. Co-Authored-By: Claude Opus 5 (1M context) --- packages/shared/src/analytics-events.ts | 2 + .../components/WebsiteChannelArtifacts.tsx | 53 ++++++-- .../components/WebsiteDashboardsIndex.tsx | 108 ++++++++++----- .../canvas/components/WebsiteLayout.tsx | 68 +++++++++- .../canvas/deleteCanvasWithUndo.test.ts | 104 +++++++++++++++ .../features/canvas/deleteCanvasWithUndo.ts | 123 ++++++++++++++++++ .../features/canvas/hooks/useDashboards.ts | 6 + .../canvas/stores/pendingCanvasDeleteStore.ts | 29 +++++ 8 files changed, 452 insertions(+), 41 deletions(-) create mode 100644 packages/ui/src/features/canvas/deleteCanvasWithUndo.test.ts create mode 100644 packages/ui/src/features/canvas/deleteCanvasWithUndo.ts create mode 100644 packages/ui/src/features/canvas/stores/pendingCanvasDeleteStore.ts diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index 83f6edc458..3025d359b8 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -947,6 +947,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/WebsiteChannelArtifacts.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx index 4ea4783e1c..d8eca72aa9 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx @@ -1,4 +1,4 @@ -import { CaretRightIcon } from "@phosphor-icons/react"; +import { CaretRightIcon, TrashIcon } from "@phosphor-icons/react"; import type { ChannelTaskRecord } from "@posthog/core/canvas/channelTaskSchemas"; import type { DashboardSummary } from "@posthog/core/canvas/dashboardSchemas"; import { formatRelativeTimeShort } from "@posthog/shared"; @@ -9,6 +9,7 @@ import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTe 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 { useIsCanvasPendingDelete } from "@posthog/ui/features/canvas/stores/pendingCanvasDeleteStore"; import { usePrArtifact } from "@posthog/ui/features/git-interaction/usePrArtifact"; import { useTasks } from "@posthog/ui/features/tasks/useTasks"; import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; @@ -140,16 +141,13 @@ export function WebsiteChannelArtifacts({ channelId }: { channelId: string }) {
{items.map((item) => item.kind === "canvas" ? ( - openCanvas(item.dashboardId)} + ts={item.ts} + onClick={openCanvas} /> ) : ( 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. diff --git a/packages/ui/src/features/canvas/components/WebsiteDashboardsIndex.tsx b/packages/ui/src/features/canvas/components/WebsiteDashboardsIndex.tsx index e4f359ead2..802081f82a 100644 --- a/packages/ui/src/features/canvas/components/WebsiteDashboardsIndex.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteDashboardsIndex.tsx @@ -1,6 +1,13 @@ import { DotsThreeIcon, LinkIcon, TrashIcon } from "@phosphor-icons/react"; import type { DashboardSummary } from "@posthog/core/canvas/dashboardSchemas"; import { + AlertDialog, + AlertDialogClose, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, Badge, Button, Card, @@ -15,6 +22,7 @@ import { import { formatRelativeTimeShort } from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { NewCanvasMenu } from "@posthog/ui/features/canvas/components/NewCanvasMenu"; +import { deleteCanvasWithUndo } from "@posthog/ui/features/canvas/deleteCanvasWithUndo"; import { FreeformCanvas } from "@posthog/ui/features/canvas/freeform/FreeformCanvas"; import { handleFreeformDataRequest } from "@posthog/ui/features/canvas/freeform/freeformDataBridge"; import { useCanvasTemplates } from "@posthog/ui/features/canvas/hooks/useCanvasTemplates"; @@ -22,9 +30,9 @@ import { useDashboardMutations, useDashboards, } from "@posthog/ui/features/canvas/hooks/useDashboards"; +import { useIsCanvasPendingDelete } from "@posthog/ui/features/canvas/stores/pendingCanvasDeleteStore"; import { copyCanvasLink } from "@posthog/ui/features/canvas/utils/copyCanvasLink"; import { useInView } from "@posthog/ui/primitives/hooks/useInView"; -import { toast } from "@posthog/ui/primitives/toast"; import { track } from "@posthog/ui/shell/analytics"; import { ErrorBoundary } from "@posthog/ui/shell/ErrorBoundary"; import { Box, Flex, Grid } from "@radix-ui/themes"; @@ -106,10 +114,20 @@ const DashboardCard = memo(function DashboardCard({ summary: DashboardSummary; templateLabel: string; }) { + // While the canvas is inside its delete-undo window the card stays in the + // grid — dimmed, with a pulsing trash can over its preview — so undoing puts + // it back exactly where it was rather than re-inserting a row. + const deleting = useIsCanvasPendingDelete(summary.id); + // The React source rides along in the list response, so the grid renders // previews without a per-card fetch (no N+1 of get()). return ( - + - + + + {deleting && ( + + + + Deleting… + + + )} + @@ -223,31 +256,22 @@ function DashboardCardMenu({ channelId: string; }) { const [open, setOpen] = useState(false); - const { deleteDashboard, isDeleting } = useDashboardMutations(); + // "Delete…" opens a confirmation rather than deleting inline — the canvas and + // its version history go away for everyone in the channel. + 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,14 +304,38 @@ function DashboardCardMenu({ setConfirmDeleteOpen(true)} > - Delete + Delete… + {/* Destructive confirm for "Delete…" — the canvas goes for everyone. */} + + + + Delete canvas + + Permanently delete {name}? + This deletes its code and version history for everyone in the + channel and cannot be undone. + + + + + Cancel + + } + /> + + + + ); } diff --git a/packages/ui/src/features/canvas/components/WebsiteLayout.tsx b/packages/ui/src/features/canvas/components/WebsiteLayout.tsx index 70105c1e05..67de99105d 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, @@ -18,6 +26,7 @@ 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 { 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 +59,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}`; @@ -72,8 +81,30 @@ function FreeformEditControls({ 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 channel. + 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) @@ -250,8 +281,41 @@ function FreeformEditControls({ {isPinned ? "Unpin from channel" : "Pin to channel"} + setConfirmDeleteOpen(true)} + > + + Delete… + + {/* Destructive confirm for "Delete…" — the canvas goes for everyone. */} + + + + Delete canvas + + Permanently delete{" "} + {dashboard?.name ?? "Canvas"} + ? This deletes its code and version history for everyone in the + channel and cannot be undone. + + + + + Cancel + + } + /> + + + + + } + /> + {label} + + ); + })} + + ); +} 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..b753c620ab --- /dev/null +++ b/packages/ui/src/features/canvas/components/FreeformPreview.tsx @@ -0,0 +1,102 @@ +import { cn, 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 { 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 ? ( + + } + > + + + + ) : ( + + ) + ) : ( + + )} + + ); +} + +function PreviewPlaceholder({ label }: { label: string }) { + return ( + + + {label} + + + ); +} diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx index d8eca72aa9..b107531ad7 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx @@ -1,21 +1,36 @@ -import { CaretRightIcon, TrashIcon } 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 { 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"; @@ -30,6 +45,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"; @@ -41,10 +58,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, { @@ -72,6 +91,7 @@ export function WebsiteChannelArtifacts({ channelId }: { channelId: string }) { ts: d.updatedAt, templateId: d.templateId, dashboardId: d.id, + code: d.code, }), ); @@ -126,39 +146,74 @@ export function WebsiteChannelArtifacts({ channelId }: { channelId: string }) { return (
-
+ {/* The list reads best narrow; card layouts want the full width. */} +
+
+ + {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. - + + + + + + No artifacts yet + + Canvases and pull requests from this{" "} + {spacesLayout ? "space's" : "channel's"} tasks show up here. + + + + ) : view === "list" ? ( +
+ {items.map((item) => ( + + ))} +
+ ) : view === "grid" ? ( +
+ {items.map((item) => ( + + ))}
) : ( -
- {items.map((item) => - item.kind === "canvas" ? ( - - ) : ( - + {items.map((item) => ( +
+ - ), - )} +
+ ))}
)}
@@ -166,6 +221,64 @@ export function WebsiteChannelArtifacts({ channelId }: { channelId: string }) { ); } +function ArtifactListItem({ + item, + onOpenCanvas, + onOpenPr, +}: { + item: ArtifactItem; + onOpenCanvas: (dashboardId: string) => void; + onOpenPr: (safeUrl: string) => void; +}) { + return item.kind === "canvas" ? ( + + ) : ( + + ); +} + +function ArtifactCard({ + item, + previewHeight, + onOpenCanvas, + onOpenPr, +}: { + item: ArtifactItem; + previewHeight: number; + 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. @@ -283,3 +396,156 @@ function ArtifactRow({ ); } + +// 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, + onClick, +}: { + dashboardId: string; + templateId: string; + title: string; + ts: number; + code?: string; + previewHeight: number; + 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} + 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, + onClick, +}: { + title: string; + prUrl: string; + ts: number; + 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} + onClick={safeUrl ? () => onClick(safeUrl) : undefined} + /> + ); +} + +function ArtifactCardShell({ + media, + icon, + title, + badge, + subtitle, + dimmed, + onClick, +}: { + media: ReactNode; + icon: ReactNode; + title: string; + badge: string; + subtitle: string; + dimmed?: 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/WebsiteDashboardsIndex.tsx b/packages/ui/src/features/canvas/components/WebsiteDashboardsIndex.tsx index 802081f82a..eb0fa12fa3 100644 --- a/packages/ui/src/features/canvas/components/WebsiteDashboardsIndex.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteDashboardsIndex.tsx @@ -21,10 +21,9 @@ import { } from "@posthog/quill"; import { formatRelativeTimeShort } from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { FreeformPreview } from "@posthog/ui/features/canvas/components/FreeformPreview"; import { NewCanvasMenu } from "@posthog/ui/features/canvas/components/NewCanvasMenu"; import { deleteCanvasWithUndo } from "@posthog/ui/features/canvas/deleteCanvasWithUndo"; -import { FreeformCanvas } from "@posthog/ui/features/canvas/freeform/FreeformCanvas"; -import { handleFreeformDataRequest } from "@posthog/ui/features/canvas/freeform/freeformDataBridge"; import { useCanvasTemplates } from "@posthog/ui/features/canvas/hooks/useCanvasTemplates"; import { useDashboardMutations, @@ -32,25 +31,10 @@ import { } from "@posthog/ui/features/canvas/hooks/useDashboards"; import { useIsCanvasPendingDelete } from "@posthog/ui/features/canvas/stores/pendingCanvasDeleteStore"; import { copyCanvasLink } from "@posthog/ui/features/canvas/utils/copyCanvasLink"; -import { useInView } from "@posthog/ui/primitives/hooks/useInView"; import { track } from "@posthog/ui/shell/analytics"; -import { ErrorBoundary } from "@posthog/ui/shell/ErrorBoundary"; import { Box, Flex, Grid } from "@radix-ui/themes"; -import { useQueryClient } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; -import { memo, useCallback, useState } from "react"; - -// Render each canvas's live app at 1/SCALE of the card width, then shrink so it -// fits inside the fixed-height 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; +import { memo, useState } from "react"; // A channel's dashboards index: a grid of cards, each showing a scaled-down // live preview. Clicking a card opens the full dashboard. @@ -144,7 +128,10 @@ const DashboardCard = memo(function DashboardCard({ > - + {deleting && ( (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, @@ -339,17 +268,3 @@ function DashboardCardMenu({ ); } - -function PreviewPlaceholder({ label }: { label: string }) { - return ( - - - {label} - - - ); -} diff --git a/packages/ui/src/features/canvas/stores/artifactsViewStore.ts b/packages/ui/src/features/canvas/stores/artifactsViewStore.ts new file mode 100644 index 0000000000..939420cf82 --- /dev/null +++ b/packages/ui/src/features/canvas/stores/artifactsViewStore.ts @@ -0,0 +1,42 @@ +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { track } from "@posthog/ui/shell/analytics"; +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +// How a space's artifacts are laid out. "list" is the dense row list, "grid" a +// uniform card grid with live canvas previews, "masonry" the same cards in +// staggered columns so previews get varied vertical room. +export type ArtifactsViewMode = "list" | "grid" | "masonry"; + +export const ARTIFACTS_VIEW_MODES: ArtifactsViewMode[] = [ + "list", + "grid", + "masonry", +]; + +interface ArtifactsViewStore { + view: ArtifactsViewMode; + setView: (view: ArtifactsViewMode, channelId?: string) => void; +} + +// Per-device preference, not per-space: picking masonry once should hold as you +// move between spaces, the way a file browser's view setting does. +export const useArtifactsViewStore = create()( + persist( + (set) => ({ + view: "list", + setView: (view, channelId) => + set((state) => { + if (state.view === view) return state; + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "artifacts_view_change", + surface: "channel_artifacts", + channel_id: channelId, + view_mode: view, + }); + return { view }; + }), + }), + { name: "artifacts-view-storage" }, + ), +); diff --git a/packages/ui/src/features/canvas/utils/masonryPreviewHeight.test.ts b/packages/ui/src/features/canvas/utils/masonryPreviewHeight.test.ts new file mode 100644 index 0000000000..f7a4ed61f2 --- /dev/null +++ b/packages/ui/src/features/canvas/utils/masonryPreviewHeight.test.ts @@ -0,0 +1,21 @@ +import { masonryPreviewHeight } from "@posthog/ui/features/canvas/utils/masonryPreviewHeight"; +import { describe, expect, it } from "vitest"; + +describe("masonryPreviewHeight", () => { + it("is stable for a key", () => { + expect(masonryPreviewHeight("canvas:abc")).toBe( + masonryPreviewHeight("canvas:abc"), + ); + }); + + it("stays inside the bucket set", () => { + const keys = Array.from({ length: 50 }, (_, i) => `canvas:${i}`); + const heights = new Set(keys.map(masonryPreviewHeight)); + expect([...heights].every((h) => [168, 224, 288].includes(h))).toBe(true); + }); + + it("staggers across keys so masonry has something to stagger", () => { + const keys = Array.from({ length: 50 }, (_, i) => `canvas:${i}`); + expect(new Set(keys.map(masonryPreviewHeight)).size).toBeGreaterThan(1); + }); +}); diff --git a/packages/ui/src/features/canvas/utils/masonryPreviewHeight.ts b/packages/ui/src/features/canvas/utils/masonryPreviewHeight.ts new file mode 100644 index 0000000000..3a53214138 --- /dev/null +++ b/packages/ui/src/features/canvas/utils/masonryPreviewHeight.ts @@ -0,0 +1,13 @@ +// Masonry needs cards of differing height or it degrades into a ragged grid. +// Canvas previews have no intrinsic height to measure (the app is rendered into +// a clipped, scaled frame), so each card gets a stable height picked from its +// key — same canvas, same height across renders and reloads, no layout churn. +const MASONRY_HEIGHTS = [168, 224, 288] as const; + +export function masonryPreviewHeight(key: string): number { + let hash = 0; + for (let i = 0; i < key.length; i++) { + hash = (hash * 31 + key.charCodeAt(i)) >>> 0; + } + return MASONRY_HEIGHTS[hash % MASONRY_HEIGHTS.length]; +} From d36a03eac271e6d02d5ae783e76afbb4f8837148 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Wed, 29 Jul 2026 11:22:47 +0100 Subject: [PATCH 03/12] feat(ui): shared page header, space breadcrumbs, artifact views Introduce a compound PageHeader primitive (header shell, heading, title, chip, description, actions, sub-nav, filters) and adopt it on Inbox, Activity, Loops, the space Artifacts / Context / Loops pages. Each adoption keeps its previous header on the flag-off branch: space pages switch with the spaces layout (useChannelsLayout), Inbox and the global Loops list with project-bluebird. Space wayfinding now reads "{space} / {page}" everywhere, with labels and icons resolved from one CHANNEL_PAGES table that the sidebar rows share, so the two can't drift. ChannelBreadcrumb renders every segment as a button (uniform padding/height), marks non-navigable segments aria-disabled without the disabled dimming, gained an optional middle segment ("{space} / Loops / {loop}"), and opens the rename editor on a single click at matching type scale. Artifacts gains list / grid / masonry views with live canvas previews, persisted per device; the freeform preview moves out of the dashboards grid so both surfaces share it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BYPVPkN4Lm3gygchrpcoqr --- .../canvas/components/ActivityView.tsx | 174 +++++++---- .../components/ChannelBreadcrumb.test.tsx | 91 +++++- .../canvas/components/ChannelBreadcrumb.tsx | 242 +++++++++++----- .../canvas/components/ChannelHeader.tsx | 59 +++- .../canvas/components/ChannelSidebar.tsx | 30 +- .../components/WebsiteChannelArtifacts.tsx | 172 ++++++----- .../components/WebsiteChannelHistory.tsx | 5 +- .../canvas/components/WebsiteChannelHome.tsx | 5 +- .../canvas/components/WebsiteChannelLoops.tsx | 120 +++++--- .../canvas/components/WebsiteContext.tsx | 32 +- .../canvas/components/WebsiteLayout.tsx | 7 +- .../canvas/components/channelPages.test.ts | 24 ++ .../canvas/components/channelPages.tsx | 53 ++++ .../components/CommandCenterView.tsx | 26 +- .../inbox/components/InboxPageHeader.tsx | 51 +++- .../features/inbox/components/InboxTabBar.tsx | 96 +++--- .../loops/components/LoopDetailView.tsx | 30 +- .../features/loops/components/LoopForm.tsx | 23 +- .../loops/components/LoopSpaceBreadcrumb.tsx | 51 ++++ .../components/LoopsListView.stories.tsx | 5 + .../loops/components/LoopsListView.test.tsx | 44 +++ .../loops/components/LoopsListView.tsx | 274 +++++++++++++----- .../task-detail/HeaderTitleEditor.tsx | 13 +- .../ui/src/primitives/PageHeader.stories.tsx | 108 +++++++ packages/ui/src/primitives/PageHeader.tsx | 189 ++++++++++++ packages/ui/src/shell/ContentHeader.tsx | 18 +- 26 files changed, 1501 insertions(+), 441 deletions(-) create mode 100644 packages/ui/src/features/canvas/components/channelPages.test.ts create mode 100644 packages/ui/src/features/canvas/components/channelPages.tsx create mode 100644 packages/ui/src/features/loops/components/LoopSpaceBreadcrumb.tsx create mode 100644 packages/ui/src/primitives/PageHeader.stories.tsx create mode 100644 packages/ui/src/primitives/PageHeader.tsx diff --git a/packages/ui/src/features/canvas/components/ActivityView.tsx b/packages/ui/src/features/canvas/components/ActivityView.tsx index 136489b667..5ca1c131ad 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, @@ -287,77 +296,116 @@ export function ActivityView() { }); }, []); - return ( -
-
-
-
- - Activity - - - Tasks you're involved in across{" "} - {spacesLayout ? "spaces" : "channels"}. - -
- {unreadCount > 0 && ( + const markAllReadButton = + unreadCount > 0 ? ( + + ) : null; + + const feed = ( + <> + {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 && ( )}
-
- {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 && ( - - )} + )} + + ); + + // 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}
+
); } 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/ChannelSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelSidebar.tsx index ac5bd8ebd8..156ac81e31 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 }[] = [ @@ -296,16 +299,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, ) => ( @@ -317,15 +321,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({ @@ -335,8 +337,7 @@ export function ChannelSidebar({ channelId }: { channelId: string }) { )} {loopsEnabled && sectionRow( - "Loops", - , + "loops", `${base}/loops`, () => void navigate({ @@ -345,8 +346,7 @@ export function ChannelSidebar({ channelId }: { channelId: string }) { }), )} {sectionRow( - "Artifacts", - , + "artifacts", `${base}/artifacts`, () => void navigate({ diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx index b107531ad7..61e693a69e 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx @@ -29,6 +29,15 @@ import { masonryPreviewHeight } from "@posthog/ui/features/canvas/utils/masonryP 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 { useNavigate } from "@tanstack/react-router"; @@ -74,7 +83,10 @@ export function WebsiteChannelArtifacts({ channelId }: { channelId: string }) { }, [channelId]); useSetHeaderContent( - useMemo(() => , [channelId]), + useMemo( + () => , + [channelId], + ), ); const { dashboards } = useDashboards(channelId); @@ -145,77 +157,105 @@ export function WebsiteChannelArtifacts({ channelId }: { channelId: string }) { ); return ( -
- {/* The list reads best narrow; card layouts want the full width. */} -
-
- - {items.length === 0 - ? "Artifacts" - : `${items.length} artifact${items.length === 1 ? "" : "s"}`} - - -
+
+ {/* 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. + + + + ) : null} - {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) => ( - - ))} -
- ) : view === "grid" ? ( -
- {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) => ( -
+
+ {/* The list reads best narrow; card layouts want the full width. */} +
+ {!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) => ( + + ))} +
+ ) : view === "grid" ? ( +
+ {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) => ( +
+ +
+ ))} +
+ )} +
); 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 11a1e9f08f..ae3e58a8f9 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"; @@ -44,6 +54,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 @@ -54,7 +65,10 @@ export function WebsiteChannelLoops({ channelId }: { channelId: string }) { const contextName = channel?.name ?? channelId; useSetHeaderContent( - useMemo(() => , [channelId]), + useMemo( + () => , + [channelId], + ), ); const attachedLoops = useMemo( @@ -82,55 +96,81 @@ export function WebsiteChannelLoops({ channelId }: { channelId: string }) { navigateToNewLoop(); }; + 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 && ( + + + + Automate #{contextName} + }> + Runs entirely in the cloud + + {createButton} + + + Build a loop that posts its runs to this context's feed, or keeps + its context.md or a canvas up to date. + + + + )}
-
- - - - Automate #{contextName} - - - - - Runs entirely in the cloud - + {!spacesLayout && ( +
+ + + + Automate #{contextName} + + + + + Runs entirely in the cloud + + + + Build a loop that posts its runs to this context's feed, or + keeps its context.md or a canvas up to date. + - - Build a loop that posts its runs to this context's feed, or - keeps its context.md or a canvas up to date. - - - -
+ {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. + + + + )} } /> )} diff --git a/packages/ui/src/features/canvas/components/channelPages.test.ts b/packages/ui/src/features/canvas/components/channelPages.test.ts new file mode 100644 index 0000000000..fd420349b3 --- /dev/null +++ b/packages/ui/src/features/canvas/components/channelPages.test.ts @@ -0,0 +1,24 @@ +import { CHANNEL_SECTIONS } from "@posthog/ui/features/canvas/channelSections"; +import { + CHANNEL_PAGES, + type ChannelPageKey, +} from "@posthog/ui/features/canvas/components/channelPages"; +import { describe, expect, it } from "vitest"; + +describe("CHANNEL_PAGES", () => { + // The two tables are keyed the same on purpose: channelSections carries the + // route segment (plain data, read by non-UI code), CHANNEL_PAGES carries the + // label and icon. A section with no page entry would render an unlabelled, + // icon-less breadcrumb leaf, so fail here instead. + it("has an entry for every routable channel section", () => { + for (const section of CHANNEL_SECTIONS) { + expect(CHANNEL_PAGES[section.key as ChannelPageKey]).toBeDefined(); + } + }); + + it("gives every page a label", () => { + for (const page of Object.values(CHANNEL_PAGES)) { + expect(page.label.trim()).not.toBe(""); + } + }); +}); diff --git a/packages/ui/src/features/canvas/components/channelPages.tsx b/packages/ui/src/features/canvas/components/channelPages.tsx new file mode 100644 index 0000000000..74ad043e22 --- /dev/null +++ b/packages/ui/src/features/canvas/components/channelPages.tsx @@ -0,0 +1,53 @@ +import { + BookOpenTextIcon, + ChatsCircleIcon, + ClockCounterClockwiseIcon, + type Icon, + PackageIcon, + RepeatIcon, + ShapesIcon, +} from "@phosphor-icons/react"; +import type { ReactNode } from "react"; + +/** + * The pages inside a space, and how each one is named and drawn. One table so + * the sidebar rows, the header breadcrumb leaf, and anything else that points + * at a space page can't drift apart — add a page here, not at the call sites. + * + * `home` is the space's feed. It's both the root route and a named page, so it + * reads as "{space} / Feed" like every sibling rather than a bare space name. + * + * Route segments and browser-tab names live in `channelSections.ts`, which is + * plain data (no React) because non-UI code reads it. + */ +export type ChannelPageKey = + | "home" + | "context" + | "loops" + | "artifacts" + | "canvases" + | "history"; + +export const CHANNEL_PAGES: Record< + ChannelPageKey, + { label: string; Icon: Icon } +> = { + home: { label: "Feed", Icon: ChatsCircleIcon }, + context: { label: "Context", Icon: BookOpenTextIcon }, + loops: { label: "Loops", Icon: RepeatIcon }, + artifacts: { label: "Artifacts", Icon: PackageIcon }, + canvases: { label: "Canvases", Icon: ShapesIcon }, + history: { label: "Recents", Icon: ClockCounterClockwiseIcon }, +}; + +export function channelPageLabel(key: ChannelPageKey): string { + return CHANNEL_PAGES[key].label; +} + +export function channelPageIcon( + key: ChannelPageKey, + opts?: { size?: number; className?: string }, +): ReactNode { + const { Icon: PageIcon } = CHANNEL_PAGES[key]; + return ; +} diff --git a/packages/ui/src/features/command-center/components/CommandCenterView.tsx b/packages/ui/src/features/command-center/components/CommandCenterView.tsx index 744cfd397e..5dfdef0b6f 100644 --- a/packages/ui/src/features/command-center/components/CommandCenterView.tsx +++ b/packages/ui/src/features/command-center/components/CommandCenterView.tsx @@ -1,6 +1,5 @@ -import { Lightning } from "@phosphor-icons/react"; -import { Box, Flex, Text } from "@radix-ui/themes"; -import { useEffect, useMemo } from "react"; +import { Box, Flex } from "@radix-ui/themes"; +import { useEffect } from "react"; import { useSetHeaderContent } from "../../../hooks/useSetHeaderContent"; import { useTaskViewed } from "../../sidebar/useTaskViewed"; import { useCommandCenterStore } from "../commandCenterStore"; @@ -28,22 +27,11 @@ export function CommandCenterView() { } }, [visibleTaskIdsKey, markAsViewed]); - const headerContent = useMemo( - () => ( - - - - Command Center - - - ), - [], - ); - - useSetHeaderContent(headerContent); + // Root-level page: no breadcrumb row. Its own toolbar names the view, and + // there's no parent space to walk back to, so the bar was an empty frame. + // (Pushing null also collapses the row inside the Channels space, where + // WebsiteLayout renders whatever the active view puts in the header store.) + useSetHeaderContent(null); return ( diff --git a/packages/ui/src/features/inbox/components/InboxPageHeader.tsx b/packages/ui/src/features/inbox/components/InboxPageHeader.tsx index 5e07d9fefd..f089a0eaad 100644 --- a/packages/ui/src/features/inbox/components/InboxPageHeader.tsx +++ b/packages/ui/src/features/inbox/components/InboxPageHeader.tsx @@ -1,12 +1,61 @@ import type { InboxTabCounts } from "@posthog/core/inbox/reportMembership"; -import { InboxTabBar } from "@posthog/ui/features/inbox/components/InboxTabBar"; +import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; +import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; +import { InboxScopeSelect } from "@posthog/ui/features/inbox/components/InboxScopeSelect"; +import { + activeTabFromPath, + InboxTabBar, + InboxTabs, + inboxScopeApplies, +} from "@posthog/ui/features/inbox/components/InboxTabBar"; +import { + PageHeader, + PageHeaderDescription, + PageHeaderFilters, + PageHeaderHeading, + PageHeaderNav, + PageHeaderTitle, + PageHeaderTitleRow, +} from "@posthog/ui/primitives/PageHeader"; import { Flex, Text } from "@radix-ui/themes"; +import { useRouterState } from "@tanstack/react-router"; interface InboxPageHeaderProps { counts: InboxTabCounts; } export function InboxPageHeader({ counts }: InboxPageHeaderProps) { + // The shared page header ships behind bluebird; everyone else keeps the + // header this page has always had. Delete the legacy branch when the flag + // graduates. + const bluebird = useFeatureFlag(PROJECT_BLUEBIRD_FLAG, import.meta.env.DEV); + const pathname = useRouterState({ select: (s) => s.location.pathname }); + + if (!bluebird) return ; + + return ( + + + + Inbox + + + Work done by your agents – pull requests, reports, and live runs. + + + + + {inboxScopeApplies(activeTabFromPath(pathname)) && ( + + + + )} + + + ); +} + +function LegacyInboxPageHeader({ counts }: InboxPageHeaderProps) { return ( s.location.pathname }); const activeKey = activeTabFromPath(pathname); return ( - { - const key = value as InboxTabKey; - navigate({ to: INBOX_TAB_LIST_ROUTE[key] }); - }} + + {inboxScopeApplies(activeKey) && } + + ); +} + +/** Whether the reviewer-scope control means anything on this tab. */ +export function inboxScopeApplies(tab: InboxTabKey): boolean { + return tab !== "runs" && tab !== "dismissed"; +} + +/** Just the tab strip — the header slots its own filters beside it. */ +export function InboxTabs({ counts }: InboxTabBarProps) { + const navigate = useNavigate(); + const pathname = useRouterState({ select: (s) => s.location.pathname }); + const activeKey = activeTabFromPath(pathname); + + return ( + { + const key = value as InboxTabKey; + navigate({ to: INBOX_TAB_LIST_ROUTE[key] }); + }} + > + - - {INBOX_TAB_KEYS.map((key) => { - const isActive = key === activeKey; - return ( - - - {INBOX_TAB_LABEL[key]} + {INBOX_TAB_KEYS.map((key) => { + const isActive = key === activeKey; + return ( + + + {INBOX_TAB_LABEL[key]} + + {/* Runs and the open-ended Archive don't get a running total — it adds no signal. */} + {key !== "runs" && key !== "dismissed" && counts[key] > 0 && ( + + {counts[key]} - {/* Runs and the open-ended Archive don't get a running total — it adds no signal. */} - {key !== "runs" && key !== "dismissed" && counts[key] > 0 && ( - - {counts[key]} - - )} - - ); - })} - - - {activeKey !== "runs" && activeKey !== "dismissed" && ( - - )} - + )} + + ); + })} + + ); } diff --git a/packages/ui/src/features/loops/components/LoopDetailView.tsx b/packages/ui/src/features/loops/components/LoopDetailView.tsx index 48abf2c7cc..88644373cc 100644 --- a/packages/ui/src/features/loops/components/LoopDetailView.tsx +++ b/packages/ui/src/features/loops/components/LoopDetailView.tsx @@ -1,4 +1,4 @@ -import { ArrowLeftIcon, RepeatIcon } from "@phosphor-icons/react"; +import { ArrowLeftIcon } from "@phosphor-icons/react"; import type { LoopSchemas } from "@posthog/api-client/loops"; import { isUploadableSkillSource } from "@posthog/core/message-editor/skillTags"; import { useHostTRPC } from "@posthog/host-router/react"; @@ -19,6 +19,7 @@ import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar"; import { assertCloudUsageAvailable } from "@posthog/ui/features/billing/preflightCloudUsage"; import { useUsageLimitStore } from "@posthog/ui/features/billing/usageLimitStore"; +import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { useOrgMembers } from "@posthog/ui/features/canvas/hooks/useOrgMembers"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; @@ -37,7 +38,7 @@ import { useHostCapabilities } from "@posthog/ui/shell/useHostCapabilities"; import { Flex, Text } from "@radix-ui/themes"; import { useQuery } from "@tanstack/react-query"; import { useLocation } from "@tanstack/react-router"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useLoop } from "../hooks/useLoop"; import { useDeleteLoop, @@ -63,6 +64,7 @@ import { formatLoopModel } from "../loopModels"; import { loopSkillBundles, primaryLoopSkillBundle } from "../loopSkill"; import { LoopLoadError } from "./LoopFallbacks"; import { LoopRunRow } from "./LoopRunRow"; +import { LoopSpaceBreadcrumb } from "./LoopSpaceBreadcrumb"; export function LoopDetailView({ loopId }: { loopId: string }) { const hasLoopListOrigin = useLocation({ @@ -89,16 +91,22 @@ export function LoopDetailView({ loopId }: { loopId: string }) { ); }, [isLoading, runsQuery.isLoading, runsQuery.isError, loop, runs.length]); + // A loop attached to a space gets a breadcrumb back to it; a project-level + // loop has nowhere to walk back to, so it drops the row entirely. + const spacesLayout = useChannelsLayout(); + const contextTarget = loop?.context_target ?? null; useSetHeaderContent( - - - - {loop?.name ?? "Loop"} - - , + useMemo( + () => + spacesLayout && contextTarget ? ( + + ) : null, + [spacesLayout, contextTarget, loop?.name], + ), ); const handleToggleEnabled = (enabled: boolean) => { diff --git a/packages/ui/src/features/loops/components/LoopForm.tsx b/packages/ui/src/features/loops/components/LoopForm.tsx index e68cd60a99..cbfe235760 100644 --- a/packages/ui/src/features/loops/components/LoopForm.tsx +++ b/packages/ui/src/features/loops/components/LoopForm.tsx @@ -6,6 +6,7 @@ import { } from "@phosphor-icons/react"; import { type LoopSchemas, LoopsApiError } from "@posthog/api-client/loops"; import { ANALYTICS_EVENTS, PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; +import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { SettingsOptionSelect } from "@posthog/ui/features/settings/SettingsOptionSelect"; import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; @@ -18,7 +19,7 @@ import { } from "@posthog/ui/router/navigationBridge"; import { track } from "@posthog/ui/shell/analytics"; import { Box, Flex, Text, TextField } from "@radix-ui/themes"; -import { type ReactNode, useEffect, useState } from "react"; +import { type ReactNode, useEffect, useMemo, useState } from "react"; import { useAuthStateValue } from "../../auth/store"; import { useCreateLoop, @@ -52,6 +53,7 @@ import { LoopModelFields } from "./LoopModelFields"; import { LoopNotificationsFields } from "./LoopNotificationsFields"; import { LoopRepositoryPicker } from "./LoopRepositoryPicker"; import { LoopInstructionsFields } from "./LoopSkillFields"; +import { LoopSpaceBreadcrumb } from "./LoopSpaceBreadcrumb"; import { LoopTriggerEditor } from "./LoopTriggerEditor"; const VISIBILITY_OPTIONS: { @@ -133,10 +135,23 @@ export function LoopForm({ loop }: LoopFormProps) { ]; const isLastStep = step === STEPS.length - 1; + // Building a loop for a space keeps a way back to it; a project-level loop + // has no parent to breadcrumb to, so the row collapses. + const spacesLayout = useChannelsLayout(); + const contextTarget = values.contextTarget; + const headerLeaf = isEdit ? loop.name : "New loop"; useSetHeaderContent( - - {isEdit ? `Edit ${loop.name}` : "New loop"} - , + useMemo( + () => + spacesLayout && contextTarget ? ( + + ) : null, + [spacesLayout, contextTarget, headerLeaf], + ), ); const triggerEndpointPath = diff --git a/packages/ui/src/features/loops/components/LoopSpaceBreadcrumb.tsx b/packages/ui/src/features/loops/components/LoopSpaceBreadcrumb.tsx new file mode 100644 index 0000000000..c2528fc677 --- /dev/null +++ b/packages/ui/src/features/loops/components/LoopSpaceBreadcrumb.tsx @@ -0,0 +1,51 @@ +import { ChannelBreadcrumb } from "@posthog/ui/features/canvas/components/ChannelBreadcrumb"; +import { + channelPageIcon, + channelPageLabel, +} from "@posthog/ui/features/canvas/components/channelPages"; +import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { useNavigate } from "@tanstack/react-router"; + +/** + * Header breadcrumb for a loop that belongs to a space: + * "{space} / Loops / {loop}", with the space and Loops segments both linking + * back. Loops live outside the space routes (/code/loops/…), so without this a + * space-attached loop is a dead end. + * + * Render this only when the spaces layout is on and the loop has a context + * target — callers pass `null` to `useSetHeaderContent` otherwise, which + * leaves a project-level loop with no breadcrumb row at all. + */ +export function LoopSpaceBreadcrumb({ + folderId, + spaceName, + leafLabel, +}: { + /** Desktop folder id of the attached space (`context_target.folder_id`). */ + folderId: string; + /** Name stamped on the loop, used until the live space list resolves. */ + spaceName: string; + leafLabel: string; +}) { + const navigate = useNavigate(); + // The loop's stored name can go stale after a rename, so prefer the live one. + const { channels } = useChannels(); + const liveName = channels.find((c) => c.id === folderId)?.name; + + return ( + + void navigate({ + to: "/website/$channelId/loops", + params: { channelId: folderId }, + }), + }} + leafLabel={leafLabel} + /> + ); +} diff --git a/packages/ui/src/features/loops/components/LoopsListView.stories.tsx b/packages/ui/src/features/loops/components/LoopsListView.stories.tsx index 3c868e4670..34a57b47cd 100644 --- a/packages/ui/src/features/loops/components/LoopsListView.stories.tsx +++ b/packages/ui/src/features/loops/components/LoopsListView.stories.tsx @@ -155,6 +155,11 @@ type Story = StoryObj; export const Comprehensive: Story = {}; +/** Bluebird: title, cloud chip and CTA move into the shared full-bleed header. */ +export const SharedPageHeader: Story = { + args: { sharedPageHeader: true }, +}; + export const LongMixedList: Story = { args: { loops: Array.from({ length: 18 }, (_, index) => { diff --git a/packages/ui/src/features/loops/components/LoopsListView.test.tsx b/packages/ui/src/features/loops/components/LoopsListView.test.tsx index 708f4fb4be..6e98ce82aa 100644 --- a/packages/ui/src/features/loops/components/LoopsListView.test.tsx +++ b/packages/ui/src/features/loops/components/LoopsListView.test.tsx @@ -108,4 +108,48 @@ describe("LoopsListViewPresentation", () => { expect(screen.queryByText("personal loop")).not.toBeInTheDocument(), ); }); + + // With the shared page header the triggers sit in the header and the panels + // stay in the scrolling body — one Tabs root spanning both, so switching has + // to keep working across that split. + it("switches tabs when the trigger strip lives in the page header", async () => { + render( + + + , + ); + + const teamTab = screen.getByRole("tab", { name: "Team loops (1)" }); + await userEvent.click(teamTab); + + expect(teamTab).toHaveAttribute("aria-selected", "true"); + expect( + within(controlledPanel(teamTab)).getByText("team loop"), + ).toBeVisible(); + }); + + it("hides the header trigger strip while loops are loading", () => { + render( + + + , + ); + + expect(screen.queryByRole("tab")).not.toBeInTheDocument(); + }); }); diff --git a/packages/ui/src/features/loops/components/LoopsListView.tsx b/packages/ui/src/features/loops/components/LoopsListView.tsx index c8df964946..697776ac2c 100644 --- a/packages/ui/src/features/loops/components/LoopsListView.tsx +++ b/packages/ui/src/features/loops/components/LoopsListView.tsx @@ -1,19 +1,26 @@ -import { - ChatCircleDotsIcon, - CloudIcon, - PlusIcon, - RepeatIcon, -} from "@phosphor-icons/react"; +import { ChatCircleDotsIcon, CloudIcon, PlusIcon } from "@phosphor-icons/react"; import type { LoopSchemas } from "@posthog/api-client/loops"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@posthog/quill"; +import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import type { UserBasic } from "@posthog/shared/domain-types"; import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; import { useOrgMembers } from "@posthog/ui/features/canvas/hooks/useOrgMembers"; +import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { StopCloudRunDialog } from "@posthog/ui/features/sessions/components/StopCloudRunDialog"; import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; import { Button } from "@posthog/ui/primitives/Button"; +import { + PageHeader, + PageHeaderActions, + PageHeaderChip, + PageHeaderDescription, + PageHeaderHeading, + PageHeaderNav, + PageHeaderTitle, + PageHeaderTitleRow, +} from "@posthog/ui/primitives/PageHeader"; import { toast } from "@posthog/ui/primitives/toast"; import { navigateToNewLoop, @@ -21,7 +28,7 @@ import { } from "@posthog/ui/router/navigationBridge"; import { track } from "@posthog/ui/shell/analytics"; import { Flex, Heading, Text } from "@radix-ui/themes"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useLoopBuilderSessions } from "../hooks/useLoopBuilderSessions"; import { useLoopLimits, useLoops } from "../hooks/useLoops"; import { @@ -69,6 +76,10 @@ function startLoopFromTemplate(template: LoopTemplate): void { export function LoopsListView() { const { data: loops, isLoading, isError, error } = useLoops(); + // The shared page header ships behind bluebird. Read here, not in the + // presentation — that renders bare in tests and Storybook, with no container + // to resolve the flags service from. + const bluebird = useFeatureFlag(PROJECT_BLUEBIRD_FLAG, import.meta.env.DEV); const authenticatedClient = useOptionalAuthenticatedClient(); const { data: currentUser, @@ -86,21 +97,9 @@ export function LoopsListView() { listError = currentUserQueryError; } - const headerContent = useMemo( - () => ( - - - - Loops - - - ), - [], - ); - useSetHeaderContent(headerContent); + // The page names itself (in-page header / title block), so it pushes no + // breadcrumb row — only a space-attached loop scene has a parent to show. + useSetHeaderContent(null); const { sessions: builderSessions, isSettled: builderSessionsSettled } = useLoopBuilderSessions(); @@ -148,6 +147,7 @@ export function LoopsListView() { return ( + const createButton = ( + + ); + + // 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 +281,26 @@ export function LoopsListViewPresentation({ } /> ) : loops.length > 0 ? ( - + sharedPageHeader ? ( + // Triggers live in the page header; only the panels sit here. + + ) : ( + + ) ) : ( )} @@ -302,7 +327,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 +391,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; From 6f47a347c2ab0da2927d85cf50cd7c60941a0e26 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Wed, 29 Jul 2026 11:48:27 +0100 Subject: [PATCH 04/12] fix(canvas): no white flash when a preview loads in dark mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sandbox document painted with a hard light fallback (background: var(--background, #fff)) during the window before its stylesheets loaded and before the host's init/set-theme message toggled `.dark` — so every canvas preview scrolling into view flashed white over a dark app. It now stays transparent until the tokens land, and the host sets color-scheme on the iframe so the UA's base canvas is dark too. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BYPVPkN4Lm3gygchrpcoqr --- .../src/features/canvas/freeform/FreeformCanvas.tsx | 5 +++++ .../features/canvas/freeform/sandboxRuntime.test.ts | 10 ++++++++++ .../src/features/canvas/freeform/sandboxRuntime.ts | 13 +++++++++++-- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/features/canvas/freeform/FreeformCanvas.tsx b/packages/ui/src/features/canvas/freeform/FreeformCanvas.tsx index f62258877c..afbce9eb32 100644 --- a/packages/ui/src/features/canvas/freeform/FreeformCanvas.tsx +++ b/packages/ui/src/features/canvas/freeform/FreeformCanvas.tsx @@ -270,6 +270,11 @@ export function FreeformCanvas({ }} // bg tracks the host theme so there's no white flash in dark mode before // the iframe paints; the canvas body uses the same --background token. + // color-scheme matters as much as the background: without it the embedded + // document's base canvas is painted white by the UA — which is what a + // preview scrolling into view flashed before its stylesheets and the + // first `init` (carrying the theme) arrived. + style={{ colorScheme: theme }} className="h-full w-full border-0 bg-background" /> ); diff --git a/packages/ui/src/features/canvas/freeform/sandboxRuntime.test.ts b/packages/ui/src/features/canvas/freeform/sandboxRuntime.test.ts index 617363d19e..6786ad4097 100644 --- a/packages/ui/src/features/canvas/freeform/sandboxRuntime.test.ts +++ b/packages/ui/src/features/canvas/freeform/sandboxRuntime.test.ts @@ -65,6 +65,16 @@ describe("buildSandboxDocument", () => { expect(html).toContain('"open-external"'); expect(html).toContain("event.defaultPrevented"); }); + + // The document paints before its stylesheets load and before the host's + // theme message arrives. A light fallback there flashed white over a dark + // app every time a canvas preview scrolled into view. + it("paints nothing of its own before the host theme lands", () => { + const html = buildSandboxDocument("edit"); + expect(html).toContain("background: var(--background, transparent)"); + expect(html).not.toContain("var(--background, #fff)"); + expect(html).toContain("html.dark { color-scheme: dark; }"); + }); }); describe("resolveExternalAnchorUrl", () => { diff --git a/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts b/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts index 641a8c0c43..8f4f73d27f 100644 --- a/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts +++ b/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts @@ -453,9 +453,18 @@ ${FREEFORM_QUILL_CSS_URLS.map( /* Fill the iframe viewport exactly so overflow scrolls on the iframe's own root scroller — the iframe is pinned to its parent's height and never grows it. */ html, body { margin: 0; padding: 0; height: 100%; } + /* No light default: leaving \`color-scheme\` alone lets the base canvas inherit + the embedder's scheme (the host sets it on the iframe), so the first paint + is already dark in a dark app. Once the host's theme message toggles + \`.dark\`, this pins it so form controls and scrollbars match too. */ + html.dark { color-scheme: dark; } /* Track the theme via Quill's tokens (set on :root / .dark) so the page chrome - flips with the host theme; fall back to light if the tokens haven't loaded. */ - body { font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; color: var(--foreground, #111); background: var(--background, #fff); } + flips with the host theme. Until those tokens land — the stylesheets are + still loading, and \`.dark\` is only applied once the host's init/set-theme + message arrives — stay transparent and inherit, so the host iframe's own + themed background shows through. A hard light fallback here flashed white + over a dark app every time a preview scrolled into view. */ + body { font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; color: var(--foreground, inherit); background: var(--background, transparent); } #root { min-height: 100vh; } From b9c1d6c3e3ae55ca56f03acb79faa50469c559e7 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Wed, 29 Jul 2026 12:10:10 +0100 Subject: [PATCH 05/12] fix(canvas): canvas menu fits its labels, and pins to a "space" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The options menu kept quill's default width, which clipped "Unpin from channel" mid-word; it now sizes to its longest item like the channel-list menus do. The label itself follows the layout — space under the new one, channel under the old. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BYPVPkN4Lm3gygchrpcoqr --- .../features/canvas/components/WebsiteLayout.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/features/canvas/components/WebsiteLayout.tsx b/packages/ui/src/features/canvas/components/WebsiteLayout.tsx index 8ee861e4de..9eebdd3169 100644 --- a/packages/ui/src/features/canvas/components/WebsiteLayout.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteLayout.tsx @@ -82,6 +82,9 @@ 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 containerNoun = useChannelsLayout() ? "space" : "channel"; const editing = useIsDashboardEditing(dashboardId); const setEditing = useDashboardEditStore((s) => s.setEditing); const { dashboard } = useDashboard(dashboardId); @@ -268,7 +271,14 @@ function FreeformEditControls({ } /> - + {/* Sized to its longest item — the default width clipped "Unpin from + space". Same treatment as the channel-list menus. */} + Refresh @@ -283,7 +293,9 @@ function FreeformEditControls({ - {isPinned ? "Unpin from channel" : "Pin to channel"} + {isPinned + ? `Unpin from ${containerNoun}` + : `Pin to ${containerNoun}`} Date: Wed, 29 Jul 2026 11:47:58 +0100 Subject: [PATCH 06/12] fix(canvas): delete confirm copy matches the undo window The dialog said the delete "cannot be undone" while the action routes through deleteCanvasWithUndo, which holds it for 8s behind an Undo toast. Say what actually happens instead, and call the container a space under the new layout, like the menu above it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BYPVPkN4Lm3gygchrpcoqr --- .../canvas/components/WebsiteDashboardsIndex.tsx | 10 ++++++---- .../src/features/canvas/components/WebsiteLayout.tsx | 9 +++++---- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/features/canvas/components/WebsiteDashboardsIndex.tsx b/packages/ui/src/features/canvas/components/WebsiteDashboardsIndex.tsx index eb0fa12fa3..e88cf4efc9 100644 --- a/packages/ui/src/features/canvas/components/WebsiteDashboardsIndex.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteDashboardsIndex.tsx @@ -25,6 +25,7 @@ import { FreeformPreview } from "@posthog/ui/features/canvas/components/Freeform import { NewCanvasMenu } from "@posthog/ui/features/canvas/components/NewCanvasMenu"; import { deleteCanvasWithUndo } from "@posthog/ui/features/canvas/deleteCanvasWithUndo"; import { useCanvasTemplates } from "@posthog/ui/features/canvas/hooks/useCanvasTemplates"; +import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { useDashboardMutations, useDashboards, @@ -185,8 +186,9 @@ function DashboardCardMenu({ channelId: string; }) { const [open, setOpen] = useState(false); + const containerNoun = useChannelsLayout() ? "space" : "channel"; // "Delete…" opens a confirmation rather than deleting inline — the canvas and - // its version history go away for everyone in the channel. + // its version history go away for everyone in the space. const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); const { invalidateDashboards } = useDashboardMutations(); @@ -246,9 +248,9 @@ function DashboardCardMenu({ Delete canvas - Permanently delete {name}? - This deletes its code and version history for everyone in the - channel and cannot be undone. + Delete {name}? Its code and + version history go for everyone in the {containerNoun}. You get a + few seconds to undo, then it's permanent. diff --git a/packages/ui/src/features/canvas/components/WebsiteLayout.tsx b/packages/ui/src/features/canvas/components/WebsiteLayout.tsx index 9eebdd3169..032b1988b5 100644 --- a/packages/ui/src/features/canvas/components/WebsiteLayout.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteLayout.tsx @@ -92,7 +92,7 @@ function FreeformEditControls({ 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 channel. + // 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 @@ -312,10 +312,11 @@ function FreeformEditControls({ Delete canvas - Permanently delete{" "} + Delete{" "} {dashboard?.name ?? "Canvas"} - ? This deletes its code and version history for everyone in the - channel and cannot be undone. + ? Its code and version history go for everyone in the{" "} + {containerNoun}. You get a few seconds to undo, then it's + permanent. From e39432787e45762dfb6bdd4d3431f672a8d87971 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Wed, 29 Jul 2026 12:17:01 +0100 Subject: [PATCH 07/12] refactor(ui): review cleanups on the page-header work - one useBluebirdFlag hook so the dev default isn't repeated per call site - type view_mode on the channel-action event instead of a bare string - drop the two redundant comments on the analytics event union - memoize the Activity feed and its mark-all-read button Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BYPVPkN4Lm3gygchrpcoqr --- packages/shared/src/analytics-events.ts | 4 +- .../canvas/components/ActivityView.tsx | 139 ++++++++++-------- .../canvas/hooks/useChannelsLayout.ts | 8 +- .../features/feature-flags/useBluebirdFlag.ts | 15 ++ .../inbox/components/InboxPageHeader.tsx | 5 +- .../features/loops/components/LoopForm.tsx | 9 +- .../loops/components/LoopsListView.tsx | 5 +- 7 files changed, 105 insertions(+), 80 deletions(-) create mode 100644 packages/ui/src/features/feature-flags/useBluebirdFlag.ts diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index e53435c68f..2da7a31ed6 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -919,7 +919,6 @@ export type ChannelActionType = | "open_mention" | "canvas_mode_toggle" | "activity_tab_change" - /** Switched the artifacts list between list / grid / masonry. */ | "artifacts_view_change"; export interface ChannelActionProperties { @@ -941,8 +940,7 @@ export interface ChannelActionProperties { armed?: boolean; /** For activity_tab_change: the tab landed on. */ tab?: string; - /** For artifacts_view_change: the layout landed on ("list"|"grid"|"masonry"). */ - view_mode?: string; + view_mode?: "list" | "grid" | "masonry"; /** Whether the underlying mutation resolved successfully. */ success?: boolean; } diff --git a/packages/ui/src/features/canvas/components/ActivityView.tsx b/packages/ui/src/features/canvas/components/ActivityView.tsx index 5ca1c131ad..046c534ce8 100644 --- a/packages/ui/src/features/canvas/components/ActivityView.tsx +++ b/packages/ui/src/features/canvas/components/ActivityView.tsx @@ -287,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", @@ -296,65 +299,81 @@ export function ActivityView() { }); }, []); - const markAllReadButton = - unreadCount > 0 ? ( - - ) : null; + const markAllReadButton = useMemo( + () => + unreadCount > 0 ? ( + + ) : null, + [unreadCount, unreadItems.length, isMarkingRead, markAllRead], + ); - const feed = ( - <> - {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 feed = useMemo( + () => ( + <> + {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 && ( + + )} +
+ )} + + ), + [ + isLoading, + items, + spacesLayout, + folderChannelIdFor, + markRead, + currentUser, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + ], ); // The shared page header ships with the spaces layout; without it the page diff --git a/packages/ui/src/features/canvas/hooks/useChannelsLayout.ts b/packages/ui/src/features/canvas/hooks/useChannelsLayout.ts index 23a0e28ffa..487f968aa4 100644 --- a/packages/ui/src/features/canvas/hooks/useChannelsLayout.ts +++ b/packages/ui/src/features/canvas/hooks/useChannelsLayout.ts @@ -1,4 +1,5 @@ -import { CHANNELS_LAYOUT_FLAG, PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; +import { CHANNELS_LAYOUT_FLAG } from "@posthog/shared"; +import { useBluebirdFlag } from "@posthog/ui/features/feature-flags/useBluebirdFlag"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; /** @@ -6,10 +7,7 @@ import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFla * No dev default, so dev matches prod; bluebird keeps its own backend guard. */ export function useChannelsLayout(): boolean { - const bluebirdEnabled = useFeatureFlag( - PROJECT_BLUEBIRD_FLAG, - import.meta.env.DEV, - ); + const bluebirdEnabled = useBluebirdFlag(); const layoutEnabled = useFeatureFlag( CHANNELS_LAYOUT_FLAG, import.meta.env.DEV, diff --git a/packages/ui/src/features/feature-flags/useBluebirdFlag.ts b/packages/ui/src/features/feature-flags/useBluebirdFlag.ts new file mode 100644 index 0000000000..627b119f48 --- /dev/null +++ b/packages/ui/src/features/feature-flags/useBluebirdFlag.ts @@ -0,0 +1,15 @@ +import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; +import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; + +/** + * The project-bluebird gate. Read this rather than the raw flag: the dev + * default lives here once, so a surface can't ship with the flag on in dev and + * off for a colleague (or the reverse) because a call site forgot it. + * + * Space-scoped surfaces want {@link useChannelsLayout} instead — that's this + * flag *and* the channels layout, which is what actually puts a page inside a + * space. + */ +export function useBluebirdFlag(): boolean { + return useFeatureFlag(PROJECT_BLUEBIRD_FLAG, import.meta.env.DEV); +} diff --git a/packages/ui/src/features/inbox/components/InboxPageHeader.tsx b/packages/ui/src/features/inbox/components/InboxPageHeader.tsx index f089a0eaad..63beacda05 100644 --- a/packages/ui/src/features/inbox/components/InboxPageHeader.tsx +++ b/packages/ui/src/features/inbox/components/InboxPageHeader.tsx @@ -1,6 +1,5 @@ import type { InboxTabCounts } from "@posthog/core/inbox/reportMembership"; -import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; -import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; +import { useBluebirdFlag } from "@posthog/ui/features/feature-flags/useBluebirdFlag"; import { InboxScopeSelect } from "@posthog/ui/features/inbox/components/InboxScopeSelect"; import { activeTabFromPath, @@ -28,7 +27,7 @@ export function InboxPageHeader({ counts }: InboxPageHeaderProps) { // The shared page header ships behind bluebird; everyone else keeps the // header this page has always had. Delete the legacy branch when the flag // graduates. - const bluebird = useFeatureFlag(PROJECT_BLUEBIRD_FLAG, import.meta.env.DEV); + const bluebird = useBluebirdFlag(); const pathname = useRouterState({ select: (s) => s.location.pathname }); if (!bluebird) return ; diff --git a/packages/ui/src/features/loops/components/LoopForm.tsx b/packages/ui/src/features/loops/components/LoopForm.tsx index cbfe235760..01e6ef80fe 100644 --- a/packages/ui/src/features/loops/components/LoopForm.tsx +++ b/packages/ui/src/features/loops/components/LoopForm.tsx @@ -5,9 +5,9 @@ import { Check, } from "@phosphor-icons/react"; import { type LoopSchemas, LoopsApiError } from "@posthog/api-client/loops"; -import { ANALYTICS_EVENTS, PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; +import { ANALYTICS_EVENTS } from "@posthog/shared"; import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; -import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; +import { useBluebirdFlag } from "@posthog/ui/features/feature-flags/useBluebirdFlag"; import { SettingsOptionSelect } from "@posthog/ui/features/settings/SettingsOptionSelect"; import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; @@ -104,10 +104,7 @@ export function LoopForm({ loop }: LoopFormProps) { // Contexts are a channels surface; hide the attachment UI when channels are // off, unless this loop is already attached so the link stays visible and // detachable. - const bluebirdEnabled = useFeatureFlag( - PROJECT_BLUEBIRD_FLAG, - import.meta.env.DEV, - ); + const bluebirdEnabled = useBluebirdFlag(); const channelsEnabled = useSidebarStore((s) => s.channelsEnabled) && bluebirdEnabled; const showContextField = channelsEnabled || !!values.contextTarget; diff --git a/packages/ui/src/features/loops/components/LoopsListView.tsx b/packages/ui/src/features/loops/components/LoopsListView.tsx index 697776ac2c..2ed371b75f 100644 --- a/packages/ui/src/features/loops/components/LoopsListView.tsx +++ b/packages/ui/src/features/loops/components/LoopsListView.tsx @@ -1,13 +1,12 @@ import { ChatCircleDotsIcon, CloudIcon, PlusIcon } from "@phosphor-icons/react"; import type { LoopSchemas } from "@posthog/api-client/loops"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@posthog/quill"; -import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import type { UserBasic } from "@posthog/shared/domain-types"; import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; import { useOrgMembers } from "@posthog/ui/features/canvas/hooks/useOrgMembers"; -import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; +import { useBluebirdFlag } from "@posthog/ui/features/feature-flags/useBluebirdFlag"; import { StopCloudRunDialog } from "@posthog/ui/features/sessions/components/StopCloudRunDialog"; import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; import { Button } from "@posthog/ui/primitives/Button"; @@ -79,7 +78,7 @@ export function LoopsListView() { // The shared page header ships behind bluebird. Read here, not in the // presentation — that renders bare in tests and Storybook, with no container // to resolve the flags service from. - const bluebird = useFeatureFlag(PROJECT_BLUEBIRD_FLAG, import.meta.env.DEV); + const bluebird = useBluebirdFlag(); const authenticatedClient = useOptionalAuthenticatedClient(); const { data: currentUser, From 25a9611026b54109b47d3ad5331c3500dcf2e4e7 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Wed, 29 Jul 2026 12:25:35 +0100 Subject: [PATCH 08/12] fix(ui): review follow-ups on headers, artifact cards and nav - loop scenes with no space keep an identifying header instead of an empty row (greptile P1) - artifacts body left-aligns under its header rather than centring - grid cards stretch to a common height, so a PR tile no longer ends short beside a canvas thumbnail - canvas previews get a skeleton while deferred and an icon + "Nothing built yet" when empty, instead of a bare line of text - the Activity nav entry is a quill icon button (28px) like its neighbours, not a hand-rolled 32px one Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BYPVPkN4Lm3gygchrpcoqr --- .../features/canvas/components/ChannelNav.tsx | 16 ++++-- .../canvas/components/FreeformPreview.tsx | 49 +++++++++++++--- .../components/WebsiteChannelArtifacts.tsx | 57 +++++++++++++++---- .../loops/components/LoopDetailView.tsx | 15 +++-- .../features/loops/components/LoopForm.tsx | 9 ++- .../loops/components/LoopHeaderTitle.tsx | 23 ++++++++ 6 files changed, 138 insertions(+), 31 deletions(-) create mode 100644 packages/ui/src/features/loops/components/LoopHeaderTitle.tsx 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/FreeformPreview.tsx b/packages/ui/src/features/canvas/components/FreeformPreview.tsx index b753c620ab..37b1f62bfc 100644 --- a/packages/ui/src/features/canvas/components/FreeformPreview.tsx +++ b/packages/ui/src/features/canvas/components/FreeformPreview.tsx @@ -1,11 +1,12 @@ -import { cn, Text } from "@posthog/quill"; +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 { useCallback } from "react"; +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. @@ -68,7 +69,12 @@ export function FreeformPreview({ } + fallback={ + } + 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({ label }: { label: string }) { +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 61e693a69e..05beaab443 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx @@ -43,6 +43,10 @@ import { openExternalUrl } from "@posthog/ui/shell/openExternal"; 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. @@ -184,11 +188,13 @@ export function WebsiteChannelArtifacts({ channelId }: { channelId: string }) { ) : null}
- {/* The list reads best narrow; card layouts want the full width. */} + {/* Left-aligned, like the page header above it — a centred column + drifts away from the title as the window widens. The list keeps a + readable measure; the card layouts spread. */}
{!spacesLayout && ( @@ -226,12 +232,16 @@ export function WebsiteChannelArtifacts({ channelId }: { channelId: string }) { ))}
) : 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) => ( @@ -291,11 +301,14 @@ function ArtifactListItem({ 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; }) { @@ -307,6 +320,7 @@ function ArtifactCard({ ts={item.ts} code={item.code} previewHeight={previewHeight} + fillHeight={fillHeight} onClick={onOpenCanvas} /> ) : ( @@ -314,6 +328,8 @@ function ArtifactCard({ title={item.title} prUrl={item.prUrl} ts={item.ts} + mediaHeight={fillHeight ? previewHeight : undefined} + fillHeight={fillHeight} onClick={onOpenPr} /> ); @@ -447,6 +463,7 @@ function CanvasArtifactCard({ ts, code, previewHeight, + fillHeight, onClick, }: { dashboardId: string; @@ -455,6 +472,7 @@ function CanvasArtifactCard({ ts: number; code?: string; previewHeight: number; + fillHeight?: boolean; onClick: (dashboardId: string) => void; }) { const deleting = useIsCanvasPendingDelete(dashboardId); @@ -488,6 +506,7 @@ function CanvasArtifactCard({ deleting ? "Deleting…" : `Updated ${formatRelativeTimeShort(ts)}` } dimmed={deleting} + fillHeight={fillHeight} onClick={deleting ? undefined : () => onClick(dashboardId)} /> ); @@ -500,11 +519,16 @@ 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 { @@ -524,8 +548,11 @@ function PrArtifactCard({
@@ -534,6 +561,7 @@ function PrArtifactCard({ title={title} badge={stateLabel || "Pull request"} subtitle={subtitle} + fillHeight={fillHeight} onClick={safeUrl ? () => onClick(safeUrl) : undefined} /> ); @@ -546,6 +574,7 @@ function ArtifactCardShell({ badge, subtitle, dimmed, + fillHeight, onClick, }: { media: ReactNode; @@ -554,6 +583,8 @@ function ArtifactCardShell({ 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; }) { @@ -566,12 +597,18 @@ function ArtifactCardShell({ // A card with nowhere to go (or mid-delete) takes no pointer events, so // the hover treatment below can key off plain group-hover. "group w-full text-left disabled:pointer-events-none", + fillHeight && "h-full", dimmed && "pointer-events-none opacity-60", )} > - -
{media}
- + +
{media}
+
{icon} diff --git a/packages/ui/src/features/loops/components/LoopDetailView.tsx b/packages/ui/src/features/loops/components/LoopDetailView.tsx index 88644373cc..b274013e43 100644 --- a/packages/ui/src/features/loops/components/LoopDetailView.tsx +++ b/packages/ui/src/features/loops/components/LoopDetailView.tsx @@ -63,6 +63,7 @@ import { import { formatLoopModel } from "../loopModels"; import { loopSkillBundles, primaryLoopSkillBundle } from "../loopSkill"; import { LoopLoadError } from "./LoopFallbacks"; +import { LoopHeaderTitle } from "./LoopHeaderTitle"; import { LoopRunRow } from "./LoopRunRow"; import { LoopSpaceBreadcrumb } from "./LoopSpaceBreadcrumb"; @@ -91,10 +92,12 @@ export function LoopDetailView({ loopId }: { loopId: string }) { ); }, [isLoading, runsQuery.isLoading, runsQuery.isError, loop, runs.length]); - // A loop attached to a space gets a breadcrumb back to it; a project-level - // loop has nowhere to walk back to, so it drops the row entirely. + // A loop attached to a space gets a breadcrumb back to it; one that belongs + // to the project (or any loop while the spaces layout is off) still names + // itself, it just has no parent to offer. const spacesLayout = useChannelsLayout(); const contextTarget = loop?.context_target ?? null; + const loopName = loop?.name ?? "Loop"; useSetHeaderContent( useMemo( () => @@ -102,10 +105,12 @@ export function LoopDetailView({ loopId }: { loopId: string }) { - ) : null, - [spacesLayout, contextTarget, loop?.name], + ) : ( + + ), + [spacesLayout, contextTarget, loopName], ), ); diff --git a/packages/ui/src/features/loops/components/LoopForm.tsx b/packages/ui/src/features/loops/components/LoopForm.tsx index 01e6ef80fe..c41cf27ca5 100644 --- a/packages/ui/src/features/loops/components/LoopForm.tsx +++ b/packages/ui/src/features/loops/components/LoopForm.tsx @@ -49,6 +49,7 @@ import { buildSkillInstructions, loopSkillBundles } from "../loopSkill"; import { LoopBehaviorFields } from "./LoopBehaviorFields"; import { LoopContextFields } from "./LoopContextFields"; import { Field } from "./LoopFormPrimitives"; +import { LoopHeaderTitle } from "./LoopHeaderTitle"; import { LoopModelFields } from "./LoopModelFields"; import { LoopNotificationsFields } from "./LoopNotificationsFields"; import { LoopRepositoryPicker } from "./LoopRepositoryPicker"; @@ -132,8 +133,8 @@ export function LoopForm({ loop }: LoopFormProps) { ]; const isLastStep = step === STEPS.length - 1; - // Building a loop for a space keeps a way back to it; a project-level loop - // has no parent to breadcrumb to, so the row collapses. + // Building a loop for a space keeps a way back to it; without one the header + // still names the scene, it just has no parent to offer. const spacesLayout = useChannelsLayout(); const contextTarget = values.contextTarget; const headerLeaf = isEdit ? loop.name : "New loop"; @@ -146,7 +147,9 @@ export function LoopForm({ loop }: LoopFormProps) { spaceName={contextTarget.name} leafLabel={headerLeaf} /> - ) : null, + ) : ( + + ), [spacesLayout, contextTarget, headerLeaf], ), ); diff --git a/packages/ui/src/features/loops/components/LoopHeaderTitle.tsx b/packages/ui/src/features/loops/components/LoopHeaderTitle.tsx new file mode 100644 index 0000000000..d3aa8faaab --- /dev/null +++ b/packages/ui/src/features/loops/components/LoopHeaderTitle.tsx @@ -0,0 +1,23 @@ +import { RepeatIcon } from "@phosphor-icons/react"; +import { Flex, Text } from "@radix-ui/themes"; + +/** + * Header lockup for a loop with no space to walk back to — a project-level + * loop, or any loop while the spaces layout is off. Names the scene without + * pretending there's a parent to click. + * + * A space-attached loop uses {@link LoopSpaceBreadcrumb} instead. + */ +export function LoopHeaderTitle({ label }: { label: string }) { + return ( + + + + {label} + + + ); +} From 7546946001016add2f027a2300b1881e024e4dbb Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Wed, 29 Jul 2026 12:34:57 +0100 Subject: [PATCH 09/12] refactor(canvas): artifacts view switcher is a quill ToggleGroup Replaces the ButtonGroup of outline buttons and its hand-rolled data-[active] styling: ToggleGroup owns the pressed state, so the selected view styles itself. Tooltips share one provider with no open delay, since the icons are the only labelling. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BYPVPkN4Lm3gygchrpcoqr --- .../canvas/components/ArtifactsViewToggle.tsx | 49 +++++++++++-------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ArtifactsViewToggle.tsx b/packages/ui/src/features/canvas/components/ArtifactsViewToggle.tsx index 973a380477..ce21ec6f35 100644 --- a/packages/ui/src/features/canvas/components/ArtifactsViewToggle.tsx +++ b/packages/ui/src/features/canvas/components/ArtifactsViewToggle.tsx @@ -1,12 +1,14 @@ import { LayoutIcon, ListIcon, SquaresFourIcon } from "@phosphor-icons/react"; import { - Button, - ButtonGroup, + ToggleGroup, + ToggleGroupItem, Tooltip, TooltipContent, + TooltipProvider, TooltipTrigger, } from "@posthog/quill"; import { + ARTIFACTS_VIEW_MODES, type ArtifactsViewMode, useArtifactsViewStore, } from "@posthog/ui/features/canvas/stores/artifactsViewStore"; @@ -22,38 +24,45 @@ const OPTIONS: { { mode: "masonry", label: "Masonry", Icon: LayoutIcon }, ]; -// Segmented control over the artifacts layout: a ButtonGroup of outline buttons -// joined into one control, the active one tinted (the same data-[active] idiom -// the sidebar's Channels/List switch uses) since outline has no selected state. +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 ( - - {OPTIONS.map(({ mode, label, Icon }) => { - const active = view === mode; - 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 }) => ( setView(mode, channelId)} > - + } /> {label} - ); - })} - + ))} + + ); } From c35dffd8e04c74b52eb66e099fdcd50bd471472d Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Wed, 29 Jul 2026 12:41:22 +0100 Subject: [PATCH 10/12] new masonry icon --- .../ui/src/features/canvas/components/ArtifactsViewToggle.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ArtifactsViewToggle.tsx b/packages/ui/src/features/canvas/components/ArtifactsViewToggle.tsx index ce21ec6f35..c2b91d3aae 100644 --- a/packages/ui/src/features/canvas/components/ArtifactsViewToggle.tsx +++ b/packages/ui/src/features/canvas/components/ArtifactsViewToggle.tsx @@ -1,4 +1,4 @@ -import { LayoutIcon, ListIcon, SquaresFourIcon } from "@phosphor-icons/react"; +import { Kanban, ListIcon, SquaresFourIcon } from "@phosphor-icons/react"; import { ToggleGroup, ToggleGroupItem, @@ -21,7 +21,7 @@ const OPTIONS: { }[] = [ { mode: "list", label: "List", Icon: ListIcon }, { mode: "grid", label: "Grid", Icon: SquaresFourIcon }, - { mode: "masonry", label: "Masonry", Icon: LayoutIcon }, + { mode: "masonry", label: "Masonry", Icon: Kanban }, ]; function isViewMode(value: string | undefined): value is ArtifactsViewMode { From 777ff79a9543e4449ebb9de91902ed80d38b4546 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Wed, 29 Jul 2026 12:46:19 +0100 Subject: [PATCH 11/12] fix(canvas): artifacts fill the pane The list was capped at 680px and the cards at 1400px, which stranded whitespace to the right of the header on a wide window. Every layout here is a scannable list rather than prose, so drop the caps; the grid and masonry gain a fourth column past 2xl now that they have the room. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BYPVPkN4Lm3gygchrpcoqr --- .../components/WebsiteChannelArtifacts.tsx | 23 ++++++++----------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx index 05beaab443..d9c9569358 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx @@ -188,15 +188,10 @@ export function WebsiteChannelArtifacts({ channelId }: { channelId: string }) { ) : null}
- {/* Left-aligned, like the page header above it — a centred column - drifts away from the title as the window widens. The list keeps a - readable measure; the card layouts spread. */} -
+ {/* 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 && (
@@ -235,7 +230,7 @@ export function WebsiteChannelArtifacts({ channelId }: { channelId: string }) { // 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) => ( +
{items.map((item) => (
{title} - + {subtitle} ); From 16f14725d87a3e1365a960c6d321a1697fd70962 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Wed, 29 Jul 2026 12:52:04 +0100 Subject: [PATCH 12/12] fix(ui): clear react-doctor findings from this branch - call useChannelsLayout unconditionally, not inside a ternary, in the two places that derive a "space"/"channel" noun from it - move inboxTabFromPath / inboxScopeApplies to core beside the tab routes they read, so the tab bar file exports components only - render the Activity feed as a memo'd child instead of JSX built in a useMemo above the parent's early return Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BYPVPkN4Lm3gygchrpcoqr --- packages/core/src/inbox/reportMembership.ts | 16 ++ .../canvas/components/ActivityView.tsx | 150 +++++++++++------- .../components/WebsiteDashboardsIndex.tsx | 3 +- .../canvas/components/WebsiteLayout.tsx | 3 +- .../inbox/components/InboxPageHeader.tsx | 10 +- .../features/inbox/components/InboxTabBar.tsx | 18 +-- 6 files changed, 121 insertions(+), 79 deletions(-) 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/ui/src/features/canvas/components/ActivityView.tsx b/packages/ui/src/features/canvas/components/ActivityView.tsx index 046c534ce8..cb3e01eba2 100644 --- a/packages/ui/src/features/canvas/components/ActivityView.tsx +++ b/packages/ui/src/features/canvas/components/ActivityView.tsx @@ -47,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, @@ -316,64 +316,18 @@ export function ActivityView() { [unreadCount, unreadItems.length, isMarkingRead, markAllRead], ); - const feed = useMemo( - () => ( - <> - {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 && ( - - )} -
- )} - - ), - [ - isLoading, - items, - spacesLayout, - folderChannelIdFor, - markRead, - currentUser, - hasNextPage, - isFetchingNextPage, - fetchNextPage, - ], + const feed = ( + ); // The shared page header ships with the spaces layout; without it the page @@ -428,3 +382,81 @@ export function ActivityView() {
); } + +/** + * 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/WebsiteDashboardsIndex.tsx b/packages/ui/src/features/canvas/components/WebsiteDashboardsIndex.tsx index e88cf4efc9..2e396f584c 100644 --- a/packages/ui/src/features/canvas/components/WebsiteDashboardsIndex.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteDashboardsIndex.tsx @@ -186,7 +186,8 @@ function DashboardCardMenu({ channelId: string; }) { const [open, setOpen] = useState(false); - const containerNoun = useChannelsLayout() ? "space" : "channel"; + 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); diff --git a/packages/ui/src/features/canvas/components/WebsiteLayout.tsx b/packages/ui/src/features/canvas/components/WebsiteLayout.tsx index 032b1988b5..a98cd7256d 100644 --- a/packages/ui/src/features/canvas/components/WebsiteLayout.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteLayout.tsx @@ -84,7 +84,8 @@ function FreeformEditControls({ const navigate = useNavigate(); // Pinning is scoped to whatever holds the canvas; the new layout calls that a // space, the old one a channel. - const containerNoun = useChannelsLayout() ? "space" : "channel"; + const spacesLayout = useChannelsLayout(); + const containerNoun = spacesLayout ? "space" : "channel"; const editing = useIsDashboardEditing(dashboardId); const setEditing = useDashboardEditStore((s) => s.setEditing); const { dashboard } = useDashboard(dashboardId); diff --git a/packages/ui/src/features/inbox/components/InboxPageHeader.tsx b/packages/ui/src/features/inbox/components/InboxPageHeader.tsx index 63beacda05..1217391dcc 100644 --- a/packages/ui/src/features/inbox/components/InboxPageHeader.tsx +++ b/packages/ui/src/features/inbox/components/InboxPageHeader.tsx @@ -1,11 +1,13 @@ -import type { InboxTabCounts } from "@posthog/core/inbox/reportMembership"; +import { + type InboxTabCounts, + inboxScopeApplies, + inboxTabFromPath, +} from "@posthog/core/inbox/reportMembership"; import { useBluebirdFlag } from "@posthog/ui/features/feature-flags/useBluebirdFlag"; import { InboxScopeSelect } from "@posthog/ui/features/inbox/components/InboxScopeSelect"; import { - activeTabFromPath, InboxTabBar, InboxTabs, - inboxScopeApplies, } from "@posthog/ui/features/inbox/components/InboxTabBar"; import { PageHeader, @@ -44,7 +46,7 @@ export function InboxPageHeader({ counts }: InboxPageHeaderProps) { - {inboxScopeApplies(activeTabFromPath(pathname)) && ( + {inboxScopeApplies(inboxTabFromPath(pathname)) && ( diff --git a/packages/ui/src/features/inbox/components/InboxTabBar.tsx b/packages/ui/src/features/inbox/components/InboxTabBar.tsx index 1c2bb84c77..21131e6d29 100644 --- a/packages/ui/src/features/inbox/components/InboxTabBar.tsx +++ b/packages/ui/src/features/inbox/components/InboxTabBar.tsx @@ -4,6 +4,8 @@ import { INBOX_TAB_LIST_ROUTE, type InboxTabCounts, type InboxTabKey, + inboxScopeApplies, + inboxTabFromPath, } from "@posthog/core/inbox/reportMembership"; import { Tabs, TabsList, TabsTrigger } from "@posthog/quill"; import { InboxScopeSelect } from "@posthog/ui/features/inbox/components/InboxScopeSelect"; @@ -14,17 +16,10 @@ interface InboxTabBarProps { counts: InboxTabCounts; } -export function activeTabFromPath(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"; -} - /** The legacy header's row: tabs with the reviewer-scope select alongside. */ export function InboxTabBar({ counts }: InboxTabBarProps) { const pathname = useRouterState({ select: (s) => s.location.pathname }); - const activeKey = activeTabFromPath(pathname); + const activeKey = inboxTabFromPath(pathname); return ( @@ -34,16 +29,11 @@ export function InboxTabBar({ counts }: InboxTabBarProps) { ); } -/** Whether the reviewer-scope control means anything on this tab. */ -export function inboxScopeApplies(tab: InboxTabKey): boolean { - return tab !== "runs" && tab !== "dismissed"; -} - /** Just the tab strip — the header slots its own filters beside it. */ export function InboxTabs({ counts }: InboxTabBarProps) { const navigate = useNavigate(); const pathname = useRouterState({ select: (s) => s.location.pathname }); - const activeKey = activeTabFromPath(pathname); + const activeKey = inboxTabFromPath(pathname); return (