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/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/shared/src/index.ts b/packages/shared/src/index.ts index 1648fb76d0..56ce3353c3 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -359,9 +359,11 @@ export type { } from "./task-creation-domain"; export { formatClockTime, + formatDaySeparatorLabel, formatRelativeTimeLong, formatRelativeTimeShort, getLocalDayDiff, + getLocalDayKey, getRelativeDateGroup, } from "./time"; export { diff --git a/packages/shared/src/time.test.ts b/packages/shared/src/time.test.ts index d0a69f92ec..f76961a0a8 100644 --- a/packages/shared/src/time.test.ts +++ b/packages/shared/src/time.test.ts @@ -1,9 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { formatClockTime, + formatDaySeparatorLabel, formatRelativeTimeLong, formatRelativeTimeShort, getLocalDayDiff, + getLocalDayKey, getRelativeDateGroup, } from "./time"; @@ -124,3 +126,37 @@ describe("getRelativeDateGroup", () => { expect(getRelativeDateGroup(NOW - 40 * DAY)).toBe("Earlier"); }); }); + +describe("getLocalDayKey", () => { + it("gives two times on the same local day one key", () => { + expect(getLocalDayKey(new Date(2026, 5, 15, 0, 1))).toBe( + getLocalDayKey(new Date(2026, 5, 15, 23, 59)), + ); + }); + + it("separates adjacent days", () => { + expect(getLocalDayKey(new Date(2026, 5, 15))).not.toBe( + getLocalDayKey(new Date(2026, 5, 16)), + ); + }); +}); + +describe("formatDaySeparatorLabel", () => { + const now = new Date(2026, 5, 15, 12); + + it.each([ + ["today", new Date(2026, 5, 15, 9), "Today"], + ["yesterday", new Date(2026, 5, 14, 9), "Yesterday"], + // Within the week the weekday alone is unambiguous. + ["earlier this week", new Date(2026, 5, 11), "Thursday 11th"], + // Past a week it needs the month, and past a year the year too. + ["last month", new Date(2026, 4, 20), "Wednesday, May 20th"], + ["last year", new Date(2025, 11, 3), "Wednesday, December 3rd, 2025"], + ])("labels %s", (_case, date: Date, expected) => { + expect(formatDaySeparatorLabel(date, now)).toBe(expected); + }); + + it("labels a future timestamp as today rather than counting backwards", () => { + expect(formatDaySeparatorLabel(new Date(2026, 5, 16), now)).toBe("Today"); + }); +}); diff --git a/packages/shared/src/time.ts b/packages/shared/src/time.ts index 3cfd21ba65..08c3e822ae 100644 --- a/packages/shared/src/time.ts +++ b/packages/shared/src/time.ts @@ -75,6 +75,48 @@ export function getLocalDayDiff( return Math.round((startOfDay(now) - startOfDay(date)) / 86_400_000); } +/** + * Local calendar-day identity, for deciding where a day separator goes. Two + * timestamps on the same day share a key regardless of time, and the key is + * built from local getters (not the UTC ISO) so the split lands on the viewer's + * midnight. + */ +export function getLocalDayKey(timestamp: number | string | Date): string { + const date = new Date(timestamp); + return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`; +} + +function ordinal(n: number): string { + const suffix = ["th", "st", "nd", "rd"]; + const rem = n % 100; + return `${n}${suffix[(rem - 20) % 10] ?? suffix[rem] ?? suffix[0]}`; +} + +/** + * A day separator's label: "Today" / "Yesterday" for the recent days, then a + * weekday + ordinal ("Monday 5th") within the week, adding the month (and the + * year when it differs) further back so older separators stay unambiguous. + * + * Shared by the space feed and the space sidebar's recents, so the same day is + * never named two different ways in one window. + */ +export function formatDaySeparatorLabel( + timestamp: number | string | Date, + now: Date = new Date(), +): string { + const date = new Date(timestamp); + const days = getLocalDayDiff(date, now); + if (days <= 0) return "Today"; + if (days === 1) return "Yesterday"; + const weekday = date.toLocaleDateString(undefined, { weekday: "long" }); + const day = ordinal(date.getDate()); + if (days < 7) return `${weekday} ${day}`; + const month = date.toLocaleDateString(undefined, { month: "long" }); + const year = + date.getFullYear() === now.getFullYear() ? "" : `, ${date.getFullYear()}`; + return `${weekday}, ${month} ${day}${year}`; +} + export function getRelativeDateGroup( timestamp: number | string, ): string | null { diff --git a/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx b/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx index c8a72cc020..ebf71c7ceb 100644 --- a/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx +++ b/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx @@ -166,8 +166,11 @@ export function BrowserTabStrip() { // 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. + // 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; diff --git a/packages/ui/src/features/canvas/components/ActivityView.tsx b/packages/ui/src/features/canvas/components/ActivityView.tsx index cb3e01eba2..c227552ae1 100644 --- a/packages/ui/src/features/canvas/components/ActivityView.tsx +++ b/packages/ui/src/features/canvas/components/ActivityView.tsx @@ -181,7 +181,7 @@ export function ActivityRow({ )} {item.isUnread && ( )} diff --git a/packages/ui/src/features/canvas/components/ChannelBackRow.tsx b/packages/ui/src/features/canvas/components/ChannelBackRow.tsx index 575441d56b..0e91183d06 100644 --- a/packages/ui/src/features/canvas/components/ChannelBackRow.tsx +++ b/packages/ui/src/features/canvas/components/ChannelBackRow.tsx @@ -1,5 +1,6 @@ import { CaretLeftIcon, StarIcon } from "@phosphor-icons/react"; import { + Button, Skeleton, Tooltip, TooltipContent, @@ -22,8 +23,9 @@ import { track } from "@posthog/ui/shell/analytics"; function RowStar({ channel }: { channel: Channel }) { const { isStarred, toggleStar } = useChannelStarToggle(channel); return ( - + ); } @@ -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,26 @@ 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 +111,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 ef985d5e6e..c0cc5547e1 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,7 +40,7 @@ import { ThreadItemTimestamp, useChatMessageScroller, } from "@posthog/quill"; -import { formatRelativeTimeShort, getLocalDayDiff } from "@posthog/shared"; +import { formatRelativeTimeShort } from "@posthog/shared"; import type { Task, TaskRunStatus, @@ -65,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, @@ -103,37 +100,6 @@ function statusBadge(status: TaskRunStatus) { ); } -// Local calendar-day identity, so tasks created on the same day share a heading -// regardless of time. Uses local getters (not the UTC ISO) so the split lands -// on the viewer's midnight. -function dayKey(iso: string): string { - const d = new Date(iso); - return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`; -} - -function ordinal(n: number): string { - const suffix = ["th", "st", "nd", "rd"]; - const rem = n % 100; - return `${n}${suffix[(rem - 20) % 10] ?? suffix[rem] ?? suffix[0]}`; -} - -// The day-separator label: "Today" / "Yesterday" for the recent days, then a -// weekday + ordinal ("Monday 5th") within the week, adding the month (and the -// year when it differs) further back so older separators stay unambiguous. -function dayLabel(iso: string, now: Date): string { - const date = new Date(iso); - const days = getLocalDayDiff(date, now); - if (days <= 0) return "Today"; - if (days === 1) return "Yesterday"; - const weekday = date.toLocaleDateString(undefined, { weekday: "long" }); - const day = ordinal(date.getDate()); - if (days < 7) return `${weekday} ${day}`; - const month = date.toLocaleDateString(undefined, { month: "long" }); - const year = - date.getFullYear() === now.getFullYear() ? "" : `, ${date.getFullYear()}`; - return `${weekday}, ${month} ${day}${year}`; -} - interface TaskStatusDisplay { // The run/environment badge ("Local", "Completed", "In progress", …). base: ReactNode; @@ -861,33 +827,19 @@ export function ChannelFeedView({ selector, which hangs over the end of the feed. */} {intro} - {entries.map((entry, index) => { - const previous = entries[index - 1]; - const showDayMarker = - !previous || - dayKey(previous.createdAt) !== dayKey(entry.createdAt); - return ( - - {showDayMarker && ( - - - {dayLabel(entry.createdAt, now)} - - - )} - {entry.kind === "task" ? ( - - ) : ( - - )} - - ); - })} + {entries.map((entry) => + entry.kind === "task" ? ( + + ) : ( + + ), + )} {pending.map((p) => ( ({ status: null as TaskStatusInput | null })); +vi.mock("@posthog/ui/features/canvas/hooks/useChannelTaskStatus", () => ({ + useChannelTaskStatus: () => mocks.status, +})); +// The row menu's spaces list and filing mutation are tRPC-backed. +vi.mock("@posthog/ui/features/canvas/hooks/useChannels", () => ({ + useChannels: () => ({ channels: [{ id: "channel-1", name: "code" }] }), +})); +vi.mock("@posthog/ui/features/canvas/hooks/useFileTaskToChannel", () => ({ + useFileTaskToChannel: () => vi.fn(), +})); +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 = { open: () => {}, togglePin: () => {}, archive: () => {}, + remove: () => {}, }; function item(overrides: Partial = {}): ChannelItemModel { @@ -24,6 +47,7 @@ function item(overrides: Partial = {}): ChannelItemModel { authorName: null, authorUuid: "user-uuid", templateId: null, + task: null, ...overrides, }; } @@ -36,21 +60,102 @@ function renderRow(model: ChannelItemModel) { ); } +beforeEach(() => { + mocks.status = null; + usePendingCanvasDeleteStore.setState({ pending: {} }); +}); + 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"], + [ + // 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 }, + "Pending — no work in flight", + ], + [ + "a queued cloud run", + { taskRunStatus: "queued" as const }, + "Pending — no work in flight", + ], + [ + "a broken run with unseen output", + { taskRunStatus: "failed" as const, isUnread: true }, + "Unread — something to read", + ], + ["a suspended task", { isSuspended: true }, "Suspended — parked"], + [ + // The cloud workflow holds a run at in_progress while it babysits CI after + // opening the PR; under a merge queue that wait can outlast the agent by + // 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 }, + "All caught up", + ], + [ + "a run whose PR url is known but state isn't", + { + taskRunStatus: "in_progress" as const, + prUrl: "https://github.com/PostHog/code/pull/1", + }, + "All caught up", + ], + [ + // A live local session is the agent typing right now, which no PR overrides. + "a streaming agent that already has a PR", + { isGenerating: true, prState: "open" as const }, + "Working", + ], + [ + "a merged PR", + { prState: "merged" as const }, + // PR state lives on the badge, so the dot stays quiet. + "All caught up", + ], + ["an idle task", {}, "All caught up"], + ])("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("badges a PR it can see the url of but not the state of", () => { + mocks.status = { + workspaceMode: "cloud", + prUrl: "https://github.com/PostHog/code/pull/1", + }; + + renderRow(item()); + + // Uncoloured, because colour is a verdict — but present, because a task that + // opened a PR must not look like it did nothing. + expect(screen.getByRole("img", { name: "Pull request" })).not.toBeNull(); + }); + + it("shows a task's badges instead of its timestamp", () => { + mocks.status = { workspaceMode: "cloud", prState: "merged" }; + + renderRow(item()); + + expect(screen.getByRole("img", { name: "Cloud" })).not.toBeNull(); + expect(screen.getByRole("img", { name: "Merged" })).not.toBeNull(); + expect(screen.queryByText(formatRelativeTimeShort(item().ts))).toBeNull(); + }); + + it("renders a canvas like a quiet task with its glyph in the badge stack", () => { renderRow( item({ key: "canvas:canvas-1", @@ -61,36 +166,171 @@ describe("ChannelItemRow", () => { }), ); - expect(screen.queryByRole("img", { name: "Running" })).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(); + }); + + 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" })).not.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" })); + it("leaves an unpinned row without one", () => { + renderRow(item()); - 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: "Pinned" })).toBeNull(); }); - it("opens the task context menu from the row", () => { - const onContextMenu = vi.fn(); + // 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", + "Rename", + "Add to Command Center", + "File to…", + "Archive", + ]; - render( + function renderWithMenu(overrides: { + onRename?: () => void; + onAddToCommandCenter?: () => void; + }) { + return render( {})} + onAddToCommandCenter={overrides.onAddToCommandCenter} /> , ); + } + + /** 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 openCard(); + + for (const label of MENU_ITEMS) { + expect(screen.getByRole("button", { name: label })).not.toBeNull(); + } + }); + + it("opens the same menu on right-click", () => { + renderWithMenu({}); fireEvent.contextMenu(screen.getByText("Investigate signup drop-off")); - expect(onContextMenu).toHaveBeenCalledOnce(); + for (const label of MENU_ITEMS) { + expect(screen.getByRole("menuitem", { name: label })).not.toBeNull(); + } + }); + + it("disables Add to Command Center when there is nowhere to put the task", async () => { + renderWithMenu({ onAddToCommandCenter: undefined }); + + await openCard(); + + // Quill keeps a disabled button focusable, so the state is aria-disabled + // rather than the native attribute. + expect( + screen.getByRole("button", { name: "Add to Command Center" }), + ).toHaveAttribute("aria-disabled", "true"); + }); + + it("renames from the hover card", async () => { + const onRename = vi.fn(); + renderWithMenu({ onRename }); + + await openCard(); + await userEvent.click(screen.getByRole("button", { name: "Rename" })); + + 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", + 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("confirms before deleting a canvas — it goes for the whole space", async () => { + const remove = vi.fn(); + const canvas = item({ + key: "canvas:c1", + kind: "canvas", + id: "c1", + title: "Web analytics overview", + }); + render( + + + , + ); + + await userEvent.hover(screen.getByText("Web analytics overview")); + await userEvent.click( + 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 ae5bc43ebb..195e40f385 100644 --- a/packages/ui/src/features/canvas/components/ChannelItemRow.tsx +++ b/packages/ui/src/features/canvas/components/ChannelItemRow.tsx @@ -1,21 +1,59 @@ import { PreviewCard } from "@base-ui/react/preview-card"; -import { Archive, FileTextIcon, PushPin } from "@phosphor-icons/react"; +import { ChatCircleIcon } from "@phosphor-icons/react"; import type { ChannelItemModel } from "@posthog/core/canvas/channelItems"; import { - isRunStatusActive, runStatusLabel, runStatusVariant, } from "@posthog/core/canvas/runStatus"; -import { Avatar, AvatarFallback, Badge } from "@posthog/quill"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + Avatar, + AvatarFallback, + AvatarGroup, + Badge, + Button, + Card, + 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, + TaskRowMenuList, + 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 { + PinnedBadge, + TaskBadgeStack, + TaskStatusDot, + TaskStatusTooltips, +} from "@posthog/ui/features/sidebar/components/items/TaskStatusDot"; +import { + type TaskDot, + 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"; -import type { ReactNode } from "react"; +import { type ReactNode, useCallback, useState } from "react"; /** * What a row can do. One object per channel rather than closures per item, so @@ -25,75 +63,181 @@ 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 HOVER_ACTION_CLASS = - "flex h-5 w-5 cursor-pointer items-center justify-center rounded text-muted-foreground transition-colors hover:bg-fill-hover hover:text-foreground"; -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"; +const TIMESTAMP_CLASS = "shrink-0 text-[11px] text-muted-foreground"; +// 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"; -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", - }) - ) : ( - - ); +/** + * 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, + 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. + * 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. */ -function RunningIcon({ children }: { children: ReactNode }) { - return ( - - {children} - - ); -} +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; } +/** + * 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 ( + + {/* `cursor-default`: a badge names a fact about the row, it isn't a + control — see the same note in TaskBadgeStack. */} + + + {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, + channelId, isActive, actions, isEditing = false, - onContextMenu, + onRename, + onAddToCommandCenter, onEditSubmit, onEditCancel, }: { item: ChannelItemModel; + /** The space this row is listed under, ticked in the menu's "File to…". */ + channelId?: string; isActive: boolean; actions: ChannelItemActions; isEditing?: boolean; - onContextMenu?: (event: React.MouseEvent) => void; + /** Puts the row into inline-rename mode. Absent for canvases. */ + onRename?: () => void; + /** Absent when the command centre has no free cell, which disables the item. */ + onAddToCommandCenter?: () => void; onEditSubmit?: (newTitle: string) => void; onEditCancel?: () => void; }) { - const icon = itemIcon(item); + 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 pendingDelete = useIsCanvasPendingDelete(item.id); + const deleting = item.kind === "canvas" && pendingDelete; + // Stable: `TaskRowMenuList` builds its item components from these, so a new + // identity each render would remount every button in the card. + const closeCard = useCallback(() => setCardOpen(false), []); 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 + // 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 — 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 + // menu drops them rather than showing them dead. + const menu: TaskRowMenuProps = + item.kind === "canvas" + ? { + kind: "canvas", + id: item.id, + title: item.title, + isPinned: item.pinned, + onTogglePin: () => actions.togglePin(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", + id: item.id, + title: item.title, + isPinned: item.pinned, + channelId, + onAddToCommandCenter, + onRename, + onTogglePin: () => actions.togglePin(item), + onArchive: () => actions.archive(item), + }; if (isEditing) { return ( @@ -108,8 +252,13 @@ 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 = ( + // 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. + {item.title}} isActive={isActive} onClick={() => actions.open(item)} - onContextMenu={onContextMenu} endContent={ - <> - - {formatRelativeTimeShort(item.ts)} - - - - actions.togglePin(item)} - > - - - - {/* Canvases can't be archived. */} - {item.kind === "task" && ( - - actions.archive(item)} + + {/* Badges take the timestamp's slot on a task row: the row's + 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" ? ( + + ) : ( + <> + {item.pinned && ( + - - - - )} - - + + + )} + + {formatRelativeTimeShort(item.ts)} + + + )} + } />
@@ -167,52 +312,123 @@ 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()} - - - )} -
-

- {author} -

-

- Created by -

+ {/* 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. 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. */} + +
+
- )} +
); + + 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} + {/* 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/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.test.tsx b/packages/ui/src/features/canvas/components/ChannelSidebar.test.tsx index 53fb6a5c3d..bcd735dcc7 100644 --- a/packages/ui/src/features/canvas/components/ChannelSidebar.test.tsx +++ b/packages/ui/src/features/canvas/components/ChannelSidebar.test.tsx @@ -2,7 +2,7 @@ import type { ChannelItemModel } from "@posthog/core/canvas/channelItems"; import { Theme } from "@radix-ui/themes"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ items: [] as ChannelItemModel[], @@ -36,15 +36,11 @@ vi.mock("@posthog/ui/features/canvas/components/ChannelsFab", () => ({ ChannelsFab: () => null, })); -// The row context menu's hooks reach for a QueryClient and the DI container, -// neither of which a unit test has. Stubbed at the module boundary, as -// WebsiteLayout.test.tsx does for the same reason. -vi.mock("@posthog/ui/features/tasks/useTaskContextMenu", () => ({ - useTaskContextMenu: () => ({ - showContextMenu: vi.fn(), - editingTaskId: null, - setEditingTaskId: vi.fn(), - }), +// The row menu's spaces list reaches for a QueryClient the unit test has no +// stack for. Stubbed at the module boundary, as WebsiteLayout.test.tsx does for +// the same reason. +vi.mock("@posthog/ui/features/canvas/hooks/useChannels", () => ({ + useChannels: () => ({ channels: [] }), })); vi.mock("@posthog/ui/features/tasks/useTaskMutations", () => ({ useRenameTask: () => ({ renameTask: vi.fn() }), @@ -52,6 +48,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 +69,7 @@ function item(overrides: Partial = {}): ChannelItemModel { // Not the viewer, so filtering to "Me" leaves nothing. authorUuid: "someone-else-uuid", templateId: null, + task: null, ...overrides, }; } @@ -98,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 }) => { @@ -162,3 +163,100 @@ describe("ChannelSidebar", () => { expect(screen.queryByText("Nothing here yet")).not.toBeInTheDocument(); }); }); + +describe("ChannelSidebar recents list", () => { + const NOW = new Date(2026, 6, 29, 12); + + beforeEach(() => { + mocks.isLoading = false; + mocks.channelMissing = false; + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.setSystemTime(NOW); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("lists recents newest first without day separators", () => { + mocks.items = [ + item({ + key: "task:a", + id: "a", + title: "Today's work", + ts: new Date(2026, 6, 29, 9).getTime(), + }), + item({ + key: "task:b", + id: "b", + title: "Also today", + ts: new Date(2026, 6, 29, 8).getTime(), + }), + item({ + key: "task:c", + id: "c", + title: "Yesterday's work", + ts: new Date(2026, 6, 28, 17).getTime(), + }), + item({ + key: "task:d", + id: "d", + title: "Older work", + ts: new Date(2026, 6, 20, 17).getTime(), + }), + ]; + + renderSidebar(); + + 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("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() }), + item({ key: "task:c", id: "c", ts: new Date(2026, 6, 29, 1).getTime() }), + ]; + + renderSidebar(); + + expect(screen.getAllByText("Investigate signup drop-off")).toHaveLength(3); + expect(screen.queryByText("Today")).toBeNull(); + }); + + 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", + title: "Kept at hand", + pinned: true, + ts: new Date(2026, 6, 20, 9).getTime(), + }), + ]; + + renderSidebar(); + + // 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 c069e1ac7d..a2e8bb520b 100644 --- a/packages/ui/src/features/canvas/components/ChannelSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ChannelSidebar.tsx @@ -8,6 +8,7 @@ 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 { + Button, cn, DropdownMenu, DropdownMenuContent, @@ -32,14 +33,14 @@ import { ChannelItemRow } from "@posthog/ui/features/canvas/components/ChannelIt 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"; -import { useTaskContextMenu } from "@posthog/ui/features/tasks/useTaskContextMenu"; import { useRenameTask } from "@posthog/ui/features/tasks/useTaskMutations"; import { useTasks } from "@posthog/ui/features/tasks/useTasks"; import { navigateToCommandCenter } from "@posthog/ui/router/navigationBridge"; @@ -54,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"); @@ -70,6 +71,7 @@ function RecentSectionHeader({ onQueryChange, createdByFilter, onCreatedByChange, + showCreatedBy, statusFilter, onStatusChange, filtersActive, @@ -80,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]" />
@@ -175,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. */} (null); const { renameTask } = useRenameTask(); const commandCenterCells = useCommandCenterStore((state) => state.cells); const assignTaskToCommandCenter = useCommandCenterStore( @@ -255,7 +273,17 @@ export function ChannelSidebar({ channelId }: { channelId: string }) { const [createdByFilter, setCreatedByFilter] = useState("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 @@ -267,15 +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], - ); + // 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({ @@ -284,41 +319,40 @@ 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. + const commandCenterAssigner = (taskId: string) => { + const cellIndex = commandCenterCells.findIndex( + (cellTaskId) => cellTaskId == null || !allTaskIds.has(cellTaskId), + ); + if (cellIndex === -1) return undefined; + return () => { + assignTaskToCommandCenter(cellIndex, taskId); + navigateToCommandCenter(); + }; + }; const taskRow = (item: (typeof items)[number]) => ( - void showContextMenu(item, event, { - isPinned: item.pinned, - isInCommandCenter: commandCenterCells.includes(item.id), - hasEmptyCommandCenterCell: commandCenterCells.some( - (taskId) => taskId == null || !allTaskIds.has(taskId), - ), - showArchivePrior: false, - onTogglePin: () => actions.togglePin(item), - onArchive: () => actions.archive(item), - onAddToCommandCenter: () => { - const cellIndex = commandCenterCells.findIndex( - (taskId) => taskId == null || !allTaskIds.has(taskId), - ); - if (cellIndex === -1) return; - assignTaskToCommandCenter(cellIndex, item.id); - navigateToCommandCenter(); - }, - }) + onRename={ + item.kind === "task" ? () => setEditingTaskId(item.id) : undefined + } + // Undefined disables the menu item: a full command centre has nowhere to + // put the task, and an action that silently does nothing is worse than a + // greyed-out one. + onAddToCommandCenter={ + item.kind === "task" && !commandCenterCells.includes(item.id) + ? commandCenterAssigner(item.id) : undefined } onEditSubmit={ @@ -341,8 +375,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, @@ -350,7 +386,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, @@ -420,15 +468,6 @@ export function ChannelSidebar({ channelId }: { channelId: string }) { )} - {showPinned && ( - <> - Pinned -
- {pinnedItems.map(taskRow)} -
- - )} - {showRecent && ( <> {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 new file mode 100644 index 0000000000..f3e3e4310e --- /dev/null +++ b/packages/ui/src/features/canvas/components/TaskRowMenu.tsx @@ -0,0 +1,226 @@ +import { CaretRightIcon } from "@phosphor-icons/react"; +import { + Button, + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSub, + ContextMenuSubTrigger, + ContextMenuTrigger, + DropdownMenu, + DropdownMenuTrigger, +} 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 { + type MenuFlyoutItem, + MenuSubFlyout, + SearchableMenuFlyout, +} from "@posthog/ui/primitives/SearchableMenuFlyout"; +import { type ComponentType, type ReactNode, useMemo } from "react"; + +/** + * 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 { + 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; + /** Absent where there's no inline rename to open — canvases, for now. */ + onRename?: () => void; + onTogglePin: () => 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 +// written once against this shape. Base UI builds context menus on the same Menu +// parts as dropdowns, so the props line up; typing them structurally keeps the +// shared content from having to know which surface it's on. +interface MenuParts { + Item: ComponentType<{ + children: ReactNode; + disabled?: boolean; + variant?: "default" | "destructive"; + onClick?: () => void; + }>; + Sub: ComponentType<{ children: ReactNode }>; + SubTrigger: ComponentType<{ children: ReactNode }>; +} + +const CONTEXT_PARTS: MenuParts = { + Item: ContextMenuItem, + Sub: ContextMenuSub, + SubTrigger: ContextMenuSubTrigger, +}; + +/** + * 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, + menu, +}: { + parts: MenuParts; + menu: TaskRowMenuProps; +}) { + 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( + PROJECT_BLUEBIRD_FLAG, + import.meta.env.DEV, + ); + const isTask = menu.kind === "task"; + const { channels } = useChannels({ enabled: bluebirdEnabled && isTask }); + const fileToChannel = useFileTaskToChannel(); + + const channelItems: MenuFlyoutItem[] = channels.map((channel) => ({ + id: channel.id, + label: channel.name, + current: channel.id === menu.channelId, + })); + + return ( + <> + {menu.isPinned ? "Unpin" : "Pin"} + {menu.onRename && Rename} + {isTask && ( + + Add to Command Center + + )} + {isTask && channelItems.length > 0 && ( + + 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… + + )} + + ); +} + +/** + * 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. + * + * `onAction` closes the surface once something has been chosen, and + * `onSubmenuOpenChange` reports the one thing that *is* a popup ("File to…"), so + * a hover surface can stay open while the pointer is inside it. + */ +export function TaskRowMenuList({ + menu, + onAction, + onSubmenuOpenChange, +}: { + menu: TaskRowMenuProps; + onAction: () => void; + onSubmenuOpenChange: (open: boolean) => void; +}) { + const parts: MenuParts = useMemo( + () => ({ + Item: ({ children, disabled, variant, onClick }) => ( + + ), + Sub: ({ children }) => ( + + {children} + + ), + // `openOnHover`, so the spaces flyout arrives the way a submenu does in + // the right-click menu — pointing at the row is the whole gesture, and + // this card is a hover surface to begin with. + SubTrigger: ({ children }) => ( + + {children} + + + } + /> + ), + }), + [onAction, onSubmenuOpenChange], + ); + + return ( +
+ +
+ ); +} + +/** The same menu on right-click, wrapping the row. */ +export function TaskRowContextMenu({ + menu, + children, +}: { + menu: TaskRowMenuProps; + children: ReactNode; +}) { + return ( + + }> + {children} + + + + + + ); +} 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/canvas/hooks/useChannelTaskStatus.ts b/packages/ui/src/features/canvas/hooks/useChannelTaskStatus.ts new file mode 100644 index 0000000000..f92790e582 --- /dev/null +++ b/packages/ui/src/features/canvas/hooks/useChannelTaskStatus.ts @@ -0,0 +1,49 @@ +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, + // The url is the early signal: a cloud run writes it the moment it opens the + // PR, long before (or without ever) resolving the PR's state. + prUrl: taskData.cloudPrUrl, + }; +} diff --git a/packages/ui/src/features/canvas/hooks/useFileTaskToChannel.ts b/packages/ui/src/features/canvas/hooks/useFileTaskToChannel.ts new file mode 100644 index 0000000000..ed6f88d4b8 --- /dev/null +++ b/packages/ui/src/features/canvas/hooks/useFileTaskToChannel.ts @@ -0,0 +1,36 @@ +import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { useChannelTaskMutations } from "@posthog/ui/features/canvas/hooks/useChannelTasks"; +import { toast } from "@posthog/ui/primitives/toast"; +import { useCallback } from "react"; + +/** + * Files a task to a space and reports the outcome, naming the space in the + * success toast. Extracted so the row menu and the native context menu file + * tasks the same way — filing is a mutation plus the two toasts that make it + * legible, and duplicating that is how the two paths drift. + */ +export function useFileTaskToChannel(): ( + channelId: string, + taskId: string, + taskTitle: string, +) => Promise { + const { fileTask } = useChannelTaskMutations(); + const { channels } = useChannels(); + + return useCallback( + async (channelId: string, taskId: string, taskTitle: string) => { + try { + await fileTask(channelId, taskId, taskTitle); + const channelName = channels.find( + (channel) => channel.id === channelId, + )?.name; + toast.success(channelName ? `Filed to ${channelName}` : "Task filed"); + } catch (error) { + toast.error("Couldn't file task", { + description: error instanceof Error ? error.message : String(error), + }); + } + }, + [channels, fileTask], + ); +} diff --git a/packages/ui/src/features/sessions/components/SessionView.tsx b/packages/ui/src/features/sessions/components/SessionView.tsx index cbaa79033e..47d21776cc 100644 --- a/packages/ui/src/features/sessions/components/SessionView.tsx +++ b/packages/ui/src/features/sessions/components/SessionView.tsx @@ -122,7 +122,7 @@ function ComposerWidth({ }) { return ( {children} diff --git a/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx b/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx index 59290aab08..6b5d8880f3 100644 --- a/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx +++ b/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx @@ -1,9 +1,7 @@ -import { Menu as BaseMenu } from "@base-ui/react/menu"; import { Archive, ArrowSquareOut, Buildings, - Check, DiscordLogo, FolderSimple, Gear, @@ -15,13 +13,6 @@ import { SignOut, } from "@phosphor-icons/react"; import { - Autocomplete, - AutocompleteCollection, - AutocompleteGroup, - AutocompleteInput, - AutocompleteItem, - AutocompleteList, - AutocompleteStatus, DropdownMenu, DropdownMenuContent, DropdownMenuGroup, @@ -34,7 +25,6 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, Item, - ItemActions, ItemContent, ItemDescription, ItemTitle, @@ -54,13 +44,17 @@ import { useProjects } from "@posthog/ui/features/projects/useProjects"; import { openSettings } from "@posthog/ui/features/settings/hooks/useOpenSettings"; import { useHoldSidebarPeek } from "@posthog/ui/features/sidebar/useHoldSidebarPeek"; import { useWhatsNewStore } from "@posthog/ui/features/updates/whatsNewStore"; +import { + type MenuFlyoutItem, + MenuSubFlyout, + SearchableMenuFlyout, +} from "@posthog/ui/primitives/SearchableMenuFlyout"; import { navigateToArchived } from "@posthog/ui/router/navigationBridge"; 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 { type ReactNode, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; /** The account / project / org menu at the bottom of the sidebar. */ export function ProjectSwitcher() { @@ -101,7 +95,7 @@ export function ProjectSwitcher() { minute: "2-digit", }) : null; - const projectItems = useMemo( + const projectItems = useMemo( () => (currentOrgGroup?.projects ?? []).map((project) => ({ id: String(project.id), @@ -112,7 +106,7 @@ export function ProjectSwitcher() { ); // Logos aren't in orgProjectsMap, so cross-reference the user's org list. - const orgItems = useMemo( + const orgItems = useMemo( () => groupedProjects.map((group) => { const logoMediaId = currentUser?.organizations?.find( @@ -204,21 +198,17 @@ export function ProjectSwitcher() { render={ {currentProject?.name ?? "No project selected"} - {impersonationExpiry - ? `Impersonating until ${impersonationExpiry}` - : (currentUser?.email ?? "No email")} + {impersonationExpiry && + `Impersonating until ${impersonationExpiry}`} - - - } /> @@ -269,14 +259,14 @@ export function ProjectSwitcher() { {currentProject?.name ?? "No project selected"} - - + handleProjectSelect(Number(id))} /> - + @@ -294,14 +284,14 @@ export function ProjectSwitcher() { {currentOrgName} - - + handleOrgSelect(id)} /> - + @@ -382,14 +372,6 @@ export function ProjectSwitcher() { ); } -type FlyoutItem = { - id: string; - label: string; - current: boolean; - icon?: ReactNode; -}; -type FlyoutSection = { items: FlyoutItem[] }; - // Deterministic palette so an org keeps the same fallback color across renders. const ORG_AVATAR_COLORS = [ "tomato", @@ -452,144 +434,3 @@ function OrgAvatar({ orgId, name, logoSrc }: OrgAvatarProps) { // compiled internals, not public API — a quill upgrade that renames them would // silently strip this flyout's styling. Drop this component in favor of // DropdownMenuSubContent once quill exposes a collisionAvoidance prop. -function PinnedSubContent({ - className, - children, -}: { - className?: string; - children: ReactNode; -}) { - return ( - - - -
- {children} -
-
-
-
- ); -} - -interface SearchableFlyoutProps { - items: FlyoutItem[]; - placeholder: string; - emptyLabel: string; - onSelect: (id: string) => void; -} - -function SearchableFlyout({ - items, - placeholder, - emptyLabel, - onSelect, -}: SearchableFlyoutProps) { - const [query, setQuery] = useState(""); - // Active item first as the anchor when switching; the rest sorted the same - // way the web app orders them (locale-aware, which floats emoji-prefixed - // names above plain ones). - const sections = useMemo( - () => [ - { - items: [ - ...items.filter((item) => item.current), - ...items - .filter((item) => !item.current) - .sort((a, b) => a.label.localeCompare(b.label)), - ], - }, - ], - [items], - ); - - return ( - // Keep keystrokes away from the surrounding menu: its typeahead handler - // sits on the submenu popup and would swallow typing meant for the search - // input. Escape still bubbles so the menu can close. - // biome-ignore lint/a11y/noStaticElementInteractions: keyboard fencing only -
{ - if (event.key !== "Escape") event.stopPropagation(); - }} - > - - inline - defaultOpen - items={sections} - value={query} - autoHighlight="always" - onValueChange={(val, eventDetails) => { - if (eventDetails.reason !== "input-change") return; - if (typeof val === "string") setQuery(val); - }} - filter={(item, q) => { - if (!q) return true; - return item.label.toLowerCase().includes(q.toLowerCase()); - }} - > - - {/* Suppress the default "{count} results" line; only show empty states. */} - - {(count: number) => - count === 0 ? ( - query ? ( - - No matches for "{query}" - - ) : ( - {emptyLabel} - ) - ) : null - } - - {/* Long lists get a FIXED height so the popup doesn't resize (and - jump) while filtering. Kept short enough that the whole flyout - fits below either trigger row, so the popup itself never grows - a second scrollbar. */} - 5 ? "h-40" : "max-h-40"} p-0 pb-0`} - > - {(section: FlyoutSection) => ( - - - {(item: FlyoutItem) => ( - onSelect(item.id)} - className="flex items-center gap-2 ring-offset-0 data-highlighted:border-transparent data-highlighted:bg-fill-hover data-highlighted:ring-0" - > - - {item.current && ( - - )} - - {item.icon && ( - - {item.icon} - - )} - {item.label} - - )} - - - )} - - -
- ); -} 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..b64e0257df 100644 --- a/packages/ui/src/features/sidebar/components/items/SidebarCountBadge.tsx +++ b/packages/ui/src/features/sidebar/components/items/SidebarCountBadge.tsx @@ -7,7 +7,9 @@ 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 new file mode 100644 index 0000000000..5bba6e83cd --- /dev/null +++ b/packages/ui/src/features/sidebar/components/items/TaskStatusDot.tsx @@ -0,0 +1,186 @@ +import { PushPin } from "@phosphor-icons/react"; +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, the brand yellow is + * working or unread, grey is quiet. The trigger renders as a span because rows are + * ` diff --git a/packages/ui/src/styles/globals.css b/packages/ui/src/styles/globals.css index 316dea96b1..7404121ac8 100644 --- a/packages/ui/src/styles/globals.css +++ b/packages/ui/src/styles/globals.css @@ -418,6 +418,37 @@ body:has(.rt-DialogOverlay[data-state="open"]) [data-quill-portal] { animation: ph-dots-frame 800ms step-end infinite; } +/* Dot-ring spinner: eight dots on a 3x3 grid, one keyframe per dot with a + staggered delay so a highlight travels the ring. Only `opacity` animates, so + 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 + primitives/DotRingSpinner.tsx, which spaces the delays. */ +@keyframes ph-dot-ring { + 0%, + 100% { + opacity: 0.2; + } + 12% { + opacity: 1; + } + 50% { + opacity: 0.2; + } +} + +.ph-dot-ring { + animation: ph-dot-ring 900ms linear infinite; +} + +/* Hold a legible, evenly dimmed ring rather than dropping the signal. */ +@media (prefers-reduced-motion: reduce) { + .ph-dot-ring { + animation: none; + opacity: 0.55; + } +} + /* Pulse animation for generating indicator */ @keyframes ph-pulse { 0%,