diff --git a/apps/code/snapshots.yml b/apps/code/snapshots.yml index 3cbfb34a08..20fbaa732f 100644 --- a/apps/code/snapshots.yml +++ b/apps/code/snapshots.yml @@ -72,30 +72,6 @@ snapshots: hash: v1.k4693efd2.1d199b2c4bba8034cb18fb5b49866b64a6eb3311591add5f58ec288710dce2e9.FCc9egSaO1Onih417yciSYKNROe8zqDZyyS6xuXCZGw billing-usagemeter--zero-spend-limit--light: hash: v1.k4693efd2.b50c18736bbde45fc89f4c0ac7fd616286e5c1ecc2a56910fffca13eaa13d8a2.31z4F0JmvMeR4yqk5pjs_OQ3MypnM2miZI9al4wuc8c - channels-taskfeedrow--agent-origin--dark: - hash: v1.k4693efd2.82f8c70a399c9ea768201933e202fcc2fde74332c5153aeabf6835ace79beee6.-SVboDRIZ_-nh4PtMliCv7e7iCtdUm-1m2xQ9Xzcdfw - channels-taskfeedrow--agent-origin--light: - hash: v1.k4693efd2.4c1db3470d5ec9b4db9872d0e048314d0078979e8fbdb602b8c648ba70d7209f._KPUvj46p33u7A4Xndz-git-4rp7VKca1MpeTztlB6w - channels-taskfeedrow--human-email-only--dark: - hash: v1.k4693efd2.0f4dece4643b7375b77746347658f4fe65b37817cdfa9ad1ba697067e619f428.f0jKZ4OYqsmeJjXffjt-amK9cwE81JF0lgSMqtn37PA - channels-taskfeedrow--human-email-only--light: - hash: v1.k4693efd2.14db0510f7ea5c57985cf0cb60ab08f5e146c7243eeb1b0b963f74e85c7d60f4.ijHQLK0lZjjUlOQuzYzisImU9DWsNmZM7i5GYfWU4E0 - channels-taskfeedrow--human-started--dark: - hash: v1.k4693efd2.e0abdae2e8ac2ef29793ea7afc2a2580c6610e07a3c640ad782af7fd3c12592a.YUgF_BTPzZoqnWXvyLBRs3w9C_P3Yfh4-cP4mUqs2NQ - channels-taskfeedrow--human-started--light: - hash: v1.k4693efd2.84c26deb1a587fe061238b3982b555167575893bacc9cd2667d4d3f74646261f.abqc0Voe6FIQFflzDJTv1KewcZ4XxcDz1a0mFlJu7oM - channels-taskfeedrow--long-prompt--dark: - hash: v1.k4693efd2.876d34660bc267af79d39a971a681ba279a870061dba10b905412112c2a5e4dd.rSw1X068udMs8Arglu_WgX5RL8WZlpAc8_kVONPQhF4 - channels-taskfeedrow--long-prompt--light: - hash: v1.k4693efd2.d0d6e4b6bfa257c3f46d991777f72c345437b0be2ee16a182fa925d3ece7dc9e.6PGeOlMxVauJSQia0FAIirzaYio79-I7H4x-9LvEUbw - channels-taskfeedrow--no-prompt--dark: - hash: v1.k4693efd2.9fa967f1a9acdeba0c50a9e45ae649f118ee26938037dbefd1bb0577067b03d4.va1lGsscqLW86-5yCKc3H6pQ8Az7_HBItkgGTnTQiYU - channels-taskfeedrow--no-prompt--light: - hash: v1.k4693efd2.a02339aecdb6fc327fdf6586e28ceeebf8490442fe217eb8bb46f1ce66bff78a.2l7XdvdW3D0GlLmPwDdTFgjZWdav68bZX_LYResVKwo - channels-taskfeedrow--no-starter--dark: - hash: v1.k4693efd2.66fff212f14afa9dd3bc532698a042383014a0cabd4331d30eff878148e1773d.RiectZxFTqk1husLYI-UV5ko1uZ4lpbjWstE7Hy3MJE - channels-taskfeedrow--no-starter--light: - hash: v1.k4693efd2.9ee4ad64ed7d2c5c62d0cd682d903b09da643d462d49aae0858afa500e92256b.OhGj64l-o7iWtxzJo1qt0A_CU9ME0ZRr_5e455RGUJQ components-permissions-permissionselector--create-new-file--dark: hash: v1.k4693efd2.c54203a4e636b83b3d24d7ed9c4ace8659db87cd8f231d9dc2ecc03320e31646.epDm7LebiLzlp0uuZBrE-Obt_anAn0xsE8bHFnm5vos components-permissions-permissionselector--create-new-file--light: @@ -708,6 +684,30 @@ snapshots: hash: v1.k4693efd2.cfff4c6bbb0acef1c9941b21d2f69c24b234c2c206282acf6fcada0cb2bd2386.xILjVPp8ecQmGbPjGDdhbRyi9KFPpA_1nfYF3bfjwuc skill-buttons-skillbuttonsmenu--default--light: hash: v1.k4693efd2.eb9bd49b9700641f6f3c90653447c9767c52e997afec9843e8649b90752faad3.VdxEtBwtkU3ioy2evcNbrEKxPjQ1GDUpZAQzFe-Uho4 + spaces-taskfeedrow--agent-origin--dark: + hash: v1.k4693efd2.82f8c70a399c9ea768201933e202fcc2fde74332c5153aeabf6835ace79beee6.yEZq_qnKxItv5u0Ydcqa1B4VCwv8lwXUvcaFScE2XO0 + spaces-taskfeedrow--agent-origin--light: + hash: v1.k4693efd2.4c1db3470d5ec9b4db9872d0e048314d0078979e8fbdb602b8c648ba70d7209f.wt_Ufkw30jHFnxEMFXE0wQG8ignuCURuUUYIvNMnJ6U + spaces-taskfeedrow--human-email-only--dark: + hash: v1.k4693efd2.611794921d5c6270a211f4a6bc3d91fd63a74a58f0c905a79271cbc7d9c73eb8.balV69yNKs9biJzweJYwpk5e1UjZ11fFersE9UMhvEU + spaces-taskfeedrow--human-email-only--light: + hash: v1.k4693efd2.961288ce410b6376bab92428ad148b881c768e9edea4821db4818d13682f50c4.79pzkpuXwARCmUS4HtZRvb2n7dM8HUZZs-ErIXX4wwg + spaces-taskfeedrow--human-started--dark: + hash: v1.k4693efd2.6e1cf0720234b1c64ba04f6f3af965d32438551771bc3680c4391bb8463b8eee.4O-iWXxbDv-L9MsuPt6KoDvK7C95R0Jj6LTYjDwwEhk + spaces-taskfeedrow--human-started--light: + hash: v1.k4693efd2.7156c1e5b2359b5f792529aa9041ad9277842d51c3423bda4f0c029c58dcef28.PF-JYrCTfHxEzFG9dnabJxDYS47wQXnxurx8Ev_LfkY + spaces-taskfeedrow--long-prompt--dark: + hash: v1.k4693efd2.00acb2487749ee5a1db2c95da8ae0e2fb2de0815d653973e60c7ceb3ff808135.pdV864kTAUM1IgcuME846VwInafp3f-WFFxxxE4H_oY + spaces-taskfeedrow--long-prompt--light: + hash: v1.k4693efd2.8a6b372abdf9c60a558c59a703f8e7c8f65fcb55fd99e6c97c6eca23e6a76781.HbZ7kfuE60F5SWucdbYLhy98POhk8MPPX2TLA_r2hz8 + spaces-taskfeedrow--no-prompt--dark: + hash: v1.k4693efd2.ff77770c9b5c656b213c139270322defa38306d714abeee6b5fe4e17e2056f88.KnYgUDb_jYEfH5aSoJbt1cwyJXAfDo_eKmIfn0fxjjc + spaces-taskfeedrow--no-prompt--light: + hash: v1.k4693efd2.d3d755ee7ea6eccbed93ea18e6dcbccbd9f77f019f5c153cb0278131b08c324f.l_KwHWHFTzODefftPGtlWsXLFi6tbH78ZRQeeY7X4Z0 + spaces-taskfeedrow--no-starter--dark: + hash: v1.k4693efd2.66fff212f14afa9dd3bc532698a042383014a0cabd4331d30eff878148e1773d.-FvbS5RpndI7QXNTpQhbQShDktPrOV_jdcWDY4MUYF4 + spaces-taskfeedrow--no-starter--light: + hash: v1.k4693efd2.9ee4ad64ed7d2c5c62d0cd682d903b09da643d462d49aae0858afa500e92256b.lCc1Q5KBHSLnYp7N9k6HEBxDPjf0SblH9zDLHQ7dYo8 task-detail-continueclisessions--importing--dark: hash: v1.k4693efd2.3196e21b9daa2f183c79eaf2c70a5775365dbee3f1d920c64927e20a0fadcbf9.FjHhjEX3urGbycQiDehA9JQ7H2zfyl1id9rDSVjrtoI task-detail-continueclisessions--importing--light: diff --git a/packages/core/src/canvas/runStatus.ts b/packages/core/src/canvas/runStatus.ts index f96bb33f98..90917af05d 100644 --- a/packages/core/src/canvas/runStatus.ts +++ b/packages/core/src/canvas/runStatus.ts @@ -37,6 +37,17 @@ export function runStatusVariant( return status ? RUN_STATUS_VARIANTS[status] : "default"; } +/** + * Whether the run is still in flight, and so worth animating in a list. Canvases + * and tasks that never started carry no run to wait on, and `not_started` is a + * queued-but-unclaimed task rather than one doing work. + */ +export function isRunStatusActive( + status: TaskRunStatus | null | undefined, +): boolean { + return status === "queued" || status === "in_progress"; +} + export const RUN_STATUS_FILTER_OPTIONS: readonly { value: TaskRunStatus | null; label: string; diff --git a/packages/core/src/command-center/grid.test.ts b/packages/core/src/command-center/grid.test.ts index e1af10334a..947423a6e9 100644 --- a/packages/core/src/command-center/grid.test.ts +++ b/packages/core/src/command-center/grid.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { BRAINROT_CELL, clampZoom, + countActiveTaskCells, getCellCount, getCellSessionId, getGridDimensions, @@ -97,3 +98,34 @@ describe("getCellSessionId", () => { expect(getCellSessionId(2)).toBe("cc-cell-2"); }); }); + +describe("countActiveTaskCells", () => { + const live = new Set(["task-1", "task-2"]); + + it("counts only cells whose task still exists", () => { + expect(countActiveTaskCells(["task-1", "task-2"], live)).toBe(2); + }); + + // Cells are persisted and only pruned on archive, so a deleted task's id + // lingers forever — counting the array's non-empty entries would never drop. + it("ignores a task that has since been deleted", () => { + expect(countActiveTaskCells(["task-1", "deleted-task"], live)).toBe(1); + }); + + it.each([ + { name: "empty cells", cells: [null, null] }, + { name: "the brainrot sentinel", cells: [BRAINROT_CELL] }, + { name: "terminal cells", cells: [makeTerminalCellValue("abc123")] }, + ])("does not count $name", ({ cells }) => { + expect(countActiveTaskCells(cells, live)).toBe(0); + }); + + it("counts a mixed grid correctly", () => { + expect( + countActiveTaskCells( + [null, BRAINROT_CELL, "task-1", "deleted", makeTerminalCellValue("t")], + live, + ), + ).toBe(1); + }); +}); diff --git a/packages/core/src/command-center/grid.ts b/packages/core/src/command-center/grid.ts index 7d2e5f8e16..36eaf5fa75 100644 --- a/packages/core/src/command-center/grid.ts +++ b/packages/core/src/command-center/grid.ts @@ -49,6 +49,21 @@ export function getTerminalCellCwd(value: string | null): string | null { return colon === -1 ? null : decodeURIComponent(rest.slice(colon + 1)); } +/** + * How many cells hold a task that still exists. + * + * Cells are persisted and only pruned when a task is archived — deleting one + * leaves its id behind forever — so a count has to be taken against the live + * task list rather than trusting the array's length. Excludes the brainrot and + * terminal sentinels, which are ambient chrome rather than parked work. + */ +export function countActiveTaskCells( + cells: readonly (string | null)[], + liveTaskIds: ReadonlySet, +): number { + return cells.filter((cell) => cell != null && liveTaskIds.has(cell)).length; +} + export function getGridDimensions(preset: LayoutPreset): GridDimensions { const [cols, rows] = preset.split("x").map(Number); return { cols, rows }; diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index f5285b079b..83f6edc458 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -891,6 +891,8 @@ export type ChannelActionType = | "leave_feedback" | "nav_click" | "open_channel" + /** Slid the sidebar back from a channel to the channel list. */ + | "browse_channels" | "collapse_channel" | "view_more_tasks" | "create" diff --git a/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx b/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx index b3f9ebeb96..5c36b5284d 100644 --- a/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx +++ b/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx @@ -29,6 +29,7 @@ import { useChannelMutations, useChannels, } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { useDashboard, useDashboards, @@ -145,6 +146,7 @@ function isAppView(value: string): value is AppView { } export function BrowserTabStrip() { + const spacesLayout = useChannelsLayout(); const logger = useService(ROOT_LOGGER); const snapshot = useTabsSnapshot(); const navigate = useNavigate(); @@ -509,7 +511,8 @@ export function BrowserTabStrip() { const meta = channelSectionFor(section); return { id: t.id, - label: meta?.label ?? channel ?? "Channel", + label: + meta?.label ?? channel ?? (spacesLayout ? "Space" : "Channel"), icon: , channelName: channel, // No section meta → the channel's index page. @@ -545,6 +548,7 @@ export function BrowserTabStrip() { params.taskId, routeChannelSection, routeAppView, + spacesLayout, ]); // Navigate to a tab, tagging the history entry with its id so the switch is diff --git a/packages/ui/src/features/browser-tabs/TabShortcutFallback.test.tsx b/packages/ui/src/features/browser-tabs/TabShortcutFallback.test.tsx new file mode 100644 index 0000000000..4151943bb4 --- /dev/null +++ b/packages/ui/src/features/browser-tabs/TabShortcutFallback.test.tsx @@ -0,0 +1,30 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { TabShortcutFallback } from "./TabShortcutFallback"; + +function pressCloseTab(): KeyboardEvent { + const event = new KeyboardEvent("keydown", { + key: "w", + code: "KeyW", + metaKey: true, + bubbles: true, + cancelable: true, + }); + document.dispatchEvent(event); + return event; +} + +describe("TabShortcutFallback", () => { + // Without a preventDefault here the key reaches Electron's Window ▸ Close + // role and takes the window — and everything unsaved in it — with it. + it("swallows Cmd+W so the host menu never sees it", () => { + render(); + expect(pressCloseTab().defaultPrevented).toBe(true); + }); + + // Disabled is how the BrowserTabStrip keeps ownership where it is mounted. + it("leaves the key alone when disabled", () => { + render(); + expect(pressCloseTab().defaultPrevented).toBe(false); + }); +}); diff --git a/packages/ui/src/features/browser-tabs/TabShortcutFallback.tsx b/packages/ui/src/features/browser-tabs/TabShortcutFallback.tsx new file mode 100644 index 0000000000..5ed7820766 --- /dev/null +++ b/packages/ui/src/features/browser-tabs/TabShortcutFallback.tsx @@ -0,0 +1,26 @@ +import { SHORTCUTS } from "@posthog/ui/features/command/keyboard-shortcuts"; +import { useHotkeys } from "react-hotkeys-hook"; + +/** + * Renders nothing — claims Cmd/Ctrl+W wherever BrowserTabStrip isn't mounted. + * + * The strip's own CLOSE_TAB handler preventDefaults unconditionally, because + * otherwise the key reaches Electron's Window ▸ Close role (`{ role: + * "windowMenu" }` in the host menu) and closes the window, losing everything in + * it. Any route that renders the app without the strip — the whole channels + * layout, and the settings shell either way — needs someone else to hold the key. + * + * The task view's editor panel keeps closing its own tab from + * usePanelKeyboardShortcuts; that handler runs too, and this one only swallows. + */ +export function TabShortcutFallback({ enabled }: { enabled: boolean }) { + useHotkeys( + SHORTCUTS.CLOSE_TAB, + (event) => { + event.preventDefault(); + }, + { enabled, enableOnFormTags: true, enableOnContentEditable: true }, + ); + + return null; +} diff --git a/packages/ui/src/features/canvas/AGENTS.md b/packages/ui/src/features/canvas/AGENTS.md index 0ea928dfbf..5c576960b7 100644 --- a/packages/ui/src/features/canvas/AGENTS.md +++ b/packages/ui/src/features/canvas/AGENTS.md @@ -25,25 +25,44 @@ The root `AGENTS.md` architecture rules still apply. - **Suffix `…` on anything that opens another step.** A menu item or button whose click opens a follow-up surface — a dialog, a nested menu, a picker, a confirmation — gets a trailing ellipsis (`…`, the character, not three dots) to - signal it isn't the final action: `New…`, `Rename channel…`, `Delete channel…`, + signal it isn't the final action: `New…`, `Rename space…`, `Delete space…`, `Choose a template…`. A label that performs its action immediately or navigates straight to a destination gets **no** ellipsis (`Edit CONTEXT.md`, `Star - channel`). When in doubt: does clicking it ask for more input or confirmation + space`). When in doubt: does clicking it ask for more input or confirmation before anything happens? If yes, add the `…`. ## Spaces & chrome -- Channels is a **top-level space** reached through the app rail (`AppNav`), +- Spaces is a **top-level space** reached through the app rail (`AppNav`), gated behind `project-bluebird` and wired in `routes/__root.tsx`. The rail's - spaces are Code (`/code`), Inbox (`/inbox`), and Channels (`/website`). -- The Channels space has **its own chrome**: rail + a persistent channel-list + spaces are Code (`/code`), Inbox (`/inbox`), and Spaces (`/website`). +- The Spaces UI has **its own chrome**: rail + a persistent channel-list sidebar (`ChannelsList`, rendered in `__root`) + the `WebsiteLayout` outlet. It does NOT use the code `HeaderRow`/`MainSidebar`, so breadcrumbs render in `WebsiteLayout`'s own top bar (below). +- Under the channels layout the sidebar is a **master/detail slider** + (`ChannelPanes` in `ChannelsSidebar.tsx`): the searchable channel list, and the + channel you're in (`ChannelSidebar`, headed by `ChannelBackRow`). Both panes + stay mounted — the offscreen one is `inert` — so the slide has something to + slide and returning to the list doesn't rebuild every row. A two-finger + horizontal swipe moves between them (`useChannelPaneSwipe`, wheel `deltaX` + accumulated per gesture and locked until the wheel goes quiet). +- In the list, "Starred"/"Channels" are headings, not parents: under the layout + the rows sit at the heading's level (no indent) and the "#"/lock glyph belongs + to the rows. The alpha's indented tree is unchanged. +- One `ChannelsFab` serves both panes: given a `channelId` it creates inside + that channel (task, canvas), and either way it can create a channel. Off the + layout it keeps its original two-item menu. Archived moves out of the sidebar + and into the account menu (`ProjectSwitcher`), beside Settings. +- **Which pane shows is view state, not a route.** `channelPaneStore` holds it, + separately from the scoped channel (`currentChannelStore`): "back to channels" + browses the list while the route, the main pane and the scoped channel stay + put. Every way into a channel — a row click, a deep link, a mention, ⌘1-9 — + ends at `showChannelPane()`, directly or through the route effect. ## Breadcrumbs -- **`WebsiteLayout` renders its own top bar.** The Channels space has no code +- **`WebsiteLayout` renders its own top bar.** The Spaces UI has no code `HeaderRow`, so breadcrumbs (and the dashboard controls) are a local bar inside `WebsiteLayout`, not pushed through the header store. - **A page does not get its own crumb — its H1 is the title.** A view that diff --git a/packages/ui/src/features/canvas/components/ActivityView.tsx b/packages/ui/src/features/canvas/components/ActivityView.tsx index 18b186efa7..4f32695523 100644 --- a/packages/ui/src/features/canvas/components/ActivityView.tsx +++ b/packages/ui/src/features/canvas/components/ActivityView.tsx @@ -20,6 +20,7 @@ import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar"; import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; import { MentionText } from "@posthog/ui/features/canvas/components/MentionText"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { useMarkTaskActivityRead } from "@posthog/ui/features/canvas/hooks/useMarkTaskActivityRead"; import { useTaskActivity } from "@posthog/ui/features/canvas/hooks/useTaskActivity"; import { normalizeChannelName } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; @@ -202,6 +203,7 @@ function ActivityRow({ // in, or messaged in — newest activity first. Rows clear as they are opened, not // when the page is; merely landing here shouldn't dismiss what you haven't read. export function ActivityView() { + const spacesLayout = useChannelsLayout(); const client = useOptionalAuthenticatedClient(); const { data: currentUser } = useCurrentUser({ client }); const { items, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage } = @@ -246,7 +248,8 @@ export function ActivityView() { Activity - Tasks you're involved in across channels. + Tasks you're involved in across {spacesLayout ? "spaces" : "channels"} + .
{isLoading && items.length === 0 ? ( @@ -261,8 +264,8 @@ export function ActivityView() { No activity yet - Tasks you create, get tagged in, or reply to across channels - land here. + Tasks you create, get tagged in, or reply to across{" "} + {spacesLayout ? "spaces" : "channels"} land here. diff --git a/packages/ui/src/features/canvas/components/ChannelBackRow.test.tsx b/packages/ui/src/features/canvas/components/ChannelBackRow.test.tsx new file mode 100644 index 0000000000..933aa37cf0 --- /dev/null +++ b/packages/ui/src/features/canvas/components/ChannelBackRow.test.tsx @@ -0,0 +1,78 @@ +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"; + +const mocks = vi.hoisted(() => ({ + channels: [] as { id: string; name: string; path: string }[], + isLoading: false, + toggleStar: vi.fn(), +})); + +vi.mock("@posthog/ui/shell/analytics", () => ({ track: vi.fn() })); +vi.mock("@posthog/ui/features/canvas/hooks/useChannels", () => ({ + useChannels: () => ({ channels: mocks.channels, isLoading: mocks.isLoading }), +})); +vi.mock("@posthog/ui/features/canvas/hooks/useChannelStars", () => ({ + useChannelStarToggle: () => ({ + isStarred: false, + toggleStar: mocks.toggleStar, + }), +})); + +import { useChannelPaneStore } from "@posthog/ui/features/canvas/stores/channelPaneStore"; +import { ChannelBackRow } from "./ChannelBackRow"; + +const ENG = { id: "eng-id", name: "engineering", path: "/engineering" }; +const ME = { id: "me-id", name: "me", path: "/me" }; + +function renderRow(channelId: string) { + return render( + + + , + ); +} + +describe("ChannelBackRow", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.channels = [ME, ENG]; + mocks.isLoading = false; + useChannelPaneStore.setState({ pane: "channel" }); + }); + + it("names the channel you're in", () => { + renderRow(ENG.id); + expect(screen.getByText("engineering")).toBeTruthy(); + }); + + it("slides back to the channel list", async () => { + const user = userEvent.setup(); + renderRow(ENG.id); + + await user.click(screen.getByRole("button", { name: "Back to spaces" })); + + expect(useChannelPaneStore.getState().pane).toBe("list"); + }); + + // #me can't be unstarred, but the well stays filled so the row doesn't + // change height (and everything below it shift) when you switch channels. + it("offers a star on shared channels only", () => { + renderRow(ENG.id); + expect(screen.getByRole("button", { name: "Star space" })).toBeTruthy(); + + renderRow(ME.id); + expect(screen.getAllByRole("button", { name: "Star space" })).toHaveLength( + 1, + ); + }); + + // A channel the project doesn't have must not read as one that's still + // loading, or the sidebar looks stuck rather than wrong. + it("says so when the channel can't be resolved", () => { + mocks.channels = []; + renderRow("gone"); + expect(screen.getByText("Unavailable")).toBeTruthy(); + }); +}); diff --git a/packages/ui/src/features/canvas/components/ChannelBackRow.tsx b/packages/ui/src/features/canvas/components/ChannelBackRow.tsx new file mode 100644 index 0000000000..99d24c87fd --- /dev/null +++ b/packages/ui/src/features/canvas/components/ChannelBackRow.tsx @@ -0,0 +1,109 @@ +import { CaretLeftIcon, StarIcon } from "@phosphor-icons/react"; +import { Skeleton } from "@posthog/quill"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { channelGlyph } from "@posthog/ui/features/canvas/components/channelGlyph"; +import { useChannelStarToggle } from "@posthog/ui/features/canvas/hooks/useChannelStars"; +import { + type Channel, + useChannels, +} from "@posthog/ui/features/canvas/hooks/useChannels"; +import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; +import { showChannelList } from "@posthog/ui/features/canvas/stores/channelPaneStore"; +import { Tooltip } from "@posthog/ui/primitives/Tooltip"; +import { track } from "@posthog/ui/shell/analytics"; + +// An overlay rather than a sibling: the back button fills the row, and nesting +// the star inside it would be a button within a button. +function RowStar({ channel }: { channel: Channel }) { + const { isStarred, toggleStar } = useChannelStarToggle(channel); + return ( + + ); +} + +/** + * The channel pane's header: the channel you're in, and the way back out of it. + * + * Clicking anywhere on the row slides the sidebar back to the channel list — + * the list is where switching happens, so this row only has to be the door to + * it. Leaving the channel scoped means the route (and the main pane) stay put. + */ +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; + + return ( +
+ + + + {showStar && current && } + {/* Inert star for #me, so the row reads the same on every channel. */} + {current && !showStar && ( + + + + )} +
+ ); +} diff --git a/packages/ui/src/features/canvas/components/ChannelBreadcrumb.tsx b/packages/ui/src/features/canvas/components/ChannelBreadcrumb.tsx index 6644ac1ddb..763d156e32 100644 --- a/packages/ui/src/features/canvas/components/ChannelBreadcrumb.tsx +++ b/packages/ui/src/features/canvas/components/ChannelBreadcrumb.tsx @@ -1,10 +1,10 @@ -import { HashIcon } from "@phosphor-icons/react"; import { Button, Tooltip, TooltipContent, TooltipTrigger, } from "@posthog/quill"; +import { channelGlyph } from "@posthog/ui/features/canvas/components/channelGlyph"; import { HeaderTitleEditor } from "@posthog/ui/features/task-detail/HeaderTitleEditor"; import { Flex, Text } from "@radix-ui/themes"; import { useNavigate } from "@tanstack/react-router"; @@ -52,7 +52,10 @@ export function ChannelBreadcrumb({ const channelSegment = ( <> - + {channelGlyph(channelName, { + size: 12, + className: "mt-px shrink-0 text-muted-foreground/80", + })} ({ + useChannelsLayout: () => false, +})); + // Radix's ScrollArea observes resizes; jsdom lacks ResizeObserver. if (typeof globalThis.ResizeObserver === "undefined") { globalThis.ResizeObserver = class { diff --git a/packages/ui/src/features/canvas/components/ChannelContextPanel.tsx b/packages/ui/src/features/canvas/components/ChannelContextPanel.tsx index 2d4d21d754..5e1904ade3 100644 --- a/packages/ui/src/features/canvas/components/ChannelContextPanel.tsx +++ b/packages/ui/src/features/canvas/components/ChannelContextPanel.tsx @@ -1,4 +1,5 @@ import { X } from "@phosphor-icons/react"; +import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { MarkdownRenderer } from "@posthog/ui/features/editor/components/MarkdownRenderer"; import { Box, Flex, ScrollArea, Text, Tooltip } from "@radix-ui/themes"; @@ -16,6 +17,7 @@ export function ChannelContextPanel({ body, onClose, }: ChannelContextPanelProps) { + const spacesLayout = useChannelsLayout(); return ( - Included with new tasks in this channel as background context. + Included with new tasks in this {spacesLayout ? "space" : "channel"}{" "} + as background context. diff --git a/packages/ui/src/features/canvas/components/ChannelFeedView.stories.tsx b/packages/ui/src/features/canvas/components/ChannelFeedView.stories.tsx index 8ff99ea14a..aa5aa64065 100644 --- a/packages/ui/src/features/canvas/components/ChannelFeedView.stories.tsx +++ b/packages/ui/src/features/canvas/components/ChannelFeedView.stories.tsx @@ -28,9 +28,9 @@ const task = (overrides: Partial = {}): Task => ({ id: "task-1", task_number: 1, slug: "task-1", - title: "Add feedback modal to channels view", + title: "Add feedback modal to spaces view", description: - "Add a feedback modal to the channels view so people can share thoughts without leaving the feed", + "Add a feedback modal to the spaces view so people can share thoughts without leaving the feed", created_at: "2026-07-17T12:00:00.000Z", updated_at: "2026-07-17T12:00:00.000Z", origin_product: "user_created", @@ -39,7 +39,7 @@ const task = (overrides: Partial = {}): Task => ({ }); const meta: Meta = { - title: "Channels/TaskFeedRow", + title: "Spaces/TaskFeedRow", component: TaskFeedRow, decorators: [ (Story) => ( @@ -56,7 +56,7 @@ type Story = StoryObj; export const HumanStarted: Story = { args: { task: task(), - children: , + children: , }, }; @@ -65,7 +65,7 @@ export const HumanEmailOnly: Story = { task: task({ created_by: user({ first_name: undefined, last_name: undefined }), title: "Make background color configurable", - description: "Make the channel background color configurable in settings", + description: "Make the space background color configurable in settings", }), children: , }, @@ -86,9 +86,9 @@ export const LongPrompt: Story = { args: { task: task({ description: - "Rework the channel feed so each row reads as the person who started the task rather than the agent, show a preview of their prompt under the header, keep the task card below, and make sure long prompts truncate cleanly instead of pushing the card down the feed", + "Rework the space feed so each row reads as the person who started the task rather than the agent, show a preview of their prompt under the header, keep the task card below, and make sure long prompts truncate cleanly instead of pushing the card down the feed", }), - children: , + children: , }, }; diff --git a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx index 369a0cf1d0..717cf52ec3 100644 --- a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx +++ b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx @@ -6,6 +6,10 @@ import { RobotIcon, } from "@phosphor-icons/react"; import { taskFeedRunStatus } from "@posthog/core/canvas/channelFeed"; +import { + RUN_STATUS_LABELS, + runStatusVariant, +} from "@posthog/core/canvas/runStatus"; import { xmlToPlainText } from "@posthog/core/message-editor/content"; import { Avatar, @@ -76,17 +80,6 @@ import { // shared query key means an open panel naturally speeds the row up too. const FEED_REPLIES_POLL_INTERVAL_MS = 15_000; -const STATUS_LABELS: Record = { - not_started: "Not started", - queued: "Queued", - in_progress: "In progress", - // "Ready", not "Completed": the agent has finished its work and the task is - // ready to look at, but the change itself isn't necessarily shipped/done. - completed: "Ready", - failed: "Failed", - cancelled: "Cancelled", -}; - // Once a PR exists its GitHub state is the truest top-line status — more // accurate than the run status, which routinely lingers on "in_progress" // (or a stale cloud status) after the agent opens the PR. Mirrors the PR @@ -102,18 +95,10 @@ const PR_STATE_LABELS: Record< }; function statusBadge(status: TaskRunStatus) { - const variant = - status === "completed" - ? "success" - : status === "failed" - ? "destructive" - : status === "in_progress" - ? "info" - : "default"; return ( - + {status === "in_progress" && } - {STATUS_LABELS[status]} + {RUN_STATUS_LABELS[status]} ); } diff --git a/packages/ui/src/features/canvas/components/ChannelHeader.tsx b/packages/ui/src/features/canvas/components/ChannelHeader.tsx index bc08328a46..69dc6d35bf 100644 --- a/packages/ui/src/features/canvas/components/ChannelHeader.tsx +++ b/packages/ui/src/features/canvas/components/ChannelHeader.tsx @@ -1,25 +1,57 @@ -import { HashIcon } from "@phosphor-icons/react"; +import { StarIcon } from "@phosphor-icons/react"; import { Button, cn } from "@posthog/quill"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { ChannelTabs } from "@posthog/ui/features/canvas/components/ChannelTabs"; -import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { channelGlyph } from "@posthog/ui/features/canvas/components/channelGlyph"; +import { useChannelStarToggle } from "@posthog/ui/features/canvas/hooks/useChannelStars"; +import { + type Channel, + useChannels, +} from "@posthog/ui/features/canvas/hooks/useChannels"; +import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { useMarkChannelSeen } from "@posthog/ui/features/canvas/hooks/useMarkChannelSeen"; +import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; +import { track } from "@posthog/ui/shell/analytics"; import { Text } from "@radix-ui/themes"; import { useNavigate, useRouterState } from "@tanstack/react-router"; -// The shared channel header: a clickable "# channel" that doubles as the Home -// item — it routes to the channel home (`/website/$channelId`, like the sidebar -// channel row) and highlights `bg-fill-selected` while you're there, the same -// pathname-driven active state the rest of the channel tab strip uses. Followed -// by that strip (Artifacts / Recents / CONTEXT.md), rendered into the -// header bar by every channel view so the tabs stay in view. +// The feed-side counterpart to the sidebar back row's hover star. +function ChannelStarButton({ channel }: { channel: Channel }) { + const { isStarred, toggleStar } = useChannelStarToggle(channel); + return ( + + ); +} + +// The shared channel header. The new layout drops the section tab strip — the +// channel sidebar carries those entries — while flag off keeps it. export function ChannelHeader({ channelId }: { channelId: string }) { const navigate = useNavigate(); + const channelsLayout = useChannelsLayout(); const { channels } = useChannels(); - const channelName = channels.find((c) => c.id === channelId)?.name; + const channel = channels.find((c) => c.id === channelId); + const channelName = channel?.name; const pathname = useRouterState({ select: (s) => s.location.pathname }); const isHome = pathname === `/website/${channelId}`; - // Every channel surface renders this header, so it is where "the viewer is - // in this channel" is known — and therefore where the channel is marked read. + // Every channel surface renders this header, so mark the channel read here. useMarkChannelSeen(channelName); return ( @@ -33,12 +65,18 @@ export function ChannelHeader({ channelId }: { channelId: string }) { size="sm" className={cn("min-w-0", isHome ? "bg-fill-selected" : "")} > - + {channelGlyph(channelName, { + size: 20, + className: "shrink-0 text-muted-foreground/80", + })} - {channelName ?? "Channel"} + {channelName ?? (channelsLayout ? "Space" : "Channel")} - + {channelsLayout && channel && channel.name !== PERSONAL_CHANNEL_NAME && ( + + )} + {!channelsLayout && }
); } diff --git a/packages/ui/src/features/canvas/components/ChannelHotkeys.test.tsx b/packages/ui/src/features/canvas/components/ChannelHotkeys.test.tsx new file mode 100644 index 0000000000..696c78a398 --- /dev/null +++ b/packages/ui/src/features/canvas/components/ChannelHotkeys.test.tsx @@ -0,0 +1,96 @@ +import { render } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + channelsLayout: true, + slots: [] as { id: string; name: string; path: string }[], + navigateToChannel: vi.fn(), +})); + +vi.mock("@posthog/ui/features/canvas/hooks/useChannelsLayout", () => ({ + useChannelsLayout: () => mocks.channelsLayout, +})); +vi.mock("@posthog/ui/features/canvas/hooks/useStarredChannelSlots", () => ({ + useStarredChannelSlots: () => ({ + slots: mocks.slots, + rest: [], + slotFor: () => undefined, + }), +})); +vi.mock("@posthog/ui/router/navigationBridge", () => ({ + navigateToChannel: (...args: unknown[]) => mocks.navigateToChannel(...args), +})); +vi.mock("@posthog/ui/shell/analytics", () => ({ track: vi.fn() })); + +import { useCurrentChannelStore } from "@posthog/ui/features/canvas/stores/currentChannelStore"; +import { ChannelHotkeys } from "./ChannelHotkeys"; + +function press(digit: string, modifiers: Partial = {}) { + document.dispatchEvent( + new KeyboardEvent("keydown", { + key: digit, + code: `Digit${digit}`, + bubbles: true, + cancelable: true, + ...modifiers, + }), + ); +} + +describe("ChannelHotkeys", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.channelsLayout = true; + mocks.slots = [ + { id: "me-id", name: "me", path: "/me" }, + { id: "eng-id", name: "eng", path: "/eng" }, + ]; + useCurrentChannelStore.setState({ currentChannelId: null }); + }); + + // The regression: this component is rendered ALONE — no sidebar at all. + // Binding the keys inside the sidebar left them unowned exactly when the + // channel list hadn't resolved yet. + it("switches channels without the sidebar being mounted", () => { + render(); + + press("1", { metaKey: true }); + + expect(mocks.navigateToChannel).toHaveBeenCalledWith("me-id"); + expect(useCurrentChannelStore.getState().currentChannelId).toBe("me-id"); + }); + + it("maps slot 2 to the first starred channel", () => { + render(); + press("2", { metaKey: true }); + expect(mocks.navigateToChannel).toHaveBeenCalledWith("eng-id"); + }); + + // mod+0 belongs to the host's "Actual Size" accelerator. + it("ignores mod+0", () => { + render(); + press("0", { metaKey: true }); + expect(mocks.navigateToChannel).not.toHaveBeenCalled(); + }); + + // ctrl+1-9 is the editor-panel tab switcher on every platform. + it("leaves pure ctrl presses to the panel tab switcher", () => { + render(); + press("1", { ctrlKey: true }); + expect(mocks.navigateToChannel).not.toHaveBeenCalled(); + }); + + it("does nothing for a slot with no channel behind it", () => { + mocks.slots = [{ id: "me-id", name: "me", path: "/me" }]; + render(); + press("5", { metaKey: true }); + expect(mocks.navigateToChannel).not.toHaveBeenCalled(); + }); + + it("stays out of the way when the layout is off", () => { + mocks.channelsLayout = false; + render(); + press("1", { metaKey: true }); + expect(mocks.navigateToChannel).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ui/src/features/canvas/components/ChannelHotkeys.tsx b/packages/ui/src/features/canvas/components/ChannelHotkeys.tsx new file mode 100644 index 0000000000..59a839e041 --- /dev/null +++ b/packages/ui/src/features/canvas/components/ChannelHotkeys.tsx @@ -0,0 +1,54 @@ +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; +import { useStarredChannelSlots } from "@posthog/ui/features/canvas/hooks/useStarredChannelSlots"; +import { useCurrentChannelStore } from "@posthog/ui/features/canvas/stores/currentChannelStore"; +import { SHORTCUTS } from "@posthog/ui/features/command/keyboard-shortcuts"; +import { navigateToChannel } from "@posthog/ui/router/navigationBridge"; +import { track } from "@posthog/ui/shell/analytics"; +import { useHotkeys } from "react-hotkeys-hook"; + +/** + * Renders nothing — the unconditional owner of ⌘1-9 (switch channel) under the + * channels layout. + * + * Mounted from the root rather than from the sidebar: the sidebar only renders + * its channel pane once a channel is already scoped, so binding there left the + * keys with no owner exactly when the user most needs them (channel list still + * loading, or failed). + * GlobalEventHandlers yields SWITCH_TASK to this whenever the layout is on, so + * there must always be someone listening. + */ +export function ChannelHotkeys() { + const channelsLayout = useChannelsLayout(); + const { slots } = useStarredChannelSlots(); + const setCurrentChannel = useCurrentChannelStore((s) => s.setCurrentChannel); + + useHotkeys( + SHORTCUTS.SWITCH_STARRED_CHANNEL, + (event, handler) => { + // Same ctrl guard as SWITCH_TASK: plain ctrl+N is the editor-panel tab + // switcher (SWITCH_TAB), so leave ctrl-only presses to it. + if (event.ctrlKey && !event.metaKey) return; + const slot = Number.parseInt(handler.keys?.[0] ?? "", 10); + if (Number.isNaN(slot)) return; + const channel = slots[slot - 1]; + if (!channel) return; + setCurrentChannel(channel.id); + navigateToChannel(channel.id); + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "open_channel", + surface: "sidebar", + channel_id: channel.id, + }); + }, + { + enabled: channelsLayout, + enableOnFormTags: true, + enableOnContentEditable: true, + preventDefault: true, + }, + [slots, setCurrentChannel], + ); + + return null; +} diff --git a/packages/ui/src/features/canvas/components/ChannelIntro.tsx b/packages/ui/src/features/canvas/components/ChannelIntro.tsx index 75cd9583ba..efdd267c03 100644 --- a/packages/ui/src/features/canvas/components/ChannelIntro.tsx +++ b/packages/ui/src/features/canvas/components/ChannelIntro.tsx @@ -9,6 +9,7 @@ import { import { getLocalDayDiff } from "@posthog/shared"; import type { TaskChannel } from "@posthog/shared/domain-types"; import { mentionChipClass } from "@posthog/ui/features/canvas/components/MentionText"; +import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; import { Heading, Text } from "@radix-ui/themes"; import { FileCheckCorner, FilePlusCorner, Info } from "lucide-react"; @@ -44,6 +45,8 @@ export function ChannelIntro({ onCreateContextMd: () => void; }) { const creator = channel?.created_by; + const spacesLayout = useChannelsLayout(); + const noun = spacesLayout ? "space" : "channel"; return (
@@ -55,9 +58,9 @@ export function ChannelIntro({ @{userDisplayName(creator ?? null)} {" "} - created this channel {creationDatePhrase(channel.created_at)}. This + created this {noun} {creationDatePhrase(channel.created_at)}. This is the very beginning of the{" "} - {channelName} channel. + {channelName} {noun}. )}
@@ -70,7 +73,7 @@ export function ChannelIntro({ Created context.md - Used in all sessions within this channel + Used in all sessions within this {noun} @@ -125,10 +128,11 @@ export function ChannelIntro({ - Learn more about channels + + Learn more about {spacesLayout ? "spaces" : "channels"} + - A channel is a group of tasks that are related to a specific - topic. + A {noun} is a group of tasks that are related to a specific topic. diff --git a/packages/ui/src/features/canvas/components/ChannelItemRow.test.tsx b/packages/ui/src/features/canvas/components/ChannelItemRow.test.tsx new file mode 100644 index 0000000000..0f87c264d1 --- /dev/null +++ b/packages/ui/src/features/canvas/components/ChannelItemRow.test.tsx @@ -0,0 +1,76 @@ +import type { ChannelItemModel } from "@posthog/core/canvas/channelItems"; +import type { TaskRunStatus } from "@posthog/shared/domain-types"; +import { Theme } from "@radix-ui/themes"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { ChannelItemRow } from "./ChannelItemRow"; + +const actions = { + open: () => {}, + togglePin: () => {}, + archive: () => {}, +}; + +function item(overrides: Partial = {}): ChannelItemModel { + return { + key: "task:task-1", + kind: "task", + id: "task-1", + title: "Investigate signup drop-off", + ts: Date.parse("2026-07-17T12:00:00.000Z"), + pinned: false, + rawStatus: null, + authorUser: null, + authorName: null, + templateId: null, + ...overrides, + }; +} + +function renderRow(model: ChannelItemModel) { + return render( + + + , + ); +} + +describe("ChannelItemRow", () => { + 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); + }); + + it("leaves a canvas, which has no run to wait on, static", () => { + renderRow( + item({ + key: "canvas:canvas-1", + kind: "canvas", + id: "canvas-1", + title: "Web analytics overview", + templateId: "web-analytics", + }), + ); + + 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(); + }); +}); diff --git a/packages/ui/src/features/canvas/components/ChannelItemRow.tsx b/packages/ui/src/features/canvas/components/ChannelItemRow.tsx index b19beca7da..d9ba598093 100644 --- a/packages/ui/src/features/canvas/components/ChannelItemRow.tsx +++ b/packages/ui/src/features/canvas/components/ChannelItemRow.tsx @@ -2,6 +2,7 @@ import { PreviewCard } from "@base-ui/react/preview-card"; import { Archive, FileTextIcon, PushPin } from "@phosphor-icons/react"; import type { ChannelItemModel } from "@posthog/core/canvas/channelItems"; import { + isRunStatusActive, runStatusLabel, runStatusVariant, } from "@posthog/core/canvas/runStatus"; @@ -47,6 +48,19 @@ function itemIcon(item: ChannelItemModel): ReactNode { ); } +/** + * 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} + + ); +} + function authorLabel(item: ChannelItemModel): string | null { if (item.authorUser) return userDisplayName(item.authorUser); return item.authorName; @@ -64,6 +78,13 @@ export function ChannelItemRow({ const icon = itemIcon(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 + ); return ( @@ -74,7 +95,7 @@ export function ChannelItemRow({
{item.title}} isActive={isActive} diff --git a/packages/ui/src/features/canvas/components/ChannelNav.tsx b/packages/ui/src/features/canvas/components/ChannelNav.tsx new file mode 100644 index 0000000000..c95247fa7a --- /dev/null +++ b/packages/ui/src/features/canvas/components/ChannelNav.tsx @@ -0,0 +1,123 @@ +import { BellIcon, EnvelopeSimple, Lightning } from "@phosphor-icons/react"; +import { cn } from "@posthog/quill"; +import { + ANALYTICS_EVENTS, + type SidebarNavItem, +} from "@posthog/shared/analytics-events"; +import { useTaskActivity } from "@posthog/ui/features/canvas/hooks/useTaskActivity"; +import { + formatHotkey, + SHORTCUTS, +} from "@posthog/ui/features/command/keyboard-shortcuts"; +import { useCommandCenterActiveCount } from "@posthog/ui/features/command-center/useCommandCenterActiveCount"; +import { useInboxAllReports } from "@posthog/ui/features/inbox/hooks/useInboxAllReports"; +import { CountBadge } from "@posthog/ui/primitives/CountBadge"; +import { Tooltip } from "@posthog/ui/primitives/Tooltip"; +import { + navigateToActivity, + navigateToInbox, + navigateToWebsiteCommandCenter, +} from "@posthog/ui/router/navigationBridge"; +import { useAppView } from "@posthog/ui/router/useAppView"; +import { track } from "@posthog/ui/shell/analytics"; +import type { ReactNode } from "react"; + +const INBOX_REFETCH_INTERVAL_MS = 60_000; + +const ICON_BADGE_CLASS = + "-top-1 -right-1 absolute h-3.5 min-w-3.5 w-auto px-1 font-semibold text-[9px] ring-2 ring-chrome"; + +function NavIcon({ + icon, + label, + shortcut, + isActive, + onClick, + badge, +}: { + icon: ReactNode; + label: string; + shortcut?: string; + isActive: boolean; + onClick: () => void; + badge?: ReactNode; +}) { + return ( + + + + ); +} + +export function ChannelNav() { + const view = useAppView(); + + const { counts } = useInboxAllReports({ + ignoreFilters: true, + refetchIntervalMs: INBOX_REFETCH_INTERVAL_MS, + }); + const { unreadCount: unseenActivity } = useTaskActivity(); + const commandCenterCount = useCommandCenterActiveCount(); + + const withTrack = (item: SidebarNavItem, action: () => void) => () => { + track(ANALYTICS_EVENTS.SIDEBAR_NAV_ITEM_CLICKED, { + item, + in_more: false, + layout: "channels", + }); + action(); + }; + + const isActivity = view.type === "activity"; + const isCommandCenter = view.type === "command-center"; + + return ( +
+ } + label="Inbox" + shortcut={formatHotkey(SHORTCUTS.INBOX)} + isActive={view.type === "inbox"} + onClick={withTrack("inbox", navigateToInbox)} + badge={} + /> + } + label="Activity" + isActive={isActivity} + onClick={withTrack("activity", navigateToActivity)} + badge={ + + } + /> + + } + label="Command Center" + isActive={isCommandCenter} + onClick={withTrack("command_center", navigateToWebsiteCommandCenter)} + badge={ + + } + /> +
+ ); +} diff --git a/packages/ui/src/features/canvas/components/ChannelSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelSidebar.tsx new file mode 100644 index 0000000000..cad0552652 --- /dev/null +++ b/packages/ui/src/features/canvas/components/ChannelSidebar.tsx @@ -0,0 +1,382 @@ +import { + BookOpenTextIcon, + ChatsCircleIcon, + FunnelSimple as FunnelSimpleIcon, + MagnifyingGlass, + PackageIcon, + RepeatIcon, +} from "@phosphor-icons/react"; +import type { CreatedByFilter } from "@posthog/core/canvas/channelItems"; +import { filterChannelItems } from "@posthog/core/canvas/channelItems"; +import { RUN_STATUS_FILTER_OPTIONS } from "@posthog/core/canvas/runStatus"; +import { + cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, + Input, + MenuLabel, + Skeleton, +} from "@posthog/quill"; +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 { useChannelItems } from "@posthog/ui/features/canvas/hooks/useChannelItems"; +import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; +import { SidebarItem } from "@posthog/ui/features/sidebar/components/SidebarItem"; +import { useNavigate, useRouterState } from "@tanstack/react-router"; +import { type ReactNode, useMemo, useState } from "react"; + +const CREATED_BY_OPTIONS: readonly { value: CreatedByFilter; label: string }[] = + [ + { value: "anyone", label: "Anyone" }, + { value: "me", label: "Me" }, + { 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"; + +const cnHeaderButton = (active: boolean) => + cn(HEADER_ICON_BUTTON_CLASS, active && "bg-fill-selected text-foreground"); + +const RECENTS_CAP = 30; + +function RecentSectionHeader({ + searchOpen, + onToggleSearch, + query, + onQueryChange, + createdByFilter, + onCreatedByChange, + statusFilter, + onStatusChange, + filtersActive, +}: { + searchOpen: boolean; + onToggleSearch: () => void; + query: string; + onQueryChange: (value: string) => void; + createdByFilter: CreatedByFilter; + onCreatedByChange: (value: CreatedByFilter) => void; + statusFilter: TaskRunStatus | null; + onStatusChange: (value: TaskRunStatus | null) => void; + filtersActive: boolean; +}) { + return ( + <> +
+
+ Recent +
+ + + + + + } + /> + + Created by + + onCreatedByChange(value as CreatedByFilter) + } + > + {CREATED_BY_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + Status + + onStatusChange( + value === "any" ? null : (value as TaskRunStatus), + ) + } + > + {RUN_STATUS_FILTER_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + +
+ {searchOpen && ( +
+ onQueryChange(event.target.value)} + placeholder="Search…" + aria-label="Search recent items" + className="h-6 text-[12px]" + /> +
+ )} + + ); +} + +// Varied widths so the loading state reads as the list it becomes. +const SKELETON_ROW_WIDTHS = [ + "w-3/5", + "w-4/5", + "w-2/5", + "w-3/4", + "w-1/2", + "w-2/3", +] as const; + +function ChannelItemsSkeleton() { + return ( +
+ + {SKELETON_ROW_WIDTHS.map((width) => ( +
+ + +
+ ))} +
+ ); +} + +/** + * The channel pane of the sidebar slider: the way back to the channel list, + * the channel's sections, then its pinned and recent tasks & canvases. + */ +export function ChannelSidebar({ channelId }: { channelId: string }) { + const navigate = useNavigate(); + const pathname = useRouterState({ select: (s) => s.location.pathname }); + const loopsEnabled = useFeatureFlag(LOOPS_FLAG, import.meta.env.DEV); + + const { items, actions, me, isLoading, channelMissing } = + useChannelItems(channelId); + + const [searchOpen, setSearchOpen] = useState(false); + const [query, setQuery] = useState(""); + const [createdByFilter, setCreatedByFilter] = + useState("anyone"); + const [statusFilter, setStatusFilter] = useState(null); + const filtersActive = createdByFilter !== "anyone" || statusFilter !== null; + + const base = `/website/${channelId}`; + // Activeness is a key comparison rather than a flag baked into each item, so + // navigating doesn't rebuild the list. + const activeKey = useMemo(() => { + const dashboard = pathname.match(/\/dashboards\/([^/]+)$/); + if (dashboard) return `canvas:${dashboard[1]}`; + const task = pathname.match(/\/tasks\/([^/]+)$/); + 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], + ); + + const sectionRow = ( + label: string, + icon: ReactNode, + to: string, + onClick: () => void, + ) => ( + + ); + + return ( +
+ + +
+ {sectionRow( + "Feed", + , + base, + () => + void navigate({ to: "/website/$channelId", params: { channelId } }), + )} + {sectionRow( + "Context", + , + `${base}/context`, + () => + void navigate({ + to: "/website/$channelId/context", + params: { channelId }, + }), + )} + {loopsEnabled && + sectionRow( + "Loops", + , + `${base}/loops`, + () => + void navigate({ + to: "/website/$channelId/loops", + params: { channelId }, + }), + )} + {sectionRow( + "Artifacts", + , + `${base}/artifacts`, + () => + void navigate({ + to: "/website/$channelId/artifacts", + params: { channelId }, + }), + )} +
+ + {/* Relative so the FAB can float over the list. */} +
+
+ {isLoading && items.length === 0 && } + + {channelMissing && ( + + + + + + Space unavailable + + It may have been deleted, or belong to another project. + + + + )} + + {pinnedItems.length > 0 && ( + <> + Pinned +
+ {pinnedItems.map((item) => ( + + ))} +
+ + )} + + {(items.some((i) => !i.pinned) || filtersActive || searchOpen) && ( + <> + { + if (searchOpen) setQuery(""); + setSearchOpen(!searchOpen); + }} + query={query} + onQueryChange={setQuery} + createdByFilter={createdByFilter} + onCreatedByChange={setCreatedByFilter} + statusFilter={statusFilter} + onStatusChange={setStatusFilter} + filtersActive={filtersActive} + /> + {recentItems.length > 0 ? ( +
+ {recentItems.map((item) => ( + + ))} +
+ ) : ( + + + + + + No matches + + Try a different search or clear the filters. + + + + )} + + )} + + {!isLoading && !channelMissing && items.length === 0 && ( + + + + + + Nothing here yet + + Tasks and canvases you create in this space show up here. + + + + )} +
+ +
+
+ ); +} diff --git a/packages/ui/src/features/canvas/components/ChannelsFab.tsx b/packages/ui/src/features/canvas/components/ChannelsFab.tsx index 1afff9639e..a7e8be4fc0 100644 --- a/packages/ui/src/features/canvas/components/ChannelsFab.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsFab.tsx @@ -1,67 +1,153 @@ -import { FileTextIcon, HashIcon, PlusIcon } from "@phosphor-icons/react"; +import { + ChartBarIcon, + FileTextIcon, + HashIcon, + PlusIcon, +} from "@phosphor-icons/react"; import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, Tooltip, TooltipContent, TooltipTrigger, } from "@posthog/quill"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { CreateChannelModal } from "@posthog/ui/features/canvas/components/CreateChannelModal"; +import { trackAndCreateCanvas } from "@posthog/ui/features/canvas/createCanvasAnalytics"; +import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; +import { useCreateAndOpenDashboard } from "@posthog/ui/features/canvas/hooks/useDashboards"; +import { + formatHotkey, + SHORTCUTS, +} from "@posthog/ui/features/command/keyboard-shortcuts"; +import { isContentEmpty } from "@posthog/ui/features/message-editor/content"; +import { useDraftStore } from "@posthog/ui/features/message-editor/draftStore"; +import { Tooltip as ShortcutTooltip } from "@posthog/ui/primitives/Tooltip"; import { openTaskInput } from "@posthog/ui/router/useOpenTask"; +import { track } from "@posthog/ui/shell/analytics"; import { useRouterState } from "@tanstack/react-router"; import { useState } from "react"; -// The create affordance for the Channels space, floated over the bottom-right -// of the channel list. It owns the create-channel modal (the list itself has no -// other entry point) and opens its menu upward, since it sits at the bottom. -export function ChannelsFab() { +/** + * The create affordance for the Channels space, floated over the bottom-right + * of whichever sidebar pane is showing. + * + * The same button on both panes, so "create" is always the same corner: given a + * channel it creates inside it (task, canvas), and either way it can create a + * channel — the list has no other entry point for that. + */ +export function ChannelsFab({ channelId }: { channelId?: string }) { + const channelsLayout = useChannelsLayout(); const [modalOpen, setModalOpen] = useState(false); + const hasDraft = useDraftStore( + (s) => !isContentEmpty(s.drafts["task-input"]), + ); + const createAndOpenCanvas = useCreateAndOpenDashboard(channelId); // New task has no /website mirror yet, so it jumps back to Code unless we're // already in the Channels space — same rule as the nav's New task row. const inChannels = useRouterState({ select: (s) => s.location.pathname.startsWith("/website"), }); + const newTask = () => { + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "new_task_open", + surface: "sidebar", + channel_id: channelId, + }); + // In a channel the task is filed there; from the list it's whatever the + // space defaults to. + if (channelId) { + openTaskInput({ channelId }); + return; + } + openTaskInput(inChannels ? { space: "website" } : undefined); + }; + + const newChannelItem = ( + setModalOpen(true)}> + + {channelsLayout ? "New space" : "New channel"} + + ); + + const trigger = ( + + ); + return ( <> - - - - - } - /> - } - /> - - Create something new - - - - setModalOpen(true)}> - - New channel - - - openTaskInput(inChannels ? { space: "website" } : undefined) - } + {channelsLayout ? ( + // The draft dot needs saying out loud, and the button is where the + // create shortcut is worth advertising. + + + + ) : ( + + } /> + + Create something new + + + )} + + {/* Off the layout this is the list's only menu, and "New channel" + has led it since it shipped — leave that alone. */} + {!channelsLayout && newChannelItem} + New task + {channelId && ( + { + // Create + open a canvas with the default template directly; + // the canvas's own composer drives what gets built. + trackAndCreateCanvas( + channelId, + undefined, + "sidebar", + () => void createAndOpenCanvas(), + ); + }} + > + + New canvas + + )} + {channelsLayout && ( + <> + + {newChannelItem} + + )} diff --git a/packages/ui/src/features/canvas/components/ChannelsList.test.tsx b/packages/ui/src/features/canvas/components/ChannelsList.test.tsx new file mode 100644 index 0000000000..0109a40b25 --- /dev/null +++ b/packages/ui/src/features/canvas/components/ChannelsList.test.tsx @@ -0,0 +1,160 @@ +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"; + +const mocks = vi.hoisted(() => ({ + channels: [] as { id: string; name: string; path: string }[], + starredPaths: [] as string[], + channelsLayout: true, + navigate: vi.fn(), +})); + +vi.mock("@posthog/ui/shell/analytics", () => ({ track: vi.fn() })); +vi.mock("@posthog/ui/features/canvas/hooks/useChannelsLayout", () => ({ + useChannelsLayout: () => mocks.channelsLayout, +})); +vi.mock("@posthog/ui/features/canvas/hooks/useChannels", () => ({ + useChannels: () => ({ channels: mocks.channels, isLoading: false }), + useChannelMutations: () => ({ createChannel: vi.fn(), isDeleting: false }), +})); +vi.mock("@posthog/ui/features/canvas/hooks/useChannelStars", () => ({ + useChannelStars: () => ({ + starredRefToShortcutId: new Map(mocks.starredPaths.map((p) => [p, p])), + }), + useChannelStarToggle: () => ({ + isStarred: false, + toggleStar: vi.fn(), + removeStar: vi.fn(), + }), +})); +vi.mock("@posthog/ui/features/canvas/hooks/useDashboards", () => ({ + useCreateAndOpenDashboard: () => vi.fn(), +})); +vi.mock("@posthog/ui/features/canvas/hooks/useUnreadChannels", () => ({ + useIsChannelUnread: () => () => false, +})); +vi.mock("@posthog/ui/features/canvas/hooks/useTaskChannels", async () => { + const actual = await vi.importActual< + typeof import("@posthog/ui/features/canvas/hooks/useTaskChannels") + >("@posthog/ui/features/canvas/hooks/useTaskChannels"); + return { ...actual, useTaskChannels: () => ({ channels: [] }) }; +}); +vi.mock("@posthog/ui/features/canvas/components/RenameChannelModal", () => ({ + RenameChannelModal: () => null, +})); +vi.mock("@tanstack/react-router", () => ({ + useNavigate: () => mocks.navigate, + useRouterState: () => "/website", +})); + +import { ChannelsList } from "./ChannelsList"; + +const ME = { id: "me-id", name: "me", path: "/me" }; +const ENG = { id: "eng-id", name: "engineering", path: "/engineering" }; +const DESIGN = { id: "design-id", name: "design", path: "/design" }; + +function renderList() { + return render( + + + , + ); +} + +describe("ChannelsList", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.channels = [ME, ENG, DESIGN]; + mocks.starredPaths = []; + mocks.channelsLayout = true; + }); + + it("pins #me above the channels, with its ⌘1 shortcut", () => { + renderList(); + const me = screen.getByText("me"); + const eng = screen.getByText("engineering"); + expect( + me.compareDocumentPosition(eng) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + // ChannelHotkeys binds ⌘1-9 to the same slots; the list is where they're + // advertised now that the switcher popover is gone. + expect(me.parentElement?.textContent).toMatch(/me(⌘|Ctrl)/); + }); + + // "Starred" and "Channels" are headings over the rows, not parents of them — + // under the layout the rows sit at the heading's level and keep the "#" for + // themselves. The alpha's tree is unchanged. + describe("group headings", () => { + beforeEach(() => { + mocks.starredPaths = [ENG.path]; + }); + + it("does not indent rows under the layout", () => { + renderList(); + expect(screen.getByText("engineering").closest(".pl-5")).toBeNull(); + }); + + it("keeps the indented tree off the layout", () => { + mocks.channelsLayout = false; + renderList(); + expect(screen.getByText("engineering").closest(".pl-5")).toBeTruthy(); + }); + + it("rebrands only the spaces layout", () => { + renderList(); + expect(screen.getByText("Spaces")).toBeTruthy(); + + mocks.channelsLayout = false; + renderList(); + expect(screen.getByText("Channels")).toBeTruthy(); + }); + }); + + describe("search", () => { + // The list is the only way to switch channels now, so with a few dozen + // channels it has to be filterable rather than only scrollable. + it("narrows the list to matching channels", async () => { + const user = userEvent.setup(); + renderList(); + + await user.type(screen.getByLabelText("Search spaces"), "eng"); + + expect(screen.getByText("engineering")).toBeTruthy(); + expect(screen.queryByText("design")).toBeNull(); + expect(screen.queryByText("me")).toBeNull(); + }); + + // Grouping is for browsing; once you've named what you want, "Starred" and + // "Channels" headings only stand between you and the one row that matches. + it("drops the group headings while filtering", async () => { + const user = userEvent.setup(); + mocks.starredPaths = [ENG.path]; + renderList(); + expect(screen.getByText("Starred")).toBeTruthy(); + + await user.type(screen.getByLabelText("Search spaces"), "eng"); + + expect(screen.queryByText("Starred")).toBeNull(); + expect(screen.getByText("engineering")).toBeTruthy(); + }); + + // The alpha renders this list as a plain tree with no slider around it, and + // ChannelHotkeys doesn't bind ⌘1-9 there either — so neither shows. + it("is absent off the channels layout, along with the shortcut hints", () => { + mocks.channelsLayout = false; + renderList(); + expect(screen.queryByLabelText("Search spaces")).toBeNull(); + expect(screen.getByText("me").parentElement?.textContent).toBe("me"); + }); + + it("says so when nothing matches", async () => { + const user = userEvent.setup(); + renderList(); + + await user.type(screen.getByLabelText("Search spaces"), "zzz"); + + expect(screen.getByText(/No spaces match/)).toBeTruthy(); + }); + }); +}); diff --git a/packages/ui/src/features/canvas/components/ChannelsList.tsx b/packages/ui/src/features/canvas/components/ChannelsList.tsx index 235760b9f8..5b7eac01e1 100644 --- a/packages/ui/src/features/canvas/components/ChannelsList.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsList.tsx @@ -7,7 +7,6 @@ import { FileTextIcon, HashIcon, LinkIcon, - LockSimpleIcon, PencilSimpleIcon, PlusIcon, StarIcon, @@ -36,6 +35,8 @@ import { DropdownMenuTrigger, Empty, EmptyHeader, + Input, + Kbd, MenuLabel, Tooltip, TooltipContent, @@ -43,6 +44,7 @@ import { TooltipTrigger, } from "@posthog/quill"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { channelGlyph } from "@posthog/ui/features/canvas/components/channelGlyph"; import { RenameChannelModal } from "@posthog/ui/features/canvas/components/RenameChannelModal"; import { trackAndCreateCanvas } from "@posthog/ui/features/canvas/createCanvasAnalytics"; import { ensurePersonalChannel } from "@posthog/ui/features/canvas/ensurePersonalChannel"; @@ -55,23 +57,32 @@ import { useChannelMutations, useChannels, } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { useCreateAndOpenDashboard } from "@posthog/ui/features/canvas/hooks/useDashboards"; +import { useStarredChannelSlots } from "@posthog/ui/features/canvas/hooks/useStarredChannelSlots"; import { PERSONAL_CHANNEL_NAME, useTaskChannels, } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; import { useIsChannelUnread } from "@posthog/ui/features/canvas/hooks/useUnreadChannels"; +import { showChannelPane } from "@posthog/ui/features/canvas/stores/channelPaneStore"; +import { + resetCurrentChannel, + useCurrentChannelStore, +} from "@posthog/ui/features/canvas/stores/currentChannelStore"; import { copyChannelLink } from "@posthog/ui/features/canvas/utils/copyChannelLink"; +import { formatHotkey } from "@posthog/ui/features/command/keyboard-shortcuts"; import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; import { OverflowTickerText, useOverflowTickerReveal, } from "@posthog/ui/primitives/OverflowTickerText"; import { toast } from "@posthog/ui/primitives/toast"; +import { openTaskInput } from "@posthog/ui/router/useOpenTask"; import { track } from "@posthog/ui/shell/analytics"; import { Box, Flex } from "@radix-ui/themes"; import { useNavigate, useRouterState } from "@tanstack/react-router"; -import { Fragment, type ReactNode, useEffect, useRef, useState } from "react"; +import { Fragment, type ReactNode, useState } from "react"; import { hostClient } from "../hostClient"; // One actionable entry in a channel's menu, rendered the same whether it @@ -99,6 +110,8 @@ function useChannelActions(channel: Channel): { confirmDelete: () => Promise; isDeleting: boolean; } { + const spacesLayout = useChannelsLayout(); + const noun = spacesLayout ? "space" : "channel"; const [renameOpen, setRenameOpen] = useState(false); // "Delete channel" opens a confirmation dialog rather than deleting inline — // the action is destructive and irreversible. @@ -131,6 +144,12 @@ function useChannelActions(channel: Channel): { await deleteChannel(channel.id); removeStar(); + // Unscope immediately if this was the current channel — otherwise the + // sidebar renders a dead id (and new tasks file against it) until the + // channels list refetches. useCurrentChannel is the backstop. + if (useCurrentChannelStore.getState().currentChannelId === channel.id) { + resetCurrentChannel(); + } track(ANALYTICS_EVENTS.CHANNEL_ACTION, { action_type: "delete", surface: "sidebar", @@ -149,7 +168,7 @@ function useChannelActions(channel: Channel): { channel_id: channel.id, success: false, }); - toast.error("Couldn't delete channel", { + toast.error(`Couldn't delete ${noun}`, { description: error instanceof Error ? error.message : String(error), }); return false; @@ -159,7 +178,7 @@ function useChannelActions(channel: Channel): { const actions: ChannelActionItem[] = [ { key: "star", - label: isStarred ? "Unstar channel" : "Star channel", + label: isStarred ? `Unstar ${noun}` : `Star ${noun}`, icon: , onSelect: () => { track(ANALYTICS_EVENTS.CHANNEL_ACTION, { @@ -178,14 +197,14 @@ function useChannelActions(channel: Channel): { }, { key: "rename", - label: "Rename channel…", + label: `Rename ${noun}…`, icon: , separatorBefore: true, onSelect: () => setRenameOpen(true), }, { key: "delete", - label: "Delete channel…", + label: `Delete ${noun}…`, icon: , variant: "destructive", onSelect: () => setConfirmDeleteOpen(true), @@ -299,13 +318,19 @@ function ChannelMenu({ function ChannelSection({ channel, isUnread, + hotkeySlot, }: { channel: Channel; /** Bolds the name: activity here the viewer hasn't seen. */ isUnread?: boolean; + /** ⌘1-9 slot, shown as a hint while the row isn't hovered. */ + hotkeySlot?: number; }) { + const spacesLayout = useChannelsLayout(); + const noun = spacesLayout ? "space" : "channel"; const navigate = useNavigate(); const pathname = useRouterState({ select: (s) => s.location.pathname }); + const setCurrentChannel = useCurrentChannelStore((s) => s.setCurrentChannel); const base = `/website/${channel.id}`; // Highlight the row whenever any of the channel's routes is open. const isActive = pathname === base || pathname.startsWith(`${base}/`); @@ -346,24 +371,28 @@ function ChannelSection({ surface: "sidebar", channel_id: channel.id, }); + // Slide before navigating: the route effect would get there + // too, but not until the navigation resolves. + showChannelPane(); + setCurrentChannel(channel.id); void navigate({ to: "/website/$channelId", params: { channelId: channel.id }, }); }} {...focusProps} - className="w-full min-w-0 justify-start gap-2 data-selected:bg-fill-selected data-selected:text-gray-12" + className="w-full min-w-0 justify-start gap-2 data-selected:bg-fill-selected data-selected:text-foreground" > - + ), + })} {channel.name} + {hotkeySlot != null && ( + + {formatHotkey(`mod+${hotkeySlot}`)} + + )} } /> @@ -430,10 +464,7 @@ function ChannelSection({ surface: "sidebar", channel_id: channel.id, }); - navigate({ - to: "/website/$channelId/new", - params: { channelId: channel.id }, - }); + openTaskInput({ channelId: channel.id }); }} > @@ -479,17 +510,17 @@ function ChannelSection({ Delete {channel.name}? - This permanently deletes the channel and can’t be undone. + This permanently deletes the {noun} and can’t be undone.
  • - The channel and its{" "} + The {noun} and its{" "} CONTEXT.md are deleted.
  • - Every canvas saved in this channel is permanently deleted. + Every canvas saved in this {noun} is permanently deleted.
  • - Filed tasks are removed from the channel, but the tasks + Filed tasks are removed from the {noun}, but the tasks themselves are not deleted.
