From 0947c2b12d9110b5ba8f39eb5eab537cbbe2f608 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Wed, 29 Jul 2026 21:11:52 +0100 Subject: [PATCH 01/18] wip --- packages/core/src/canvas/channelItems.test.ts | 1 + packages/core/src/canvas/channelItems.ts | 11 + .../features/browser-tabs/BrowserTabStrip.tsx | 22 +- .../canvas/components/ChannelItemRow.test.tsx | 91 +++++-- .../canvas/components/ChannelItemRow.tsx | 79 +++--- .../canvas/components/ChannelSidebar.test.tsx | 5 + .../canvas/hooks/useChannelTaskStatus.ts | 46 ++++ .../design-system/DesignSystemView.tsx | 59 ++++ .../features/design-system/TaskNavColumn.tsx | 54 ++++ .../features/design-system/taskIconSpecs.ts | 256 ++++++++++++++++++ .../src/features/design-system/taskRows.tsx | 42 +++ .../settings/components/SettingsPanel.tsx | 38 ++- .../components/items/TaskStatusDot.tsx | 141 ++++++++++ .../components/items/taskStatusVocabulary.ts | 223 +++++++++++++++ packages/ui/src/primitives/DotRingSpinner.tsx | 71 +++++ packages/ui/src/router/navigationBridge.ts | 4 + packages/ui/src/router/routeTree.gen.ts | 21 ++ .../ui/src/router/routes/design-system.tsx | 11 + packages/ui/src/router/useAppView.ts | 3 + packages/ui/src/styles/globals.css | 31 +++ 20 files changed, 1136 insertions(+), 73 deletions(-) create mode 100644 packages/ui/src/features/canvas/hooks/useChannelTaskStatus.ts create mode 100644 packages/ui/src/features/design-system/DesignSystemView.tsx create mode 100644 packages/ui/src/features/design-system/TaskNavColumn.tsx create mode 100644 packages/ui/src/features/design-system/taskIconSpecs.ts create mode 100644 packages/ui/src/features/design-system/taskRows.tsx create mode 100644 packages/ui/src/features/sidebar/components/items/TaskStatusDot.tsx create mode 100644 packages/ui/src/features/sidebar/components/items/taskStatusVocabulary.ts create mode 100644 packages/ui/src/primitives/DotRingSpinner.tsx create mode 100644 packages/ui/src/router/routes/design-system.tsx diff --git a/packages/core/src/canvas/channelItems.test.ts b/packages/core/src/canvas/channelItems.test.ts index 59e9861790..63cec64f72 100644 --- a/packages/core/src/canvas/channelItems.test.ts +++ b/packages/core/src/canvas/channelItems.test.ts @@ -157,6 +157,7 @@ function model(over: Partial = {}): ChannelItemModel { authorName: null, authorUuid: ME.uuid, templateId: null, + task: null, ...over, }; } diff --git a/packages/core/src/canvas/channelItems.ts b/packages/core/src/canvas/channelItems.ts index 4834e6603a..9bd9214fce 100644 --- a/packages/core/src/canvas/channelItems.ts +++ b/packages/core/src/canvas/channelItems.ts @@ -17,6 +17,15 @@ export interface ChannelItemModel { authorName: string | null; authorUuid: string | null; templateId: string | null; + /** + * The source task record for `kind: "task"` rows, `null` for canvases. Rows + * need the whole task, not a projection of it: the status dot is derived from + * session/workspace/viewed state that only the renderer holds, and the hooks + * that supply it (`useChannelTaskData`, `useTaskPrStatus`) take a `Task`. + * Carrying the reference here keeps that a lookup the list already did rather + * than a second pass over every row. + */ + task: Task | null; } export interface ChannelItemOwner { @@ -60,6 +69,7 @@ export function buildChannelItems({ authorName: d.createdBy ?? null, authorUuid: d.createdByUuid ?? null, templateId: d.templateId, + task: null, })); const taskItems: ChannelItemModel[] = feedTasks.flatMap((task) => @@ -78,6 +88,7 @@ export function buildChannelItems({ authorName: null, authorUuid: task.created_by?.uuid ?? null, templateId: null, + task, }, ], ); diff --git a/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx b/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx index c8a72cc020..ae1de5553f 100644 --- a/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx +++ b/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx @@ -1,5 +1,6 @@ import { BrainIcon, + BugIcon, PlugsConnectedIcon, RobotIcon, SquaresFourIcon, @@ -125,7 +126,13 @@ type TabRef = { // The top-level app pages that can be a tab. Keyed by useAppView's view.type; // each maps to its canonical route (a task/canvas/channel tab has its own // route, these don't) plus the strip's label + icon. -type AppView = "inbox" | "agents" | "skills" | "mcp-servers" | "command-center"; +type AppView = + | "inbox" + | "agents" + | "skills" + | "mcp-servers" + | "command-center" + | "design-system"; const APP_VIEW_META: Record = { inbox: { label: "Inbox", icon: }, @@ -139,6 +146,7 @@ const APP_VIEW_META: Record = { label: "Command center", icon: , }, + "design-system": { label: "Design system", icon: }, }; function isAppView(value: string): value is AppView { @@ -165,9 +173,12 @@ export function BrowserTabStrip() { // plain task tab (no channel) belongs to the Code experience. The space // decides where a task/blank tab navigates. const inChannels = pathname.startsWith("/website"); - // Top-level app pages (Inbox, Agents, Skills, MCP servers, Command Center) - // are tab targets too. useAppView normalizes both the /code routes and - // their /website mirrors to the same view.type, so a tab survives either space. + // Top-level app pages (Inbox, Agents, Skills, MCP servers, Command Center, + // the Design system debug page) are tab targets too. useAppView normalizes + // both the /code routes and their /website mirrors to the same view.type, so + // a tab survives either space. A top-level route that ISN'T here falls + // through to `task-input`, and the strip then reconciles the location against + // the wrong tab and navigates straight back off the page. const view = useAppView(); const routeAppView: AppView | null = isAppView(view.type) ? view.type : null; @@ -611,6 +622,9 @@ export function BrowserTabStrip() { case "command-center": navigate({ to: "/command-center", state }); break; + case "design-system": + navigate({ to: "/design-system", state }); + break; default: { // Exhaustiveness guard: a new AppView value fails to compile here // until its canonical route is wired above — so the tab-target set diff --git a/packages/ui/src/features/canvas/components/ChannelItemRow.test.tsx b/packages/ui/src/features/canvas/components/ChannelItemRow.test.tsx index 2981229c1f..92dcb2208f 100644 --- a/packages/ui/src/features/canvas/components/ChannelItemRow.test.tsx +++ b/packages/ui/src/features/canvas/components/ChannelItemRow.test.tsx @@ -1,8 +1,18 @@ import type { ChannelItemModel } from "@posthog/core/canvas/channelItems"; -import type { TaskRunStatus } from "@posthog/shared/domain-types"; +import { formatRelativeTimeShort } from "@posthog/shared"; +import type { TaskStatusInput } from "@posthog/ui/features/sidebar/components/items/taskStatusVocabulary"; 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"; + +// The row's status comes from live session/workspace state and a per-task tRPC +// query, none of which a unit test has. Stubbed at the module boundary, as +// ChannelSidebar.test.tsx does for the same reason. +const mocks = vi.hoisted(() => ({ status: null as TaskStatusInput | null })); +vi.mock("@posthog/ui/features/canvas/hooks/useChannelTaskStatus", () => ({ + useChannelTaskStatus: () => mocks.status, +})); + import { ChannelItemRow } from "./ChannelItemRow"; const actions = { @@ -24,6 +34,7 @@ function item(overrides: Partial = {}): ChannelItemModel { authorName: null, authorUuid: "user-uuid", templateId: null, + task: null, ...overrides, }; } @@ -36,21 +47,59 @@ function renderRow(model: ChannelItemModel) { ); } +beforeEach(() => { + mocks.status = null; +}); + describe("ChannelItemRow", () => { + // The dot vocabulary in one table: what the row's leading mark says for each + // state a task can be in. Only the states a reader can act on get a voice — + // run mechanics (queued, failed) resolve to "working" or "something to read". it.each([ - ["queued" as const, true], - ["in_progress" as const, true], - ["not_started" as const, false], - ["completed" as const, false], - ["failed" as const, false], - ["cancelled" as const, false], - ])("marks %s as running: %s", (rawStatus: TaskRunStatus, running) => { - renderRow(item({ rawStatus })); - - expect(!!screen.queryByRole("img", { name: "Running" })).toBe(running); + [ + "a permission prompt", + { needsPermission: true }, + "Needs permission — blocked on you", + ], + ["a streaming agent", { isGenerating: true }, "Working"], + [ + "a cloud run in flight", + { taskRunStatus: "in_progress" as const }, + "Working", + ], + ["a queued cloud run", { taskRunStatus: "queued" as const }, "Working"], + [ + "a broken run with unseen output", + { taskRunStatus: "failed" as const, isUnread: true }, + "Unread — something to read", + ], + ["a suspended task", { isSuspended: true }, "Suspended — parked"], + [ + "a merged PR", + { prState: "merged" as const }, + // PR state lives on the badge, so the dot stays quiet. + "Nothing owed to you", + ], + ["an idle task", {}, "Nothing owed to you"], + ])("labels %s", (_case, status: TaskStatusInput, label) => { + mocks.status = status; + + renderRow(item()); + + expect(screen.getByRole("img", { name: label })).not.toBeNull(); }); - it("leaves a canvas, which has no run to wait on, static", () => { + it("shows a task's badges instead of its timestamp", () => { + mocks.status = { workspaceMode: "cloud", prState: "merged" }; + + renderRow(item()); + + expect(screen.getByRole("img", { name: "Cloud run" })).not.toBeNull(); + expect(screen.getByRole("img", { name: "Merged" })).not.toBeNull(); + expect(screen.queryByText(formatRelativeTimeShort(item().ts))).toBeNull(); + }); + + it("leaves a canvas its template glyph and timestamp, having no run", () => { renderRow( item({ key: "canvas:canvas-1", @@ -61,18 +110,10 @@ describe("ChannelItemRow", () => { }), ); - expect(screen.queryByRole("img", { name: "Running" })).toBeNull(); - }); - - // The point of the shimmer over a spinner: a running task still looks like a - // task, so the list stays scannable by kind while work is in flight. - it("keeps the item's own glyph while running", () => { - renderRow(item({ rawStatus: "in_progress" })); - - const running = screen.getByRole("img", { name: "Running" }); - expect(running).toHaveClass("ph-shimmer"); - // The glyph is wrapped, not replaced — no spinner swapped in its place. - expect(running.querySelector("svg")).not.toBeNull(); + expect( + screen.queryByRole("img", { name: "Nothing owed to you" }), + ).toBeNull(); + expect(screen.getByText(formatRelativeTimeShort(item().ts))).not.toBeNull(); }); it("opens the task context menu from the row", () => { diff --git a/packages/ui/src/features/canvas/components/ChannelItemRow.tsx b/packages/ui/src/features/canvas/components/ChannelItemRow.tsx index ae5bc43ebb..833851129f 100644 --- a/packages/ui/src/features/canvas/components/ChannelItemRow.tsx +++ b/packages/ui/src/features/canvas/components/ChannelItemRow.tsx @@ -1,8 +1,7 @@ import { PreviewCard } from "@base-ui/react/preview-card"; -import { Archive, FileTextIcon, PushPin } from "@phosphor-icons/react"; +import { Archive, PushPin } from "@phosphor-icons/react"; import type { ChannelItemModel } from "@posthog/core/canvas/channelItems"; import { - isRunStatusActive, runStatusLabel, runStatusVariant, } from "@posthog/core/canvas/runStatus"; @@ -10,8 +9,15 @@ import { Avatar, AvatarFallback, Badge } from "@posthog/quill"; import { formatRelativeTimeShort } from "@posthog/shared"; import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar"; import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon"; +import { useChannelTaskStatus } from "@posthog/ui/features/canvas/hooks/useChannelTaskStatus"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; import { InlineEditInput } from "@posthog/ui/features/sidebar/components/items/TaskItem"; +import { + TaskBadgeStack, + TaskStatusDot, + TaskStatusTooltips, +} from "@posthog/ui/features/sidebar/components/items/TaskStatusDot"; +import { taskDot } from "@posthog/ui/features/sidebar/components/items/taskStatusVocabulary"; import { SidebarItem } from "@posthog/ui/features/sidebar/components/SidebarItem"; import { NestedButton } from "@posthog/ui/primitives/NestedButton"; import { Tooltip } from "@posthog/ui/primitives/Tooltip"; @@ -36,30 +42,18 @@ const HOVER_TOOLBAR_CLASS = "hidden shrink-0 items-center gap-0.5 group-hover:flex"; const TIMESTAMP_CLASS = "shrink-0 text-[11px] text-muted-foreground group-hover:hidden"; +// Same hide-on-hover rule as the timestamp it replaces, so the pin/archive +// toolbar has the slot to itself while the pointer is on the row. +const TRAILING_CLASS = "flex shrink-0 items-center group-hover:hidden"; -function itemIcon(item: ChannelItemModel): ReactNode { - return item.kind === "canvas" ? ( - // Matches the schema's own default for boards saved before templating. - iconForTemplate(item.templateId ?? "freeform", { - size: 15, - className: "text-violet-9", - }) - ) : ( - - ); -} - -/** - * Marks a row whose run is still going. The glyph is kept and shimmered rather - * than swapped for a spinner, so the list stays scannable by kind while it - * moves — you can still tell a running task from a running canvas. - */ -function RunningIcon({ children }: { children: ReactNode }) { - return ( - - {children} - - ); +// A canvas has no run to report, so it keeps its template glyph. Tasks moved to +// the status dot, which is why only canvases still have an icon function. +function canvasIcon(item: ChannelItemModel): ReactNode { + // Matches the schema's own default for boards saved before templating. + return iconForTemplate(item.templateId ?? "freeform", { + size: 15, + className: "text-violet-9", + }); } function authorLabel(item: ChannelItemModel): string | null { @@ -84,16 +78,14 @@ export function ChannelItemRow({ onEditSubmit?: (newTitle: string) => void; onEditCancel?: () => void; }) { - const icon = itemIcon(item); + const status = useChannelTaskStatus(item); const statusLabel = runStatusLabel(item.rawStatus); const author = authorLabel(item); - // Only the row shimmers. The preview card spells the status out in a badge, - // so animating its copy of the icon would say the same thing twice. - const rowIcon = isRunStatusActive(item.rawStatus) ? ( - {icon} - ) : ( - icon - ); + // A task's leading mark is its state (dot / working spinner); a canvas has no + // state, so it keeps the template glyph. The preview card always shows the + // glyph — it has room to say what the row is as well as how it's doing. + const icon = canvasIcon(item); + const rowIcon = status ? : icon; if (isEditing) { return ( @@ -108,7 +100,9 @@ export function ChannelItemRow({ ); } - return ( + // One tooltip provider per task row, shared by its dot and badges so moving + // between them doesn't re-wait the open delay. Canvas rows have neither. + const row = ( - - {formatRelativeTimeShort(item.ts)} - + {/* Badges take the timestamp's slot on a task row: the row's + identity (source, cloud, PR) is what you scan a task list + for, and the relative age is still in the preview card. */} + {status ? ( + + + + ) : ( + + {formatRelativeTimeShort(item.ts)} + + )} ); + + return status ? {row} : row; } diff --git a/packages/ui/src/features/canvas/components/ChannelSidebar.test.tsx b/packages/ui/src/features/canvas/components/ChannelSidebar.test.tsx index 53fb6a5c3d..b561f4fb6e 100644 --- a/packages/ui/src/features/canvas/components/ChannelSidebar.test.tsx +++ b/packages/ui/src/features/canvas/components/ChannelSidebar.test.tsx @@ -52,6 +52,10 @@ vi.mock("@posthog/ui/features/tasks/useTaskMutations", () => ({ vi.mock("@posthog/ui/features/tasks/useTasks", () => ({ useTasks: () => ({ data: [] }), })); +// A row's status dot reaches for live session state and a per-task PR query. +vi.mock("@posthog/ui/features/canvas/hooks/useChannelTaskStatus", () => ({ + useChannelTaskStatus: () => null, +})); import { ChannelSidebar } from "./ChannelSidebar"; @@ -69,6 +73,7 @@ function item(overrides: Partial = {}): ChannelItemModel { // Not the viewer, so filtering to "Me" leaves nothing. authorUuid: "someone-else-uuid", templateId: null, + task: null, ...overrides, }; } diff --git a/packages/ui/src/features/canvas/hooks/useChannelTaskStatus.ts b/packages/ui/src/features/canvas/hooks/useChannelTaskStatus.ts new file mode 100644 index 0000000000..84fb9e3608 --- /dev/null +++ b/packages/ui/src/features/canvas/hooks/useChannelTaskStatus.ts @@ -0,0 +1,46 @@ +import type { ChannelItemModel } from "@posthog/core/canvas/channelItems"; +import { useChannelTaskData } from "@posthog/ui/features/canvas/hooks/useChannelTaskData"; +import type { TaskStatusInput } from "@posthog/ui/features/sidebar/components/items/taskStatusVocabulary"; +import { useTaskPrStatus } from "@posthog/ui/features/sidebar/useTaskPrStatus"; +import { useWorkspace } from "@posthog/ui/features/workspace/useWorkspace"; + +/** + * The state behind a channel row's status dot and badges, or `null` for a canvas + * (which has no run to report). + * + * Assembled per row, the same way the Code sidebar's `TaskRow` does it, because + * that's the only place the inputs exist together: the derived flags come from + * renderer state (live session, workspace, viewed timestamps) and the PR state + * from a per-task query, so none of it can be baked into the item list in core. + * Keeping the composition in a hook rather than in the row leaves the row a + * component that renders, and gives tests one module to stub. + */ +export function useChannelTaskStatus( + item: ChannelItemModel, +): TaskStatusInput | null { + const task = item.task ?? undefined; + const taskData = useChannelTaskData(task); + const workspace = useWorkspace(task?.id); + const { prState, hasDiff } = useTaskPrStatus({ + id: task?.id ?? "", + cloudPrUrl: taskData?.cloudPrUrl ?? null, + taskRunEnvironment: taskData?.taskRunEnvironment ?? null, + }); + + if (!taskData) return null; + return { + workspaceMode: + workspace?.mode ?? + (taskData.taskRunEnvironment === "cloud" ? "cloud" : undefined), + isGenerating: taskData.isGenerating, + isUnread: taskData.isUnread, + isPinned: taskData.isPinned, + isSuspended: taskData.isSuspended, + needsPermission: taskData.needsPermission, + taskRunStatus: taskData.taskRunStatus, + originProduct: taskData.originProduct, + slackThreadUrl: taskData.slackThreadUrl, + prState, + hasDiff, + }; +} diff --git a/packages/ui/src/features/design-system/DesignSystemView.tsx b/packages/ui/src/features/design-system/DesignSystemView.tsx new file mode 100644 index 0000000000..1c98ac4ad4 --- /dev/null +++ b/packages/ui/src/features/design-system/DesignSystemView.tsx @@ -0,0 +1,59 @@ +import { BugIcon } from "@phosphor-icons/react"; +import { TaskNavColumn } from "@posthog/ui/features/design-system/TaskNavColumn"; +import { + CurrentTaskRow, + CustomTaskRow, +} from "@posthog/ui/features/design-system/taskRows"; +import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; +import { + PageHeader, + PageHeaderChip, + PageHeaderDescription, + PageHeaderHeading, + PageHeaderTitle, + PageHeaderTitleRow, +} from "@posthog/ui/primitives/PageHeader"; + +/** + * Debug page for the task row treatments the sidebar and spaces draw. Both + * columns are real nav lists over the same dummy tasks — no queries, no live + * state — so every state is on screen at once instead of whichever ones the + * current workspace happens to have. Each row is labelled with the vocabulary + * for its state, so the columns are self-documenting. + */ +export function DesignSystemView() { + // A root-level debug page has no space to walk back to, so it contributes no + // breadcrumb (matching the command centre). + useSetHeaderContent(null); + + return ( +
+ + + + Design system + }>Debug + + + Task rows in every state the status cascade can reach, labelled with + what that state means. Hover a row to ticker the label, or its dot + and badges for the shorthand. + + + + +
+ } + /> + } + /> +
+
+ ); +} diff --git a/packages/ui/src/features/design-system/TaskNavColumn.tsx b/packages/ui/src/features/design-system/TaskNavColumn.tsx new file mode 100644 index 0000000000..50e491522a --- /dev/null +++ b/packages/ui/src/features/design-system/TaskNavColumn.tsx @@ -0,0 +1,54 @@ +import { MenuLabel } from "@posthog/quill"; +import { + TASK_ICON_SPEC_GROUPS, + type TaskIconSpec, +} from "@posthog/ui/features/design-system/taskIconSpecs"; +import type { ReactNode } from "react"; + +/** + * A preview column dressed as the real thing: a bordered nav panel with a + * title, its own scroll, and sidebar section labels between the state groups. + * Judging a row treatment in a bare table lies about the two things that + * actually decide it — how it reads at nav width, and how it reads stacked + * against twenty neighbours. + */ +export function TaskNavColumn({ + title, + subtitle, + renderRow, +}: { + title: string; + subtitle: string; + renderRow: (spec: TaskIconSpec) => ReactNode; +}) { + return ( +
+
+ {title} + + {subtitle} + +
+ {/* A plain overflow container, like the real space sidebar — Radix's + ScrollArea sizes its viewport child to max-content, which stops rows + from shrinking and pushes the trailing content out of sight. */} +
+
+ {TASK_ICON_SPEC_GROUPS.map((group) => ( +
+ + {group.label} + + {group.specs.map((spec) => ( +
{renderRow(spec)}
+ ))} +
+ ))} +
+
+
+ ); +} diff --git a/packages/ui/src/features/design-system/taskIconSpecs.ts b/packages/ui/src/features/design-system/taskIconSpecs.ts new file mode 100644 index 0000000000..cbc1a54eee --- /dev/null +++ b/packages/ui/src/features/design-system/taskIconSpecs.ts @@ -0,0 +1,256 @@ +import type { TaskIconProps } from "@posthog/ui/features/sidebar/components/items/TaskIcon"; + +/** + * One dummy task in a known state. The row's label is the *vocabulary* for that + * state rather than a task title: the page exists to agree on what each state + * means, and saying it in the row beats a legend the eye has to cross-reference. + * The phrases also run long enough to overflow a nav column, which is what + * makes the hover ticker worth looking at. + */ +export interface TaskIconSpec { + id: string; + vocab: string; + /** Dummy relative age, so the shipped row's trailing timestamp is realistic. */ + age: string; + props: TaskIconProps; +} + +/** A run of states that answer the same question, kept as a nav section. */ +export interface TaskIconSpecGroup { + label: string; + specs: TaskIconSpec[]; +} + +const SLACK_THREAD_URL = "https://posthog.slack.com/archives/C000/p000"; + +/** + * Every state `TaskIcon`'s cascade can land on, grouped by the question the + * icon is answering. Order inside a group follows the cascade's own priority, + * so a column read top-to-bottom is also a read of which state wins. + */ +export const TASK_ICON_SPEC_GROUPS: readonly TaskIconSpecGroup[] = [ + { + label: "Needs you", + specs: [ + { + id: "needs-permission", + vocab: "Needs permission — blocked, wants you now", + age: "2m", + props: { needsPermission: true }, + }, + { + id: "generating", + vocab: "Working — the agent is producing output right now", + age: "4m", + props: { isGenerating: true }, + }, + { + id: "unread", + vocab: "Unread — there is something here to read", + age: "just now", + props: { isUnread: true, prState: "open" }, + }, + ], + }, + { + label: "Local work", + specs: [ + { + id: "local-idle", + vocab: "Idle — nothing running, nothing changed", + age: "11m", + props: {}, + }, + { + id: "local-diff", + vocab: "Has changes — uncommitted work on the branch", + age: "18m", + props: { hasDiff: true }, + }, + { + id: "suspended", + vocab: "Suspended — parked, resume when you want", + age: "26m", + props: { isSuspended: true }, + }, + ], + }, + { + label: "Pull requests", + specs: [ + { + id: "pr-draft", + vocab: "Draft PR — open, not asking for review", + age: "41m", + props: { prState: "draft" }, + }, + { + id: "pr-open", + vocab: "PR ready — open and waiting on review", + age: "52m", + props: { prState: "open" }, + }, + { + id: "pr-merged", + vocab: "Merged — landed on the base branch", + age: "1h", + props: { prState: "merged" }, + }, + { + id: "pr-closed", + vocab: "PR closed — shut without merging", + age: "1h", + props: { prState: "closed" }, + }, + ], + }, + { + // Deliberately undramatic. Run mechanics — queued, claiming a sandbox, + // erroring out — are ours, not the reader's, so most of this group collapses + // into "working", "something to read", or silence. The task detail is where + // a run explains itself. + label: "Cloud runs", + specs: [ + { + id: "cloud-not-started", + vocab: "Not picked up yet — quiet, nothing has happened", + age: "2h", + props: { workspaceMode: "cloud" }, + }, + { + id: "cloud-queued", + vocab: "Starting — same as working, on purpose", + age: "2h", + props: { workspaceMode: "cloud", taskRunStatus: "queued" }, + }, + { + id: "cloud-running", + vocab: "Working — no mechanics, just magic", + age: "3h", + props: { workspaceMode: "cloud", taskRunStatus: "in_progress" }, + }, + { + id: "cloud-completed-unread", + vocab: "Done — output you haven't read", + age: "4h", + props: { + workspaceMode: "cloud", + taskRunStatus: "completed", + isUnread: true, + }, + }, + { + id: "cloud-completed-seen", + vocab: "Done and read — nothing owed, no badge of honour", + age: "4h", + props: { workspaceMode: "cloud", taskRunStatus: "completed" }, + }, + { + id: "cloud-cancelled", + vocab: "Stopped — quiet, it just isn't running", + age: "5h", + props: { workspaceMode: "cloud", taskRunStatus: "cancelled" }, + }, + { + id: "cloud-failed", + vocab: "Run broke — reads as unread, the detail explains why", + age: "6h", + props: { + workspaceMode: "cloud", + taskRunStatus: "failed", + isUnread: true, + }, + }, + ], + }, + { + // Every origin shares one source badge now, so this group is really a test + // of whether its tooltip is enough to tell them apart. + label: "Origins", + specs: [ + { + id: "origin-slack-local", + vocab: "From Slack — local run", + age: "8h", + props: { + originProduct: "slack", + slackThreadUrl: SLACK_THREAD_URL, + }, + }, + { + id: "origin-slack-running", + vocab: "From Slack — working", + age: "9h", + props: { + workspaceMode: "cloud", + taskRunStatus: "in_progress", + originProduct: "slack", + slackThreadUrl: SLACK_THREAD_URL, + }, + }, + { + id: "origin-signal-report", + vocab: "From Signals — working", + age: "11h", + props: { + workspaceMode: "cloud", + taskRunStatus: "in_progress", + originProduct: "signal_report", + }, + }, + { + id: "origin-signals-scout", + vocab: "From a Signals scout — ready", + age: "13h", + props: { + workspaceMode: "cloud", + taskRunStatus: "completed", + originProduct: "signals_scout", + }, + }, + { + id: "origin-support-queue", + vocab: "From the support queue — local run", + age: "16h", + props: { originProduct: "support_queue" }, + }, + { + id: "origin-session-summaries", + vocab: "From session summaries — ready", + age: "18h", + props: { + workspaceMode: "cloud", + taskRunStatus: "completed", + originProduct: "session_summaries", + }, + }, + { + id: "origin-error-tracking", + vocab: "From error tracking — its run broke, reads as unread", + age: "21h", + props: { + workspaceMode: "cloud", + taskRunStatus: "failed", + originProduct: "error_tracking", + isUnread: true, + }, + }, + { + id: "origin-eval-clusters", + vocab: "From evals — local run", + age: "1d", + props: { originProduct: "eval_clusters" }, + }, + { + id: "origin-automation", + vocab: "From an automation — starting", + age: "1d", + props: { + workspaceMode: "cloud", + taskRunStatus: "queued", + originProduct: "automation", + }, + }, + ], + }, +]; diff --git a/packages/ui/src/features/design-system/taskRows.tsx b/packages/ui/src/features/design-system/taskRows.tsx new file mode 100644 index 0000000000..9b4a191f3b --- /dev/null +++ b/packages/ui/src/features/design-system/taskRows.tsx @@ -0,0 +1,42 @@ +import type { TaskIconSpec } from "@posthog/ui/features/design-system/taskIconSpecs"; +import { TaskIcon } from "@posthog/ui/features/sidebar/components/items/TaskIcon"; +import { + TaskBadgeStack, + TaskStatusDot, + TaskStatusTooltips, +} from "@posthog/ui/features/sidebar/components/items/TaskStatusDot"; +import { taskDot } from "@posthog/ui/features/sidebar/components/items/taskStatusVocabulary"; +import { SidebarItem } from "@posthog/ui/features/sidebar/components/SidebarItem"; + +const TIMESTAMP_CLASS = "shrink-0 text-[11px] text-muted-foreground"; + +/** What ships today: the cascade's single glyph, with a timestamp trailing. */ +export function CurrentTaskRow({ spec }: { spec: TaskIconSpec }) { + return ( + } + label={spec.vocab} + endContent={{spec.age}} + /> + ); +} + +/** + * The proposal, built from the same components the space task list uses — so + * this page previews the real thing rather than a mock of it. Both columns use + * the real `SidebarItem`, so hovering a row tickers the label as it does in the + * app. + */ +export function CustomTaskRow({ spec }: { spec: TaskIconSpec }) { + return ( + + } + label={spec.vocab} + endContent={} + /> + + ); +} diff --git a/packages/ui/src/features/settings/components/SettingsPanel.tsx b/packages/ui/src/features/settings/components/SettingsPanel.tsx index 575c6c40ff..af6721af1f 100644 --- a/packages/ui/src/features/settings/components/SettingsPanel.tsx +++ b/packages/ui/src/features/settings/components/SettingsPanel.tsx @@ -2,6 +2,7 @@ import { ArrowLeft, ArrowsClockwise, Bell, + Bug, CaretRight, Code, CreditCard, @@ -246,7 +247,9 @@ export function SettingsPanel({ return ( setCategory(item.id)} /> @@ -254,6 +257,19 @@ export function SettingsPanel({ })} ))} + + {/* Debug surfaces are their own routes rather than settings + categories — they're app pages that happen to be reachable from + here, so they leave the settings shell instead of swapping the + pane. */} +
+ Debug + } + onClick={nav.navigateToDesignSystem} + /> +
@@ -317,12 +333,20 @@ export function SettingsPanel({ } interface SidebarNavItemProps { - item: SidebarItem; - isActive: boolean; + label: string; + icon: ReactNode; + hasChevron?: boolean; + isActive?: boolean; onClick: () => void; } -function SidebarNavItem({ item, isActive, onClick }: SidebarNavItemProps) { +function SidebarNavItem({ + label, + icon, + hasChevron, + isActive, + onClick, +}: SidebarNavItemProps) { return ( ); } diff --git a/packages/ui/src/features/sidebar/components/items/TaskStatusDot.tsx b/packages/ui/src/features/sidebar/components/items/TaskStatusDot.tsx new file mode 100644 index 0000000000..8369adc3da --- /dev/null +++ b/packages/ui/src/features/sidebar/components/items/TaskStatusDot.tsx @@ -0,0 +1,141 @@ +import { + Avatar, + AvatarFallback, + AvatarGroup, + cn, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@posthog/quill"; +import { + DOT_TONE_VAR, + type TaskDot, + type TaskStatusInput, + TONE_ICON_VAR, + taskBadges, +} from "@posthog/ui/features/sidebar/components/items/taskStatusVocabulary"; +import { DotRingSpinner } from "@posthog/ui/primitives/DotRingSpinner"; +import type { ReactElement, ReactNode } from "react"; + +const DOT_SIZE = 8; +// Exactly the plain dot's box. Anything larger and a working row's label starts +// further right than its neighbours' — the icon column has to hold one width or +// the list stops looking like a list. +const SPINNER_SIZE = DOT_SIZE; +// Enough to still find the dot if you look for it, not enough to count as one of +// the list's live rows. +const FAINT_OPACITY = 0.4; +// One provider per row, so a row's dot and badges share a hover delay and handing +// off between them doesn't re-wait. +const TOOLTIP_DELAY_MS = 200; + +/** + * A label-only tooltip. Two things keep it out of the way, because one isn't + * enough: `disableHoverablePopup` stops Base UI holding the popup open when the + * pointer moves onto it, and `pointer-events-none` is the guarantee — a popup + * that can't receive the pointer can't be hovered, can't swallow a click meant + * for the row underneath, and can't have its text dragged into a selection. + */ +function RowTooltip({ + label, + side, + children, +}: { + label: string; + side: "top" | "right"; + children: ReactElement; +}) { + return ( + + + + {label} + + + ); +} + +/** + * A task's state as a single dot: blue wants a decision, amber is working or + * unread, grey is quiet. The trigger renders as a span because rows are + * ` + ); } @@ -54,14 +56,20 @@ export function ChannelBackRow({ channelId }: { channelId: string }) { const { channels, isLoading } = useChannels(); const current = channels.find((c) => c.id === channelId); const showStar = current != null && current.name !== PERSONAL_CHANNEL_NAME; + const glyph = channelGlyph(current?.name, { + size: 14, + space: spacesLayout, + className: "text-muted-foreground", + }); return (
{ track(ANALYTICS_EVENTS.CHANNEL_ACTION, { @@ -71,25 +79,27 @@ export function ChannelBackRow({ channelId }: { channelId: string }) { }); showChannelList(); }} - // Fixed height with an unconditional star well: sized off its - // contents, a starrable channel ran 4px taller than #me and - // everything below shifted on switch. No border — it's a row in - // the sidebar like the ones under it, not a control sitting on - // top. - className="flex h-8 w-full items-center gap-1.5 rounded-md px-2 text-left transition-colors hover:bg-fill-hover" + // Quill's own height and radius, so this reads as one of the rows + // under it rather than a control sitting on top. The star well is + // unconditional (see the reserved span below): sized off its + // contents, a starrable channel ran taller than #me and everything + // below shifted on switch. + className="w-full gap-1.5 text-left" > - - {channelGlyph(current?.name, { - size: 14, - space: spacesLayout, - className: "text-muted-foreground", - })} - + {/* Only #me still has a glyph under the layout, and its well is + drawn only when there's something in it — an empty 16px column + in front of every other space's name is worse than the name + starting where the caret leaves off. */} + {glyph && ( + + {glyph} + + )} {current ? ( current.name @@ -102,7 +112,7 @@ export function ChannelBackRow({ channelId }: { channelId: string }) { )} - + } /> Back to spaces diff --git a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx index 8ea1c482e2..c3dd5529d9 100644 --- a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx +++ b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx @@ -18,8 +18,6 @@ import { Badge, Card, CardContent, - ChatMarker, - ChatMarkerContent, ChatMessageScroller, ChatMessageScrollerButton, ChatMessageScrollerContent, @@ -42,11 +40,7 @@ import { ThreadItemTimestamp, useChatMessageScroller, } from "@posthog/quill"; -import { - formatDaySeparatorLabel, - formatRelativeTimeShort, - getLocalDayKey, -} from "@posthog/shared"; +import { formatRelativeTimeShort } from "@posthog/shared"; import type { Task, TaskRunStatus, @@ -69,7 +63,6 @@ import { useInView } from "@posthog/ui/primitives/hooks/useInView"; import { Text } from "@radix-ui/themes"; import { Link } from "@tanstack/react-router"; import { - Fragment, memo, type ReactNode, useCallback, @@ -832,34 +825,19 @@ export function ChannelFeedView({ gutter they hug the scroll container and get clipped. */} {intro} - {entries.map((entry, index) => { - const previous = entries[index - 1]; - const showDayMarker = - !previous || - getLocalDayKey(previous.createdAt) !== - getLocalDayKey(entry.createdAt); - return ( - - {showDayMarker && ( - - - {formatDaySeparatorLabel(entry.createdAt, now)} - - - )} - {entry.kind === "task" ? ( - - ) : ( - - )} - - ); - })} + {entries.map((entry) => + entry.kind === "task" ? ( + + ) : ( + + ), + )} {pending.map((p) => ( {}, togglePin: () => {}, archive: () => {}, + remove: () => {}, }; function item(overrides: Partial = {}): ChannelItemModel { @@ -145,7 +146,7 @@ describe("ChannelItemRow", () => { expect(screen.queryByText(formatRelativeTimeShort(item().ts))).toBeNull(); }); - it("leaves a canvas its template glyph and timestamp, having no run", () => { + it("renders a canvas like a quiet task with its glyph in the badge stack", () => { renderRow( item({ key: "canvas:canvas-1", @@ -157,12 +158,28 @@ describe("ChannelItemRow", () => { ); expect( - screen.queryByRole("img", { name: "Nothing owed to you" }), - ).toBeNull(); - expect(screen.getByText(formatRelativeTimeShort(item().ts))).not.toBeNull(); + screen.getByRole("img", { name: "Nothing owed to you" }), + ).not.toBeNull(); + expect(screen.getByRole("img", { name: "Canvas" })).not.toBeNull(); + expect(screen.queryByText(formatRelativeTimeShort(item().ts))).toBeNull(); + }); + + it("marks a pinned row with the pin badge, alongside its status badges", () => { + mocks.status = { workspaceMode: "cloud" }; + + renderRow(item({ pinned: true })); + + expect(screen.getByRole("img", { name: "Pinned" })).not.toBeNull(); + expect(screen.getByRole("img", { name: "Cloud run" })).not.toBeNull(); }); - // The "…" button and right-click render the same item list from one + it("leaves an unpinned row without one", () => { + renderRow(item()); + + expect(screen.queryByRole("img", { name: "Pinned" })).toBeNull(); + }); + + // The hover card and right-click render the same item list from one // definition, so both are asserted against the same expectations. const MENU_ITEMS = [ "Pin", @@ -189,15 +206,19 @@ describe("ChannelItemRow", () => { ); } - it("opens the row menu from the … button", async () => { + /** Hovers the row and waits for its preview card, which opens on a delay. */ + async function openCard() { + await userEvent.hover(screen.getByText("Investigate signup drop-off")); + return screen.findByRole("button", { name: "Pin" }, { timeout: 2000 }); + } + + it("puts the row's actions in the hover card", async () => { renderWithMenu({}); - await userEvent.click(screen.getByRole("button", { name: "Task actions" })); + await openCard(); - // The popup mounts in a portal a tick later, so the first assertion waits. - expect(await screen.findByRole("menuitem", { name: "Pin" })).not.toBeNull(); for (const label of MENU_ITEMS) { - expect(screen.getByRole("menuitem", { name: label })).not.toBeNull(); + expect(screen.getByRole("button", { name: label })).not.toBeNull(); } }); @@ -214,36 +235,74 @@ describe("ChannelItemRow", () => { it("disables Add to Command Center when there is nowhere to put the task", async () => { renderWithMenu({ onAddToCommandCenter: undefined }); - await userEvent.click(screen.getByRole("button", { name: "Task actions" })); + await openCard(); + // Quill keeps a disabled button focusable, so the state is aria-disabled + // rather than the native attribute. expect( - await screen.findByRole("menuitem", { name: "Add to Command Center" }), - ).toHaveAttribute("data-disabled"); + screen.getByRole("button", { name: "Add to Command Center" }), + ).toHaveAttribute("aria-disabled", "true"); }); - it("renames from the menu", async () => { + it("renames from the hover card", async () => { const onRename = vi.fn(); renderWithMenu({ onRename }); - await userEvent.click(screen.getByRole("button", { name: "Task actions" })); - await userEvent.click( - await screen.findByRole("menuitem", { name: "Rename" }), - ); + await openCard(); + await userEvent.click(screen.getByRole("button", { name: "Rename" })); expect(onRename).toHaveBeenCalledOnce(); }); - it("gives a canvas no menu — it can't be archived, filed, or pinned to a cell", () => { + it("gives a canvas the actions it has: pin and delete, not archive or filing", async () => { + const canvas = item({ + key: "canvas:c1", + kind: "canvas", + id: "c1", + title: "Web analytics overview", + }); + render( + + + , + ); + + await userEvent.hover(screen.getByText("Web analytics overview")); + + expect( + await screen.findByRole("button", { name: "Pin" }, { timeout: 2000 }), + ).not.toBeNull(); + expect(screen.getByRole("button", { name: "Delete" })).not.toBeNull(); + // A canvas can't be archived, filed to a space, or given a command-centre + // cell, so those items aren't drawn at all rather than drawn dead. + for (const absent of ["Archive", "File to…", "Add to Command Center"]) { + expect(screen.queryByRole("button", { name: absent })).toBeNull(); + } + }); + + it("deletes a canvas from its menu", async () => { + const remove = vi.fn(); + const canvas = item({ + key: "canvas:c1", + kind: "canvas", + id: "c1", + title: "Web analytics overview", + }); render( , ); - expect(screen.queryByRole("button", { name: "Task actions" })).toBeNull(); + await userEvent.hover(screen.getByText("Web analytics overview")); + await userEvent.click( + await screen.findByRole("button", { name: "Delete" }, { timeout: 2000 }), + ); + + expect(remove).toHaveBeenCalledWith(canvas); }); }); diff --git a/packages/ui/src/features/canvas/components/ChannelItemRow.tsx b/packages/ui/src/features/canvas/components/ChannelItemRow.tsx index 4997ca84d3..4f69dbd3ef 100644 --- a/packages/ui/src/features/canvas/components/ChannelItemRow.tsx +++ b/packages/ui/src/features/canvas/components/ChannelItemRow.tsx @@ -1,29 +1,48 @@ import { PreviewCard } from "@base-ui/react/preview-card"; +import { ChatCircleIcon } from "@phosphor-icons/react"; import type { ChannelItemModel } from "@posthog/core/canvas/channelItems"; import { runStatusLabel, runStatusVariant, } from "@posthog/core/canvas/runStatus"; -import { Avatar, AvatarFallback, Badge } from "@posthog/quill"; +import { + Avatar, + AvatarFallback, + AvatarGroup, + Badge, + Card, + CardContent, + Item, + ItemContent, + ItemDescription, + ItemGroup, + ItemMedia, + ItemSeparator, + ItemTitle, + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@posthog/quill"; import { formatRelativeTimeShort } from "@posthog/shared"; import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar"; import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon"; import { TaskRowContextMenu, - TaskRowMenuButton, + TaskRowMenuList, type TaskRowMenuProps, } from "@posthog/ui/features/canvas/components/TaskRowMenu"; import { useChannelTaskStatus } from "@posthog/ui/features/canvas/hooks/useChannelTaskStatus"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; import { InlineEditInput } from "@posthog/ui/features/sidebar/components/items/TaskItem"; import { + PinnedBadge, TaskBadgeStack, TaskStatusDot, TaskStatusTooltips, } from "@posthog/ui/features/sidebar/components/items/TaskStatusDot"; import { taskDot } from "@posthog/ui/features/sidebar/components/items/taskStatusVocabulary"; import { SidebarItem } from "@posthog/ui/features/sidebar/components/SidebarItem"; -import type { ReactNode } from "react"; +import { type ReactNode, useState } from "react"; /** * What a row can do. One object per channel rather than closures per item, so @@ -33,22 +52,28 @@ export interface ChannelItemActions { open: (item: ChannelItemModel) => void; togglePin: (item: ChannelItemModel) => void; archive: (item: ChannelItemModel) => void; + /** Canvases only — a task is archived, not deleted. */ + remove: (item: ChannelItemModel) => void; } // The channel sidebar's own chrome. Deliberately not shared with the Code // sidebar's TaskItem: that one is still on the absolute gray scale, while these // rows use the theme's fill/foreground tokens. -const MENU_BUTTON_CLASS = - "hidden h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded text-muted-foreground transition-colors hover:bg-fill-hover hover:text-foreground group-hover:flex data-popup-open:flex data-popup-open:bg-fill-hover data-popup-open:text-foreground"; const TIMESTAMP_CLASS = "shrink-0 text-[11px] text-muted-foreground"; -// The badges stay put while the "…" button appears beside them. They used to -// give up the slot to a two-button toolbar; one button fits alongside, and a -// row's identity is worth more than the couple of pixels. -const TRAILING_CLASS = "flex shrink-0 items-center"; +// The badges own the trailing slot outright now that the actions have moved to +// the hover card — a row's identity is what you scan a task list for. The gap is +// between stacks (a pin, then the status badges), not within one. +const TRAILING_CLASS = "flex shrink-0 items-center gap-1"; -// A canvas has no run to report, so it keeps its template glyph. Tasks moved to -// the status dot, which is why only canvases still have an icon function. -function canvasIcon(item: ChannelItemModel): ReactNode { +/** + * What the card leads with. A canvas gets its template glyph in canvas violet; a + * task gets the chat glyph the sidebar uses for a task with nothing going on — + * before this, a task was shown wearing a canvas's icon. + */ +function previewGlyph(item: ChannelItemModel): ReactNode { + if (item.kind !== "canvas") { + return ; + } // Matches the schema's own default for boards saved before templating. return iconForTemplate(item.templateId ?? "freeform", { size: 15, @@ -61,6 +86,57 @@ function authorLabel(item: ChannelItemModel): string | null { return item.authorName; } +/** + * One badge in a row's trailing stack, named on hover like the ones + * `TaskBadgeStack` draws — the row's tooltip provider is already up, so this + * shares its open delay. + */ +function RowBadge({ label, children }: { label: string; children: ReactNode }) { + return ( + + + + {children} + + + } + /> + + {label} + + + ); +} + +/** + * A canvas's trailing stack: the pin, then its template glyph. Same stack as a + * task's badges — a pinned canvas reads the way a pinned task does. + */ +function CanvasBadgeStack({ + item, + pinned, +}: { + item: ChannelItemModel; + pinned?: boolean; +}) { + return ( + + {pinned ? : null} + + {/* Violet is the canvas colour everywhere else it appears — the + artifacts list, the thread panel, the pinned menu — so the badge says + "canvas" the same way they do. */} + {iconForTemplate(item.templateId ?? "freeform", { + size: 9, + className: "text-violet-9", + })} + + + ); +} + export function ChannelItemRow({ item, isActive, @@ -83,27 +159,38 @@ export function ChannelItemRow({ onEditCancel?: () => void; }) { const status = useChannelTaskStatus(item); + const [cardOpen, setCardOpen] = useState(false); + const [submenuOpen, setSubmenuOpen] = useState(false); const statusLabel = runStatusLabel(item.rawStatus); const author = authorLabel(item); - // A task's leading mark is its state (dot / working spinner); a canvas has no - // state, so it keeps the template glyph. The preview card always shows the - // glyph — it has room to say what the row is as well as how it's doing. - const icon = canvasIcon(item); - const rowIcon = status ? : icon; - // Canvases have no menu: they can't be archived, filed, or put in the command - // centre, which is most of it. - const menu: TaskRowMenuProps | null = - item.kind === "task" && onRename + // The row's leading mark is always the task-list state vocabulary. Canvases + // have no live run, so they use the quiet dot and move their glyph to the + // right-side identity stack. + const rowIcon = ; + const previewIcon = previewGlyph(item); + // A canvas gets the same menu with the items it actually has: pin, and delete + // instead of archive. Filing and command-centre cells are task-shaped, and the + // menu drops them rather than showing them dead. + const menu: TaskRowMenuProps = + item.kind === "canvas" ? { - taskId: item.id, - taskTitle: item.title, + kind: "canvas", + id: item.id, + title: item.title, + isPinned: item.pinned, + onTogglePin: () => actions.togglePin(item), + onDelete: () => actions.remove(item), + } + : { + kind: "task", + id: item.id, + title: item.title, isPinned: item.pinned, onAddToCommandCenter, onRename, onTogglePin: () => actions.togglePin(item), onArchive: () => actions.archive(item), - } - : null; + }; if (isEditing) { return ( @@ -121,7 +208,10 @@ export function ChannelItemRow({ // One tooltip provider per task row, shared by its dot and badges so moving // between them doesn't re-wait the open delay. Canvas rows have neither. const row = ( - + // Controlled so the card survives its own submenu: "File to…" opens in a + // portal outside the card, and the pointer moving there reads as leaving the + // card, which would take the menu down with it. + actions.open(item)} endContent={ - <> + {/* Badges take the timestamp's slot on a task row: the row's - identity (source, cloud, PR) is what you scan a task list - for, and the relative age is still in the preview card. */} + identity (pin, source, cloud, PR) is what you scan a task + list for, and the relative age is still in the preview + card. The pin joins whichever stack the row has, rather + than standing beside it as a badge of its own. */} {status ? ( - - - + + ) : item.kind === "canvas" ? ( + ) : ( - - {formatRelativeTimeShort(item.ts)} - - )} - {/* Every row action now lives behind the one button, so the - row at rest shows status rather than controls. */} - {menu && ( - + <> + {item.pinned && ( + + + + )} + + {formatRelativeTimeShort(item.ts)} + + )} - + } />
@@ -169,60 +265,82 @@ export function ChannelItemRow({ sideOffset={10} className="z-50" > - -
- - {icon} - -
-

- {item.title} -

-

- {item.kind === "canvas" ? "Canvas" : "Task"} · updated{" "} - {formatRelativeTimeShort(item.ts)} -

-
-
- {statusLabel && ( -
- - {statusLabel} - -
- )} - {author && ( -
- {item.authorUser ? ( - - ) : ( - - - {author.charAt(0).toUpperCase()} - - + {/* The card is quill's `Card` and `Item` parts throughout — the popup + itself carries no surface styling, so this window's hover card + matches every other card in the app rather than a hand-tuned + shadow of its own. */} + }> + + {/* ItemGroup's own gap is sized for cards of stacked panels, and + Item only reports its size through the class it takes — not a + data attribute the group can match — so the tight gap is set + here. */} + + + {previewIcon} + + {item.title} + + {item.kind === "canvas" ? "Canvas" : "Task"} · updated{" "} + {formatRelativeTimeShort(item.ts)} + + + + {statusLabel && ( + // `self-start` because ItemGroup is a stretching column and a + // full-width badge reads as a banner. + + {statusLabel} + + )} + {author && ( + <> + {/* Every section of the card gets the rule above it, + canvases included — the author is a different fact from + the thing's identity whether or not there are actions + under it. */} + + + + {item.authorUser ? ( + + ) : ( + + + {author.charAt(0).toUpperCase()} + + + )} + + + {author} + Created by + + + )} -
-

- {author} -

-

- Created by -

-
-
- )} + {/* The row's actions live here now: a row at rest shows its + status, and the card is already the surface you're pointing + at when you want to do something to it. */} + + setCardOpen(false)} + onSubmenuOpenChange={setSubmenuOpen} + /> + +
); - const tipped = status ? {row} : row; - // Right-click opens the same menu the "…" button does, so the two can't drift. - return menu ? ( - {tipped} - ) : ( - tipped - ); + const tipped = {row}; + // Right-click opens the same actions the hover card lists, from the same + // definition, so the two can't drift. + return {tipped}; } diff --git a/packages/ui/src/features/canvas/components/ChannelSidebar.test.tsx b/packages/ui/src/features/canvas/components/ChannelSidebar.test.tsx index 584b2ee4d8..bcd735dcc7 100644 --- a/packages/ui/src/features/canvas/components/ChannelSidebar.test.tsx +++ b/packages/ui/src/features/canvas/components/ChannelSidebar.test.tsx @@ -99,18 +99,18 @@ describe("ChannelSidebar", () => { what: "nothing has arrived yet", state: { items: [], isLoading: true }, shown: [] as string[], - hidden: ["Recent", "No matches", "Nothing here yet"], + hidden: ["Sessions", "No matches", "Nothing here yet"], }, { what: "the space is settled and genuinely empty", state: { items: [], isLoading: false }, shown: ["Nothing here yet"], - hidden: ["Recent", "No matches"], + hidden: ["Sessions", "No matches"], }, { what: "the space is settled with items", state: { items: [item()], isLoading: false }, - shown: ["Recent", "Investigate signup drop-off"], + shown: ["Sessions", "Investigate signup drop-off"], hidden: ["No matches", "Nothing here yet"], }, ])("shows one state when $what", ({ state, shown, hidden }) => { @@ -164,8 +164,7 @@ describe("ChannelSidebar", () => { }); }); -describe("ChannelSidebar recents grouping", () => { - // A fixed clock, so "Today" and "Yesterday" mean the same thing on every run. +describe("ChannelSidebar recents list", () => { const NOW = new Date(2026, 6, 29, 12); beforeEach(() => { @@ -179,7 +178,7 @@ describe("ChannelSidebar recents grouping", () => { vi.useRealTimers(); }); - it("splits recents into day sections, newest first", () => { + it("lists recents newest first without day separators", () => { mocks.items = [ item({ key: "task:a", @@ -209,12 +208,16 @@ describe("ChannelSidebar recents grouping", () => { renderSidebar(); - expect(screen.getByText("Today")).not.toBeNull(); - expect(screen.getByText("Yesterday")).not.toBeNull(); - expect(screen.getByText("Monday, July 20th")).not.toBeNull(); + expect(screen.getByText("Today's work")).not.toBeNull(); + expect(screen.getByText("Also today")).not.toBeNull(); + expect(screen.getByText("Yesterday's work")).not.toBeNull(); + expect(screen.getByText("Older work")).not.toBeNull(); + expect(screen.queryByText("Today")).toBeNull(); + expect(screen.queryByText("Yesterday")).toBeNull(); + expect(screen.queryByText("Monday, July 20th")).toBeNull(); }); - it("gives one day a single heading however many items it holds", () => { + it("keeps items from the same day as plain rows", () => { mocks.items = [ item({ key: "task:a", id: "a", ts: new Date(2026, 6, 29, 9).getTime() }), item({ key: "task:b", id: "b", ts: new Date(2026, 6, 29, 8).getTime() }), @@ -223,11 +226,18 @@ describe("ChannelSidebar recents grouping", () => { renderSidebar(); - expect(screen.getAllByText("Today")).toHaveLength(1); + expect(screen.getAllByText("Investigate signup drop-off")).toHaveLength(3); + expect(screen.queryByText("Today")).toBeNull(); }); - it("leaves pinned items ungrouped — that list is a shelf, not a timeline", () => { + it("lists pins in the one session list, ahead of newer items", () => { mocks.items = [ + item({ + key: "task:newer", + id: "newer", + title: "Filed this morning", + ts: new Date(2026, 6, 30, 9).getTime(), + }), item({ key: "task:pinned", id: "pinned", @@ -239,9 +249,14 @@ describe("ChannelSidebar recents grouping", () => { renderSidebar(); - // The pinned item is listed, but no day heading is drawn for it. - expect(screen.getByText("Kept at hand")).not.toBeNull(); - expect(screen.queryByText("Monday, July 20th")).toBeNull(); - expect(screen.queryByText("Today")).toBeNull(); + // No section of its own — a pin is a mark on a session, and the row's badge + // is what says so. + expect(screen.queryByText("Pinned")).toBeNull(); + const titles = screen + .getAllByText(/Kept at hand|Filed this morning/) + .map((el) => el.textContent); + // Older, but pinned: it sorts above the newer row rather than risking the + // recents cap. + expect(titles).toEqual(["Kept at hand", "Filed this morning"]); }); }); diff --git a/packages/ui/src/features/canvas/components/ChannelSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelSidebar.tsx index 3841d17add..af3fb724dc 100644 --- a/packages/ui/src/features/canvas/components/ChannelSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ChannelSidebar.tsx @@ -4,15 +4,11 @@ import { MagnifyingGlass, PackageIcon, } from "@phosphor-icons/react"; -import type { - ChannelItemModel, - CreatedByFilter, -} from "@posthog/core/canvas/channelItems"; +import type { CreatedByFilter } from "@posthog/core/canvas/channelItems"; import { filterChannelItems } from "@posthog/core/canvas/channelItems"; import { RUN_STATUS_FILTER_OPTIONS } from "@posthog/core/canvas/runStatus"; import { - ChatMarker, - ChatMarkerContent, + Button, cn, DropdownMenu, DropdownMenuContent, @@ -30,21 +26,18 @@ import { Skeleton, SkeletonText, } from "@posthog/quill"; -import { - formatDaySeparatorLabel, - getLocalDayKey, - LOOPS_FLAG, -} from "@posthog/shared"; +import { LOOPS_FLAG } from "@posthog/shared"; 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 { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; import { useCommandCenterStore } from "@posthog/ui/features/command-center/commandCenterStore"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { SidebarItem } from "@posthog/ui/features/sidebar/components/SidebarItem"; @@ -53,7 +46,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 { Fragment, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; const CREATED_BY_OPTIONS: readonly { value: CreatedByFilter; label: string }[] = [ @@ -62,11 +55,11 @@ const CREATED_BY_OPTIONS: readonly { value: CreatedByFilter; label: string }[] = { value: "others", label: "Other people" }, ] as const; -const HEADER_ICON_BUTTON_CLASS = - "flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-fill-hover hover:text-foreground"; - +// The header's icon buttons are quill's ghost button at the 20px scale; only the +// sticky state is ours, because quill styles the transient open state (hover, +// popup) but has no notion of "search is showing" or "a filter is applied". const cnHeaderButton = (active: boolean) => - cn(HEADER_ICON_BUTTON_CLASS, active && "bg-fill-selected text-foreground"); + cn("text-muted-foreground", active && "bg-fill-selected text-foreground"); const RECENTS_CAP = 30; const log = logger.scope("channel-sidebar"); @@ -78,6 +71,7 @@ function RecentSectionHeader({ onQueryChange, createdByFilter, onCreatedByChange, + showCreatedBy, statusFilter, onStatusChange, filtersActive, @@ -88,35 +82,39 @@ function RecentSectionHeader({ onQueryChange: (value: string) => void; createdByFilter: CreatedByFilter; onCreatedByChange: (value: CreatedByFilter) => void; + /** False in #me, where every session is yours and the filter says nothing. */ + showCreatedBy: boolean; statusFilter: TaskRunStatus | null; onStatusChange: (value: TaskRunStatus | null) => void; filtersActive: boolean; }) { return ( <> -
+
- Recent + Sessions
- + - + } /> - Created by - - onCreatedByChange(value as CreatedByFilter) - } - > - {CREATED_BY_OPTIONS.map((option) => ( - - {option.label} - - ))} - - + {/* #me holds only your own sessions, so "created by" can only ever + answer "you" — the whole group is dropped rather than shown with + two options that empty the list. */} + {showCreatedBy && ( + <> + Created by + + onCreatedByChange(value as CreatedByFilter) + } + > + {CREATED_BY_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + + )} Status onQueryChange(event.target.value)} placeholder="Search…" - aria-label="Search recent items" + aria-label="Search sessions" className="h-6 text-[12px]" />
@@ -183,7 +191,7 @@ const SKELETON_ROW_WIDTHS = [60, 80, 40, 75, 50, 66] as const; function ChannelItemsSkeleton() { return (
- {/* Stands in for the "Recent" MenuLabel, so it carries that label's scale. */} + {/* Stands in for the "Sessions" MenuLabel, so it carries that label's scale. */} ("anyone"); const [statusFilter, setStatusFilter] = useState(null); - const filtersActive = createdByFilter !== "anyone" || statusFilter !== null; + // Every session in #me is yours, so the author filter has nothing to sort by. + // The state survives a space switch, so the value is neutralised here as well + // as hidden — otherwise "Other people" carried in from a shared space would + // empty this list with no visible control to undo it. + const { channels } = useChannels(); + const isPersonalChannel = + channels.find((c) => c.id === channelId)?.name === PERSONAL_CHANNEL_NAME; + const createdBy: CreatedByFilter = isPersonalChannel + ? "anyone" + : createdByFilter; + const filtersActive = createdBy !== "anyone" || statusFilter !== null; const base = `/website/${channelId}`; // Activeness is a key comparison rather than a flag baked into each item, so @@ -277,39 +295,22 @@ export function ChannelSidebar({ channelId }: { channelId: string }) { return task ? `task:${task[1]}` : null; }, [pathname]); - const pinnedItems = useMemo(() => items.filter((i) => i.pinned), [items]); - const recentItems = useMemo( - () => - filterChannelItems( - items.filter((i) => !i.pinned), - { query, createdBy: createdByFilter, status: statusFilter, me }, - ).slice(0, RECENTS_CAP), - [items, query, createdByFilter, statusFilter, me], - ); - - // Recents are already newest-first, so grouping is a single pass that breaks - // whenever the calendar day changes. Pinned items are deliberately left - // ungrouped: that list is a shelf you put things on, not a timeline, and its - // order says nothing about when anything happened. - const recentDays = useMemo(() => { - const groups: { key: string; label: string; items: ChannelItemModel[] }[] = - []; - const now = new Date(); - for (const item of recentItems) { - const key = getLocalDayKey(item.ts); - const last = groups.at(-1); - if (last && last.key === key) { - last.items.push(item); - } else { - groups.push({ - key, - label: formatDaySeparatorLabel(item.ts, now), - items: [item], - }); - } - } - return groups; - }, [recentItems]); + // One list, pins included — a pin is a mark on a session, not a different kind + // of thing, and the row's own badge says so. They sort to the top because a pin + // is a request not to lose the thing: below the recency order it would fall off + // the end of the cap. + const recentItems = useMemo(() => { + const matching = filterChannelItems(items, { + query, + createdBy, + status: statusFilter, + me, + }); + return [ + ...matching.filter((i) => i.pinned), + ...matching.filter((i) => !i.pinned), + ].slice(0, RECENTS_CAP); + }, [items, query, createdBy, statusFilter, me]); const narrowed = filtersActive || searchOpen; const listState = listStateOf({ @@ -318,12 +319,9 @@ export function ChannelSidebar({ channelId }: { channelId: string }) { itemCount: items.length, narrowed, }); - // The list's two sections, which only exist once there are items. With - // everything pinned there's nothing left to list — but keep the header while - // it's narrowed, so you can undo whatever emptied it. - const showPinned = listState === "ready" && pinnedItems.length > 0; - const showRecent = - listState === "ready" && (items.some((i) => !i.pinned) || narrowed); + // The one section, which only exists once there are items — but its header + // stays while the list is narrowed, so you can undo whatever emptied it. + const showRecent = listState === "ready"; // The first free command-centre cell, or nothing if every cell is taken by a // task that still exists. @@ -376,8 +374,10 @@ 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. + // Label comes from the shared space-page table, so a sidebar row and the + // header breadcrumb for the same page can never disagree. No icon: this is a + // four-row list of words, and glyphs here only compete with the status dots + // in the sessions list below for the eye's attention. const sectionRow = ( page: ChannelPageKey, to: string, @@ -385,7 +385,6 @@ export function ChannelSidebar({ channelId }: { channelId: string }) { ) => (
+ {/* Starting a session is what you came here to do, so it leads the + pane's list of places rather than hiding behind one of them. */} + + void navigate({ + to: "/website/$channelId/new", + params: { channelId }, + }) + } + /> {sectionRow( "home", base, @@ -455,15 +467,6 @@ export function ChannelSidebar({ channelId }: { channelId: string }) { )} - {showPinned && ( - <> - Pinned -
- {pinnedItems.map(taskRow)} -
- - )} - {showRecent && ( <> {recentItems.length > 0 ? (
- {recentDays.map((day) => ( - - {/* The same separator the space feed uses for its day - breaks, so one window never names a day two ways. */} - - {day.label} - - {day.items.map(taskRow)} - - ))} + {recentItems.map(taskRow)}
) : ( diff --git a/packages/ui/src/features/canvas/components/ChannelsList.tsx b/packages/ui/src/features/canvas/components/ChannelsList.tsx index 57a7191976..1b648d1959 100644 --- a/packages/ui/src/features/canvas/components/ChannelsList.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsList.tsx @@ -143,7 +143,7 @@ function SpaceRowSurface({ {channel.name} + {/* `!mr-0` undoes quill's `.quill-button kbd { margin-right: -4px }`, + which is meant to let a shortcut hang into a button's own + padding. Here the row's inner span is `truncate` (overflow + hidden) and `ml-auto` eats every pixel of slack, so the hang + had nowhere to go and the last 4px of the hint was cut off. */} {hotkeySlot != null && ( - + {formatHotkey(`mod+${hotkeySlot}`)} )} @@ -757,7 +762,7 @@ function PersonalChannelRow({ hotkeySlot }: { hotkeySlot?: number }) { {PERSONAL_CHANNEL_NAME} {hotkeySlot != null && ( - + {formatHotkey(`mod+${hotkeySlot}`)} )} diff --git a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx index d9f75f0947..05f0d80851 100644 --- a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx @@ -234,6 +234,13 @@ export function ChannelsSidebar() { {channelsLayout ? ( <> + {/* Which project you're in is the outermost thing about this window, + so under the layout it sits above the nav row rather than in the + footer. Its menu opens downward, which is the right direction + from the top of a sidebar. */} + + + @@ -270,9 +277,13 @@ export function ChannelsSidebar() { - - - + {/* The code layout keeps it in the footer: that sidebar's top is the nav + section and task header, and there's no nav row to sit above. */} + {!channelsLayout && ( + + + + )} ); diff --git a/packages/ui/src/features/canvas/components/TaskRowMenu.tsx b/packages/ui/src/features/canvas/components/TaskRowMenu.tsx index ee00e4e5ba..4a9f5d10e8 100644 --- a/packages/ui/src/features/canvas/components/TaskRowMenu.tsx +++ b/packages/ui/src/features/canvas/components/TaskRowMenu.tsx @@ -1,5 +1,6 @@ -import { DotsThreeIcon } from "@phosphor-icons/react"; +import { CaretRightIcon } from "@phosphor-icons/react"; import { + Button, ContextMenu, ContextMenuContent, ContextMenuItem, @@ -8,41 +9,45 @@ import { ContextMenuSubTrigger, ContextMenuTrigger, DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuSub, - DropdownMenuSubTrigger, DropdownMenuTrigger, + Separator, } from "@posthog/quill"; import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useFileTaskToChannel } from "@posthog/ui/features/canvas/hooks/useFileTaskToChannel"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; -import { NestedButton } from "@posthog/ui/primitives/NestedButton"; import { type MenuFlyoutItem, MenuSubFlyout, SearchableMenuFlyout, } from "@posthog/ui/primitives/SearchableMenuFlyout"; -import type { ComponentType, ReactNode } from "react"; +import { type ComponentType, type ReactNode, useMemo } from "react"; /** - * What a task row's menu can do. The row owns the handlers because they're the - * same ones its list already has (pin, archive, rename inline); only filing — + * What a row's menu can do. The row owns the handlers because they're the same + * ones its list already has (pin, archive, delete, rename inline); only filing — * which needs the channel list and a mutation — belongs to the menu. + * + * Canvases share this menu but not all of it: they can be pinned and deleted, + * and they can't be filed to a space or given a command-centre cell, both of + * which are task-shaped. `kind` is what decides, so a canvas gets a menu of the + * actions it has rather than a full one with half its items dead. */ export interface TaskRowMenuProps { - taskId: string; - taskTitle: string; + kind: "task" | "canvas"; + id: string; + title: string; isPinned: boolean; /** The channel this task is already filed to, ticked in "File to…". */ channelId?: string; /** Absent when the command centre is full, which disables the item. */ onAddToCommandCenter?: () => void; - onRename: () => void; + /** Absent where there's no inline rename to open — canvases, for now. */ + onRename?: () => void; onTogglePin: () => void; - onArchive: () => void; + /** Tasks are archived; canvases are deleted (with an undo window). */ + onArchive?: () => void; + onDelete?: () => void; } // The two menus differ only in which primitives draw them, so the item list is @@ -60,13 +65,6 @@ interface MenuParts { SubTrigger: ComponentType<{ children: ReactNode }>; } -const DROPDOWN_PARTS: MenuParts = { - Item: DropdownMenuItem, - Separator: DropdownMenuSeparator, - Sub: DropdownMenuSub, - SubTrigger: DropdownMenuSubTrigger, -}; - const CONTEXT_PARTS: MenuParts = { Item: ContextMenuItem, Separator: ContextMenuSeparator, @@ -75,8 +73,8 @@ const CONTEXT_PARTS: MenuParts = { }; /** - * The task row's actions, in the order the native menu used: the two edits, then - * the two places a task can be sent, then the destructive one last. + * The row's actions, in the order the native menu used: the edits, then the + * places a task can be sent, then the destructive one last. */ function TaskRowMenuItems({ parts, @@ -92,7 +90,8 @@ function TaskRowMenuItems({ PROJECT_BLUEBIRD_FLAG, import.meta.env.DEV, ); - const { channels } = useChannels({ enabled: bluebirdEnabled }); + const isTask = menu.kind === "task"; + const { channels } = useChannels({ enabled: bluebirdEnabled && isTask }); const fileToChannel = useFileTaskToChannel(); const channelItems: MenuFlyoutItem[] = channels.map((channel) => ({ @@ -104,15 +103,19 @@ function TaskRowMenuItems({ return ( <> {menu.isPinned ? "Unpin" : "Pin"} - Rename - - - Add to Command Center - - {channelItems.length > 0 && ( + {menu.onRename && Rename} + {isTask && ( + <> + + + Add to Command Center + + + )} + {isTask && channelItems.length > 0 && ( <> @@ -123,53 +126,90 @@ function TaskRowMenuItems({ placeholder="Search spaces…" emptyLabel="No spaces" onSelect={(channelId) => - fileToChannel(channelId, menu.taskId, menu.taskTitle) + fileToChannel(channelId, menu.id, menu.title) } /> )} - - Archive + {menu.onArchive && ( + <> + + Archive + + )} + {menu.onDelete && ( + <> + + Delete + + )} ); } /** - * The row's "…" button. Only rendered on hover by the row, so it's a button that - * appears rather than one that dims — a row at rest shows its status, not its - * controls. + * The same actions as a plain list, for a surface that is already open — the + * row's hover card. Rows are quill buttons rather than menu items because + * nothing here is a popup: there's no menu root to give `DropdownMenuItem` its + * keyboard handling, and a button is what quill offers for a click target in a + * card. * - * A `NestedButton` rather than a ` + ), + Separator: () => , + Sub: ({ children }) => ( + + {children} + + ), + SubTrigger: ({ children }) => ( + + {children} + + + } + /> + ), + }), + [onAction, onSubmenuOpenChange], + ); + return ( - - {}} - > - - - } - /> - - - - +
+ +
); } diff --git a/packages/ui/src/features/canvas/components/channelGlyph.test.tsx b/packages/ui/src/features/canvas/components/channelGlyph.test.tsx index 27401260b1..2d01ef485c 100644 --- a/packages/ui/src/features/canvas/components/channelGlyph.test.tsx +++ b/packages/ui/src/features/canvas/components/channelGlyph.test.tsx @@ -1,4 +1,4 @@ -import { CubeIcon, HashIcon, LockSimpleIcon } from "@phosphor-icons/react"; +import { HashIcon, LockSimpleIcon } from "@phosphor-icons/react"; import type { ReactElement } from "react"; import { describe, expect, it } from "vitest"; import { channelGlyph, isPrivateChannel } from "./channelGlyph"; @@ -23,7 +23,6 @@ describe("isPrivateChannel", () => { describe("channelGlyph", () => { it.each([ ["channel", false, HashIcon], - ["space", true, CubeIcon], ["private space", true, LockSimpleIcon], ])("renders the %s glyph", (_, space, expectedIcon) => { const name = expectedIcon === LockSimpleIcon ? "me" : "engineering"; @@ -31,4 +30,10 @@ describe("channelGlyph", () => { expect(glyph.type).toBe(expectedIcon); }); + + // A shared space carries no mark at all: the cube said nothing the name + // didn't, and only the private one is worth calling out. + it("gives a shared space no glyph", () => { + expect(channelGlyph("engineering", { space: true })).toBeNull(); + }); }); diff --git a/packages/ui/src/features/canvas/components/channelGlyph.tsx b/packages/ui/src/features/canvas/components/channelGlyph.tsx index 8e790e3548..f571d66065 100644 --- a/packages/ui/src/features/canvas/components/channelGlyph.tsx +++ b/packages/ui/src/features/canvas/components/channelGlyph.tsx @@ -1,5 +1,4 @@ import { - CubeIcon, HashIcon, type IconWeight, LockSimpleIcon, @@ -24,8 +23,13 @@ export function isPrivateChannel(channelName: string | undefined): boolean { } /** - * A channel's leading glyph: a lock when it's private, otherwise a cube for the - * Spaces layout or a hash for legacy Channels. + * A channel's leading glyph: a lock when it's private, a hash under the legacy + * Channels layout, and nothing at all for a space. + * + * Spaces dropped their cube because it said nothing the name didn't — a column + * of identical marks is noise, and the only thing worth calling out in that list + * is the one space that isn't shared. The hash stays where it still separates a + * channel from the other things in that tree. */ export function channelGlyph( channelName: string | undefined, @@ -36,11 +40,8 @@ export function channelGlyph( space?: boolean; }, ): ReactNode { - const Icon = isPrivateChannel(channelName) - ? LockSimpleIcon - : opts?.space - ? CubeIcon - : HashIcon; + if (!isPrivateChannel(channelName) && opts?.space) return null; + const Icon = isPrivateChannel(channelName) ? LockSimpleIcon : HashIcon; return ( { void archiveTask({ taskId: item.id }); }, + // Canvases only, and through the shared undo window: the row disappears at + // once and the host isn't told until the toast expires, so an accidental + // delete costs nothing. + remove: (item) => { + if (item.kind !== "canvas") return; + deleteCanvasWithUndo({ + dashboardId: item.id, + channelId, + name: item.title, + surface: "sidebar", + invalidate: invalidateDashboards, + }); + }, }), - [channelId, navigate, setCanvasPinned, togglePin, archiveTask], + [ + channelId, + navigate, + setCanvasPinned, + togglePin, + archiveTask, + invalidateDashboards, + ], ); // A channel that isn't in the list will never resolve, so stop reporting diff --git a/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx b/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx index 945946005d..3ffb650845 100644 --- a/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx +++ b/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx @@ -25,7 +25,6 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, Item, - ItemActions, ItemContent, ItemDescription, ItemTitle, @@ -55,7 +54,6 @@ import { openExternalUrl } from "@posthog/ui/shell/openExternal"; import { isMac } from "@posthog/ui/utils/platform"; import { getPostHogUrl } from "@posthog/ui/utils/urls"; import { Avatar, Box } from "@radix-ui/themes"; -import { ChevronRightIcon } from "lucide-react"; import { useMemo, useState } from "react"; /** The account / project / org menu at the bottom of the sidebar. */ @@ -207,14 +205,10 @@ export function ProjectSwitcher() { {currentProject?.name ?? "No project selected"} - {impersonationExpiry - ? `Impersonating until ${impersonationExpiry}` - : (currentUser?.email ?? "No email")} + {impersonationExpiry && + `Impersonating until ${impersonationExpiry}`} - - - } /> diff --git a/packages/ui/src/features/sidebar/components/SidebarItem.tsx b/packages/ui/src/features/sidebar/components/SidebarItem.tsx index cb74d897a8..e1be24a5fa 100644 --- a/packages/ui/src/features/sidebar/components/SidebarItem.tsx +++ b/packages/ui/src/features/sidebar/components/SidebarItem.tsx @@ -64,7 +64,6 @@ export function SidebarItem({ type="button" className={cn( "group flex w-full cursor-default text-left text-[13px] leading-snug transition-colors", - "focus-visible:-outline-offset-2 focus-visible:outline-2 focus-visible:outline-accent-8", "disabled:opacity-100 data-active:bg-fill-selected data-selected:bg-(--gray-3)", isDimmed && "opacity-50", )} @@ -74,7 +73,7 @@ export function SidebarItem({ onDragStart={onDragStart} style={{ paddingLeft: getSidebarItemPaddingLeft(depth), - paddingRight: "8px", + paddingRight: "4px", }} onClick={onClick} onDoubleClick={onDoubleClick} diff --git a/packages/ui/src/features/sidebar/components/items/SidebarCountBadge.tsx b/packages/ui/src/features/sidebar/components/items/SidebarCountBadge.tsx index 39c720f005..9b407aec36 100644 --- a/packages/ui/src/features/sidebar/components/items/SidebarCountBadge.tsx +++ b/packages/ui/src/features/sidebar/components/items/SidebarCountBadge.tsx @@ -7,7 +7,8 @@ export function SidebarCountBadge({ count, title }: SidebarCountBadgeProps) { if (count <= 0) return null; return ( ); diff --git a/packages/ui/src/features/sidebar/components/items/TaskStatusDot.tsx b/packages/ui/src/features/sidebar/components/items/TaskStatusDot.tsx index 8369adc3da..c91daf55a1 100644 --- a/packages/ui/src/features/sidebar/components/items/TaskStatusDot.tsx +++ b/packages/ui/src/features/sidebar/components/items/TaskStatusDot.tsx @@ -1,3 +1,4 @@ +import { PushPin } from "@phosphor-icons/react"; import { Avatar, AvatarFallback, @@ -104,10 +105,38 @@ export function TaskStatusDot({ dot }: { dot: TaskDot }) { ); } -/** A task's identity as stacked avatars: source, cloud, and PR/branch. */ -export function TaskBadgeStack({ status }: { status: TaskStatusInput }) { +/** + * The pin, in the vocabulary's yellow: this row was put here on purpose. Lives + * with the badges because it belongs in their stack — pinned rows sit in the one + * list with everything else, so the badge is what says a row is pinned. + */ +export function PinnedBadge() { + return ( + + + + + + + + ); +} + +/** + * A task's identity as stacked avatars: the pin, then source, cloud, and + * PR/branch. The pin goes first, which in a reversed stack puts it leftmost and + * underneath — it says how the row got here, not what came out of it. + */ +export function TaskBadgeStack({ + status, + pinned, +}: { + status: TaskStatusInput; + pinned?: boolean; +}) { return ( + {pinned ? : null} {taskBadges(status).map(({ key, Icon, label, tone }) => ( {/* The tooltip names the badge on hover; `aria-label` is what names it diff --git a/packages/ui/src/primitives/CountBadge.tsx b/packages/ui/src/primitives/CountBadge.tsx index ffa24d0bdd..b6b500407a 100644 --- a/packages/ui/src/primitives/CountBadge.tsx +++ b/packages/ui/src/primitives/CountBadge.tsx @@ -1,6 +1,10 @@ import { cn } from "@posthog/quill"; -/** Unread counts read red; ambient "how much is parked here" counts stay grey. */ +/** + * Unread counts read yellow — the same amber the status dots use for "something + * is owed to you". Red is reserved for failure. Ambient "how much is parked + * here" counts stay grey. + */ export type CountBadgeTone = "notification" | "neutral"; interface CountBadgeProps { @@ -22,7 +26,9 @@ function countBadgeSizeClass(label: string): string { } const TONE_CLASS: Record = { - notification: "bg-(--red-9) text-(--gray-contrast)", + // `--amber-contrast` rather than the grey one: amber-9 is a light fill, so its + // readable foreground is dark in both themes. + notification: "bg-(--amber-9) text-(--amber-contrast)", // Theme tokens, not the absolute gray scale: these sit on chrome whose // lightness relationship to gray-N inverts between light and dark. neutral: "bg-muted text-muted-foreground", From 80e6d704956a98fc380fc606bbb5b52339a47968 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Thu, 30 Jul 2026 15:50:24 +0100 Subject: [PATCH 05/18] refactor(spaces): flatten the row hover card, confirm canvas deletes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card was carrying quill's card padding on top of its own sections, which read as a lot of air around four short lines. Its padding is off now and each section pays for its own inset, so the rules run edge to edge and the action rows highlight full width. - Deleting a canvas confirms first, with the same copy the artifacts grid and the canvas header use — it goes for everyone in the space. The undo window still follows the confirm. - A canvas waiting out that window flashes a red dot labelled "Deleting…". It stays in the list rather than vanishing, so the row has to say what is happening to it. - "File to…" opens on hover in the card, the way a submenu does on right-click. Delete is quill's destructive button. - Menu separators are gone from both surfaces. - The quiet dot reads "All caught up" rather than "Nothing owed to you". Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JrvaQrZNBNSexddG78u4Nt --- .../canvas/components/ChannelItemRow.test.tsx | 41 +++- .../canvas/components/ChannelItemRow.tsx | 194 ++++++++++++------ .../canvas/components/TaskRowMenu.tsx | 78 ++++--- .../components/items/taskStatusVocabulary.ts | 2 +- 4 files changed, 197 insertions(+), 118 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ChannelItemRow.test.tsx b/packages/ui/src/features/canvas/components/ChannelItemRow.test.tsx index 610aa5b947..dc3de7529c 100644 --- a/packages/ui/src/features/canvas/components/ChannelItemRow.test.tsx +++ b/packages/ui/src/features/canvas/components/ChannelItemRow.test.tsx @@ -24,6 +24,7 @@ vi.mock("@posthog/ui/features/feature-flags/useFeatureFlag", () => ({ useFeatureFlag: () => true, })); +import { usePendingCanvasDeleteStore } from "@posthog/ui/features/canvas/stores/pendingCanvasDeleteStore"; import { ChannelItemRow } from "./ChannelItemRow"; const actions = { @@ -61,6 +62,7 @@ function renderRow(model: ChannelItemModel) { beforeEach(() => { mocks.status = null; + usePendingCanvasDeleteStore.setState({ pending: {} }); }); describe("ChannelItemRow", () => { @@ -92,7 +94,7 @@ describe("ChannelItemRow", () => { // hours, so the PR's existence has to win over the run's claim. "a run still babysitting CI behind an open PR", { taskRunStatus: "in_progress" as const, prState: "open" as const }, - "Nothing owed to you", + "All caught up", ], [ "a run whose PR url is known but state isn't", @@ -100,7 +102,7 @@ describe("ChannelItemRow", () => { taskRunStatus: "in_progress" as const, prUrl: "https://github.com/PostHog/code/pull/1", }, - "Nothing owed to you", + "All caught up", ], [ // A live local session is the agent typing right now, which no PR overrides. @@ -112,9 +114,9 @@ describe("ChannelItemRow", () => { "a merged PR", { prState: "merged" as const }, // PR state lives on the badge, so the dot stays quiet. - "Nothing owed to you", + "All caught up", ], - ["an idle task", {}, "Nothing owed to you"], + ["an idle task", {}, "All caught up"], ])("labels %s", (_case, status: TaskStatusInput, label) => { mocks.status = status; @@ -157,9 +159,7 @@ describe("ChannelItemRow", () => { }), ); - expect( - screen.getByRole("img", { name: "Nothing owed to you" }), - ).not.toBeNull(); + expect(screen.getByRole("img", { name: "All caught up" })).not.toBeNull(); expect(screen.getByRole("img", { name: "Canvas" })).not.toBeNull(); expect(screen.queryByText(formatRelativeTimeShort(item().ts))).toBeNull(); }); @@ -254,6 +254,21 @@ describe("ChannelItemRow", () => { expect(onRename).toHaveBeenCalledOnce(); }); + it("flashes a red dot while a canvas waits out its delete-undo window", () => { + const canvas = item({ + key: "canvas:c1", + kind: "canvas", + id: "c1", + title: "Web analytics overview", + }); + usePendingCanvasDeleteStore.getState().markPending("c1"); + + renderRow(canvas); + + expect(screen.getByRole("img", { name: "Deleting…" })).not.toBeNull(); + expect(screen.queryByRole("img", { name: "All caught up" })).toBeNull(); + }); + it("gives a canvas the actions it has: pin and delete, not archive or filing", async () => { const canvas = item({ key: "canvas:c1", @@ -272,7 +287,7 @@ describe("ChannelItemRow", () => { expect( await screen.findByRole("button", { name: "Pin" }, { timeout: 2000 }), ).not.toBeNull(); - expect(screen.getByRole("button", { name: "Delete" })).not.toBeNull(); + expect(screen.getByRole("button", { name: "Delete…" })).not.toBeNull(); // A canvas can't be archived, filed to a space, or given a command-centre // cell, so those items aren't drawn at all rather than drawn dead. for (const absent of ["Archive", "File to…", "Add to Command Center"]) { @@ -280,7 +295,7 @@ describe("ChannelItemRow", () => { } }); - it("deletes a canvas from its menu", async () => { + it("confirms before deleting a canvas — it goes for the whole space", async () => { const remove = vi.fn(); const canvas = item({ key: "canvas:c1", @@ -300,7 +315,13 @@ describe("ChannelItemRow", () => { await userEvent.hover(screen.getByText("Web analytics overview")); await userEvent.click( - await screen.findByRole("button", { name: "Delete" }, { timeout: 2000 }), + await screen.findByRole("button", { name: "Delete…" }, { timeout: 2000 }), + ); + + // The menu item only opens the confirm; nothing is deleted until it is. + expect(remove).not.toHaveBeenCalled(); + await userEvent.click( + await screen.findByRole("button", { name: /^Delete$/ }), ); expect(remove).toHaveBeenCalledWith(canvas); diff --git a/packages/ui/src/features/canvas/components/ChannelItemRow.tsx b/packages/ui/src/features/canvas/components/ChannelItemRow.tsx index 4f69dbd3ef..fcd51132b5 100644 --- a/packages/ui/src/features/canvas/components/ChannelItemRow.tsx +++ b/packages/ui/src/features/canvas/components/ChannelItemRow.tsx @@ -6,12 +6,19 @@ import { runStatusVariant, } from "@posthog/core/canvas/runStatus"; import { + AlertDialog, + AlertDialogClose, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, Avatar, AvatarFallback, AvatarGroup, Badge, + Button, Card, - CardContent, Item, ItemContent, ItemDescription, @@ -32,6 +39,7 @@ import { type TaskRowMenuProps, } from "@posthog/ui/features/canvas/components/TaskRowMenu"; import { useChannelTaskStatus } from "@posthog/ui/features/canvas/hooks/useChannelTaskStatus"; +import { useIsCanvasPendingDelete } from "@posthog/ui/features/canvas/stores/pendingCanvasDeleteStore"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; import { InlineEditInput } from "@posthog/ui/features/sidebar/components/items/TaskItem"; import { @@ -40,7 +48,10 @@ import { TaskStatusDot, TaskStatusTooltips, } from "@posthog/ui/features/sidebar/components/items/TaskStatusDot"; -import { taskDot } from "@posthog/ui/features/sidebar/components/items/taskStatusVocabulary"; +import { + type TaskDot, + taskDot, +} from "@posthog/ui/features/sidebar/components/items/taskStatusVocabulary"; import { SidebarItem } from "@posthog/ui/features/sidebar/components/SidebarItem"; import { type ReactNode, useState } from "react"; @@ -81,6 +92,18 @@ function previewGlyph(item: ChannelItemModel): ReactNode { }); } +/** + * A canvas waiting out its delete-undo window. Red and flashing because it is + * the one row state that is about to stop existing — everything else in this + * vocabulary is something you can come back to. + */ +const DELETING_DOT: TaskDot = { + tone: "red", + style: "solid", + pulse: true, + label: "Deleting…", +}; + function authorLabel(item: ChannelItemModel): string | null { if (item.authorUser) return userDisplayName(item.authorUser); return item.authorName; @@ -161,12 +184,19 @@ export function ChannelItemRow({ const status = useChannelTaskStatus(item); const [cardOpen, setCardOpen] = useState(false); const [submenuOpen, setSubmenuOpen] = useState(false); + const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); + // A canvas inside its undo window stays in the list rather than vanishing and + // reappearing on Undo, so the row has to say what's happening to it. + const deleting = useIsCanvasPendingDelete(item.id); const statusLabel = runStatusLabel(item.rawStatus); const author = authorLabel(item); // The row's leading mark is always the task-list state vocabulary. Canvases // have no live run, so they use the quiet dot and move their glyph to the - // right-side identity stack. - const rowIcon = ; + // right-side identity stack — except while one is being deleted, which is the + // one thing a canvas row has to shout. + const rowIcon = ( + + ); const previewIcon = previewGlyph(item); // A canvas gets the same menu with the items it actually has: pin, and delete // instead of archive. Filing and command-centre cells are task-shaped, and the @@ -179,7 +209,9 @@ export function ChannelItemRow({ title: item.title, isPinned: item.pinned, onTogglePin: () => actions.togglePin(item), - onDelete: () => actions.remove(item), + // Confirm first, like the canvas menus in the artifacts grid and the + // canvas header: the canvas and its history go for everyone. + onDelete: () => setConfirmDeleteOpen(true), } : { kind: "task", @@ -268,71 +300,67 @@ export function ChannelItemRow({ {/* The card is quill's `Card` and `Item` parts throughout — the popup itself carries no surface styling, so this window's hover card matches every other card in the app rather than a hand-tuned - shadow of its own. */} - }> - - {/* ItemGroup's own gap is sized for cards of stacked panels, and - Item only reports its size through the class it takes — not a - data attribute the group can match — so the tight gap is set - here. */} - - - {previewIcon} - - {item.title} - - {item.kind === "canvas" ? "Canvas" : "Task"} · updated{" "} - {formatRelativeTimeShort(item.ts)} - - - - {statusLabel && ( - // `self-start` because ItemGroup is a stretching column and a - // full-width badge reads as a banner. - + shadow of its own. The card's own padding is off (`gap-0 py-0`): + each section pays for its own inset, which is what lets the rules + run edge to edge and the action rows highlight full width. */} + } + > + + + {previewIcon} + + {item.title} + + {item.kind === "canvas" ? "Canvas" : "Task"} · updated{" "} + {formatRelativeTimeShort(item.ts)} + + + + {statusLabel && ( +
+ {statusLabel} - )} - {author && ( - <> - {/* Every section of the card gets the rule above it, - canvases included — the author is a different fact from - the thing's identity whether or not there are actions - under it. */} - - - - {item.authorUser ? ( - - ) : ( - - - {author.charAt(0).toUpperCase()} - - - )} - - - {author} - Created by - - - - )} - {/* The row's actions live here now: a row at rest shows its - status, and the card is already the surface you're pointing - at when you want to do something to it. */} - +
+ )} + {author && ( + <> + {/* Every section of the card gets the rule above it, canvases + included — the author is a different fact from the thing's + identity whether or not there are actions under it. */} + + + + {item.authorUser ? ( + + ) : ( + + + {author.charAt(0).toUpperCase()} + + + )} + + + {author} + Created by + + + + )} + {/* The row's actions live here now: a row at rest shows its + status, and the card is already the surface you're pointing at + when you want to do something to it. */} + +
setCardOpen(false)} onSubmenuOpenChange={setSubmenuOpen} /> - - +
+
@@ -342,5 +370,43 @@ export function ChannelItemRow({ const tipped = {row}; // Right-click opens the same actions the hover card lists, from the same // definition, so the two can't drift. - return {tipped}; + return ( + <> + {tipped} + {/* The same confirm the artifacts grid and the canvas header show: a + canvas goes for everyone in the space, so it isn't a one-click action + however small the row is. The undo window still follows. */} + + + + Delete canvas + + Delete {item.title}? Its code + and version history go for everyone in the space. You get a few + seconds to undo, then it's permanent. + + + + + Cancel + + } + /> + + + + + + ); } diff --git a/packages/ui/src/features/canvas/components/TaskRowMenu.tsx b/packages/ui/src/features/canvas/components/TaskRowMenu.tsx index 4a9f5d10e8..f3e3e4310e 100644 --- a/packages/ui/src/features/canvas/components/TaskRowMenu.tsx +++ b/packages/ui/src/features/canvas/components/TaskRowMenu.tsx @@ -4,13 +4,11 @@ import { ContextMenu, ContextMenuContent, ContextMenuItem, - ContextMenuSeparator, ContextMenuSub, ContextMenuSubTrigger, ContextMenuTrigger, DropdownMenu, DropdownMenuTrigger, - Separator, } from "@posthog/quill"; import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; @@ -58,16 +56,15 @@ interface MenuParts { Item: ComponentType<{ children: ReactNode; disabled?: boolean; + variant?: "default" | "destructive"; onClick?: () => void; }>; - Separator: ComponentType>; Sub: ComponentType<{ children: ReactNode }>; SubTrigger: ComponentType<{ children: ReactNode }>; } const CONTEXT_PARTS: MenuParts = { Item: ContextMenuItem, - Separator: ContextMenuSeparator, Sub: ContextMenuSub, SubTrigger: ContextMenuSubTrigger, }; @@ -83,7 +80,7 @@ function TaskRowMenuItems({ parts: MenuParts; menu: TaskRowMenuProps; }) { - const { Item, Separator, Sub, SubTrigger } = parts; + const { Item, Sub, SubTrigger } = parts; // "File to…" is a Project Bluebird feature; gate the channel fetch behind the // flag so neither the submenu nor its request reaches ungated users. const bluebirdEnabled = useFeatureFlag( @@ -105,45 +102,35 @@ function TaskRowMenuItems({ {menu.isPinned ? "Unpin" : "Pin"} {menu.onRename && Rename} {isTask && ( - <> - - - Add to Command Center - - + + Add to Command Center + )} {isTask && channelItems.length > 0 && ( - <> - - - File to… - - - fileToChannel(channelId, menu.id, menu.title) - } - /> - - - - )} - {menu.onArchive && ( - <> - - Archive - + + File to… + + + fileToChannel(channelId, menu.id, menu.title) + } + /> + + )} + {menu.onArchive && Archive} + {/* The ellipsis is the promise that a confirm follows — deleting a canvas + takes it away from everyone in the space. */} {menu.onDelete && ( - <> - - Delete - + + Delete… + )} ); @@ -171,9 +158,9 @@ export function TaskRowMenuList({ }) { const parts: MenuParts = useMemo( () => ({ - Item: ({ children, disabled, onClick }) => ( + Item: ({ children, disabled, variant, onClick }) => (
))} - - {/* Debug surfaces are their own routes rather than settings - categories — they're app pages that happen to be reachable from - here, so they leave the settings shell instead of swapping the - pane. */} -
- Debug - } - onClick={nav.navigateToDesignSystem} - /> -
@@ -333,20 +317,12 @@ export function SettingsPanel({ } interface SidebarNavItemProps { - label: string; - icon: ReactNode; - hasChevron?: boolean; - isActive?: boolean; + item: SidebarItem; + isActive: boolean; onClick: () => void; } -function SidebarNavItem({ - label, - icon, - hasChevron, - isActive, - onClick, -}: SidebarNavItemProps) { +function SidebarNavItem({ item, isActive, onClick }: SidebarNavItemProps) { return ( ); } diff --git a/packages/ui/src/router/navigationBridge.ts b/packages/ui/src/router/navigationBridge.ts index f3270163b7..4360eda430 100644 --- a/packages/ui/src/router/navigationBridge.ts +++ b/packages/ui/src/router/navigationBridge.ts @@ -195,10 +195,6 @@ export function navigateToCommandCenter(): void { track(ANALYTICS_EVENTS.COMMAND_CENTER_VIEWED); } -export function navigateToDesignSystem(): void { - void getRouterOrNull()?.navigate({ to: "/design-system" }); -} - export function navigateToSkills(): void { void getRouterOrNull()?.navigate({ to: "/skills" }); } diff --git a/packages/ui/src/router/routeTree.gen.ts b/packages/ui/src/router/routeTree.gen.ts index 2f7a631037..98bd7a2d28 100644 --- a/packages/ui/src/router/routeTree.gen.ts +++ b/packages/ui/src/router/routeTree.gen.ts @@ -13,7 +13,6 @@ import { Route as WebsiteRouteImport } from './routes/website' import { Route as UsageRouteImport } from './routes/usage' import { Route as SkillsRouteImport } from './routes/skills' import { Route as McpServersRouteImport } from './routes/mcp-servers' -import { Route as DesignSystemRouteImport } from './routes/design-system' import { Route as CommandCenterRouteImport } from './routes/command-center' import { Route as IndexRouteImport } from './routes/index' import { Route as WebsiteIndexRouteImport } from './routes/website/index' @@ -101,11 +100,6 @@ const McpServersRoute = McpServersRouteImport.update({ path: '/mcp-servers', getParentRoute: () => rootRouteImport, } as any) -const DesignSystemRoute = DesignSystemRouteImport.update({ - id: '/design-system', - path: '/design-system', - getParentRoute: () => rootRouteImport, -} as any) const CommandCenterRoute = CommandCenterRouteImport.update({ id: '/command-center', path: '/command-center', @@ -462,7 +456,6 @@ const CodeAgentsApplicationsIdOrSlugSessionsSessionIdRoute = export interface FileRoutesByFullPath { '/': typeof IndexRoute '/command-center': typeof CommandCenterRoute - '/design-system': typeof DesignSystemRoute '/mcp-servers': typeof McpServersRoute '/skills': typeof SkillsRoute '/usage': typeof UsageRoute @@ -535,7 +528,6 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/command-center': typeof CommandCenterRoute - '/design-system': typeof DesignSystemRoute '/mcp-servers': typeof McpServersRoute '/skills': typeof SkillsRoute '/usage': typeof UsageRoute @@ -597,7 +589,6 @@ export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/command-center': typeof CommandCenterRoute - '/design-system': typeof DesignSystemRoute '/mcp-servers': typeof McpServersRoute '/skills': typeof SkillsRoute '/usage': typeof UsageRoute @@ -672,7 +663,6 @@ export interface FileRouteTypes { fullPaths: | '/' | '/command-center' - | '/design-system' | '/mcp-servers' | '/skills' | '/usage' @@ -745,7 +735,6 @@ export interface FileRouteTypes { to: | '/' | '/command-center' - | '/design-system' | '/mcp-servers' | '/skills' | '/usage' @@ -806,7 +795,6 @@ export interface FileRouteTypes { | '__root__' | '/' | '/command-center' - | '/design-system' | '/mcp-servers' | '/skills' | '/usage' @@ -880,7 +868,6 @@ export interface FileRouteTypes { export interface RootRouteChildren { IndexRoute: typeof IndexRoute CommandCenterRoute: typeof CommandCenterRoute - DesignSystemRoute: typeof DesignSystemRoute McpServersRoute: typeof McpServersRoute SkillsRoute: typeof SkillsRoute UsageRoute: typeof UsageRoute @@ -930,13 +917,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof McpServersRouteImport parentRoute: typeof rootRouteImport } - '/design-system': { - id: '/design-system' - path: '/design-system' - fullPath: '/design-system' - preLoaderRoute: typeof DesignSystemRouteImport - parentRoute: typeof rootRouteImport - } '/command-center': { id: '/command-center' path: '/command-center' @@ -1640,7 +1620,6 @@ const CodeLoopsLoopIdRouteWithChildren = CodeLoopsLoopIdRoute._addFileChildren( const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, CommandCenterRoute: CommandCenterRoute, - DesignSystemRoute: DesignSystemRoute, McpServersRoute: McpServersRoute, SkillsRoute: SkillsRoute, UsageRoute: UsageRoute, diff --git a/packages/ui/src/router/routes/design-system.tsx b/packages/ui/src/router/routes/design-system.tsx deleted file mode 100644 index 3f86ec1c7f..0000000000 --- a/packages/ui/src/router/routes/design-system.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { DesignSystemView } from "@posthog/ui/features/design-system/DesignSystemView"; -import { - AppPageSkeleton, - withRouteSkeleton, -} from "@posthog/ui/router/routeSkeletons"; -import { createFileRoute } from "@tanstack/react-router"; - -export const Route = createFileRoute("/design-system")({ - component: DesignSystemView, - ...withRouteSkeleton(AppPageSkeleton), -}); diff --git a/packages/ui/src/router/useAppView.ts b/packages/ui/src/router/useAppView.ts index f4788a9406..3b623ccbf0 100644 --- a/packages/ui/src/router/useAppView.ts +++ b/packages/ui/src/router/useAppView.ts @@ -19,7 +19,6 @@ export type AppViewType = | "command-center" | "skills" | "mcp-servers" - | "design-system" | "settings"; export interface AppView { @@ -81,8 +80,6 @@ function deriveFromMatches(matches: Match[]): AppView { case "/mcp-servers": case "/website/mcp-servers": return { type: "mcp-servers" }; - case "/design-system": - return { type: "design-system" }; case "/settings/$category": case "/settings/": return { type: "settings" }; diff --git a/packages/ui/src/styles/globals.css b/packages/ui/src/styles/globals.css index 8166787bdb..7404121ac8 100644 --- a/packages/ui/src/styles/globals.css +++ b/packages/ui/src/styles/globals.css @@ -423,7 +423,7 @@ body:has(.rt-DialogOverlay[data-state="open"]) [data-quill-portal] { this composites without repainting — and unlike the braille spinner it isn't a font glyph, so it stays circular at any size and in any typeface. `ph-dot-ring 900ms` must stay in step with RING_DURATION_MS in - features/design-system/DotRingSpinner.tsx, which spaces the delays. */ + primitives/DotRingSpinner.tsx, which spaces the delays. */ @keyframes ph-dot-ring { 0%, 100% { From 2695c1ad42f17ed83e7b3df29325a5cf02a7bd78 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Fri, 31 Jul 2026 10:09:58 +0100 Subject: [PATCH 10/18] move traffic lights to align with other items at 0 zoom --- apps/code/src/main/window.ts | 7 +++++-- .../ui/src/features/sidebar/components/ProjectSwitcher.tsx | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/code/src/main/window.ts b/apps/code/src/main/window.ts index 62edd9f0ea..48367c7c93 100644 --- a/apps/code/src/main/window.ts +++ b/apps/code/src/main/window.ts @@ -208,11 +208,14 @@ export function createWindow(): void { const platformWindowConfig = process.platform === "darwin" ? { - titleBarStyle: "hiddenInset" as const, + // "hidden", not "hiddenInset": hiddenInset keeps macOS's own inset and + // ignores trafficLightPosition's y, which parked the dots near the + // bottom of the bar. "hidden" honours the position we ask for. + titleBarStyle: "hidden" as const, // Centre the traffic lights vertically with the title bar's back/forward // buttons (40px bar, 24px buttons → centre at y=20; 12px dots → top at 14). // x mirrors y so the inset from the top and the left match. - trafficLightPosition: { x: 14, y: 14 }, + trafficLightPosition: { x: 14, y: 12 }, // Exposes the titlebar-area-* CSS env vars so the renderer can // clear the traffic lights exactly; their size varies by macOS // version (bigger on Tahoe), so it must not hardcode a width. diff --git a/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx b/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx index 3ffb650845..ad75fb92ca 100644 --- a/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx +++ b/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx @@ -198,7 +198,7 @@ export function ProjectSwitcher() { render={ From aa01254c19a9e92f03a25a159191e3674a9308f7 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Fri, 31 Jul 2026 10:23:51 +0100 Subject: [PATCH 11/18] fix spacing around project picker --- packages/ui/src/features/canvas/components/ChannelNav.tsx | 2 +- .../ui/src/features/canvas/components/ChannelSidebar.tsx | 2 +- .../ui/src/features/sidebar/components/ProjectSwitcher.tsx | 2 +- packages/ui/src/router/routes/__root.tsx | 5 ++--- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ChannelNav.tsx b/packages/ui/src/features/canvas/components/ChannelNav.tsx index fe76afeb19..1349e33119 100644 --- a/packages/ui/src/features/canvas/components/ChannelNav.tsx +++ b/packages/ui/src/features/canvas/components/ChannelNav.tsx @@ -165,7 +165,7 @@ export function ChannelNav() { // cannot do that — the skip window is provider state, and isolated // providers never share it. -
+
diff --git a/packages/ui/src/features/canvas/components/ChannelSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelSidebar.tsx index 6a057aaa75..a2e8bb520b 100644 --- a/packages/ui/src/features/canvas/components/ChannelSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ChannelSidebar.tsx @@ -393,7 +393,7 @@ export function ChannelSidebar({ channelId }: { channelId: string }) { ); return ( -
+
diff --git a/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx b/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx index ad75fb92ca..6b5d8880f3 100644 --- a/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx +++ b/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx @@ -198,7 +198,7 @@ export function ProjectSwitcher() { render={ diff --git a/packages/ui/src/router/routes/__root.tsx b/packages/ui/src/router/routes/__root.tsx index ff418538cc..fe2122fdad 100644 --- a/packages/ui/src/router/routes/__root.tsx +++ b/packages/ui/src/router/routes/__root.tsx @@ -379,7 +379,6 @@ function RootLayout() { From 060a99520849f15b930b0c48a78c3ff917ffd42c Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Fri, 31 Jul 2026 10:31:10 +0100 Subject: [PATCH 12/18] channel row preview card cleanup --- .../canvas/components/ChannelItemRow.tsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ChannelItemRow.tsx b/packages/ui/src/features/canvas/components/ChannelItemRow.tsx index c77b8f3a69..861e6a0fbf 100644 --- a/packages/ui/src/features/canvas/components/ChannelItemRow.tsx +++ b/packages/ui/src/features/canvas/components/ChannelItemRow.tsx @@ -258,7 +258,10 @@ export function ChannelItemRow({ // Controlled so the card survives its own submenu: "File to…" opens in a // portal outside the card, and the pointer moving there reads as leaving the // card, which would take the menu down with it. - + } + render={ + + } > - {previewIcon} + + {previewIcon} + {item.title} @@ -357,7 +367,7 @@ export function ChannelItemRow({ )} - + {author} Created by From d2d0355544d9f8ee7517dc8c47390b4defecde71 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Fri, 31 Jul 2026 10:31:55 +0100 Subject: [PATCH 13/18] remove debug --- .../ui/src/features/canvas/components/ChannelItemRow.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ChannelItemRow.tsx b/packages/ui/src/features/canvas/components/ChannelItemRow.tsx index 861e6a0fbf..195e40f385 100644 --- a/packages/ui/src/features/canvas/components/ChannelItemRow.tsx +++ b/packages/ui/src/features/canvas/components/ChannelItemRow.tsx @@ -258,10 +258,7 @@ export function ChannelItemRow({ // Controlled so the card survives its own submenu: "File to…" opens in a // portal outside the card, and the pointer moving there reads as leaving the // card, which would take the menu down with it. - + Date: Fri, 31 Jul 2026 10:38:31 +0100 Subject: [PATCH 14/18] differentiate cloud and local by changing local to laptop icon, pinned icon gets primary color for easy finding --- .../src/features/sidebar/components/items/TaskStatusDot.tsx | 2 +- .../sidebar/components/items/taskStatusVocabulary.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/features/sidebar/components/items/TaskStatusDot.tsx b/packages/ui/src/features/sidebar/components/items/TaskStatusDot.tsx index a91bab8a5e..5bba6e83cd 100644 --- a/packages/ui/src/features/sidebar/components/items/TaskStatusDot.tsx +++ b/packages/ui/src/features/sidebar/components/items/TaskStatusDot.tsx @@ -123,7 +123,7 @@ export function PinnedBadge() { className="cursor-default" > - + diff --git a/packages/ui/src/features/sidebar/components/items/taskStatusVocabulary.ts b/packages/ui/src/features/sidebar/components/items/taskStatusVocabulary.ts index a8bbd9e608..b5286390cd 100644 --- a/packages/ui/src/features/sidebar/components/items/taskStatusVocabulary.ts +++ b/packages/ui/src/features/sidebar/components/items/taskStatusVocabulary.ts @@ -1,11 +1,11 @@ import { ArrowSquareIn, - ChatCircle, Cloud, GitBranch, GitMerge, GitPullRequest, type Icon, + Laptop, } from "@phosphor-icons/react"; import { getOriginProductMeta, @@ -209,7 +209,7 @@ export function taskBadges(props: TaskStatusInput): TaskBadge[] { }); } if (props.workspaceMode === "cloud") { - badges.push({ key: "cloud", Icon: Cloud, label: "Cloud run" }); + badges.push({ key: "cloud", Icon: Cloud, label: "Cloud" }); } if (props.prState === "merged") { badges.push({ @@ -257,7 +257,7 @@ export function taskBadges(props: TaskStatusInput): TaskBadge[] { }); } if (badges.length === 0) { - badges.push({ key: "local", Icon: ChatCircle, label: "Local task" }); + badges.push({ key: "local", Icon: Laptop, label: "Local" }); } return badges; } From 1d0b072d2b8fe21e59675204029935c25361e080 Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Fri, 31 Jul 2026 10:49:26 +0100 Subject: [PATCH 15/18] =?UTF-8?q?if=20props.isGenerating=E2=86=92=20yellow?= =?UTF-8?q?=20spinner,=20if=20(runClaimsWork=20and=20not=20hasPullRequest)?= =?UTF-8?q?=20=E2=86=92=20yellow=20solid=20(Pending=20-=20no=20work=20in?= =?UTF-8?q?=20flight)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../canvas/components/ChannelItemRow.test.tsx | 13 +++++++-- .../components/items/taskStatusVocabulary.ts | 29 ++++++++++++++----- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ChannelItemRow.test.tsx b/packages/ui/src/features/canvas/components/ChannelItemRow.test.tsx index dc3de7529c..00fc36a2c1 100644 --- a/packages/ui/src/features/canvas/components/ChannelItemRow.test.tsx +++ b/packages/ui/src/features/canvas/components/ChannelItemRow.test.tsx @@ -77,11 +77,18 @@ describe("ChannelItemRow", () => { ], ["a streaming agent", { isGenerating: true }, "Working"], [ - "a cloud run in flight", + // The run says in_progress, but nothing is streaming: a local run never + // gets a terminal status written, and the cloud one holds in_progress past + // the agent. Live, but not moving — the still dot, not the spinner. + "a run claiming progress with nothing in flight", { taskRunStatus: "in_progress" as const }, - "Working", + "Pending — no work in flight", + ], + [ + "a queued cloud run", + { taskRunStatus: "queued" as const }, + "Pending — no work in flight", ], - ["a queued cloud run", { taskRunStatus: "queued" as const }, "Working"], [ "a broken run with unseen output", { taskRunStatus: "failed" as const, isUnread: true }, diff --git a/packages/ui/src/features/sidebar/components/items/taskStatusVocabulary.ts b/packages/ui/src/features/sidebar/components/items/taskStatusVocabulary.ts index b5286390cd..3b3196762c 100644 --- a/packages/ui/src/features/sidebar/components/items/taskStatusVocabulary.ts +++ b/packages/ui/src/features/sidebar/components/items/taskStatusVocabulary.ts @@ -107,9 +107,10 @@ export interface TaskDot { * the reader actually gets is output they haven't seen, which is `isUnread`, and * the run's real story lives in the task detail where there's room to tell it. * - * Queued is folded into working for the same reason. "Waiting on a sandbox" and - * "a sandbox is writing code" are one fact to the reader — it's happening — so - * they share the spinner rather than teaching a distinction only we care about. + * Queued is folded into pending for the same reason. "Waiting on a sandbox" and + * "a run that hasn't been closed out" are one fact to the reader — it's live but + * nothing is moving — so they share the still yellow dot. Only a prompt in + * flight spins. * * And a run that has already opened a PR is not working, whatever its status * says. The cloud workflow keeps the run `in_progress` while it babysits CI @@ -127,11 +128,13 @@ export function taskDot(props: TaskStatusInput): TaskDot { label: "Needs permission — blocked on you", }; } - // A live local session streaming output still counts as working — that's the - // agent typing right now, not a run status we inferred. - const runClaimsWork = - props.taskRunStatus === "in_progress" || props.taskRunStatus === "queued"; - if (props.isGenerating || (runClaimsWork && !hasPullRequest(props))) { + // The spinner is reserved for a prompt actually in flight — the agent typing + // right now. A run status can't earn it: nothing writes a terminal status when + // a local agent goes idle, and the cloud workflow holds `in_progress` while it + // babysits CI, so both keep claiming work for as long as the row exists. A + // spinner that never stops is a lie about the machine, so the claim gets the + // still dot below instead. + if (props.isGenerating) { return { tone: "yellow", style: "solid", @@ -140,6 +143,16 @@ export function taskDot(props: TaskStatusInput): TaskDot { label: "Working", }; } + const runClaimsWork = + props.taskRunStatus === "in_progress" || props.taskRunStatus === "queued"; + if (runClaimsWork && !hasPullRequest(props)) { + return { + tone: "yellow", + style: "solid", + pulse: false, + label: "Pending — no work in flight", + }; + } if (props.isUnread) { // Solid, not flashing: fresh output is worth a look but isn't blocking the // run the way a permission prompt is, and two moving states in one list From 3cd46cb57122690a97911668e74d8dcf1e4cc4ab Mon Sep 17 00:00:00 2001 From: Adam Leith Date: Fri, 31 Jul 2026 11:06:34 +0100 Subject: [PATCH 16/18] fix channel item row test --- .../ui/src/features/canvas/components/ChannelItemRow.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ChannelItemRow.test.tsx b/packages/ui/src/features/canvas/components/ChannelItemRow.test.tsx index 00fc36a2c1..9f73215f05 100644 --- a/packages/ui/src/features/canvas/components/ChannelItemRow.test.tsx +++ b/packages/ui/src/features/canvas/components/ChannelItemRow.test.tsx @@ -150,7 +150,7 @@ describe("ChannelItemRow", () => { renderRow(item()); - expect(screen.getByRole("img", { name: "Cloud run" })).not.toBeNull(); + expect(screen.getByRole("img", { name: "Cloud" })).not.toBeNull(); expect(screen.getByRole("img", { name: "Merged" })).not.toBeNull(); expect(screen.queryByText(formatRelativeTimeShort(item().ts))).toBeNull(); }); @@ -177,7 +177,7 @@ describe("ChannelItemRow", () => { renderRow(item({ pinned: true })); expect(screen.getByRole("img", { name: "Pinned" })).not.toBeNull(); - expect(screen.getByRole("img", { name: "Cloud run" })).not.toBeNull(); + expect(screen.getByRole("img", { name: "Cloud" })).not.toBeNull(); }); it("leaves an unpinned row without one", () => { From 45cf233abc889ca51e06c529af4a66a685ca0031 Mon Sep 17 00:00:00 2001 From: JonathanLab Date: Fri, 31 Jul 2026 12:11:13 +0200 Subject: [PATCH 17/18] fix(pi): clear retry status when streaming resumes --- .../translatePiConversation.test.ts | 24 +++++++++-- .../conversation/translatePiConversation.ts | 35 ++++++++++++---- .../StatusNotificationView.test.ts | 30 +++++++++++++- .../session-update/StatusNotificationView.tsx | 40 ++++++++++++++----- 4 files changed, 106 insertions(+), 23 deletions(-) diff --git a/packages/agent/src/pi/conversation/translatePiConversation.test.ts b/packages/agent/src/pi/conversation/translatePiConversation.test.ts index 4db19cb58e..f0f3e4d6b5 100644 --- a/packages/agent/src/pi/conversation/translatePiConversation.test.ts +++ b/packages/agent/src/pi/conversation/translatePiConversation.test.ts @@ -162,11 +162,17 @@ describe("createPiConversationTranslator", () => { delayMs: 1000, }, ]); + const retriedMessage = assistant([{ type: "text", text: "Done" }]); expect( translator.translateEvent({ - type: "auto_retry_end", - success: true, - attempt: 1, + type: "message_update", + message: retriedMessage, + assistantMessageEvent: { + type: "text_delta", + contentIndex: 0, + delta: "Done", + partial: retriedMessage, + }, }), ).toEqual([ { @@ -175,7 +181,19 @@ describe("createPiConversationTranslator", () => { status: "retrying", isComplete: true, }, + { + type: "assistant_message_chunk", + timestamp: 10, + content: { type: "text", text: "Done" }, + }, ]); + expect( + translator.translateEvent({ + type: "auto_retry_end", + success: true, + attempt: 1, + }), + ).toEqual([]); }); it("renders terminal Pi runtime errors inline", () => { diff --git a/packages/agent/src/pi/conversation/translatePiConversation.ts b/packages/agent/src/pi/conversation/translatePiConversation.ts index 745145623d..ad025153a0 100644 --- a/packages/agent/src/pi/conversation/translatePiConversation.ts +++ b/packages/agent/src/pi/conversation/translatePiConversation.ts @@ -115,7 +115,25 @@ export function createPiConversationTranslator(): PiConversationTranslator { let latestRuntimeTimestamp = 0; let latestConversationTimestamp = 0; let pendingRuntimeError: AgentConversationEvent | undefined; + let retrying = false; let directBashSequence = 0; + + function completeRetry(timestamp: number): AgentConversationEvent[] { + if (!retrying) { + return []; + } + + retrying = false; + return [ + { + type: "runtime_status", + timestamp, + status: "retrying", + isComplete: true, + }, + ]; + } + let activeDirectBash: | { nextOutputBytes: number; @@ -257,6 +275,7 @@ export function createPiConversationTranslator(): PiConversationTranslator { if (update.type === "text_delta" && update.delta) { streamedAssistantTimestamps.add(event.message.timestamp); return [ + ...completeRetry(event.message.timestamp), { type: "assistant_message_chunk", timestamp: event.message.timestamp, @@ -268,6 +287,7 @@ export function createPiConversationTranslator(): PiConversationTranslator { if (update.type === "thinking_delta" && update.delta) { streamedAssistantTimestamps.add(event.message.timestamp); return [ + ...completeRetry(event.message.timestamp), { type: "assistant_thought_chunk", timestamp: event.message.timestamp, @@ -407,7 +427,11 @@ export function createPiConversationTranslator(): PiConversationTranslator { } if (event.type === "auto_retry_start") { + const completedEvents = completeRetry(latestConversationTimestamp); + retrying = true; + return [ + ...completedEvents, { type: "runtime_status", timestamp: latestConversationTimestamp, @@ -421,14 +445,9 @@ export function createPiConversationTranslator(): PiConversationTranslator { } if (event.type === "auto_retry_end") { - const events: AgentConversationEvent[] = [ - { - type: "runtime_status", - timestamp: latestConversationTimestamp, - status: "retrying", - isComplete: true, - }, - ]; + const events: AgentConversationEvent[] = completeRetry( + latestConversationTimestamp, + ); if (!event.success && event.finalError) { events.push({ diff --git a/packages/ui/src/features/sessions/components/session-update/StatusNotificationView.test.ts b/packages/ui/src/features/sessions/components/session-update/StatusNotificationView.test.ts index d3be297220..9f7419f4c9 100644 --- a/packages/ui/src/features/sessions/components/session-update/StatusNotificationView.test.ts +++ b/packages/ui/src/features/sessions/components/session-update/StatusNotificationView.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { formatCompactionFailure } from "./StatusNotificationView"; +import { + formatCompactionFailure, + formatRetryStatus, +} from "./StatusNotificationView"; describe("formatCompactionFailure", () => { it.each([ @@ -16,3 +19,28 @@ describe("formatCompactionFailure", () => { expect(formatCompactionFailure(error)).toBe(expected); }); }); + +describe("formatRetryStatus", () => { + it.each([ + { + input: { + attempt: 1, + maxAttempts: 3, + message: "Rate limit reached for gpt-5.6-terra on token ...", + remainingMs: 0, + }, + expected: "Rate limit reached. Retrying now (attempt 1 of 3)", + }, + { + input: { + attempt: 2, + maxAttempts: 3, + message: "Server overloaded", + remainingMs: 2_000, + }, + expected: "Retrying in 2s (attempt 2 of 3)", + }, + ])("renders a concise retry message", ({ input, expected }) => { + expect(formatRetryStatus(input)).toBe(expected); + }); +}); diff --git a/packages/ui/src/features/sessions/components/session-update/StatusNotificationView.tsx b/packages/ui/src/features/sessions/components/session-update/StatusNotificationView.tsx index bb468e5794..5ec5c873d3 100644 --- a/packages/ui/src/features/sessions/components/session-update/StatusNotificationView.tsx +++ b/packages/ui/src/features/sessions/components/session-update/StatusNotificationView.tsx @@ -35,6 +35,29 @@ export function formatCompactionFailure(error?: string): string { return detail ? `Compacting failed: ${detail}` : "Compacting failed"; } +export function formatRetryStatus({ + attempt, + maxAttempts, + message, + remainingMs, +}: { + attempt?: number; + maxAttempts?: number; + message?: string; + remainingMs: number; +}): string { + const rateLimited = /\b(429|rate limit|too many requests)\b/i.test( + message ?? "", + ); + const retryAt = + remainingMs > 0 ? `in ${formatDuration(remainingMs, 0)}` : "now"; + const attemptLabel = + attempt && maxAttempts ? ` (attempt ${attempt} of ${maxAttempts})` : ""; + const prefix = rateLimited ? "Rate limit reached. " : ""; + + return `${prefix}Retrying ${retryAt}${attemptLabel}`; +} + export function StatusNotificationView({ status, isComplete, @@ -179,14 +202,12 @@ function RetryingStatusView({ return () => clearInterval(interval); }, [delayMs, startedAt]); - const attemptLabel = - attempt && maxAttempts - ? `Attempt ${attempt} of ${maxAttempts}` - : "Retrying"; - const retryLabel = - remainingMs > 0 - ? `${attemptLabel} in ${formatDuration(remainingMs, 1)}` - : `${attemptLabel} now`; + const retryLabel = formatRetryStatus({ + attempt, + maxAttempts, + message, + remainingMs, + }); return ( @@ -194,9 +215,6 @@ function RetryingStatusView({ {retryLabel} - {message && ( - {message} - )} From de9badb565908d406a1ab06cbc431a2cced4f969 Mon Sep 17 00:00:00 2001 From: JonathanLab Date: Fri, 31 Jul 2026 12:15:16 +0200 Subject: [PATCH 18/18] fix(ui): recognize Pi rate-limit variants --- .../session-update/StatusNotificationView.test.ts | 9 +++++++++ .../components/session-update/StatusNotificationView.tsx | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/features/sessions/components/session-update/StatusNotificationView.test.ts b/packages/ui/src/features/sessions/components/session-update/StatusNotificationView.test.ts index 9f7419f4c9..9ed69e072a 100644 --- a/packages/ui/src/features/sessions/components/session-update/StatusNotificationView.test.ts +++ b/packages/ui/src/features/sessions/components/session-update/StatusNotificationView.test.ts @@ -31,6 +31,15 @@ describe("formatRetryStatus", () => { }, expected: "Rate limit reached. Retrying now (attempt 1 of 3)", }, + { + input: { + attempt: 2, + maxAttempts: 3, + message: "Rate limited", + remainingMs: 2_000, + }, + expected: "Rate limit reached. Retrying in 2s (attempt 2 of 3)", + }, { input: { attempt: 2, diff --git a/packages/ui/src/features/sessions/components/session-update/StatusNotificationView.tsx b/packages/ui/src/features/sessions/components/session-update/StatusNotificationView.tsx index 5ec5c873d3..e1e3b15b76 100644 --- a/packages/ui/src/features/sessions/components/session-update/StatusNotificationView.tsx +++ b/packages/ui/src/features/sessions/components/session-update/StatusNotificationView.tsx @@ -46,7 +46,7 @@ export function formatRetryStatus({ message?: string; remainingMs: number; }): string { - const rateLimited = /\b(429|rate limit|too many requests)\b/i.test( + const rateLimited = /\b429\b|rate[ _]limit(?:ed)?|too many requests/i.test( message ?? "", ); const retryAt =