+ );
+});
diff --git a/packages/ui/src/features/canvas/components/ArtifactsViewToggle.tsx b/packages/ui/src/features/canvas/components/ArtifactsViewToggle.tsx
new file mode 100644
index 0000000000..c2b91d3aae
--- /dev/null
+++ b/packages/ui/src/features/canvas/components/ArtifactsViewToggle.tsx
@@ -0,0 +1,68 @@
+import { Kanban, ListIcon, SquaresFourIcon } from "@phosphor-icons/react";
+import {
+ ToggleGroup,
+ ToggleGroupItem,
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@posthog/quill";
+import {
+ ARTIFACTS_VIEW_MODES,
+ type ArtifactsViewMode,
+ useArtifactsViewStore,
+} from "@posthog/ui/features/canvas/stores/artifactsViewStore";
+import type { ComponentType } from "react";
+
+const OPTIONS: {
+ mode: ArtifactsViewMode;
+ label: string;
+ Icon: ComponentType<{ size?: number; weight?: "bold" }>;
+}[] = [
+ { mode: "list", label: "List", Icon: ListIcon },
+ { mode: "grid", label: "Grid", Icon: SquaresFourIcon },
+ { mode: "masonry", label: "Masonry", Icon: Kanban },
+];
+
+function isViewMode(value: string | undefined): value is ArtifactsViewMode {
+ return ARTIFACTS_VIEW_MODES.some((mode) => mode === value);
+}
+
+// Layout switcher for the artifacts list. A quill ToggleGroup carries the
+// pressed state itself, so there's no hand-rolled active styling here.
+export function ArtifactsViewToggle({ channelId }: { channelId?: string }) {
+ const view = useArtifactsViewStore((s) => s.view);
+ const setView = useArtifactsViewStore((s) => s.setView);
+
+ return (
+
+ {
+ // Pressing the active item would otherwise clear the group — a view
+ // is always on, so ignore the empty result.
+ const mode = next[0];
+ if (isViewMode(mode)) setView(mode, channelId);
+ }}
+ >
+ {OPTIONS.map(({ mode, label, Icon }) => (
+
+
+
+
+ }
+ />
+ {label}
+
+ ))}
+
+
+ );
+}
diff --git a/packages/ui/src/features/canvas/components/ChannelBreadcrumb.test.tsx b/packages/ui/src/features/canvas/components/ChannelBreadcrumb.test.tsx
index f7124726ee..0353b144bf 100644
--- a/packages/ui/src/features/canvas/components/ChannelBreadcrumb.test.tsx
+++ b/packages/ui/src/features/canvas/components/ChannelBreadcrumb.test.tsx
@@ -1,9 +1,21 @@
import { Theme } from "@radix-ui/themes";
import { fireEvent, render, screen } from "@testing-library/react";
-import { describe, expect, it, vi } from "vitest";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+// Hoisted so the module factory below can read them, and each test can steer
+// the current route / assert navigations.
+const router = vi.hoisted(() => ({
+ pathname: "/website/team/artifacts",
+ navigate: vi.fn(),
+}));
vi.mock("@tanstack/react-router", () => ({
- useNavigate: () => vi.fn(),
+ useNavigate: () => router.navigate,
+ useRouterState: ({
+ select,
+ }: {
+ select: (state: { location: { pathname: string } }) => T;
+ }) => select({ location: { pathname: router.pathname } }),
}));
vi.mock("@posthog/ui/features/canvas/hooks/useChannelsLayout", () => ({
useChannelsLayout: () => true,
@@ -12,6 +24,11 @@ vi.mock("@posthog/ui/features/canvas/hooks/useChannelsLayout", () => ({
import { ChannelBreadcrumb } from "./ChannelBreadcrumb";
describe("ChannelBreadcrumb", () => {
+ beforeEach(() => {
+ router.pathname = "/website/team/artifacts";
+ router.navigate.mockClear();
+ });
+
it("closes title editing when the editable leaf changes", () => {
const onRename = vi.fn();
const { rerender } = render(
@@ -25,7 +42,14 @@ describe("ChannelBreadcrumb", () => {
,
);
- fireEvent.doubleClick(screen.getByText("Task A"));
+ // A renamable leaf stays a live control, so it isn't marked disabled.
+ expect(screen.getByRole("button", { name: "Task A" })).not.toHaveAttribute(
+ "aria-disabled",
+ );
+
+ // One click opens the editor — the leaf never navigates, so a click has
+ // nothing else to mean.
+ fireEvent.click(screen.getByRole("button", { name: "Task A" }));
expect(screen.getByRole("textbox")).toHaveValue("Task A");
rerender(
@@ -43,4 +67,65 @@ describe("ChannelBreadcrumb", () => {
expect(screen.getByText("Task B")).toBeInTheDocument();
expect(onRename).not.toHaveBeenCalled();
});
+
+ it("navigates home from the root segment on a sub-page", () => {
+ render(
+
+
+ ,
+ );
+
+ const root = screen.getByRole("button", { name: /Team/ });
+ expect(root).not.toHaveAttribute("aria-disabled", "true");
+ fireEvent.click(root);
+ expect(router.navigate).toHaveBeenCalledWith({
+ to: "/website/$channelId",
+ params: { channelId: "team" },
+ });
+ });
+
+ it("links the middle segment to its section", () => {
+ const onMiddleClick = vi.fn();
+ render(
+
+
+ ,
+ );
+
+ // Every segment is a Button so they share padding and height; the leaf is
+ // the current page, so it's the disabled one.
+ expect(screen.getAllByRole("button")).toHaveLength(3);
+ fireEvent.click(screen.getByRole("button", { name: "Loops" }));
+ expect(onMiddleClick).toHaveBeenCalledTimes(1);
+ expect(
+ screen.getByRole("button", { name: "CI failure summary" }),
+ ).toHaveAttribute("aria-disabled", "true");
+ });
+
+ it("disables the root segment on the space's own index", () => {
+ router.pathname = "/website/team";
+ render(
+
+
+ ,
+ );
+
+ const root = screen.getByRole("button", { name: /Team/ });
+ expect(root).toHaveAttribute("aria-disabled", "true");
+ fireEvent.click(root);
+ expect(router.navigate).not.toHaveBeenCalled();
+ });
});
diff --git a/packages/ui/src/features/canvas/components/ChannelBreadcrumb.tsx b/packages/ui/src/features/canvas/components/ChannelBreadcrumb.tsx
index e47614aa05..670f071518 100644
--- a/packages/ui/src/features/canvas/components/ChannelBreadcrumb.tsx
+++ b/packages/ui/src/features/canvas/components/ChannelBreadcrumb.tsx
@@ -1,5 +1,6 @@
import {
Button,
+ cn,
Tooltip,
TooltipContent,
TooltipTrigger,
@@ -8,7 +9,7 @@ import { channelGlyph } from "@posthog/ui/features/canvas/components/channelGlyp
import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout";
import { HeaderTitleEditor } from "@posthog/ui/features/task-detail/HeaderTitleEditor";
import { Flex, Text } from "@radix-ui/themes";
-import { useNavigate } from "@tanstack/react-router";
+import { useNavigate, useRouterState } from "@tanstack/react-router";
import { type ReactNode, useState } from "react";
interface ChannelBreadcrumbProps {
@@ -19,14 +20,23 @@ interface ChannelBreadcrumbProps {
* sidebar channel row and the channel-view header.
*/
channelId?: string;
+ /**
+ * An optional segment between the space and the leaf — the section a scene
+ * belongs to, e.g. "{space} / Loops / {loop}". `onClick` links it; without
+ * one it reads as a plain step.
+ */
+ middle?: { icon?: ReactNode; label: string; onClick?: () => void };
/** Optional leading icon for the leaf segment (e.g. a canvas's tier icon). */
leafIcon?: ReactNode;
- /** The trailing (current page) segment label. */
- leafLabel: string;
+ /**
+ * The trailing (current page) segment label. Omitted at a space's root, which
+ * renders the channel segment alone — same size and styling either way.
+ */
+ leafLabel?: string;
editScopeKey?: string;
/**
- * When provided, the leaf becomes inline-editable: double-click to rename,
- * Enter or blur to submit, Escape to cancel. Receives the trimmed new value.
+ * When provided, the leaf becomes inline-editable: click to rename, Enter or
+ * blur to submit, Escape to cancel. Receives the trimmed new value.
*/
onRename?: (next: string) => void;
/** Right-aligned slot pushed to the far end of the bar (e.g. an opener). */
@@ -35,11 +45,13 @@ interface ChannelBreadcrumbProps {
// "# channel / leaf" header breadcrumb shared across channel scenes (CONTEXT.md,
// new + existing tasks, canvases). The leaf can carry a tier icon and, when
-// onRename is given, edits inline using the same editor as task titles. When
-// channelId is given, the "# channel" segment links back to the channel home.
+// onRename is given, edits inline on a single click using the same editor as
+// task titles. When channelId is given, the "# channel" segment links back to
+// the channel home.
export function ChannelBreadcrumb({
channelName,
channelId,
+ middle,
leafIcon,
leafLabel,
editScopeKey,
@@ -47,84 +59,164 @@ export function ChannelBreadcrumb({
trailing,
}: ChannelBreadcrumbProps) {
const spacesLayout = useChannelsLayout();
- const currentEditScope = editScopeKey ?? leafLabel;
+ // Only a leaf is renamable, so the scope key falls back to its label.
+ const currentEditScope = editScopeKey ?? leafLabel ?? "";
const [editingScope, setEditingScope] = useState(null);
const editing = editingScope === currentEditScope;
const navigate = useNavigate();
-
- const channelSegment = (
- <>
- {channelGlyph(channelName, {
- size: 12,
- space: spacesLayout,
- className: "mt-px shrink-0 text-muted-foreground/80",
- })}
-
- {channelName}
-
- >
- );
+ const pathname = useRouterState({ select: (s) => s.location.pathname });
+ const atChannelHome = channelId
+ ? pathname === `/website/${channelId}`
+ : false;
return (
-
- {channelId ? (
-
- ) : (
-
{channelSegment}
- )}
- /
-
- {leafIcon && (
- {leafIcon}
- )}
- {editing && onRename ? (
- {
- setEditingScope(null);
- onRename(next);
- }}
- onCancel={() => setEditingScope(null)}
+ {/* flex-1 so the inline editor can stretch across the row; the trailing
+ slot still sits at the far end. */}
+
+
+ void navigate({
+ to: "/website/$channelId",
+ params: { channelId },
+ })
+ : undefined
+ }
+ />
+ {middle && (
+ <>
+
+
- ) : (
-
- setEditingScope(currentEditScope)
- : undefined
- }
+ >
+ )}
+ {leafLabel !== undefined && (
+ <>
+
+ {editing && onRename ? (
+ // Matches the segment it replaces — same height, padding and type
+ // scale as a `size="sm"` button — so opening the editor doesn't
+ // jump the row. It takes the rest of the row, since a long name is
+ // exactly what you're most likely to be editing.
+ {
+ setEditingScope(null);
+ onRename(next);
+ }}
+ onCancel={() => setEditingScope(null)}
+ className="h-6 px-2 font-normal text-[13px]"
+ />
+ ) : onRename ? (
+ // Only a renamable leaf gets a tooltip: it carries a user-authored
+ // name that can be long enough to truncate. Fixed section labels
+ // never overflow, so a tooltip there is just noise.
+
+ }>
+ {/* A renamable leaf is a live control — a click opens the
+ editor — so it reads as one: full-strength text, pointer
+ cursor, hover fill. */}
+ setEditingScope(currentEditScope)}
/>
- }
- >
- {leafLabel}
-
- {leafLabel}
-
- )}
-
+
+ {leafLabel}
+
+ ) : (
+
+ )}
+ >
+ )}
{trailing}
);
}
+
+/**
+ * One segment of the breadcrumb. Always a Button, so every segment carries the
+ * same padding, height and icon gap whether or not it goes anywhere — the leaf
+ * used to be bare text, which left it visually adrift from its siblings.
+ *
+ * Without `onClick` the segment is genuinely inert: `aria-disabled` (so quill
+ * drops the hover fill and assistive tech reads it as unavailable) plus
+ * `pointer-events-none`, and out of the tab order. The disabled dimming is
+ * overridden — a breadcrumb has to stay readable.
+ */
+function BreadcrumbSegment({
+ icon,
+ label,
+ strong,
+ muted,
+ onClick,
+ ...rest
+}: {
+ icon?: ReactNode;
+ label: string;
+ /** The root segment carries the space name, which reads heavier. */
+ strong?: boolean;
+ /** The leaf is the current page, so it sits back from the linked segments. */
+ muted?: boolean;
+ /** Navigates, or (on a renamable leaf) opens the inline editor. */
+ onClick?: () => void;
+}) {
+ const interactive = Boolean(onClick);
+
+ return (
+
+ );
+}
+
+function BreadcrumbSeparator() {
+ return (
+ /
+ );
+}
diff --git a/packages/ui/src/features/canvas/components/ChannelHeader.tsx b/packages/ui/src/features/canvas/components/ChannelHeader.tsx
index 289cb5b713..40f65fb29e 100644
--- a/packages/ui/src/features/canvas/components/ChannelHeader.tsx
+++ b/packages/ui/src/features/canvas/components/ChannelHeader.tsx
@@ -1,25 +1,64 @@
import { Button, cn } from "@posthog/quill";
+import { ChannelBreadcrumb } from "@posthog/ui/features/canvas/components/ChannelBreadcrumb";
import { ChannelTabs } from "@posthog/ui/features/canvas/components/ChannelTabs";
import { channelGlyph } from "@posthog/ui/features/canvas/components/channelGlyph";
+import {
+ type ChannelPageKey,
+ channelPageIcon,
+ channelPageLabel,
+} from "@posthog/ui/features/canvas/components/channelPages";
import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels";
import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout";
import { useMarkChannelSeen } from "@posthog/ui/features/canvas/hooks/useMarkChannelSeen";
import { Text } from "@radix-ui/themes";
import { useNavigate, useRouterState } from "@tanstack/react-router";
-// The shared channel header. The new layout drops the section tab strip — the
-// channel sidebar carries those entries — while flag off keeps it. Starring
-// lives on the sidebar back row and the channel list, not here.
-export function ChannelHeader({ channelId }: { channelId: string }) {
- const navigate = useNavigate();
+// The shared channel header. Every space scene renders the same breadcrumb —
+// the root segment is identical whether or not there's a leaf, so the space
+// name doesn't change size between the space home and its sub-pages. The new
+// layout drops the section tab strip (the channel sidebar carries those
+// entries); flag off keeps it. Starring lives on the sidebar back row and the
+// channel list, not here.
+export function ChannelHeader({
+ channelId,
+ page,
+}: {
+ channelId: string;
+ /**
+ * Which space page this is — supplies the leaf's label and icon. Every space
+ * page names itself, the feed included ("{space} / Feed"); omitting it leaves
+ * the root segment alone, for scenes that carry no page of their own.
+ */
+ page?: ChannelPageKey;
+}) {
const channelsLayout = useChannelsLayout();
const { channels } = useChannels();
const channelName = channels.find((c) => c.id === channelId)?.name;
- const pathname = useRouterState({ select: (s) => s.location.pathname });
- const isHome = pathname === `/website/${channelId}`;
// Every channel surface renders this header, so mark the channel read here.
useMarkChannelSeen(channelName);
+ // Channels-layout off keeps the header it has always had: the channel pill
+ // plus the section tab strip, no breadcrumb. Delete this branch when the
+ // layout flag graduates.
+ if (!channelsLayout) return ;
+
+ return (
+
+ );
+}
+
+function LegacyChannelHeader({ channelId }: { channelId: string }) {
+ const navigate = useNavigate();
+ const { channels } = useChannels();
+ const channelName = channels.find((c) => c.id === channelId)?.name;
+ const pathname = useRouterState({ select: (s) => s.location.pathname });
+ const isHome = pathname === `/website/${channelId}`;
+
return (
- {!channelsLayout && }
+
);
}
diff --git a/packages/ui/src/features/canvas/components/ChannelNav.tsx b/packages/ui/src/features/canvas/components/ChannelNav.tsx
index 23f0a6fe81..9c324c7676 100644
--- a/packages/ui/src/features/canvas/components/ChannelNav.tsx
+++ b/packages/ui/src/features/canvas/components/ChannelNav.tsx
@@ -98,6 +98,10 @@ interface NavButtonProps extends ComponentPropsWithRef<"button"> {
badge?: ReactNode;
}
+// Same quill Button as NavIcon above — this variant only exists because the
+// Activity entry is a Popover trigger, so it needs to forward the trigger's
+// props and ref. Hand-rolling the button here left it a size larger than its
+// neighbours.
function NavButton({
icon,
label,
@@ -109,23 +113,23 @@ function NavButton({
...buttonProps
}: NavButtonProps) {
return (
-
+
);
}
diff --git a/packages/ui/src/features/canvas/components/ChannelSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelSidebar.tsx
index e1589e2a1f..c069e1ac7d 100644
--- a/packages/ui/src/features/canvas/components/ChannelSidebar.tsx
+++ b/packages/ui/src/features/canvas/components/ChannelSidebar.tsx
@@ -1,10 +1,8 @@
import {
- BookOpenTextIcon,
ChatsCircleIcon,
FunnelSimple as FunnelSimpleIcon,
MagnifyingGlass,
PackageIcon,
- RepeatIcon,
} from "@phosphor-icons/react";
import type { CreatedByFilter } from "@posthog/core/canvas/channelItems";
import { filterChannelItems } from "@posthog/core/canvas/channelItems";
@@ -32,6 +30,11 @@ import type { TaskRunStatus } from "@posthog/shared/domain-types";
import { ChannelBackRow } from "@posthog/ui/features/canvas/components/ChannelBackRow";
import { ChannelItemRow } from "@posthog/ui/features/canvas/components/ChannelItemRow";
import { ChannelsFab } from "@posthog/ui/features/canvas/components/ChannelsFab";
+import {
+ type ChannelPageKey,
+ channelPageIcon,
+ channelPageLabel,
+} from "@posthog/ui/features/canvas/components/channelPages";
import { useChannelItems } from "@posthog/ui/features/canvas/hooks/useChannelItems";
import { useCommandCenterStore } from "@posthog/ui/features/command-center/commandCenterStore";
import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag";
@@ -42,7 +45,7 @@ import { useTasks } from "@posthog/ui/features/tasks/useTasks";
import { navigateToCommandCenter } from "@posthog/ui/router/navigationBridge";
import { logger } from "@posthog/ui/shell/logger";
import { useNavigate, useRouterState } from "@tanstack/react-router";
-import { type ReactNode, useMemo, useState } from "react";
+import { useMemo, useState } from "react";
const CREATED_BY_OPTIONS: readonly { value: CreatedByFilter; label: string }[] =
[
@@ -338,16 +341,17 @@ export function ChannelSidebar({ channelId }: { channelId: string }) {
/>
);
+ // Label and icon come from the shared space-page table, so a sidebar row and
+ // the header breadcrumb for the same page can never disagree.
const sectionRow = (
- label: string,
- icon: ReactNode,
+ page: ChannelPageKey,
to: string,
onClick: () => void,
) => (
@@ -359,15 +363,13 @@ export function ChannelSidebar({ channelId }: { channelId: string }) {
{sectionRow(
- "Feed",
- ,
+ "home",
base,
() =>
void navigate({ to: "/website/$channelId", params: { channelId } }),
)}
{sectionRow(
- "Context",
- ,
+ "context",
`${base}/context`,
() =>
void navigate({
@@ -377,8 +379,7 @@ export function ChannelSidebar({ channelId }: { channelId: string }) {
)}
{loopsEnabled &&
sectionRow(
- "Loops",
- ,
+ "loops",
`${base}/loops`,
() =>
void navigate({
@@ -387,8 +388,7 @@ export function ChannelSidebar({ channelId }: { channelId: string }) {
}),
)}
{sectionRow(
- "Artifacts",
- ,
+ "artifacts",
`${base}/artifacts`,
() =>
void navigate({
diff --git a/packages/ui/src/features/canvas/components/FreeformPreview.tsx b/packages/ui/src/features/canvas/components/FreeformPreview.tsx
new file mode 100644
index 0000000000..37b1f62bfc
--- /dev/null
+++ b/packages/ui/src/features/canvas/components/FreeformPreview.tsx
@@ -0,0 +1,137 @@
+import { ShapesIcon, WarningIcon } from "@phosphor-icons/react";
+import { cn, Skeleton, Text } from "@posthog/quill";
+import { FreeformCanvas } from "@posthog/ui/features/canvas/freeform/FreeformCanvas";
+import { handleFreeformDataRequest } from "@posthog/ui/features/canvas/freeform/freeformDataBridge";
+import { useInView } from "@posthog/ui/primitives/hooks/useInView";
+import { ErrorBoundary } from "@posthog/ui/shell/ErrorBoundary";
+import { Box, Flex } from "@radix-ui/themes";
+import { useQueryClient } from "@tanstack/react-query";
+import { type ReactNode, useCallback } from "react";
+
+// Render each canvas's live app at 1/SCALE of the card width, then shrink so it
+// fits inside the preview frame as a thumbnail.
+const PREVIEW_SCALE = 0.4;
+
+// Mount a preview only while it's near the viewport, and UNMOUNT it once it
+// scrolls away (once: false). This caps how many full preview trees / sandbox
+// iframes are live at any time, so a channel with many large canvases doesn't
+// accumulate pages of off-screen DOM. The margin pre-mounts a little early so
+// scrolling doesn't flash an empty frame. The fixed-height frame keeps the
+// layout stable across mount/unmount (no scroll jump).
+const PREVIEW_VIEWPORT = { once: false, rootMargin: "400px 0px" } as const;
+
+// A freeform (React-in-iframe) canvas preview: the app rendered at PREVIEW_SCALE
+// in a clipped frame. Deferred until near the viewport, and runs with NO
+// analytics so it fires no events.
+export function FreeformPreview({
+ code,
+ height = 176,
+ className,
+}: {
+ code?: string;
+ /** Frame height in px. Taller frames simply reveal more of the app. */
+ height?: number;
+ className?: string;
+}) {
+ const [ref, inView] = useInView(PREVIEW_VIEWPORT);
+
+ // Preview data handler: swallow captures so a thumbnail never emits analytics
+ // events, but let reads through (cached, shared with the full view) so the
+ // preview shows real-ish content. (posthog-js itself is never booted — no
+ // `analytics` prop — so there's no autocapture/pageview/replay either.)
+ const queryClient = useQueryClient();
+ const onDataRequest = useCallback(
+ (method: string, payload: unknown) =>
+ method === "capture"
+ ? Promise.resolve({ ok: true })
+ : handleFreeformDataRequest(method, payload, queryClient),
+ [queryClient],
+ );
+
+ return (
+
+ {code ? (
+ inView ? (
+
+ }
+ label="Preview unavailable"
+ />
+ }
+ >
+
+
+
+ ) : (
+ // Deferred, not broken: a shimmer reads as "coming", where a line of
+ // text reads as the final state.
+
+ )
+ ) : (
+ }
+ label="Nothing built yet"
+ />
+ )}
+
+ );
+}
+
+function PreviewPlaceholder({
+ icon,
+ label,
+}: {
+ icon?: ReactNode;
+ label: string;
+}) {
+ return (
+
+ {icon}
+
+ {label}
+
+
+ );
+}
+
+/** Stand-in for a preview that hasn't mounted yet — the shape of a small app:
+ * a title bar, a chart block, a couple of rows. */
+function PreviewSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx
index 4ea4783e1c..d9c9569358 100644
--- a/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx
+++ b/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx
@@ -1,23 +1,52 @@
-import { CaretRightIcon } from "@phosphor-icons/react";
+import { CaretRightIcon, FilesIcon, TrashIcon } from "@phosphor-icons/react";
import type { ChannelTaskRecord } from "@posthog/core/canvas/channelTaskSchemas";
import type { DashboardSummary } from "@posthog/core/canvas/dashboardSchemas";
+import {
+ Badge,
+ Card,
+ CardContent,
+ cn,
+ Empty,
+ EmptyDescription,
+ EmptyHeader,
+ EmptyMedia,
+ EmptyTitle,
+ Text,
+} from "@posthog/quill";
import { formatRelativeTimeShort } from "@posthog/shared";
import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events";
import { useArchivedTaskIds } from "@posthog/ui/features/archive/useArchivedTaskIds";
+import { ArtifactsViewToggle } from "@posthog/ui/features/canvas/components/ArtifactsViewToggle";
import { ChannelHeader } from "@posthog/ui/features/canvas/components/ChannelHeader";
import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon";
+import { FreeformPreview } from "@posthog/ui/features/canvas/components/FreeformPreview";
import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout";
import { useChannelTasks } from "@posthog/ui/features/canvas/hooks/useChannelTasks";
import { useDashboards } from "@posthog/ui/features/canvas/hooks/useDashboards";
+import { useArtifactsViewStore } from "@posthog/ui/features/canvas/stores/artifactsViewStore";
+import { useIsCanvasPendingDelete } from "@posthog/ui/features/canvas/stores/pendingCanvasDeleteStore";
+import { masonryPreviewHeight } from "@posthog/ui/features/canvas/utils/masonryPreviewHeight";
import { usePrArtifact } from "@posthog/ui/features/git-interaction/usePrArtifact";
import { useTasks } from "@posthog/ui/features/tasks/useTasks";
import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent";
+import {
+ PageHeader,
+ PageHeaderActions,
+ PageHeaderChip,
+ PageHeaderDescription,
+ PageHeaderHeading,
+ PageHeaderTitle,
+ PageHeaderTitleRow,
+} from "@posthog/ui/primitives/PageHeader";
import { track } from "@posthog/ui/shell/analytics";
import { openExternalUrl } from "@posthog/ui/shell/openExternal";
-import { Text } from "@radix-ui/themes";
import { useNavigate } from "@tanstack/react-router";
import { type ReactNode, useCallback, useEffect, useMemo } from "react";
+// Uniform media height for the grid: cards line up row to row, and a PR tile
+// (which has nothing to preview) fills the same band as a canvas thumbnail.
+const GRID_PREVIEW_HEIGHT = 176;
+
// Artifacts are the durable outputs of a channel's work. Canvases for now; PRs
// are surfaced from each filed task's latest run output. More kinds (reports,
// files, …) slot into this union later.
@@ -29,6 +58,8 @@ type ArtifactItem =
ts: number;
templateId: string;
dashboardId: string;
+ /** Live React source, along for the ride so cards preview without a get(). */
+ code?: string;
}
| {
kind: "pr";
@@ -40,10 +71,12 @@ type ArtifactItem =
// A channel's artifacts: canvases and the pull requests produced by its tasks,
// most recent first. Sibling of the History tab, but scoped to outputs rather
-// than the full activity stream.
+// than the full activity stream. The view toggle switches between a dense row
+// list and card layouts that preview each canvas live.
export function WebsiteChannelArtifacts({ channelId }: { channelId: string }) {
const spacesLayout = useChannelsLayout();
const navigate = useNavigate();
+ const view = useArtifactsViewStore((s) => s.view);
useEffect(() => {
track(ANALYTICS_EVENTS.CHANNEL_ACTION, {
@@ -54,7 +87,10 @@ export function WebsiteChannelArtifacts({ channelId }: { channelId: string }) {
}, [channelId]);
useSetHeaderContent(
- useMemo(() => , [channelId]),
+ useMemo(
+ () => ,
+ [channelId],
+ ),
);
const { dashboards } = useDashboards(channelId);
@@ -71,6 +107,7 @@ export function WebsiteChannelArtifacts({ channelId }: { channelId: string }) {
ts: d.updatedAt,
templateId: d.templateId,
dashboardId: d.id,
+ code: d.code,
}),
);
@@ -124,50 +161,212 @@ export function WebsiteChannelArtifacts({ channelId }: { channelId: string }) {
);
return (
-
-
- {items.length === 0 ? (
-
-
- No artifacts yet
-
-
+
+ {/* Full-bleed header over a container-width body — the Inbox shape. Ships
+ behind the spaces layout like every other space page header; off,
+ the view switcher rides above the list instead. */}
+ {spacesLayout ? (
+
+
+
+ Artifacts
+ {items.length > 0 && (
+ }>
+ {items.length} item{items.length === 1 ? "" : "s"}
+
+ )}
+
+
+
+
+
Canvases and pull requests from this{" "}
- {spacesLayout ? "space's" : "channel's"} tasks show up here.
-
-
+ {/* 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. */}
+
+ )}
+ {items.length === 0 ? (
+
+
+
+
+
+ No artifacts yet
+
+ Canvases and pull requests from this{" "}
+ {spacesLayout ? "space's" : "channel's"} tasks show up here.
+
+
+
+ ) : view === "list" ? (
+
+ {items.map((item) => (
+ openCanvas(item.dashboardId)}
+ item={item}
+ onOpenCanvas={openCanvas}
+ onOpenPr={openPr}
/>
- ) : (
-
+ ) : view === "grid" ? (
+ // items-stretch + a full-height card: a PR tile (no preview to
+ // show) matches the canvas cards in its row instead of ending
+ // short.
+
+ {items.map((item) => (
+
- ),
- )}
-
- )}
+ ))}
+
+ ) : (
+ // CSS columns rather than a JS masonry: cards are self-contained and
+ // never reflow into each other, so break-inside-avoid is enough. The
+ // trade-off is column-major order — newest runs down column one, not
+ // across the row — which is fine for a browse-y wall of previews.
+
+ {items.map((item) => (
+
+
+
+ ))}
+
+ )}
+
);
}
+function ArtifactListItem({
+ item,
+ onOpenCanvas,
+ onOpenPr,
+}: {
+ item: ArtifactItem;
+ onOpenCanvas: (dashboardId: string) => void;
+ onOpenPr: (safeUrl: string) => void;
+}) {
+ return item.kind === "canvas" ? (
+
+ ) : (
+
+ );
+}
+
+function ArtifactCard({
+ item,
+ previewHeight,
+ fillHeight,
+ onOpenCanvas,
+ onOpenPr,
+}: {
+ item: ArtifactItem;
+ previewHeight: number;
+ /** Grid only: stretch to the tallest card in the row. */
+ fillHeight?: boolean;
+ onOpenCanvas: (dashboardId: string) => void;
+ onOpenPr: (safeUrl: string) => void;
+}) {
+ return item.kind === "canvas" ? (
+
+ ) : (
+
+ );
+}
+
+// A canvas artifact row. While the canvas is inside its delete-undo window the
+// row stays put — its template icon becomes a pulsing trash can and the row
+// stops opening — so undoing puts it back exactly where it was.
+function CanvasArtifactRow({
+ dashboardId,
+ templateId,
+ title,
+ ts,
+ onClick,
+}: {
+ dashboardId: string;
+ templateId: string;
+ title: string;
+ ts: number;
+ onClick: (dashboardId: string) => void;
+}) {
+ const deleting = useIsCanvasPendingDelete(dashboardId);
+
+ return (
+
+ ) : (
+ iconForTemplate(templateId, { size: 15, className: "text-violet-9" })
+ )
+ }
+ title={title}
+ subtitle={
+ deleting ? "Deleting…" : `Canvas · ${formatRelativeTimeShort(ts)}`
+ }
+ onClick={deleting ? undefined : () => onClick(dashboardId)}
+ />
+ );
+}
+
// A PR artifact row. The PR's lifecycle state (open / draft / merged / closed)
// comes from usePrArtifact, which also gates the URL — PR links come from run
// output, so a row must not fetch from whatever host that names.
@@ -225,7 +424,7 @@ function ArtifactRow({
type="button"
onClick={onClick}
disabled={!onClick}
- className="group flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-left transition-colors enabled:hover:bg-gray-3"
+ className="group flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-left transition-colors enabled:hover:bg-fill-hover"
>
{title}
-
+
{subtitle}
);
}
+
+// The card form of a canvas artifact: a live preview of the canvas above its
+// title. Same delete-undo behaviour as the row — the card stays in place,
+// dimmed, until the undo window closes.
+function CanvasArtifactCard({
+ dashboardId,
+ templateId,
+ title,
+ ts,
+ code,
+ previewHeight,
+ fillHeight,
+ onClick,
+}: {
+ dashboardId: string;
+ templateId: string;
+ title: string;
+ ts: number;
+ code?: string;
+ previewHeight: number;
+ fillHeight?: boolean;
+ onClick: (dashboardId: string) => void;
+}) {
+ const deleting = useIsCanvasPendingDelete(dashboardId);
+
+ return (
+
+
+ {deleting && (
+
+
+
+ Deleting…
+
+
+ )}
+ >
+ }
+ icon={iconForTemplate(templateId, {
+ size: 14,
+ className: "text-violet-9",
+ })}
+ title={title}
+ badge="Canvas"
+ subtitle={
+ deleting ? "Deleting…" : `Updated ${formatRelativeTimeShort(ts)}`
+ }
+ dimmed={deleting}
+ fillHeight={fillHeight}
+ onClick={deleting ? undefined : () => onClick(dashboardId)}
+ />
+ );
+}
+
+// The card form of a PR artifact. A PR has nothing to preview, so its media
+// slot is a short tinted band carrying the lifecycle icon — which also keeps PR
+// cards visibly shorter than canvas cards in the masonry layout.
+function PrArtifactCard({
+ title,
+ prUrl,
+ ts,
+ mediaHeight,
+ fillHeight,
+ onClick,
+}: {
+ title: string;
+ prUrl: string;
+ ts: number;
+ /** Grid only: match the canvas thumbnails' band instead of a short strip. */
+ mediaHeight?: number;
+ fillHeight?: boolean;
+ onClick: (safeUrl: string) => void;
+}) {
+ const {
+ safeUrl,
+ title: prTitle,
+ stateLabel,
+ Icon,
+ iconColor,
+ accentColor,
+ } = usePrArtifact(prUrl);
+
+ const subtitle = [prTitle, formatRelativeTimeShort(ts)]
+ .filter(Boolean)
+ .join(" · ");
+
+ return (
+
+
+
+ }
+ icon={}
+ title={title}
+ badge={stateLabel || "Pull request"}
+ subtitle={subtitle}
+ fillHeight={fillHeight}
+ onClick={safeUrl ? () => onClick(safeUrl) : undefined}
+ />
+ );
+}
+
+function ArtifactCardShell({
+ media,
+ icon,
+ title,
+ badge,
+ subtitle,
+ dimmed,
+ fillHeight,
+ onClick,
+}: {
+ media: ReactNode;
+ icon: ReactNode;
+ title: string;
+ badge: string;
+ subtitle: string;
+ dimmed?: boolean;
+ /** Grid only: fill the row so neighbouring cards end at the same line. */
+ fillHeight?: boolean;
+ /** Absent for a card with nowhere safe to go — a non-github PR link. */
+ onClick?: () => void;
+}) {
+ return (
+
+ );
+}
diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelHistory.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelHistory.tsx
index 4780ce51b0..2dfe4a6106 100644
--- a/packages/ui/src/features/canvas/components/WebsiteChannelHistory.tsx
+++ b/packages/ui/src/features/canvas/components/WebsiteChannelHistory.tsx
@@ -42,7 +42,10 @@ export function WebsiteChannelHistory({ channelId }: { channelId: string }) {
}, [channelId]);
useSetHeaderContent(
- useMemo(() => , [channelId]),
+ useMemo(
+ () => ,
+ [channelId],
+ ),
);
const { dashboards } = useDashboards(channelId);
diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx
index bee6dd19a9..ba9c092d2c 100644
--- a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx
+++ b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx
@@ -96,7 +96,10 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) {
}, [backendChannel, feedMessages]);
useSetHeaderContent(
- useMemo(() => , [channelId]),
+ useMemo(
+ () => ,
+ [channelId],
+ ),
);
const composerRef = useRef(null);
diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelLoops.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelLoops.tsx
index 38231afa59..44efad88aa 100644
--- a/packages/ui/src/features/canvas/components/WebsiteChannelLoops.tsx
+++ b/packages/ui/src/features/canvas/components/WebsiteChannelLoops.tsx
@@ -1,7 +1,17 @@
import { CloudIcon, PlusIcon } from "@phosphor-icons/react";
import { ChannelHeader } from "@posthog/ui/features/canvas/components/ChannelHeader";
+import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout";
import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent";
import { Button } from "@posthog/ui/primitives/Button";
+import {
+ PageHeader,
+ PageHeaderActions,
+ PageHeaderChip,
+ PageHeaderDescription,
+ PageHeaderHeading,
+ PageHeaderTitle,
+ PageHeaderTitleRow,
+} from "@posthog/ui/primitives/PageHeader";
import { navigateToNewLoop } from "@posthog/ui/router/navigationBridge";
import { Flex, Heading, Text } from "@radix-ui/themes";
import { useMemo } from "react";
@@ -47,6 +57,7 @@ function contextQuickStarts(name: string): { label: string; prompt: string }[] {
* this context. `channelId` is the desktop folder id, matching `context_target.folder_id`. */
export function WebsiteChannelLoops({ channelId }: { channelId: string }) {
const { data: loops, isLoading, isError } = useLoops();
+ const spacesLayout = useChannelsLayout();
const limits = useLoopLimits();
const limitReason =
limits?.atLimit === true
@@ -58,7 +69,10 @@ export function WebsiteChannelLoops({ channelId }: { channelId: string }) {
const isPersonal = contextName === PERSONAL_CHANNEL_NAME;
useSetHeaderContent(
- useMemo(() => , [channelId]),
+ useMemo(
+ () => ,
+ [channelId],
+ ),
);
const attachedLoops = useMemo(
@@ -99,56 +113,78 @@ export function WebsiteChannelLoops({ channelId }: { channelId: string }) {
navigateToNewLoop();
};
+ const title = isPersonal ? "Loops" : `Automate #${contextName}`;
+ const description =
+ "Put your work on autopilot. Loops run on a schedule, on an API call, or when something happens on GitHub. You can finally close the laptop!";
+ const createButton = (
+
+ );
+
return (
+ {/* The shared page header ships with the spaces layout; without it the
+ in-container title block below is used. Delete that branch when the
+ layout flag graduates. */}
+ {spacesLayout && (
+
+
+
+ {title}
+ }>
+ Runs entirely in the cloud
+
+ {createButton}
+
+ {description}
+
+
+ )}
+
+
+ {title}
+
+
+
+ Runs entirely in the cloud
+
+
+
+ {description}
+
-
- Put your work on autopilot. Loops run on a schedule, on an API
- call, or when something happens on GitHub. You can finally close
- the laptop!
-
-
-
-
+ {createButton}
+
+ )}
{isLoading ? (
diff --git a/packages/ui/src/features/canvas/components/WebsiteContext.tsx b/packages/ui/src/features/canvas/components/WebsiteContext.tsx
index 3c815483b2..4b8f783b37 100644
--- a/packages/ui/src/features/canvas/components/WebsiteContext.tsx
+++ b/packages/ui/src/features/canvas/components/WebsiteContext.tsx
@@ -13,6 +13,7 @@ import {
import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events";
import { ChannelHeader } from "@posthog/ui/features/canvas/components/ChannelHeader";
import { CreateChannelModal } from "@posthog/ui/features/canvas/components/CreateChannelModal";
+import { channelPageIcon } from "@posthog/ui/features/canvas/components/channelPages";
import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels";
import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout";
import {
@@ -22,6 +23,14 @@ import {
} from "@posthog/ui/features/canvas/hooks/useFolderInstructions";
import { MarkdownRenderer } from "@posthog/ui/features/editor/components/MarkdownRenderer";
import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent";
+import {
+ PageHeader,
+ PageHeaderChip,
+ PageHeaderDescription,
+ PageHeaderHeading,
+ PageHeaderTitle,
+ PageHeaderTitleRow,
+} from "@posthog/ui/primitives/PageHeader";
import { track } from "@posthog/ui/shell/analytics";
import {
Box,
@@ -90,7 +99,7 @@ export function WebsiteContext({ channelId }: WebsiteContextProps) {
}, [latest?.content, hasDraft]);
const headerContent = useMemo(
- () => ,
+ () => ,
[channelId],
);
useSetHeaderContent(headerContent);
@@ -163,6 +172,27 @@ export function WebsiteContext({ channelId }: WebsiteContextProps) {
return (
+ {/* The shared page header ships with the spaces layout; without it the
+ page opens straight onto its mode toolbar as it always has. */}
+ {spacesLayout && (
+
+
+
+ Context
+ {latest?.version != null && (
+
+ v{latest.version}
+
+ )}
+
+
+ Background every agent working in this{" "}
+ {spacesLayout ? "space" : "channel"} reads before it starts — what
+ lives here, who cares about it, and how to work on it.
+
+
+
+ )}
+
-
+
+
+ {deleting && (
+
+
+
+ Deleting…
+
+
+ )}
+
@@ -155,64 +176,6 @@ const DashboardCard = memo(function DashboardCard({
);
});
-// A freeform (React-in-iframe) canvas preview: the app rendered at PREVIEW_SCALE
-// in a clipped frame, the same shape as DashboardPreview. Deferred until near
-// the viewport, and runs with NO analytics so it fires no events.
-function FreeformPreview({ code }: { code?: string }) {
- const [ref, inView] = useInView(PREVIEW_VIEWPORT);
-
- // Preview data handler: swallow captures so a thumbnail never emits analytics
- // events, but let reads through (cached, shared with the full view) so the
- // preview shows real-ish content. (posthog-js itself is never booted — no
- // `analytics` prop — so there's no autocapture/pageview/replay either.)
- const queryClient = useQueryClient();
- const onDataRequest = useCallback(
- (method: string, payload: unknown) =>
- method === "capture"
- ? Promise.resolve({ ok: true })
- : handleFreeformDataRequest(method, payload, queryClient),
- [queryClient],
- );
-
- return (
-
- {code ? (
- inView ? (
-
- }
- >
-
-
-
- ) : (
-
- )
- ) : (
-
- )}
-
- );
-}
-
function DashboardCardMenu({
id,
name,
@@ -223,31 +186,24 @@ function DashboardCardMenu({
channelId: string;
}) {
const [open, setOpen] = useState(false);
- const { deleteDashboard, isDeleting } = useDashboardMutations();
+ const spacesLayout = useChannelsLayout();
+ const containerNoun = spacesLayout ? "space" : "channel";
+ // "Delete…" opens a confirmation rather than deleting inline — the canvas and
+ // its version history go away for everyone in the space.
+ const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
+ const { invalidateDashboards } = useDashboardMutations();
- const onDelete = () => {
- deleteDashboard(id)
- .then(() => {
- track(ANALYTICS_EVENTS.DASHBOARD_ACTION, {
- action_type: "delete",
- surface: "dashboards_grid",
- channel_id: channelId,
- dashboard_id: id,
- success: true,
- });
- })
- .catch((error) => {
- track(ANALYTICS_EVENTS.DASHBOARD_ACTION, {
- action_type: "delete",
- surface: "dashboards_grid",
- channel_id: channelId,
- dashboard_id: id,
- success: false,
- });
- toast.error("Couldn't delete canvas", {
- description: error instanceof Error ? error.message : String(error),
- });
- });
+ // The card disappears immediately, but the delete isn't sent until the undo
+ // toast's timer runs out — Undo simply cancels it.
+ const confirmDelete = () => {
+ setConfirmDeleteOpen(false);
+ deleteCanvasWithUndo({
+ dashboardId: id,
+ channelId,
+ name,
+ surface: "dashboards_grid",
+ invalidate: invalidateDashboards,
+ });
};
return (
@@ -280,28 +236,38 @@ function DashboardCardMenu({
setConfirmDeleteOpen(true)}
>
- Delete
+ Delete…
+ {/* Destructive confirm for "Delete…" — the canvas goes for everyone. */}
+
+
+
+ Delete canvas
+
+ Delete {name}? Its code and
+ version history go for everyone in the {containerNoun}. You get a
+ few seconds to undo, then it's permanent.
+
+
+
+
+ Cancel
+
+ }
+ />
+
+
+
+
);
}
-
-function PreviewPlaceholder({ label }: { label: string }) {
- return (
-
-
- {label}
-
-
- );
-}
diff --git a/packages/ui/src/features/canvas/components/WebsiteLayout.tsx b/packages/ui/src/features/canvas/components/WebsiteLayout.tsx
index 70105c1e05..a98cd7256d 100644
--- a/packages/ui/src/features/canvas/components/WebsiteLayout.tsx
+++ b/packages/ui/src/features/canvas/components/WebsiteLayout.tsx
@@ -5,9 +5,17 @@ import {
LinkIcon,
PencilSimpleIcon,
PushPinIcon,
+ TrashIcon,
XIcon,
} from "@phosphor-icons/react";
import {
+ AlertDialog,
+ AlertDialogClose,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
Button,
DropdownMenu,
DropdownMenuContent,
@@ -17,7 +25,12 @@ import {
import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events";
import { ChannelBreadcrumb } from "@posthog/ui/features/canvas/components/ChannelBreadcrumb";
import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon";
+import {
+ channelPageIcon,
+ channelPageLabel,
+} from "@posthog/ui/features/canvas/components/channelPages";
import { NewCanvasMenu } from "@posthog/ui/features/canvas/components/NewCanvasMenu";
+import { deleteCanvasWithUndo } from "@posthog/ui/features/canvas/deleteCanvasWithUndo";
import { CanvasFrameHost } from "@posthog/ui/features/canvas/freeform/CanvasFrameHost";
import { useCanvasFrameStore } from "@posthog/ui/features/canvas/freeform/canvasFrameStore";
import { CANVAS_QUERY_KEY } from "@posthog/ui/features/canvas/freeform/freeformDataBridge";
@@ -50,7 +63,7 @@ import {
useParams,
useRouterState,
} from "@tanstack/react-router";
-import type { ReactNode } from "react";
+import { type ReactNode, useState } from "react";
function threadIdFor(dashboardId: string): string {
return `dashboard:${dashboardId}`;
@@ -69,11 +82,37 @@ function FreeformEditControls({
dashboardId: string;
}) {
const navigate = useNavigate();
+ // Pinning is scoped to whatever holds the canvas; the new layout calls that a
+ // space, the old one a channel.
+ const spacesLayout = useChannelsLayout();
+ const containerNoun = spacesLayout ? "space" : "channel";
const editing = useIsDashboardEditing(dashboardId);
const setEditing = useDashboardEditStore((s) => s.setEditing);
const { dashboard } = useDashboard(dashboardId);
- const { forkFreeform, isCreating, setPinned } = useDashboardMutations();
+ const { forkFreeform, isCreating, setPinned, invalidateDashboards } =
+ useDashboardMutations();
const isPinned = dashboard?.pinnedAt != null;
+ // "Delete…" opens a confirmation rather than deleting inline — the canvas and
+ // its version history go away for everyone in the space.
+ const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
+
+ // Once confirmed the canvas vanishes from every list and we leave for the
+ // space's artifacts list, but the delete isn't sent until the undo toast's
+ // timer runs out — Undo simply cancels it.
+ const confirmDelete = () => {
+ setConfirmDeleteOpen(false);
+ deleteCanvasWithUndo({
+ dashboardId,
+ channelId,
+ name: dashboard?.name ?? "Canvas",
+ surface: "canvas",
+ invalidate: invalidateDashboards,
+ });
+ void navigate({
+ to: "/website/$channelId/artifacts",
+ params: { channelId },
+ });
+ };
const onTogglePin = () => {
void setPinned(dashboardId, !isPinned)
@@ -233,7 +272,14 @@ function FreeformEditControls({
}
/>
-
+ {/* Sized to its longest item — the default width clipped "Unpin from
+ space". Same treatment as the channel-list menus. */}
+
Refresh
@@ -248,10 +294,46 @@ function FreeformEditControls({
- {isPinned ? "Unpin from channel" : "Pin to channel"}
+ {isPinned
+ ? `Unpin from ${containerNoun}`
+ : `Pin to ${containerNoun}`}
+
+ setConfirmDeleteOpen(true)}
+ >
+
+ Delete…
+ {/* Destructive confirm for "Delete…" — the canvas goes for everyone. */}
+
+
+
+ Delete canvas
+
+ Delete{" "}
+ {dashboard?.name ?? "Canvas"}
+ ? Its code and version history go for everyone in the{" "}
+ {containerNoun}. You get a few seconds to undo, then it's
+ permanent.
+
+
+
+
+ Cancel
+
+ }
+ />
+
+
+
+
}
/>
)}
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/canvas/deleteCanvasWithUndo.test.ts b/packages/ui/src/features/canvas/deleteCanvasWithUndo.test.ts
new file mode 100644
index 0000000000..2337f486e4
--- /dev/null
+++ b/packages/ui/src/features/canvas/deleteCanvasWithUndo.test.ts
@@ -0,0 +1,104 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const deleteMutate = vi.fn().mockResolvedValue(undefined);
+const toastSuccess = vi.fn();
+const toastError = vi.fn();
+
+vi.mock("@posthog/ui/features/canvas/hostClient", () => ({
+ hostClient: () => ({ dashboards: { delete: { mutate: deleteMutate } } }),
+}));
+vi.mock("@posthog/ui/primitives/toast", () => ({
+ toast: {
+ success: (...args: unknown[]) => toastSuccess(...args),
+ error: (...args: unknown[]) => toastError(...args),
+ dismiss: vi.fn(),
+ },
+}));
+vi.mock("@posthog/ui/shell/analytics", () => ({ track: vi.fn() }));
+
+import {
+ CANVAS_DELETE_UNDO_MS,
+ deleteCanvasWithUndo,
+} from "@posthog/ui/features/canvas/deleteCanvasWithUndo";
+import { usePendingCanvasDeleteStore } from "@posthog/ui/features/canvas/stores/pendingCanvasDeleteStore";
+
+function schedule(invalidate = vi.fn()) {
+ deleteCanvasWithUndo({
+ dashboardId: "d1",
+ channelId: "c1",
+ name: "Weekly report",
+ surface: "dashboards_grid",
+ invalidate,
+ });
+ return invalidate;
+}
+
+// The Undo button handed to the toast.
+function undo(): () => void {
+ const options = toastSuccess.mock.calls.at(-1)?.[1] as {
+ action: { onClick: () => void };
+ };
+ return options.action.onClick;
+}
+
+function isPending(id: string): boolean {
+ return !!usePendingCanvasDeleteStore.getState().pending[id];
+}
+
+describe("deleteCanvasWithUndo", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.clearAllMocks();
+ usePendingCanvasDeleteStore.setState({ pending: {} });
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("hides the canvas immediately but sends nothing until the window closes", async () => {
+ const invalidate = schedule();
+
+ expect(isPending("d1")).toBe(true);
+ expect(deleteMutate).not.toHaveBeenCalled();
+
+ await vi.advanceTimersByTimeAsync(CANVAS_DELETE_UNDO_MS);
+
+ expect(deleteMutate).toHaveBeenCalledWith({ id: "d1" });
+ expect(invalidate).toHaveBeenCalled();
+ expect(isPending("d1")).toBe(false);
+ });
+
+ it("undo cancels the delete outright — nothing is sent", async () => {
+ schedule();
+ undo()();
+
+ expect(isPending("d1")).toBe(false);
+
+ await vi.advanceTimersByTimeAsync(CANVAS_DELETE_UNDO_MS * 2);
+
+ expect(deleteMutate).not.toHaveBeenCalled();
+ });
+
+ it("restores the canvas and toasts when the delete fails", async () => {
+ deleteMutate.mockRejectedValueOnce(new Error("host offline"));
+ schedule();
+
+ await vi.advanceTimersByTimeAsync(CANVAS_DELETE_UNDO_MS);
+
+ expect(isPending("d1")).toBe(false);
+ expect(toastError).toHaveBeenCalledWith(
+ "Couldn't delete canvas",
+ expect.objectContaining({ description: "host offline" }),
+ );
+ });
+
+ it("re-deleting the same canvas restarts the window instead of stacking commits", async () => {
+ schedule();
+ await vi.advanceTimersByTimeAsync(CANVAS_DELETE_UNDO_MS / 2);
+ schedule();
+ await vi.advanceTimersByTimeAsync(CANVAS_DELETE_UNDO_MS);
+
+ expect(deleteMutate).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/ui/src/features/canvas/deleteCanvasWithUndo.ts b/packages/ui/src/features/canvas/deleteCanvasWithUndo.ts
new file mode 100644
index 0000000000..0b2f0c5d71
--- /dev/null
+++ b/packages/ui/src/features/canvas/deleteCanvasWithUndo.ts
@@ -0,0 +1,123 @@
+import {
+ ANALYTICS_EVENTS,
+ type ChannelsSurface,
+} from "@posthog/shared/analytics-events";
+import { hostClient } from "@posthog/ui/features/canvas/hostClient";
+import { usePendingCanvasDeleteStore } from "@posthog/ui/features/canvas/stores/pendingCanvasDeleteStore";
+import { toast } from "@posthog/ui/primitives/toast";
+import { track } from "@posthog/ui/shell/analytics";
+
+// How long the "Deleted artifact" toast stays up — and, because nothing is sent
+// to the host until it expires, how long the user has to undo.
+export const CANVAS_DELETE_UNDO_MS = 8000;
+
+// Canvases waiting out their undo window, so a second delete of the same canvas
+// (or an undo) can cancel the pending commit. Module-level on purpose: the
+// window outlives the component that started it (deleting from the canvas
+// itself navigates away immediately).
+const pendingTimers = new Map>();
+
+interface DeleteCanvasWithUndoOptions {
+ dashboardId: string;
+ channelId: string;
+ /** Canvas name, for the toast copy. */
+ name: string;
+ surface: ChannelsSurface;
+ /** Refresh the canvas queries once the delete actually lands. */
+ invalidate: () => void;
+}
+
+/**
+ * Delete a canvas with an undo window: the canvas disappears from every list
+ * immediately (via the pending store) but the host isn't told until the toast's
+ * timer runs out. Undo just cancels that timer, so nothing is ever recreated.
+ */
+export function deleteCanvasWithUndo({
+ dashboardId,
+ channelId,
+ name,
+ surface,
+ invalidate,
+}: DeleteCanvasWithUndoOptions): void {
+ const { markPending, clearPending } = usePendingCanvasDeleteStore.getState();
+ const toastId = `canvas-delete-undo-${dashboardId}`;
+
+ // A repeat delete for the same canvas restarts the window rather than
+ // stacking two commits.
+ const existing = pendingTimers.get(dashboardId);
+ if (existing) clearTimeout(existing);
+
+ markPending(dashboardId);
+
+ const commit = async () => {
+ pendingTimers.delete(dashboardId);
+ try {
+ await hostClient().dashboards.delete.mutate({ id: dashboardId });
+ track(ANALYTICS_EVENTS.DASHBOARD_ACTION, {
+ action_type: "delete",
+ surface,
+ channel_id: channelId,
+ dashboard_id: dashboardId,
+ success: true,
+ });
+ invalidate();
+ } catch (error) {
+ track(ANALYTICS_EVENTS.DASHBOARD_ACTION, {
+ action_type: "delete",
+ surface,
+ channel_id: channelId,
+ dashboard_id: dashboardId,
+ success: false,
+ });
+ toast.error("Couldn't delete canvas", {
+ description: error instanceof Error ? error.message : String(error),
+ });
+ } finally {
+ // Either it's gone from the server (and the refreshed list won't include
+ // it) or the delete failed and it should come back.
+ clearPending(dashboardId);
+ }
+ };
+
+ pendingTimers.set(
+ dashboardId,
+ setTimeout(() => void commit(), CANVAS_DELETE_UNDO_MS),
+ );
+
+ toast.success("Deleted artifact", {
+ id: toastId,
+ description: name,
+ duration: CANVAS_DELETE_UNDO_MS,
+ action: {
+ label: "Undo",
+ onClick: () => {
+ toast.dismiss(toastId);
+ undoCanvasDelete({ dashboardId, channelId, surface });
+ },
+ },
+ });
+}
+
+function undoCanvasDelete({
+ dashboardId,
+ channelId,
+ surface,
+}: {
+ dashboardId: string;
+ channelId: string;
+ surface: ChannelsSurface;
+}): void {
+ const timer = pendingTimers.get(dashboardId);
+ // Nothing to cancel means the window already closed and the delete is in
+ // flight or done; leave the pending flag to `commit`.
+ if (!timer) return;
+ clearTimeout(timer);
+ pendingTimers.delete(dashboardId);
+ usePendingCanvasDeleteStore.getState().clearPending(dashboardId);
+ track(ANALYTICS_EVENTS.DASHBOARD_ACTION, {
+ action_type: "delete_undo",
+ surface,
+ channel_id: channelId,
+ dashboard_id: dashboardId,
+ });
+}
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; }
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/canvas/hooks/useDashboards.ts b/packages/ui/src/features/canvas/hooks/useDashboards.ts
index e04e981523..808c645cb0 100644
--- a/packages/ui/src/features/canvas/hooks/useDashboards.ts
+++ b/packages/ui/src/features/canvas/hooks/useDashboards.ts
@@ -52,6 +52,9 @@ export function useDashboards(
},
),
);
+ // Canvases inside their delete-undo window stay in the list — surfaces mark
+ // them as deleting (see usePendingCanvasDeleteStore) rather than removing a
+ // row that Undo would put straight back.
return { dashboards: data ?? [], isLoading };
}
@@ -138,6 +141,9 @@ export function useDashboardMutations() {
);
return {
+ // Refresh the canvas queries after a mutation that didn't go through this
+ // hook (the undo-window delete commits outside React).
+ invalidateDashboards: invalidate,
createDashboard: (channelId: string, name: string, templateId?: string) =>
create.mutateAsync({ channelId, name, templateId }),
deleteDashboard: (id: string) => remove.mutateAsync({ id }),
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/stores/pendingCanvasDeleteStore.ts b/packages/ui/src/features/canvas/stores/pendingCanvasDeleteStore.ts
new file mode 100644
index 0000000000..de774fcca3
--- /dev/null
+++ b/packages/ui/src/features/canvas/stores/pendingCanvasDeleteStore.ts
@@ -0,0 +1,29 @@
+import { create } from "zustand";
+
+interface PendingCanvasDeleteState {
+ // Canvases the user has deleted but whose delete hasn't been sent yet — the
+ // undo window. They stay in their lists, marked as deleting, and the mark
+ // simply clears (nothing is recreated) if the user hits Undo. State only: the
+ // timer and the eventual commit live in `deleteCanvasWithUndo`.
+ pending: Record;
+ markPending: (dashboardId: string) => void;
+ clearPending: (dashboardId: string) => void;
+}
+
+export const usePendingCanvasDeleteStore = create(
+ (set) => ({
+ pending: {},
+ markPending: (dashboardId) =>
+ set((s) => ({ pending: { ...s.pending, [dashboardId]: true } })),
+ clearPending: (dashboardId) =>
+ set((s) => {
+ const { [dashboardId]: _dropped, ...rest } = s.pending;
+ return { pending: rest };
+ }),
+ }),
+);
+
+/** True while this canvas is inside its delete-undo window. */
+export function useIsCanvasPendingDelete(dashboardId: string): boolean {
+ return usePendingCanvasDeleteStore((s) => !!s.pending[dashboardId]);
+}
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];
+}
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/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 5e07d9fefd..1217391dcc 100644
--- a/packages/ui/src/features/inbox/components/InboxPageHeader.tsx
+++ b/packages/ui/src/features/inbox/components/InboxPageHeader.tsx
@@ -1,12 +1,62 @@
-import type { InboxTabCounts } from "@posthog/core/inbox/reportMembership";
-import { InboxTabBar } from "@posthog/ui/features/inbox/components/InboxTabBar";
+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 {
+ InboxTabBar,
+ InboxTabs,
+} 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 = useBluebirdFlag();
+ 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(inboxTabFromPath(pathname)) && (
+
+
+
+ )}
+
+
+ );
+}
+
+function LegacyInboxPageHeader({ counts }: InboxPageHeaderProps) {
return (
s.location.pathname });
+ const activeKey = inboxTabFromPath(pathname);
+
+ return (
+
+
+ {inboxScopeApplies(activeKey) && }
+
+ );
}
-export function InboxTabBar({ counts }: InboxTabBarProps) {
+/** 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 (
-
- {
- const key = value as InboxTabKey;
- navigate({ to: INBOX_TAB_LIST_ROUTE[key] });
- }}
+ {
+ 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..b274013e43 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,
@@ -62,7 +63,9 @@ 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";
export function LoopDetailView({ loopId }: { loopId: string }) {
const hasLoopListOrigin = useLocation({
@@ -89,16 +92,26 @@ 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; 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(
-
-
-
- {loop?.name ?? "Loop"}
-
- ,
+ useMemo(
+ () =>
+ spacesLayout && contextTarget ? (
+
+ ) : (
+
+ ),
+ [spacesLayout, contextTarget, loopName],
+ ),
);
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..c41cf27ca5 100644
--- a/packages/ui/src/features/loops/components/LoopForm.tsx
+++ b/packages/ui/src/features/loops/components/LoopForm.tsx
@@ -5,8 +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 { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag";
+import { ANALYTICS_EVENTS } from "@posthog/shared";
+import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout";
+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";
@@ -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,
@@ -48,10 +49,12 @@ 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";
import { LoopInstructionsFields } from "./LoopSkillFields";
+import { LoopSpaceBreadcrumb } from "./LoopSpaceBreadcrumb";
import { LoopTriggerEditor } from "./LoopTriggerEditor";
const VISIBILITY_OPTIONS: {
@@ -102,10 +105,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;
@@ -133,10 +133,25 @@ export function LoopForm({ loop }: LoopFormProps) {
];
const isLastStep = step === STEPS.length - 1;
+ // 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";
useSetHeaderContent(
-
- {isEdit ? `Edit ${loop.name}` : "New loop"}
- ,
+ useMemo(
+ () =>
+ spacesLayout && contextTarget ? (
+
+ ) : (
+
+ ),
+ [spacesLayout, contextTarget, headerLeaf],
+ ),
);
const triggerEndpointPath =
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}
+
+
+ );
+}
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..2ed371b75f 100644
--- a/packages/ui/src/features/loops/components/LoopsListView.tsx
+++ b/packages/ui/src/features/loops/components/LoopsListView.tsx
@@ -1,9 +1,4 @@
-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 { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events";
@@ -11,9 +6,20 @@ 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 { 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";
+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 +27,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 +75,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 = useBluebirdFlag();
const authenticatedClient = useOptionalAuthenticatedClient();
const {
data: currentUser,
@@ -86,21 +96,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 +146,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 +280,26 @@ export function LoopsListViewPresentation({
}
/>
) : loops.length > 0 ? (
-
+ sharedPageHeader ? (
+ // Triggers live in the page header; only the panels sit here.
+
+ ) : (
+
+ )
) : (
)}
@@ -302,7 +326,50 @@ export function LoopsListViewPresentation({
-
+ >
+ );
+
+ if (!sharedPageHeader) {
+ return (
+
+ {body}
+
+ );
+ }
+
+ // One Tabs root spanning header and body: the trigger strip sits in the
+ // header's sub-nav, its panels stay down in the scrolling body.
+ return (
+
+
+
+
+ Loops
+ }>
+ Runs entirely in the cloud
+
+ {createButton}
+
+
+ Put your work on autopilot. Loops run on a schedule, on an API call,
+ or when something happens on GitHub. You can finally close the
+ laptop!
+
+
+ {hasTabs && (
+
+
+
+ )}
+
+ {body}
+
);
}
@@ -323,18 +390,64 @@ function LoopListTabs({
}) {
return (
-
-
-
- My loops ({personalLoops.length})
-
-
-
-
- Team loops ({teamLoops.length})
-
-
-
+
+
+
+ );
+}
+
+/** The trigger strip. Rendered inside the page header when one is present. */
+function LoopTabsList({
+ personalCount,
+ teamCount,
+}: {
+ personalCount: number;
+ teamCount: number;
+}) {
+ return (
+
+
+
+ My loops ({personalCount})
+
+
+
+
+ Team loops ({teamCount})
+
+
+
+ );
+}
+
+/** The panels. Always in the scrolling body, wherever the triggers live. */
+function LoopTabPanels({
+ personalLoops,
+ teamLoops,
+ members,
+ membersLoading,
+ membersError,
+ membersComplete,
+}: {
+ personalLoops: LoopSchemas.Loop[];
+ teamLoops: LoopSchemas.Loop[];
+ members: UserBasic[];
+ membersLoading: boolean;
+ membersError: boolean;
+ membersComplete: boolean;
+}) {
+ return (
+ <>
{personalLoops.length > 0 ? (
)}
-
+ >
);
}
diff --git a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx
index 6a80be72ab..fbe795c71e 100644
--- a/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx
+++ b/packages/ui/src/features/sessions/components/chat-thread/ChatThread.tsx
@@ -8,6 +8,7 @@ import {
import { WorkerPoolContextProvider } from "@pierre/diffs/react";
import { useService } from "@posthog/di/react";
import {
+ Button,
ChatBubble,
ChatBubbleContent,
ChatMarker,
@@ -22,7 +23,15 @@ import {
ChatMessageScrollerItem,
ChatMessageScrollerProvider,
ChatMessageScrollerViewport,
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuSeparator,
+ ContextMenuTrigger,
cn,
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
useChatMessageScroller,
useChatMessageScrollerScrollable,
useChatMessageScrollerVisibility,
@@ -62,8 +71,10 @@ import {
type ThreadScrollResume,
type TurnRow,
} from "@posthog/ui/features/sessions/components/chat-thread/threadVirtualization";
+import { buildTurnCopyText } from "@posthog/ui/features/sessions/components/chat-thread/turnCopyText";
import { usePromptRecallSource } from "@posthog/ui/features/sessions/components/chat-thread/usePromptRecallSource";
import { VirtualThreadScrollBody } from "@posthog/ui/features/sessions/components/chat-thread/VirtualThreadScrollBody";
+import { copyFromContextMenu } from "@posthog/ui/features/sessions/components/copyContextTarget";
import { GitActionMessage } from "@posthog/ui/features/sessions/components/GitActionMessage";
import { GitActionResult } from "@posthog/ui/features/sessions/components/GitActionResult";
import { isUserInitiatedConversationItem } from "@posthog/ui/features/sessions/components/isUserInitiatedConversationItem";
@@ -90,6 +101,10 @@ import {
useOptimisticItemsForTask,
useSessionIsCloud,
} from "@posthog/ui/features/sessions/sessionStore";
+import {
+ useSessionViewActions,
+ useShowRawLogs,
+} from "@posthog/ui/features/sessions/sessionViewStore";
import { useThreadScrollRequest } from "@posthog/ui/features/sessions/threadNavigationStore";
import type { UserMessageAttachment } from "@posthog/ui/features/sessions/userMessageTypes";
import {
@@ -98,14 +113,15 @@ import {
} from "@posthog/ui/features/sessions/useSessionTaskId";
import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore";
import { SkillButtonActionMessage } from "@posthog/ui/features/skill-buttons/components/SkillButtonActionMessage";
+import { toast } from "@posthog/ui/primitives/toast";
import { useCopy } from "@posthog/ui/primitives/useCopy";
import {
DIFF_WORKER_FACTORY,
type DiffWorkerFactory,
} from "@posthog/ui/shell/diffWorkerHost";
-import { IconButton, Tooltip } from "@radix-ui/themes";
import {
memo,
+ type ReactElement,
type ReactNode,
type RefObject,
useCallback,
@@ -199,14 +215,16 @@ function groupToolRuns(items: ConversationItem[]): ThreadItem[] {
* Collapse each contiguous run of non-user rows into one {@link AgentTurn}, broken only by a
* user-initiated row (which stays standalone so it remains the scroll anchor for the sticky header
* and auto-follow). The turn block renders as a single muted card, tightening the spacing between
- * the agent's successive replies and tool calls.
+ * the agent's successive replies and tool calls. Each turn records the user-initiated row that
+ * opened it, so "Copy turn" can lead with the prompt the turn answered.
*/
function groupIntoTurns(rows: ThreadItem[]): TurnRow[] {
const out: TurnRow[] = [];
let buffer: ThreadItem[] = [];
+ let prompt: ThreadItem | undefined;
const flush = () => {
if (buffer.length > 0) {
- out.push({ type: "agent_turn", id: buffer[0].id, items: buffer });
+ out.push({ type: "agent_turn", id: buffer[0].id, items: buffer, prompt });
buffer = [];
}
};
@@ -218,6 +236,7 @@ function groupIntoTurns(rows: ThreadItem[]): TurnRow[] {
if (isUserInitiatedConversationItem(row)) {
flush();
out.push(row);
+ prompt = row;
} else {
buffer.push(row);
}
@@ -238,22 +257,59 @@ function formatTimestamp(ts: number): string {
}
/**
- * Hover-revealed timestamp rendered right-aligned under agent-side content (the end-aligned user
- * bubble keeps its own right-aligned footer). Sits inside a `group` container so it fades in only
- * while that container is hovered. Shown once per completed agent turn (under the turn card)
- * rather than on every message — per-row it was too noisy.
+ * Hover-revealed footer under a completed agent turn: the turn's timestamp plus a button copying
+ * the whole turn. Rendered right-aligned under agent-side content — the end-aligned user bubble
+ * keeps its own footer — inside a `group` container, so it fades in only while that turn is
+ * hovered. Once per turn rather than per row, which was too noisy.
*/
-function RowTimestamp({ timestamp }: { timestamp?: number }) {
+function TurnFooter({
+ timestamp,
+ copyText,
+}: {
+ timestamp?: number;
+ copyText?: string;
+}) {
if (timestamp == null) return null;
return (
{formatTimestamp(timestamp)}
+ {copyText && }
);
}
+/**
+ * Shared copy affordance for the message and turn footers. Stays muted whether idle or just-copied —
+ * the icon swap is the confirmation, so the row never lights up in a colour the thread doesn't use
+ * elsewhere.
+ */
+function CopyButton({ value, label }: { value: string; label: string }) {
+ const { copied, copy } = useCopy();
+ const [hovered, setHovered] = useState(false);
+ return (
+ // Held open for the life of the `copied` window so the confirmation lands even when the click
+ // moves the pointer off the button; hover drives it the rest of the time.
+
+ copy(value)}
+ className="text-muted-foreground hover:text-foreground"
+ >
+ {copied ? : }
+
+ }
+ />
+ {copied ? "Copied!" : label}
+
+ );
+}
+
/**
* End-aligned user bubble. The text is clamped to five lines (`max-height: 5lh` + `overflow-hidden`,
* which — unlike `-webkit-line-clamp` — reliably clamps markdown's block `
+ );
+}
+
+/** 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;