@@ -508,7 +539,7 @@ function ChannelSection({ }) } > - Delete channel + Delete {noun} @@ -521,9 +552,10 @@ function ChannelSection({ // The feed and task ownership live on the per-user backend personal channel; // the "me" folder is the bridge that keeps the folder-keyed surfaces // (CONTEXT.md, artifacts) routable, created lazily on first open. -function PersonalChannelRow() { +function PersonalChannelRow({ hotkeySlot }: { hotkeySlot?: number }) { const navigate = useNavigate(); const pathname = useRouterState({ select: (s) => s.location.pathname }); + const setCurrentChannel = useCurrentChannelStore((s) => s.setCurrentChannel); const { channels } = useChannels(); const { createChannel, isCreating } = useChannelMutations(); // Listing backend channels lazily provisions the personal channel server-side. @@ -557,6 +589,8 @@ function PersonalChannelRow() { const open = async () => { const channelId = await ensureFolderId(); if (!channelId) return; + showChannelPane(); + setCurrentChannel(channelId); void navigate({ to: "/website/$channelId", params: { channelId } }); }; @@ -568,7 +602,7 @@ function PersonalChannelRow() { surface: "sidebar", channel_id: channelId, }); - void navigate({ to: "/website/$channelId/new", params: { channelId } }); + openTaskInput({ channelId }); }; const newCanvas = async () => { @@ -591,18 +625,18 @@ function PersonalChannelRow() { data-selected={isActive || undefined} disabled={isCreating} onClick={() => void open()} - className="w-full min-w-0 justify-start gap-2 data-selected:bg-fill-selected data-selected:text-gray-12" + className="w-full min-w-0 justify-start gap-2 data-selected:bg-fill-selected data-selected:text-foreground" > - + ), + })} {PERSONAL_CHANNEL_NAME} - {/* The lock and the hover "+" share the right edge, so fade the lock - out as the "+" comes in. */} - + {hotkeySlot != null && ( + + {formatHotkey(`mod+${hotkeySlot}`)} + + )}
@@ -684,18 +712,23 @@ const CHANNELS_SECTION_ID = "channels:all"; // the label styling) and animates the panel height (which janked on a list this // long). Unstyled parts give a plain label row that snaps. // -// The whole header row is the trigger. It rests as a "#" and swaps to a chevron -// on hover or keyboard focus, so the row only advertises the disclosure when -// you're actually reaching for it. +// The whole header row is the trigger. Under the layout the icon well rests +// empty and fills with a chevron on hover or keyboard focus, so the row only +// advertises the disclosure when you're reaching for it — a "#" there read as a +// channel named "Starred", and the glyph belongs to the rows, not the label +// above them. function ChannelGroup({ sectionId, label, className, + flat, children, }: { sectionId: string; label: string; className?: string; + /** Layout-only: rows sit at the label's level instead of indented under it. */ + flat?: boolean; children: ReactNode; }) { const collapsedSections = useSidebarStore((s) => s.collapsedSections); @@ -720,10 +753,12 @@ function ChannelGroup({ render={} />} > - + {!flat && ( + + )} {isOpen ? ( -
{children}
+
{children}
); } -// The channel list — the Channels space sidebar body. The private "#me" +// The channel list — the list pane of the sidebar slider. The private "#me" // channel is pinned at the top; starred channels surface in their own section // so the ones you use most stay in reach; the rest sit under a "Channels" // label. Creating anything goes through the floating ChannelsFab, mounted by @@ -757,62 +792,124 @@ function ChannelGroup({ export function ChannelsList() { const { channels: allChannels, isLoading } = useChannels(); const { starredRefToShortcutId } = useChannelStars(); + // ChannelHotkeys owns the keys these slots describe; sharing the derivation + // keeps the advertised key and the key that fires in agreement — including + // the fact that it only binds them under the layout, so off it the list + // advertises nothing. + const { slotFor } = useStarredChannelSlots(); + // Search and the shortcut hints belong to the slider, where this list is a + // pane you switch channels from. The alpha still renders it as a plain tree. + const channelsLayout = useChannelsLayout(); const isUnread = useIsChannelUnread(); + const [query, setQuery] = useState(""); + const normalizedQuery = channelsLayout ? query.trim().toLowerCase() : ""; + const matches = (name: string) => + !normalizedQuery || name.toLowerCase().includes(normalizedQuery); + // The "me" folder renders as the pinned personal row, not a shared channel. + const me = allChannels.find((c) => c.name === PERSONAL_CHANNEL_NAME); const channels = allChannels.filter((c) => c.name !== PERSONAL_CHANNEL_NAME); const starred = channels.filter((c) => starredRefToShortcutId.has(c.path)); const others = channels.filter((c) => !starredRefToShortcutId.has(c.path)); - // Fire CHANNELS_SPACE_VIEWED once per space mount, after channels first load - // (so the counts are accurate). The sidebar stays mounted while navigating - // between channels, so this naturally fires once per entry into the space. - const viewedTrackedRef = useRef(false); - useEffect(() => { - if (isLoading || viewedTrackedRef.current) return; - viewedTrackedRef.current = true; - track(ANALYTICS_EVENTS.CHANNELS_SPACE_VIEWED, { - channel_count: channels.length, - starred_count: starred.length, - }); - }, [isLoading, channels.length, starred.length]); + // Searching collapses the sections into one flat list: the group labels only + // stand between you and the row you already named, and an empty "Starred" + // heading reads as a result that isn't there. + const searchResults = channels.filter((c) => matches(c.name)); + const meMatches = matches(PERSONAL_CHANNEL_NAME); + const noMatches = + normalizedQuery !== "" && !meMatches && !searchResults.length; return ( // One shared provider groups every row tooltip so that once one shows, // moving to the next row reveals its tooltip instantly (no re-delay). - {/* Bottom padding clears the floating create button (ChannelsFab), so the - last channel stays reachable at full scroll. */} - - - - {starred.length > 0 && ( - - {starred.map((channel) => ( - - ))} - + + {channelsLayout && ( + + setQuery(event.target.value)} + placeholder="Search spaces…" + aria-label="Search spaces" + className="h-7 text-[13px]" + /> + )} + {/* Bottom padding clears the floating create button (ChannelsFab), so + the last channel stays reachable at full scroll. */} + + {normalizedQuery ? ( + <> + {meMatches && } + {searchResults.map((channel) => ( + + ))} + {noMatches && ( + + + No {channelsLayout ? "spaces" : "channels"} match “ + {query.trim()}”. + + + )} + + ) : ( + <> + - - {!isLoading && channels.length === 0 && ( - - No channels yet. - + {starred.length > 0 && ( + + {starred.map((channel) => ( + + ))} + + )} + + + {!isLoading && channels.length === 0 && ( + + + No {channelsLayout ? "spaces" : "channels"} yet. + + + )} + {others.map((channel) => ( + + ))} + + )} - {others.map((channel) => ( - - ))} - + ); diff --git a/packages/ui/src/features/canvas/components/ChannelsSidebar.test.tsx b/packages/ui/src/features/canvas/components/ChannelsSidebar.test.tsx new file mode 100644 index 0000000000..6db5d123b4 --- /dev/null +++ b/packages/ui/src/features/canvas/components/ChannelsSidebar.test.tsx @@ -0,0 +1,400 @@ +import { Theme } from "@radix-ui/themes"; +import { act, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + featureFlags: new Map(), + channelsLayout: false, + channelsEnabled: false, + channels: [] as { id: string; name: string; path: string }[], + channelsLoading: false, + archivedTaskIds: new Set(), + navigateToArchived: vi.fn(), + track: vi.fn(), + routeChannelId: undefined as string | undefined, +})); + +vi.mock("@posthog/ui/shell/analytics", () => ({ + track: (...args: unknown[]) => mocks.track(...args), +})); + +vi.mock("@posthog/ui/features/feature-flags/useFeatureFlag", () => ({ + useFeatureFlag: (key: string) => mocks.featureFlags.get(key) ?? true, +})); +vi.mock("@posthog/ui/features/canvas/hooks/useChannelsLayout", () => ({ + useChannelsLayout: () => mocks.channelsLayout, +})); +vi.mock("@posthog/ui/features/canvas/hooks/useChannels", () => ({ + useChannels: () => ({ + channels: mocks.channels, + isLoading: mocks.channelsLoading, + }), +})); +vi.mock("@posthog/ui/features/archive/useArchivedTaskIds", () => ({ + useArchivedTaskIds: () => mocks.archivedTaskIds, +})); +vi.mock("@posthog/ui/features/canvas/hooks/useChannelStars", () => ({ + useChannelStars: () => ({ starredRefToShortcutId: new Map() }), +})); +vi.mock("@posthog/ui/router/navigationBridge", () => ({ + navigateToArchived: (...args: unknown[]) => mocks.navigateToArchived(...args), +})); + +// The sidebar's children each mount their own query stack; this suite is about +// the shell's own decisions, so they're stubbed out. +vi.mock("@posthog/ui/features/canvas/components/ChannelNav", () => ({ + ChannelNav: () => null, +})); +vi.mock("@posthog/ui/features/canvas/components/ChannelSidebar", () => ({ + ChannelSidebar: ({ channelId }: { channelId: string }) => ( +
{channelId}
+ ), +})); +vi.mock("@posthog/ui/features/canvas/components/ChannelsList", () => ({ + ChannelsList: () =>
, +})); +vi.mock("@posthog/ui/features/canvas/components/ChannelsFab", () => ({ + ChannelsFab: () => null, +})); +vi.mock("@posthog/ui/features/sidebar/components/SidebarNavSection", () => ({ + SidebarNavSection: () =>
, +})); +vi.mock("@posthog/ui/features/sidebar/components/TasksHeader", () => ({ + TasksHeader: () =>
, +})); +vi.mock("@posthog/ui/features/sidebar/components/SidebarMenu", () => ({ + SidebarMenu: () =>
, +})); +vi.mock("@posthog/ui/features/sidebar/components/ProjectSwitcher", () => ({ + ProjectSwitcher: () => null, +})); +vi.mock("@posthog/ui/features/sidebar/components/UpdateBanner", () => ({ + UpdateBanner: () => null, +})); +vi.mock("@posthog/ui/features/loops/components/LoopsPromoCard", () => ({ + LoopsPromoCard: () => null, +})); +vi.mock("@posthog/ui/features/workspace/useWorkspace", () => ({ + useWorkspaces: () => ({ data: {}, isFetched: true }), +})); +vi.mock("@tanstack/react-router", () => ({ + useParams: () => ({ channelId: mocks.routeChannelId }), +})); + +import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { + showChannelList, + useChannelPaneStore, +} from "@posthog/ui/features/canvas/stores/channelPaneStore"; +import { useCurrentChannelStore } from "@posthog/ui/features/canvas/stores/currentChannelStore"; +import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; +import { ChannelsSidebar } from "./ChannelsSidebar"; + +function renderSidebar() { + return render( + + + , + ); +} + +const ME = { id: "me-id", name: "me", path: "/me" }; + +describe("ChannelsSidebar", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.featureFlags.clear(); + mocks.channelsLayout = false; + mocks.channels = []; + mocks.channelsLoading = false; + mocks.archivedTaskIds = new Set(); + mocks.track.mockClear(); + mocks.routeChannelId = undefined; + useCurrentChannelStore.setState({ currentChannelId: null }); + useChannelPaneStore.setState({ pane: "channel" }); + useSidebarStore.setState({ channelsEnabled: false, open: true }); + }); + + // The sidebar is a two-pane slider: the channel list, and the channel you're + // in. Both stay mounted, so "which one is showing" is the offscreen pane + // being inert rather than unmounted. + describe("the channel-list slider", () => { + const ENG = { id: "eng-id", name: "eng", path: "/eng" }; + const listIsInteractive = () => + !screen.getByTestId("channels-list").parentElement?.hasAttribute("inert"); + + beforeEach(() => { + mocks.channelsLayout = true; + mocks.channels = [ME, ENG]; + }); + + it("rests on the channel you're in", () => { + mocks.routeChannelId = ENG.id; + renderSidebar(); + expect(screen.getByTestId("channel-sidebar").textContent).toBe(ENG.id); + expect(listIsInteractive()).toBe(false); + }); + + // Browsing the list is a sidebar move, not a navigation: the channel stays + // scoped, so the main pane keeps showing what it was showing. + it("shows the list on the way back, without leaving the channel", () => { + mocks.routeChannelId = ENG.id; + renderSidebar(); + + act(() => showChannelList()); + + expect(listIsInteractive()).toBe(true); + expect(useCurrentChannelStore.getState().currentChannelId).toBe(ENG.id); + }); + + // Opening a channel from anywhere — a deep link, a mention, ⌘1-9 — has to + // land on the channel even if the list was left open. + it("follows the route back into a channel", () => { + mocks.routeChannelId = ENG.id; + const { rerender } = renderSidebar(); + act(() => showChannelList()); + + mocks.routeChannelId = ME.id; + rerender( + + + , + ); + + expect(listIsInteractive()).toBe(false); + expect(screen.getByTestId("channel-sidebar").textContent).toBe(ME.id); + }); + + it("stays on the list while no channel resolves", () => { + mocks.channels = [ENG]; + renderSidebar(); + expect(listIsInteractive()).toBe(true); + expect(screen.queryByTestId("channel-sidebar")).toBeNull(); + }); + + // A trackpad swipe reaches the panes as a horizontal wheel. Right (negative + // deltaX, the platform "back" direction) leaves the channel; left returns to + // the one still scoped. + describe("swiping", () => { + // Wheel deltas within one gesture arrive back to back; a pause between + // them is what ends it. Fake timers let a test say which it's sending. + const wheel = (deltaX: number, deltaY = 0) => + act(() => { + screen.getByTestId("channels-list").dispatchEvent( + new WheelEvent("wheel", { + deltaX, + deltaY, + bubbles: true, + cancelable: true, + }), + ); + }); + const pause = () => act(() => void vi.advanceTimersByTime(500)); + + beforeEach(() => { + vi.useFakeTimers(); + mocks.routeChannelId = ENG.id; + }); + afterEach(() => vi.useRealTimers()); + + it("goes back to the list and forward into the channel", () => { + renderSidebar(); + + wheel(-80); + expect(listIsInteractive()).toBe(true); + // The channel is browsed away from, not left. + expect(useCurrentChannelStore.getState().currentChannelId).toBe(ENG.id); + + pause(); + wheel(80); + expect(listIsInteractive()).toBe(false); + }); + + // One flick is dozens of small deltas, so the distance has to add up + // across them rather than be read off any one event. + it("adds a gesture's deltas up", () => { + renderSidebar(); + wheel(-20); + expect(listIsInteractive()).toBe(false); + wheel(-20); + wheel(-20); + expect(listIsInteractive()).toBe(true); + }); + + it("ignores a mostly-vertical wheel", () => { + renderSidebar(); + wheel(-80, -200); + expect(listIsInteractive()).toBe(false); + }); + + it("forgets a nudge once the gesture ends", () => { + renderSidebar(); + wheel(-30); + pause(); + wheel(-30); + expect(listIsInteractive()).toBe(false); + }); + + // The momentum tail of one flick keeps delivering deltas; read as fresh + // travel they'd swipe straight back to where the flick started. + it("does not let one flick's momentum swipe twice", () => { + renderSidebar(); + wheel(-80); + wheel(200); + expect(listIsInteractive()).toBe(true); + }); + }); + }); + + describe("the Archived row", () => { + beforeEach(() => { + mocks.archivedTaskIds = new Set(["archived-1"]); + }); + + // The layout puts an Archive action on every item row, so the destination + // still has to exist — it moved into the account menu (ProjectSwitcher), + // which is where navigateToArchived is called from now. + it("leaves the sidebar body under the channels layout", () => { + mocks.channelsLayout = true; + mocks.channels = [ME]; + renderSidebar(); + expect(screen.queryByText("Archived")).toBeNull(); + }); + + it("is present with neither channels world on", () => { + renderSidebar(); + expect(screen.getByText("Archived")).toBeTruthy(); + }); + + // The alpha replaced the task list with the channel tree, so the row went + // with it — that part is deliberate. + it("is absent in the channels alpha", () => { + useSidebarStore.setState({ channelsEnabled: true }); + mocks.featureFlags.set(PROJECT_BLUEBIRD_FLAG, true); + renderSidebar(); + expect(screen.queryByText("Archived")).toBeNull(); + }); + + it("stays hidden when nothing is archived", () => { + mocks.channelsLayout = true; + mocks.archivedTaskIds = new Set(); + renderSidebar(); + expect(screen.queryByText("Archived")).toBeNull(); + }); + }); + + describe("auto-scoping to #me", () => { + it("keeps a deep-linked channel instead of overwriting it with #me", () => { + mocks.channelsLayout = true; + mocks.channels = [ME, { id: "eng-id", name: "eng", path: "/eng" }]; + mocks.routeChannelId = "eng-id"; + + renderSidebar(); + + expect(useCurrentChannelStore.getState().currentChannelId).toBe("eng-id"); + }); + + it("scopes to the personal channel once the list lands", () => { + mocks.channelsLayout = true; + mocks.channels = [ME]; + renderSidebar(); + expect(useCurrentChannelStore.getState().currentChannelId).toBe("me-id"); + }); + + // Both flags behind the layout re-evaluate on every flags payload, so a + // momentary false must not permanently strand the sidebar unscoped: the + // auto-scope latch has to reset when the layout turns off. + it("re-scopes after the layout flag flickers off and back on", () => { + mocks.channelsLayout = true; + mocks.channels = [ME]; + const { rerender } = renderSidebar(); + expect(useCurrentChannelStore.getState().currentChannelId).toBe("me-id"); + + mocks.channelsLayout = false; + rerender( + + + , + ); + expect(useCurrentChannelStore.getState().currentChannelId).toBeNull(); + + mocks.channelsLayout = true; + rerender( + + + , + ); + expect(useCurrentChannelStore.getState().currentChannelId).toBe("me-id"); + }); + + it("does not scope to a channel the project does not have", () => { + mocks.channelsLayout = true; + mocks.channels = [{ id: "eng", name: "eng", path: "/eng" }]; + renderSidebar(); + expect(useCurrentChannelStore.getState().currentChannelId).toBeNull(); + expect(screen.queryByTestId("channel-sidebar")).toBeNull(); + }); + + // A stale id from a previous project must not be rendered as a channel. + it("clears a scoped channel missing from the loaded list", () => { + mocks.channelsLayout = true; + mocks.channels = [ME]; + useCurrentChannelStore.setState({ currentChannelId: "from-old-project" }); + renderSidebar(); + expect(useCurrentChannelStore.getState().currentChannelId).not.toBe( + "from-old-project", + ); + }); + }); + + it("renders the flag-off sidebar menu untouched", () => { + renderSidebar(); + expect(screen.getByTestId("sidebar-menu")).toBeTruthy(); + expect(screen.getByTestId("sidebar-nav-section")).toBeTruthy(); + }); + + describe("space-viewed tracking", () => { + // The event used to fire from ChannelsList, which the new layout barely + // renders — so space adoption would have read as zero once the flag landed. + it("fires from the shell under the channels layout", () => { + mocks.channelsLayout = true; + mocks.channels = [ME, { id: "eng", name: "eng", path: "/eng" }]; + renderSidebar(); + expect(mocks.track).toHaveBeenCalledWith( + ANALYTICS_EVENTS.CHANNELS_SPACE_VIEWED, + { channel_count: 1, starred_count: 0, layout: "channels" }, + ); + }); + + it("does not fire outside the channels world", () => { + mocks.channels = [ME]; + renderSidebar(); + expect(mocks.track).not.toHaveBeenCalledWith( + ANALYTICS_EVENTS.CHANNELS_SPACE_VIEWED, + expect.anything(), + ); + }); + + it("fires again after leaving and re-entering the channels world", () => { + mocks.channelsLayout = true; + mocks.channels = [ME]; + const { rerender } = renderSidebar(); + + mocks.channelsLayout = false; + rerender( + + + , + ); + mocks.channelsLayout = true; + rerender( + + + , + ); + + expect(mocks.track).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx index 92b7caca39..d9f75f0947 100644 --- a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx @@ -1,10 +1,23 @@ import { ArchiveIcon } from "@phosphor-icons/react"; -import { Separator } from "@posthog/quill"; +import { cn, Separator } from "@posthog/quill"; import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; import { useArchivedTaskIds } from "@posthog/ui/features/archive/useArchivedTaskIds"; +import { ChannelNav } from "@posthog/ui/features/canvas/components/ChannelNav"; +import { ChannelSidebar } from "@posthog/ui/features/canvas/components/ChannelSidebar"; import { ChannelsFab } from "@posthog/ui/features/canvas/components/ChannelsFab"; import { ChannelsList } from "@posthog/ui/features/canvas/components/ChannelsList"; import { useChannelsSidebarStore } from "@posthog/ui/features/canvas/components/channelsSidebarStore"; +import { useChannelPaneSwipe } from "@posthog/ui/features/canvas/hooks/useChannelPaneSwipe"; +import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; +import { useCurrentChannel } from "@posthog/ui/features/canvas/hooks/useCurrentChannel"; +import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; +import { useTrackChannelsSpaceViewed } from "@posthog/ui/features/canvas/hooks/useTrackChannelsSpaceViewed"; +import { + showChannelList, + showChannelPane, + useChannelPaneStore, +} from "@posthog/ui/features/canvas/stores/channelPaneStore"; +import { useCurrentChannelStore } from "@posthog/ui/features/canvas/stores/currentChannelStore"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { LoopsPromoCard } from "@posthog/ui/features/loops/components/LoopsPromoCard"; import { useOnboardingStore } from "@posthog/ui/features/onboarding/onboardingStore"; @@ -13,6 +26,7 @@ import { SidebarMenu } from "@posthog/ui/features/sidebar/components/SidebarMenu import { SidebarNavSection } from "@posthog/ui/features/sidebar/components/SidebarNavSection"; import { TasksHeader } from "@posthog/ui/features/sidebar/components/TasksHeader"; import { UpdateBanner } from "@posthog/ui/features/sidebar/components/UpdateBanner"; +import { CHANNELS_SIDEBAR_MIN_WIDTH } from "@posthog/ui/features/sidebar/constants"; import { beginSidebarPeek, cancelSidebarPeek, @@ -21,15 +35,69 @@ import { } from "@posthog/ui/features/sidebar/sidebarPeekStore"; import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; import { useWorkspaces } from "@posthog/ui/features/workspace/useWorkspace"; +import { ErrorBoundary } from "@posthog/ui/primitives/ErrorBoundary"; import { useSidebarEdgeHoverPeek } from "@posthog/ui/primitives/hooks/useSidebarEdgeHoverPeek"; import { ResizableSidebar } from "@posthog/ui/primitives/ResizableSidebar"; import { navigateToArchived } from "@posthog/ui/router/navigationBridge"; import { Box, Flex } from "@radix-ui/themes"; -import { useDeferredValue, useEffect } from "react"; +import { useParams } from "@tanstack/react-router"; +import { useDeferredValue, useEffect, useRef } from "react"; + +/** + * The sidebar slider: the channel list and the channel you're in, laid out side + * by side in a track that translates between them. + * + * Both panes stay mounted so the slide has something to slide, and so coming + * back to the list doesn't rebuild every row's menus and dialogs. The offscreen + * one is `inert`, keeping it out of the tab order and off screen readers. + * + * A two-finger horizontal swipe moves between them, so the back row isn't the + * only way out of a channel — and swiping the other way returns to the channel + * that stayed scoped the whole time. + */ +function ChannelPanes({ + channelId, + showList, +}: { + channelId: string | null; + showList: boolean; +}) { + const panesRef = useRef(null); + useChannelPaneSwipe(panesRef, { + // With no channel to slide to, the list is all there is — leave the gesture + // to the platform rather than eat it for a slide that can't happen. + enabled: channelId != null, + onBack: showChannelList, + onForward: showChannelPane, + }); -// The unified app sidebar (Code merged into the Bluebird chrome). Top to -// bottom: the merged global nav, the Tasks/Channels body header, then the -// selected list and the workspace switcher pinned to the bottom. + return ( + +
+
+ + +
+
+ {channelId && ( + } + resetKey={channelId} + > + + + )} +
+
+
+ ); +} export function ChannelsSidebar() { const width = useChannelsSidebarStore((state) => state.width); const setWidth = useChannelsSidebarStore((state) => state.setWidth); @@ -78,15 +146,69 @@ export function ChannelsSidebar() { ); const channelsEnabled = useSidebarStore((s) => s.channelsEnabled) && bluebirdEnabled; - // The Switch in TasksHeader reads the live value and flips instantly. - // Swapping the sidebar body mounts a heavy tree (ChannelsList: the channels - // query + a provider-laden row per channel), so defer that decision: the - // urgent commit keeps the current body and paints the toggle, then the tree - // mounts in a follow-up non-blocking render. - const bodyChannelsEnabled = useDeferredValue(channelsEnabled); + const channelsLayout = useChannelsLayout(); + const channelsWorld = channelsLayout || channelsEnabled; + const bodyChannelsWorld = useDeferredValue(channelsWorld); + // Under the layout the row moves into the account menu (ProjectSwitcher), + // beside Settings — the bottom of the sidebar belongs to the channel list. + const showArchivedRow = !channelsLayout && !bodyChannelsWorld; + useTrackChannelsSpaceViewed({ + enabled: channelsWorld, + layout: channelsLayout ? "channels" : "code", + }); + + const minWidth = channelsLayout ? CHANNELS_SIDEBAR_MIN_WIDTH : undefined; + useEffect(() => { + if (channelsLayout && width < CHANNELS_SIDEBAR_MIN_WIDTH) { + setWidth(CHANNELS_SIDEBAR_MIN_WIDTH); + } + }, [channelsLayout, width, setWidth]); const archivedTaskIds = useArchivedTaskIds(); + const params = useParams({ strict: false }); + const routeChannelId = params.channelId; + const setCurrentChannel = useCurrentChannelStore((s) => s.setCurrentChannel); + const { currentChannelId, channels } = useCurrentChannel({ + enabled: channelsLayout, + }); + useEffect(() => { + if (!channelsLayout || !routeChannelId) return; + setCurrentChannel(routeChannelId); + // Landing on a channel — a deep link, a mention, ⌘1-9 — is a request to see + // it, so the slider follows the route even if the list was being browsed. + showChannelPane(); + }, [channelsLayout, routeChannelId, setCurrentChannel]); + + // Browsing the list is view state, not navigation: you stay in the channel + // (route and main pane unchanged) while you look around. With no channel to + // slide to there's only the list. + const pane = useChannelPaneStore((s) => s.pane); + const showList = pane === "list" || currentChannelId == null; + + const autoScopedRef = useRef(false); + useEffect(() => { + if (!channelsLayout) { + autoScopedRef.current = false; + return; + } + // A route-scoped channel wins over the default. Both effects run from the + // same render on a cold deep link, so without this guard the route effect + // writes its channel and this later effect immediately overwrites it with + // #me using the stale `currentChannelId` captured by that render. + if (routeChannelId || autoScopedRef.current || currentChannelId) return; + const me = channels.find((c) => c.name === PERSONAL_CHANNEL_NAME); + if (!me) return; + autoScopedRef.current = true; + setCurrentChannel(me.id); + }, [ + channelsLayout, + channels, + currentChannelId, + routeChannelId, + setCurrentChannel, + ]); + return ( - - + {!channelsLayout && ( + <> + + + + )} - {/* Body: the channel tree when channels are on, otherwise the task - list. Each owns its own scroll region. Gated on the deferred value so - the toggle paints before this heavy swap. */} - {bodyChannelsEnabled ? ( + {channelsLayout ? ( + <> + + + + ) : bodyChannelsWorld ? ( <> - {/* The fab is a sibling of the scroll region, not a child, so it - stays pinned to the bottom-right instead of scrolling away. */} - - - + @@ -128,9 +253,7 @@ export function ChannelsSidebar() { - {/* Archived is a task-list affordance — hidden while channels are on, - since the body then shows the channel tree, not tasks. */} - {!channelsEnabled && archivedTaskIds.size > 0 && ( + {showArchivedRow && archivedTaskIds.size > 0 && (