From 491440e51438ca5f92921ec88790d0aa9386cf87 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 25 Jul 2026 15:44:09 +0200 Subject: [PATCH 01/57] docs: add spaces layout prototype plan Analyze the spaces/task-view layout mockup against what exists today (Channels alpha, PanelLayout tabs, task thread, PR review) and lay out a phased prototype plan built on existing pieces, with deferred items and open questions called out. Generated-By: PostHog Code Task-Id: c401c7cb-e436-4da3-80bf-1d3008ee5d38 --- docs/plans/spaces-layout-prototype.md | 132 ++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 docs/plans/spaces-layout-prototype.md diff --git a/docs/plans/spaces-layout-prototype.md b/docs/plans/spaces-layout-prototype.md new file mode 100644 index 0000000000..22333f2ee7 --- /dev/null +++ b/docs/plans/spaces-layout-prototype.md @@ -0,0 +1,132 @@ +# Plan: "Spaces" Layout Prototype + +Status: draft — for team discussion; scoped as a shareable prototype, not a final architecture. +Surface: Channels space (`/website/*`), behind the existing `project-bluebird` alpha plus one new layout toggle. The default Code experience is untouched. + +## The idea (from the mockup) + +A new layout organized around **spaces** — Arc-browser-style workspaces you flip between, one per channel: + +1. **Spaces switcher.** Channels (`# code`, `# my-playground`, …) become swipeable spaces. A dot row at the bottom of the sidebar switches between them (hover shows the name, `+` = "choose or create a channel → create a new space"). The landing state lists channels; entering one scopes the whole sidebar to it. +2. **Per-space sidebar.** `+ New task` on top, then the channel name, then three nav entries — **Context**, **Loops**, **Artifacts** — that open in the center view, then a **pinned** section (tasks and canvases together), then **recents** (tasks and canvases mixed). +3. **Top bar.** A persistent **Search** field in the middle; an **Activity** bell and an **Inbox** icon on the right — both open in the main view. +4. **Task view as a tabbed container.** One task hosts multiple tabs: `Chat 1 | Terminal | Chat 2 | Canvas | PR 1 | +`. The `+` offers new chat / terminal / canvas — only what actually works today needs to be enabled. Clicking an artifact opens it as a new tab inside the task; the PR tab shows the diff. Composer at the bottom. +5. **Right "Activity" panel** on the task, with three tabs: + - **Timeline** — explicitly "the same thing we call the thread today, just a different name." + - **Artifacts** — outputs of/belonging to this task: PRs (with state + comment counts), canvases, the originating Slack thread as an external link. Canvas commenting doesn't exist yet and that's acknowledged. + - **Comments** — top-level comments on the task itself (`+ Add new`), each showing its source context (general, a canvas, a Slack channel, a PR) and reply count. + +## What already exists (mockup → codebase) + +The single biggest input to this plan: **most of the mockup's nouns already exist** in the Channels/Bluebird alpha. The prototype is largely re-chroming and generalizing, not net-new systems. + +| Mockup element | Today | Where | +| --- | --- | --- | +| `# channel` list, create/star channels | Built (alpha) | `packages/ui/src/features/canvas/components/ChannelsList.tsx`, `CreateChannelModal.tsx`, `hooks/useChannels.ts`, `hooks/useChannelStars.ts` | +| Space-per-channel routing | Built — every per-channel section already has a route | `/website/$channelId/{context,loops,artifacts,history}` under `packages/ui/src/router/routes/website/` | +| Context nav entry | Built, labeled "CONTEXT.md", as an in-channel header tab | `WebsiteContext.tsx`, `channelSections.ts`, `ChannelTabs.tsx` | +| Loops nav entry | Built (global + per-channel) | `WebsiteChannelLoops.tsx`, `/code/loops` | +| Artifacts nav entry (channel level) | Built — canvases + PRs union | `WebsiteChannelArtifacts.tsx` | +| Recents (tasks + canvases mixed) | Built, as the "Recents" channel tab | `WebsiteChannelHistory.tsx` | +| Pinned items | Three separate systems: pinned tasks (global), pinned canvases (per channel), starred channels | `features/sidebar/usePinnedTasks.ts`, `ChannelPinnedMenu.tsx` / `dashboard.pinnedAt`, `useChannelStars.ts` | +| Task view with tabs | Built — `PanelLayout` is a tabbed/splittable tree; default = Chat + Terminal tabs, N terminals supported | `features/panels/`, `packages/core/src/panels/panelLayoutTransforms.ts` (`addTerminalTab`), `TabContentRenderer.tsx` | +| PR tab showing diff | Built as the `review` tab type / resizable review pane (incl. inline PR comment threads) | `features/code-review/ReviewPage.tsx`, `CloudReviewPage.tsx`, `reviewNavigationStore.ts` | +| Multiple chats per task | **Does not exist** — hard 1:1 task↔session (`sessionStore` keys one run per task; new session replaces) | `packages/core/src/sessions/sessionStore.ts` | +| Canvas tab inside a task | **Does not exist live** — only a read-only instructions snapshot; canvases are channel-routed, linked by `generationTaskId` | `CanvasInstructionsTab.tsx`, `packages/core/src/canvas/dashboardSchemas.ts` | +| Right panel on a task | Built — `ThreadSidebar` docks `ThreadPanel` next to `TaskDetail`, but only on the channel task route | `router/routes/website/$channelId/tasks/$taskId.tsx`, `ThreadSidebar.tsx`, `ThreadPanel.tsx` | +| Timeline ("thread renamed") | Built — durable task thread: human comments + `pr_created`/`canvas_created` artifact rows + composer | `packages/core/src/canvas/threadTimeline.ts`, `hooks/useTaskThread.ts`, `TaskThreadMessage` in `@posthog/shared/domain-types` | +| Artifact comment counts | Partially — real for PRs via GitHub; nothing for canvases (acknowledged in the mockup) | `features/pr-review/usePrComments.ts`, `usePrReviewThreads.ts` | +| Slack thread as external-link artifact | Data exists — one-way link on the run | `latest_run.state.slack_thread_url` (read in `packages/core/src/sidebar/buildSidebarData.ts`) | +| Task-level Comments tab | **No discrete concept** — human `TaskThreadMessage` rows are the closest substrate; no replies/anchoring model | `TaskThreadMessage`, `ThreadPanel.tsx` | +| Top-bar search field | ⌘K command palette only (modal); sidebar "Search" row opens it | `features/command/CommandMenu.tsx` | +| Top-bar Activity bell | Built as a **sidebar** row + full route (mentions feed, channels-only) | `ActivityItem`, `/website/activity`, `ActivityView.tsx` | +| Top-bar Inbox icon | Built as a **sidebar** row + full route; no `/website` mirror yet ("jumps back to Code") | `InboxItem`, `/code/inbox`, `InboxView` | +| Arc-style space dots / swipe | **Does not exist** | — | + +## Prototype principles + +1. **One switch to flip.** Everything lands behind a `spacesLayout` view-state toggle in `sidebarStore` (persisted, dev-default off), surfaced next to the existing "Channels" alpha switch. Flipping it swaps the chrome inside the Channels space only. Anyone on the team can try it and flip back; the Code space and the current Channels layout keep working unchanged. +2. **No new backend or domain models.** Every panel is fed by data that already exists (task thread messages, run output/state, dashboards, GitHub PR data). Where the mockup wants data we don't have (canvas comments, comment replies, multi-session), the UI shows what exists and omits or disables the rest — exactly as the mockup allows ("for now we can do what is available"). +3. **Renames are cheap, do them properly.** "Thread" → "Timeline" and "CONTEXT.md" → "Context" are label changes in the new chrome only; no store/schema renames during the prototype. +4. **Navigation reuses existing routes.** Sidebar entries and top-bar icons navigate to routes that already exist (`/website/$channelId/*`, `/website/activity`); the only new route is an inbox mirror. + +## Design decisions + +### Shell & top bar +- When `spacesLayout` is on (and inside `/website`), the title bar center swaps the browser-tab strip for a **search field** that opens the existing ⌘K `CommandMenu` on click/focus (no new search engine; the palette already finds tasks, channels, files, actions). +- Right side of the title bar gains two icon buttons: **Activity** (bell, unread-mention dot from `useUnreadChannels`/`activitySeenStore`) → navigates to `/website/activity`; **Inbox** → navigates to a new `/website/inbox` mirror route rendering the existing `InboxView` (same pattern as the existing `/website/activity|command-center|skills|mcp-servers` mirrors), so opening it doesn't eject you from the space. +- Open question tracked below: the mockup has no global tab strip (tabs move *inside* the task). The prototype hides `BrowserTabStrip` in spaces mode to match; if that feels bad we re-show it. + +### Sidebar in space mode +New `SpaceSidebar` body inside `ChannelsSidebar` (rendered when `spacesLayout` && a channel is active; the current all-channels list remains the landing/no-space state): +- `+ New task` → `/website/$channelId/new` (files the task into the space's channel — route exists). +- `# channel` header row → channel home (`/website/$channelId`). +- **Context / Loops / Artifacts** rows → the three existing section routes. "Recents" drops out of the tab row: it *becomes* the sidebar list below. +- **Pinned**: pinned canvases of this channel (`dashboard.pinnedAt`, exists) + pinned tasks filed to this channel (`usePinnedTasks` ∩ `useChannelTasks`). First place the two pinning systems render together. +- **Recents**: the same union `WebsiteChannelHistory` already computes (filed tasks + canvases, most-recent-first), rendered as sidebar rows. +- Footer: **space dots** (see below), then the existing settings gear + `ProjectSwitcher` (the mockup's "Account" button). + +### Space dots (the Arc bit) +- One dot per **starred channel**, plus the current channel if it isn't starred. Starred channels are the curated "spaces" set — the concept and persistence already exist (`useChannelStars`); the dots are just a new, compact renderer for it. +- Click a dot → navigate to that channel (its space). Hover → tooltip with `# name`. Active dot filled. `+` → the existing choose/create channel flow (`CreateChannelModal`). +- Keyboard: `ctrl+alt+←/→` cycles spaces. Trackpad swipe (wheel `deltaX` on the sidebar/canvas) is a stretch goal — noted, not required for the prototype. + +### Task view (center) +- The route stays `/website/$channelId/tasks/$taskId`; `TaskDetail`'s existing `PanelLayout` **is** the mockup's tab row (Chat + Terminal are already tabs; the breadcrumb `#channel / task` already renders in the header). +- The panel `+` becomes a small dropdown: **New terminal** (works today, `addTerminalTab`), **New chat** and **New canvas** shown disabled with a "soon" hint — per the mockup's own note that only what's available needs to work. +- **Artifact → tab**: clicking an artifact in the right panel opens it inside the task: + - PR → the existing `review` tab type (diff view — matches "on click, show diff"). + - Canvas → new `TabData` variant `canvas` mounting the existing canvas renderer (`WebsiteDashboard`'s inner component) read-only inside a panel tab; if the iframe-in-panel plumbing fights back, prototype fallback is navigating to the canvas route. + - Slack thread → external link (OS browser), as in the mockup. + +### Right "Activity" panel +Replace the docked `ThreadPanel` (in spaces mode) with an `ActivityPanel` in the same `ThreadSidebar` shell (resizable/collapsible for free), with a header and three tabs: +- **Timeline** — the existing `ThreadPanel` content unchanged (human messages, `pr_created`/`canvas_created` artifact cards, agent status, composer). The mockup says this is the current thread under a new name, so we treat it exactly that way. +- **Artifacts** — new task-scoped list derived with no new backend: PRs from `latest_run.output.pr_url` + `pr_created` thread events (state + comment count via `usePrInfo`/`usePrComments`/`usePrReviewThreads`), canvases from `canvas_created` thread events / `generationTaskId`, and the Slack thread from `latest_run.state.slack_thread_url` rendered as an external link. Canvas/Slack rows render without counts (no data yet — acknowledged in the mockup). +- **Comments** — the human-authored subset of the same task thread (`TaskThreadMessage` where `author_kind: "human"` and no `event`), plus the existing composer as `+ Add new`. Flat list for the prototype: no replies, no artifact anchoring (needs a data model — deferred). If a message payload carries a source, show it as a badge. + +## Build plan + +**Phase 1 — Shell (the part you can *feel* first).** +Toggle in `sidebarStore` + settings row; `SpaceSidebar` (nav entries, pinned, recents); space dots + keyboard cycling; top bar swap (search field, bell, inbox icons); `/website/inbox` mirror route. +Touches: `features/sidebar/sidebarStore.ts`, `features/canvas/components/ChannelsSidebar.tsx` (+ new `SpaceSidebar.tsx`, `SpaceDots.tsx`), `router/routes/__root.tsx` (title bar), new `router/routes/website/inbox.tsx`. + +**Phase 2 — Activity panel.** +`ActivityPanel.tsx` with the three tabs inside `ThreadSidebar`; `TaskArtifactsTab.tsx` (derived artifact list + PR comment counts); `TaskCommentsTab.tsx` (filtered thread + composer); Timeline = existing `ThreadPanel`. +Touches: `features/canvas/components/` (new files), reuses `threadTimeline.ts`, `useTaskThread.ts`, `features/pr-review/usePr*`. + +**Phase 3 — Task tabs.** +`+` dropdown on the panel tab strip (terminal live; chat/canvas disabled); `canvas` tab type in `panelTypes.ts`/`TabContentRenderer.tsx`; wire artifact clicks → open review tab / canvas tab / external link. +Touches: `packages/core/src/panels/panelTypes.ts` + `panelLayoutTransforms.ts`, `features/panels/components/TabbedPanel.tsx`, `TabContentRenderer.tsx`, `features/task-detail/`. + +**Phase 4 — Polish & demo.** +Empty states (quill ``), unread dots on space dots, transition animation between spaces (simple slide; Arc-grade springs later), a short demo script in this doc, then a team walkthrough build. + +Phases 1 and 2 are independent and can land in either order; 3 depends on 2 (artifact clicks) only for wiring. + +## Explicitly deferred (needs real data-model work) + +- **Multiple chat sessions per task** — requires breaking the 1:1 `taskId ↔ taskRunId` model in `sessionStore`/backend. The `+` menu ships with "New chat" disabled. +- **Comment replies & artifact-anchored comment threads** — needs a comments model (parent id, anchor ref) beyond `TaskThreadMessage`. +- **Canvas comments** — acknowledged in the mockup as not existing yet. +- **Slack thread sync** — only the one-way originating link exists; rendering it as an external-link artifact is the prototype scope. +- **Unified pinning model** — the prototype *renders* pinned tasks + canvases together but keeps the three existing persistence mechanisms. +- **Trackpad swipe + spring physics** for space switching; multi-window interactions with spaces. +- **Reconciling with the browser-tabs PRD** (`docs/plans/browser-tabs.md`) — spaces mode hides the global strip for now; if the team wants both, the tab strip could scope to the active space. + +## Open questions + +1. **Landing state** — is the all-channels sidebar the "no space selected" home (assumed here), with clicking a channel entering its space? +2. **Which channels are spaces?** Assumed: starred channels form the dot row (curated, Arc-like), everything else reachable via search/landing. Or should every channel get a dot? +3. **Global tab strip** — the mockup drops it in favor of tabs inside the task. Hide it in spaces mode (assumed), or keep both levels of tabs? +4. **Inbox/Activity identity** — top-bar Inbox = the existing Self-driving inbox (reports/PRs/runs), Activity = the mentions feed, just relocated? Or is a different notion of "inbox" intended? +5. **"Context"** = today's per-channel CONTEXT.md renamed, correct? +6. **Pinned scope** — pinned tasks are global today; the space sidebar shows only those filed to the current channel. OK? +7. **Comments v1** — flat comments on the existing task-thread substrate (no replies/anchors) acceptable for the prototype? +8. **Disabled `+` items** — show "New chat"/"New canvas" disabled with a "soon" hint (assumed, communicates the vision), or hide them entirely? + +## How to try it + +1. Enable the **Channels** alpha switch in the sidebar (Bluebird flag is default-on in dev). +2. Enable **Spaces layout** next to it. +3. Star two or three channels → they appear as dots. Enter one, run a task, open the Activity panel tabs, click a PR artifact to get the diff tab. From 63a6ccaf0f0b526e88c376b9baae64f623b9b139 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 25 Jul 2026 15:44:10 +0200 Subject: [PATCH 02/57] feat(canvas): spaces layout prototype (Arc-style spaces + task Activity panel) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With Channels on: the sidebar scopes to the active channel (space) with Context/Loops/Artifacts nav, pinned + recent items, and an Arc-style dot switcher (starred channels; click/swipe/Ctrl+Alt+arrows, slide transition). Title bar swaps the tab strip for a search pill plus Activity and Inbox icons. The task thread panel becomes an Activity panel with Timeline/Artifacts/Comments tabs — artifacts derived from the thread, run output, and Slack link, with live PR state and comment counts. The task tab strip's + is now a chat/terminal/canvas menu (only terminal enabled). Generated-By: PostHog Code Task-Id: c401c7cb-e436-4da3-80bf-1d3008ee5d38 --- docs/plans/spaces-layout-prototype.md | 30 +- .../canvas/components/ChannelsSidebar.tsx | 41 ++- .../features/canvas/components/SpaceDots.tsx | 128 ++++++++ .../canvas/components/SpaceSidebar.tsx | 283 ++++++++++++++++++ .../canvas/components/SpacesTitleBar.tsx | 76 +++++ .../canvas/components/TaskArtifactsList.tsx | 237 +++++++++++++++ .../canvas/components/ThreadPanel.tsx | 103 +++++-- .../ui/src/features/canvas/hooks/useSpaces.ts | 62 ++++ .../src/features/canvas/stores/spaceStore.ts | 26 ++ .../panels/components/TabbedPanel.tsx | 51 +++- packages/ui/src/router/routes/__root.tsx | 15 +- 11 files changed, 992 insertions(+), 60 deletions(-) create mode 100644 packages/ui/src/features/canvas/components/SpaceDots.tsx create mode 100644 packages/ui/src/features/canvas/components/SpaceSidebar.tsx create mode 100644 packages/ui/src/features/canvas/components/SpacesTitleBar.tsx create mode 100644 packages/ui/src/features/canvas/components/TaskArtifactsList.tsx create mode 100644 packages/ui/src/features/canvas/hooks/useSpaces.ts create mode 100644 packages/ui/src/features/canvas/stores/spaceStore.ts diff --git a/docs/plans/spaces-layout-prototype.md b/docs/plans/spaces-layout-prototype.md index 22333f2ee7..ef7fa612c6 100644 --- a/docs/plans/spaces-layout-prototype.md +++ b/docs/plans/spaces-layout-prototype.md @@ -1,7 +1,7 @@ # Plan: "Spaces" Layout Prototype -Status: draft — for team discussion; scoped as a shareable prototype, not a final architecture. -Surface: Channels space (`/website/*`), behind the existing `project-bluebird` alpha plus one new layout toggle. The default Code experience is untouched. +Status: implemented (one shot, prototype quality) — this doc is the design rationale and the map of what's real vs deferred. +Surface: Channels space (`/website/*`), gated by the existing `project-bluebird` alpha + Channels toggle. Turning Channels on now gets the spaces layout directly (no extra toggle — prototype, no backward-compat layer). The default Code experience is untouched. ## The idea (from the mockup) @@ -45,7 +45,7 @@ The single biggest input to this plan: **most of the mockup's nouns already exis ## Prototype principles -1. **One switch to flip.** Everything lands behind a `spacesLayout` view-state toggle in `sidebarStore` (persisted, dev-default off), surfaced next to the existing "Channels" alpha switch. Flipping it swaps the chrome inside the Channels space only. Anyone on the team can try it and flip back; the Code space and the current Channels layout keep working unchanged. +1. **One switch to flip.** The existing "Channels" alpha switch is the gate — turning it on gets the spaces layout (prototype: no second toggle, no backward-compat layer for the old channels chrome). Flipping it off returns to the unchanged Code experience. 2. **No new backend or domain models.** Every panel is fed by data that already exists (task thread messages, run output/state, dashboards, GitHub PR data). Where the mockup wants data we don't have (canvas comments, comment replies, multi-session), the UI shows what exists and omits or disables the rest — exactly as the mockup allows ("for now we can do what is available"). 3. **Renames are cheap, do them properly.** "Thread" → "Timeline" and "CONTEXT.md" → "Context" are label changes in the new chrome only; no store/schema renames during the prototype. 4. **Navigation reuses existing routes.** Sidebar entries and top-bar icons navigate to routes that already exist (`/website/$channelId/*`, `/website/activity`); the only new route is an inbox mirror. @@ -85,24 +85,13 @@ Replace the docked `ThreadPanel` (in spaces mode) with an `ActivityPanel` in the - **Artifacts** — new task-scoped list derived with no new backend: PRs from `latest_run.output.pr_url` + `pr_created` thread events (state + comment count via `usePrInfo`/`usePrComments`/`usePrReviewThreads`), canvases from `canvas_created` thread events / `generationTaskId`, and the Slack thread from `latest_run.state.slack_thread_url` rendered as an external link. Canvas/Slack rows render without counts (no data yet — acknowledged in the mockup). - **Comments** — the human-authored subset of the same task thread (`TaskThreadMessage` where `author_kind: "human"` and no `event`), plus the existing composer as `+ Add new`. Flat list for the prototype: no replies, no artifact anchoring (needs a data model — deferred). If a message payload carries a source, show it as a badge. -## Build plan +## What was built (single pass) -**Phase 1 — Shell (the part you can *feel* first).** -Toggle in `sidebarStore` + settings row; `SpaceSidebar` (nav entries, pinned, recents); space dots + keyboard cycling; top bar swap (search field, bell, inbox icons); `/website/inbox` mirror route. -Touches: `features/sidebar/sidebarStore.ts`, `features/canvas/components/ChannelsSidebar.tsx` (+ new `SpaceSidebar.tsx`, `SpaceDots.tsx`), `router/routes/__root.tsx` (title bar), new `router/routes/website/inbox.tsx`. +**Shell.** With Channels on: the all-channels list is the landing; entering a channel scopes the sidebar to that space (`SpaceSidebar.tsx`: New task, `#channel` header, Context/Loops/Artifacts rows, Pinned, Recent). `SpaceDots.tsx` renders the Arc-style dot row above the account footer (starred channels = spaces, `#` = back to landing, `+` = create channel); switching slides the sidebar in the travel direction (framer-motion), horizontal trackpad swipe and `Ctrl+Alt+←/→` cycle spaces. The current space persists across channel-less routes (inbox, activity) via `stores/spaceStore.ts`. Title bar (`SpacesTitleBar.tsx` + `__root.tsx`): the browser-tab strip is replaced by a centered search pill (opens the ⌘K palette) plus Activity-bell (unread-mention dot) and Inbox icons. -**Phase 2 — Activity panel.** -`ActivityPanel.tsx` with the three tabs inside `ThreadSidebar`; `TaskArtifactsTab.tsx` (derived artifact list + PR comment counts); `TaskCommentsTab.tsx` (filtered thread + composer); Timeline = existing `ThreadPanel`. -Touches: `features/canvas/components/` (new files), reuses `threadTimeline.ts`, `useTaskThread.ts`, `features/pr-review/usePr*`. +**Activity panel.** `ThreadPanel.tsx` is now the Activity panel: header renamed, with Timeline / Artifacts / Comments tabs. Timeline = the existing thread untouched; Comments = the human rows of the same thread + the existing composer; Artifacts = `TaskArtifactsList.tsx`, derived with no new backend (thread `pr_created`/`canvas_created` events + run output PR + `slack_thread_url`), with live PR state + comment counts from GitHub. PR rows open the diff in the review pane; canvas rows open the canvas; Slack opens externally. -**Phase 3 — Task tabs.** -`+` dropdown on the panel tab strip (terminal live; chat/canvas disabled); `canvas` tab type in `panelTypes.ts`/`TabContentRenderer.tsx`; wire artifact clicks → open review tab / canvas tab / external link. -Touches: `packages/core/src/panels/panelTypes.ts` + `panelLayoutTransforms.ts`, `features/panels/components/TabbedPanel.tsx`, `TabContentRenderer.tsx`, `features/task-detail/`. - -**Phase 4 — Polish & demo.** -Empty states (quill ``), unread dots on space dots, transition animation between spaces (simple slide; Arc-grade springs later), a short demo script in this doc, then a team walkthrough build. - -Phases 1 and 2 are independent and can land in either order; 3 depends on 2 (artifact clicks) only for wiring. +**Task tabs.** The panel tab strip's `+` is a dropdown: New terminal (works), New chat / New canvas disabled with a "Soon" hint (`TabbedPanel.tsx`) — per the mockup's "only what's available" note. ## Explicitly deferred (needs real data-model work) @@ -128,5 +117,6 @@ Phases 1 and 2 are independent and can land in either order; 3 depends on 2 (art ## How to try it 1. Enable the **Channels** alpha switch in the sidebar (Bluebird flag is default-on in dev). -2. Enable **Spaces layout** next to it. -3. Star two or three channels → they appear as dots. Enter one, run a task, open the Activity panel tabs, click a PR artifact to get the diff tab. +2. Star two or three channels → they appear as dots at the bottom of the sidebar. Enter one to scope the sidebar to that space. +3. Switch spaces by clicking dots, swiping horizontally on the sidebar, or `Ctrl+Alt+←/→`. The `#` in the dot row returns to the all-channels landing. +4. Open a task in the space: the right Activity panel has Timeline / Artifacts / Comments; clicking a PR artifact opens its diff. The `+` on the task tab strip shows the new chat/terminal/canvas menu. diff --git a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx index 3afc553050..17a3a15015 100644 --- a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx @@ -5,6 +5,9 @@ import { useArchivedTaskIds } from "@posthog/ui/features/archive/useArchivedTask 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 { SpaceDots } from "@posthog/ui/features/canvas/components/SpaceDots"; +import { SpaceSidebar } from "@posthog/ui/features/canvas/components/SpaceSidebar"; +import { useSpaceStore } from "@posthog/ui/features/canvas/stores/spaceStore"; 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"; @@ -24,6 +27,7 @@ import { useSidebarEdgeHoverPeek } from "@posthog/ui/primitives/hooks/useSidebar import { ResizableSidebar } from "@posthog/ui/primitives/ResizableSidebar"; import { navigateToArchived } from "@posthog/ui/router/navigationBridge"; import { Box, Flex } from "@radix-ui/themes"; +import { useParams } from "@tanstack/react-router"; import { useDeferredValue, useEffect } from "react"; // The unified app sidebar (Code merged into the Bluebird chrome). Top to @@ -87,6 +91,20 @@ export function ChannelsSidebar() { const archivedTaskIds = useArchivedTaskIds(); + // Spaces layout (prototype): while a channel is active the sidebar scopes to + // it (Arc-style space). The route is the source of truth — visiting any + // /website/$channelId page adopts that channel as the current space, and it + // sticks across channel-less routes (inbox, activity) until the user leaves + // via the all-channels landing (the "#" in the dot row). + const params = useParams({ strict: false }); + const routeChannelId = params.channelId; + const currentChannelId = useSpaceStore((s) => s.currentChannelId); + const setCurrentChannel = useSpaceStore((s) => s.setCurrentChannel); + useEffect(() => { + if (routeChannelId) setCurrentChannel(routeChannelId); + }, [routeChannelId, setCurrentChannel]); + const inSpace = channelsEnabled && currentChannelId != null; + return ( {/* The nav owns the "Enable channels" toggle + Canvas rows (gated by - the same flag), so this section carries the whole merged nav. */} - + the same flag), so this section carries the whole merged nav. + Hidden while a space is active: the space sidebar is the whole + body then (search/inbox/activity live in the title bar). */} + {!inSpace && } - {/* 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 ? ( + {/* Body: inside a space, the space-scoped sidebar; otherwise the + channel tree when channels are on, else the task list. Each owns + its own scroll region. Gated on the deferred value so the toggle + paints before this heavy swap. */} + {bodyChannelsEnabled && inSpace && currentChannelId ? ( + + + + ) : bodyChannelsEnabled ? ( <> {/* The fab is a sibling of the scroll region, not a child, so it @@ -148,6 +173,10 @@ export function ChannelsSidebar() { + {/* Arc-style space switcher: one dot per starred channel. Rendered + whenever channels are on so the landing shows the dot row too. */} + {channelsEnabled && } + {/* Workspace switcher pinned to the bottom. Its dropdown carries the Settings entry, so there's no separate Settings row. */} diff --git a/packages/ui/src/features/canvas/components/SpaceDots.tsx b/packages/ui/src/features/canvas/components/SpaceDots.tsx new file mode 100644 index 0000000000..bcbb0cc238 --- /dev/null +++ b/packages/ui/src/features/canvas/components/SpaceDots.tsx @@ -0,0 +1,128 @@ +import { HashIcon, PlusIcon } from "@phosphor-icons/react"; +import { + cn, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@posthog/quill"; +import { CreateChannelModal } from "@posthog/ui/features/canvas/components/CreateChannelModal"; +import { useSpaces } from "@posthog/ui/features/canvas/hooks/useSpaces"; +import { useIsChannelUnread } from "@posthog/ui/features/canvas/hooks/useUnreadChannels"; +import { useSpaceStore } from "@posthog/ui/features/canvas/stores/spaceStore"; +import { useNavigate } from "@tanstack/react-router"; +import { motion } from "framer-motion"; +import { useEffect, useState } from "react"; + +/** + * The Arc-style space switcher pinned above the sidebar footer: one dot per + * space (starred channel), a "#" escape hatch to the all-channels landing, and + * a "+" that creates a new channel (= a new space). Ctrl+Alt+←/→ cycles. + */ +export function SpaceDots() { + const navigate = useNavigate(); + const { spaces, currentChannelId, switchTo, cycle } = useSpaces(); + const setCurrentChannel = useSpaceStore((s) => s.setCurrentChannel); + const isUnread = useIsChannelUnread(); + const [createOpen, setCreateOpen] = useState(false); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (!event.ctrlKey || !event.altKey) return; + if (event.key === "ArrowLeft") { + event.preventDefault(); + cycle(-1); + } else if (event.key === "ArrowRight") { + event.preventDefault(); + cycle(1); + } + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [cycle]); + + const goToLanding = () => { + setCurrentChannel(null); + void navigate({ to: "/website" }); + }; + + return ( + +
+ + + + + } + /> + All channels + + +
+ {spaces.map((channel) => { + const active = channel.id === currentChannelId; + return ( + + switchTo(channel)} + className="group flex size-4 shrink-0 items-center justify-center" + > + + + } + /> + # {channel.name} + + ); + })} +
+ + + setCreateOpen(true)} + className="flex size-5 shrink-0 items-center justify-center rounded text-gray-10 transition-colors hover:bg-gray-3 hover:text-gray-12" + > + + + } + /> + New space (channel) + +
+ +
+ ); +} diff --git a/packages/ui/src/features/canvas/components/SpaceSidebar.tsx b/packages/ui/src/features/canvas/components/SpaceSidebar.tsx new file mode 100644 index 0000000000..c1057197a8 --- /dev/null +++ b/packages/ui/src/features/canvas/components/SpaceSidebar.tsx @@ -0,0 +1,283 @@ +import { + BookOpenTextIcon, + FileTextIcon, + HashIcon, + PackageIcon, + PlusIcon, + RepeatIcon, +} from "@phosphor-icons/react"; +import type { ChannelTaskRecord } from "@posthog/core/canvas/channelTaskSchemas"; +import type { DashboardSummary } from "@posthog/core/canvas/dashboardSchemas"; +import { MenuLabel } from "@posthog/quill"; +import { LOOPS_FLAG } from "@posthog/shared"; +import { useArchivedTaskIds } from "@posthog/ui/features/archive/useArchivedTaskIds"; +import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon"; +import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { useChannelTasks } from "@posthog/ui/features/canvas/hooks/useChannelTasks"; +import { useDashboards } from "@posthog/ui/features/canvas/hooks/useDashboards"; +import { useSpaces } from "@posthog/ui/features/canvas/hooks/useSpaces"; +import { useSpaceStore } from "@posthog/ui/features/canvas/stores/spaceStore"; +import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; +import { SidebarItem } from "@posthog/ui/features/sidebar/components/SidebarItem"; +import { usePinnedTasks } from "@posthog/ui/features/sidebar/usePinnedTasks"; +import { useTasks } from "@posthog/ui/features/tasks/useTasks"; +import { navigateToChannelNewTask } from "@posthog/ui/router/navigationBridge"; +import { useNavigate, useRouterState } from "@tanstack/react-router"; +import { motion } from "framer-motion"; +import { type ReactNode, useMemo, useRef } from "react"; + +interface SpaceItem { + key: string; + kind: "task" | "canvas"; + title: string; + ts: number; + pinned: boolean; + icon: ReactNode; + isActive: boolean; + onClick: () => void; +} + +const RECENTS_CAP = 30; + +// How much horizontal trackpad travel counts as a space swipe, and how long +// the switcher stays locked afterwards so one gesture moves one space. +const SWIPE_THRESHOLD = 90; +const SWIPE_LOCK_MS = 500; +const SWIPE_RESET_MS = 300; + +/** + * The sidebar body while a space (channel) is active — the Arc-style layout: + * New task, the channel header, the channel's sections (Context / Loops / + * Artifacts) as nav rows, then pinned and recent tasks & canvases. Slides in + * on space switches; horizontal trackpad swipes cycle spaces. + */ +export function SpaceSidebar({ channelId }: { channelId: string }) { + const navigate = useNavigate(); + const pathname = useRouterState({ select: (s) => s.location.pathname }); + const direction = useSpaceStore((s) => s.direction); + const { cycle } = useSpaces(); + const loopsEnabled = useFeatureFlag(LOOPS_FLAG, import.meta.env.DEV); + + const { channels } = useChannels(); + const channelName = + channels.find((c) => c.id === channelId)?.name ?? "channel"; + + const { dashboards } = useDashboards(channelId); + const { tasks: filedTasks } = useChannelTasks(channelId); + const { data: tasks } = useTasks(); + const archivedTaskIds = useArchivedTaskIds(); + const { pinnedTaskIds } = usePinnedTasks(); + + const base = `/website/${channelId}`; + + const items = useMemo(() => { + const canvasItems: SpaceItem[] = dashboards.map((d: DashboardSummary) => ({ + key: `canvas:${d.id}`, + kind: "canvas", + title: d.name, + ts: d.updatedAt, + pinned: d.pinnedAt != null, + icon: iconForTemplate(d.templateId, { + size: 15, + className: "text-violet-9", + }), + isActive: pathname === `${base}/dashboards/${d.id}`, + onClick: () => + void navigate({ + to: "/website/$channelId/dashboards/$dashboardId", + params: { channelId, dashboardId: d.id }, + }), + })); + + const taskById = new Map(tasks?.map((t) => [t.id, t]) ?? []); + const taskItems: SpaceItem[] = filedTasks.flatMap( + (f: ChannelTaskRecord) => { + const task = taskById.get(f.taskId); + if (archivedTaskIds.has(f.taskId) || !task) return []; + return [ + { + key: `task:${f.id}`, + kind: "task" as const, + title: task.title || "Untitled task", + ts: Date.parse(task.updated_at) || 0, + pinned: pinnedTaskIds.has(f.taskId), + icon: , + isActive: pathname === `${base}/tasks/${f.taskId}`, + onClick: () => + void navigate({ + to: "/website/$channelId/tasks/$taskId", + params: { channelId, taskId: f.taskId }, + }), + }, + ]; + }, + ); + + return [...canvasItems, ...taskItems].sort((a, b) => b.ts - a.ts); + }, [ + dashboards, + filedTasks, + tasks, + archivedTaskIds, + pinnedTaskIds, + pathname, + base, + channelId, + navigate, + ]); + + const pinnedItems = items.filter((i) => i.pinned); + const recentItems = items.filter((i) => !i.pinned).slice(0, RECENTS_CAP); + + // Horizontal trackpad swipe cycles spaces, Arc-style. Accumulate deltaX and + // fire once past the threshold; ignore mostly-vertical wheel events so list + // scrolling never switches spaces. + const swipeAccum = useRef(0); + const swipeLockUntil = useRef(0); + const swipeLastEvent = useRef(0); + const onWheel = (event: React.WheelEvent) => { + if (Math.abs(event.deltaX) <= Math.abs(event.deltaY)) return; + const now = Date.now(); + if (now < swipeLockUntil.current) return; + if (now - swipeLastEvent.current > SWIPE_RESET_MS) swipeAccum.current = 0; + swipeLastEvent.current = now; + swipeAccum.current += event.deltaX; + if (Math.abs(swipeAccum.current) >= SWIPE_THRESHOLD) { + const delta = swipeAccum.current > 0 ? 1 : -1; + swipeAccum.current = 0; + swipeLockUntil.current = now + SWIPE_LOCK_MS; + cycle(delta); + } + }; + + const sectionRow = ( + label: string, + icon: ReactNode, + to: string, + onClick: () => void, + ) => ( + + ); + + return ( + +
+ } + label="New task" + isActive={pathname === `${base}/new`} + onClick={() => navigateToChannelNewTask(channelId)} + /> +
+ + {/* Channel header: the space's identity, click = channel home. */} + + +
+ {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 }, + }), + )} +
+ +
+ {pinnedItems.length > 0 && ( + <> + Pinned +
+ {pinnedItems.map((item) => ( + + ))} +
+ + )} + + {recentItems.length > 0 && ( + <> + Recent +
+ {recentItems.map((item) => ( + + ))} +
+ + )} + + {pinnedItems.length === 0 && recentItems.length === 0 && ( +

+ Tasks and canvases you create in this space show up here. +

+ )} +
+
+ ); +} diff --git a/packages/ui/src/features/canvas/components/SpacesTitleBar.tsx b/packages/ui/src/features/canvas/components/SpacesTitleBar.tsx new file mode 100644 index 0000000000..758613aec2 --- /dev/null +++ b/packages/ui/src/features/canvas/components/SpacesTitleBar.tsx @@ -0,0 +1,76 @@ +import { BellIcon, MagnifyingGlassIcon, TrayIcon } from "@phosphor-icons/react"; +import { countUnseenActivity } from "@posthog/core/canvas/mentionActivity"; +import { Button } from "@posthog/quill"; +import { useMentionActivity } from "@posthog/ui/features/canvas/hooks/useMentionActivity"; +import { useActivitySeenStore } from "@posthog/ui/features/canvas/stores/activitySeenStore"; +import { + navigateToActivity, + navigateToInbox, +} from "@posthog/ui/router/navigationBridge"; +import { useCommandMenuStore } from "@posthog/ui/shell/commandMenuStore"; +import { isMac } from "@posthog/ui/utils/platform"; +import { Flex } from "@radix-ui/themes"; +import { useMemo } from "react"; + +/** + * The spaces-layout title bar center: a persistent search pill that opens the + * command menu. Replaces the browser-tab strip while channels are on (the + * prototype moves tabs inside the task view instead). + */ +export function SpacesSearchField() { + const toggleCommandMenu = useCommandMenuStore((s) => s.toggle); + return ( + + + + ); +} + +/** + * Activity (bell, with an unread-mentions dot) and Inbox buttons for the + * spaces-layout title bar. Both open in the main view; the sidebar keeps its + * current space. + */ +export function SpacesTitleBarActions() { + const { items } = useMentionActivity(); + const lastSeenAt = useActivitySeenStore((s) => s.lastSeenAt); + const unseen = useMemo( + () => countUnseenActivity(items, lastSeenAt), + [items, lastSeenAt], + ); + + return ( + <> + + + + ); +} diff --git a/packages/ui/src/features/canvas/components/TaskArtifactsList.tsx b/packages/ui/src/features/canvas/components/TaskArtifactsList.tsx new file mode 100644 index 0000000000..2668c9814d --- /dev/null +++ b/packages/ui/src/features/canvas/components/TaskArtifactsList.tsx @@ -0,0 +1,237 @@ +import { + ArrowSquareOutIcon, + PackageIcon, + SlackLogoIcon, +} from "@phosphor-icons/react"; +import type { ThreadTimelineRow } from "@posthog/core/canvas/threadTimeline"; +import { + getPrVisualConfig, + parsePrNumber, +} from "@posthog/core/git-interaction/prStatus"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@posthog/quill"; +import type { Task, TaskThreadMessage } from "@posthog/shared/domain-types"; +import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon"; +import { useReviewNavigationStore } from "@posthog/ui/features/code-review/reviewNavigationStore"; +import { getPrVisualIcon } from "@posthog/ui/features/git-interaction/prIcon"; +import { usePrDetails } from "@posthog/ui/features/git-interaction/usePrDetails"; +import { usePrComments } from "@posthog/ui/features/pr-review/usePrComments"; +import { usePrReviewThreads } from "@posthog/ui/features/pr-review/usePrReviewThreads"; +import { openExternalUrl } from "@posthog/ui/shell/openExternal"; +import { parseShareLink } from "@posthog/ui/utils/posthogLinks"; +import { navigateToShareTarget } from "@posthog/ui/utils/shareLinks"; +import { getPostHogUrl } from "@posthog/ui/utils/urls"; +import { type ReactNode, useMemo } from "react"; + +type ArtifactRow = + | { kind: "pr"; key: string; url: string } + | { kind: "canvas"; key: string; name: string; url: string | null } + | { kind: "slack"; key: string; url: string }; + +function parseHttpsUrl(url: string): URL | null { + try { + const parsed = new URL(url); + return parsed.protocol === "https:" ? parsed : null; + } catch { + return null; + } +} + +/** + * Everything this task produced or is anchored to, derived with no new + * backend: PRs and canvases the agent announced on the task thread, the run + * output's PR as a fallback, and the originating Slack thread (external link). + */ +function buildRows( + task: Task, + timeline: ThreadTimelineRow[], +): ArtifactRow[] { + const rows: ArtifactRow[] = []; + const seenPrUrls = new Set(); + + for (const row of timeline) { + if (row.kind !== "artifact") continue; + if (row.artifact.kind === "pr") { + if (seenPrUrls.has(row.artifact.url)) continue; + seenPrUrls.add(row.artifact.url); + rows.push({ kind: "pr", key: row.message.id, url: row.artifact.url }); + } else { + rows.push({ + kind: "canvas", + key: row.message.id, + name: row.artifact.name, + url: row.artifact.url, + }); + } + } + + const outputPr = task.latest_run?.output?.pr_url; + if (typeof outputPr === "string" && outputPr && !seenPrUrls.has(outputPr)) { + rows.push({ kind: "pr", key: `output-pr:${outputPr}`, url: outputPr }); + } + + const slackUrl = task.latest_run?.state?.slack_thread_url; + if (typeof slackUrl === "string" && slackUrl) { + rows.push({ kind: "slack", key: "slack-thread", url: slackUrl }); + } + + return rows; +} + +function ArtifactListRow({ + icon, + title, + detail, + external, + onOpen, +}: { + icon: ReactNode; + title: string; + detail?: string | null; + external?: boolean; + onOpen?: () => void; +}) { + return ( + + ); +} + +// PR row: live state + comment count from GitHub. Clicking opens the diff in +// the task's review pane (the mockup's "on click, show diff"). +function PrRow({ url, taskId }: { url: string; taskId: string }) { + const parsed = parseHttpsUrl(url); + const safeUrl = parsed?.origin === "https://github.com" ? parsed.href : null; + const { + meta: { state, merged, draft }, + } = usePrDetails(safeUrl); + const comments = usePrComments(safeUrl); + const threads = usePrReviewThreads(safeUrl); + const setReviewMode = useReviewNavigationStore((s) => s.setReviewMode); + + const config = getPrVisualConfig(state ?? "open", merged, draft); + const PrIcon = getPrVisualIcon(config.icon); + const prNumber = safeUrl ? parsePrNumber(safeUrl) : null; + + const commentCount = + (comments.data?.length ?? 0) + + (threads.data ?? []).reduce( + (sum, thread) => sum + thread.comments.length, + 0, + ); + const detailParts = [ + state ? config.label : null, + comments.data || threads.data + ? `${commentCount} ${commentCount === 1 ? "comment" : "comments"}` + : null, + ].filter(Boolean); + + return ( + + } + title={prNumber ? `Pull request #${prNumber}` : "Pull request"} + detail={detailParts.join(" · ") || null} + onOpen={() => setReviewMode(taskId, "split")} + /> + ); +} + +function CanvasRow({ name, url }: { name: string; url: string | null }) { + const parsed = url ? parseHttpsUrl(url) : null; + const target = parsed ? parseShareLink(parsed.href) : null; + const open = + parsed && target + ? () => { + const currentPostHogUrl = getPostHogUrl("/"); + const currentPostHogOrigin = currentPostHogUrl + ? parseHttpsUrl(currentPostHogUrl)?.origin + : null; + if (parsed.origin === currentPostHogOrigin) { + navigateToShareTarget(target); + } else { + openExternalUrl(parsed.href); + } + } + : undefined; + return ( + + ); +} + +export function TaskArtifactsList({ + task, + timeline, +}: { + task: Task; + timeline: ThreadTimelineRow[]; +}) { + const rows = useMemo(() => buildRows(task, timeline), [task, timeline]); + + if (rows.length === 0) { + return ( + + + + + + No artifacts yet + + Pull requests and canvases produced while working on this task show + up here. + + + + ); + } + + return ( +
+ {rows.map((row) => + row.kind === "pr" ? ( + + ) : row.kind === "canvas" ? ( + + ) : ( + } + title="Slack thread" + detail="External" + external + onOpen={() => openExternalUrl(row.url)} + /> + ), + )} +
+ ); +} diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.tsx index 8b242509ee..b6ad4bbb96 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.tsx @@ -25,6 +25,7 @@ import { AvatarFallback, Badge, Button, + cn, DropdownMenu, DropdownMenuContent, DropdownMenuItem, @@ -61,6 +62,7 @@ import { TaskCard } from "@posthog/ui/features/canvas/components/ChannelFeedView import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon"; import { MentionComposer } from "@posthog/ui/features/canvas/components/MentionComposer"; import { MentionText } from "@posthog/ui/features/canvas/components/MentionText"; +import { TaskArtifactsList } from "@posthog/ui/features/canvas/components/TaskArtifactsList"; import { ThreadTimestamp } from "@posthog/ui/features/canvas/components/ThreadTimestamp"; import { useOrgMembers } from "@posthog/ui/features/canvas/hooks/useOrgMembers"; import { @@ -332,6 +334,42 @@ function ThreadLoadingState() { ); } +// The Activity panel's sub-views: Timeline is the task thread as before (the +// mockup renames it), Artifacts lists the task's outputs, Comments filters the +// thread to the human conversation. +type ActivityTab = "timeline" | "artifacts" | "comments"; + +const ACTIVITY_TABS: readonly { key: ActivityTab; label: string }[] = [ + { key: "timeline", label: "Timeline" }, + { key: "artifacts", label: "Artifacts" }, + { key: "comments", label: "Comments" }, +] as const; + +function ActivityTabsRow({ + tab, + onTabChange, +}: { + tab: ActivityTab; + onTabChange: (tab: ActivityTab) => void; +}) { + return ( +
+ {ACTIVITY_TABS.map((t) => ( + + ))} +
+ ); +} + function ThreadHeader({ onClose, onToggleCollapsed, @@ -344,7 +382,7 @@ function ThreadHeader({ return (
- Thread + Activity
{onOpenFull && (
); } diff --git a/packages/ui/src/features/canvas/hooks/useSpaces.ts b/packages/ui/src/features/canvas/hooks/useSpaces.ts new file mode 100644 index 0000000000..07da151dfe --- /dev/null +++ b/packages/ui/src/features/canvas/hooks/useSpaces.ts @@ -0,0 +1,62 @@ +import { useChannelStars } from "@posthog/ui/features/canvas/hooks/useChannelStars"; +import { + type Channel, + useChannels, +} from "@posthog/ui/features/canvas/hooks/useChannels"; +import { useSpaceStore } from "@posthog/ui/features/canvas/stores/spaceStore"; +import { navigateToChannel } from "@posthog/ui/router/navigationBridge"; +import { useCallback, useMemo } from "react"; + +/** + * The spaces in the Arc-style dot switcher: the user's starred channels (their + * curated set), plus the current channel appended when it isn't starred — a + * temporarily visited space, like opening an unstarred Arc space via search. + */ +export function useSpaces(): { + spaces: Channel[]; + currentChannelId: string | null; + currentIndex: number; + switchTo: (channel: Channel) => void; + cycle: (delta: 1 | -1) => void; +} { + const { channels } = useChannels(); + const { starredRefToShortcutId } = useChannelStars(); + const currentChannelId = useSpaceStore((s) => s.currentChannelId); + const setCurrentChannel = useSpaceStore((s) => s.setCurrentChannel); + + const spaces = useMemo(() => { + const starred = channels.filter((c) => starredRefToShortcutId.has(c.path)); + const current = channels.find((c) => c.id === currentChannelId); + if (current && !starred.some((c) => c.id === current.id)) { + starred.push(current); + } + return starred; + }, [channels, starredRefToShortcutId, currentChannelId]); + + const currentIndex = spaces.findIndex((c) => c.id === currentChannelId); + + const switchTo = useCallback( + (channel: Channel) => { + if (channel.id === currentChannelId) return; + const from = spaces.findIndex((c) => c.id === currentChannelId); + const to = spaces.findIndex((c) => c.id === channel.id); + // Unknown positions (entering from the landing) read as moving right. + const direction = from !== -1 && to !== -1 && to < from ? -1 : 1; + setCurrentChannel(channel.id, direction); + navigateToChannel(channel.id); + }, + [spaces, currentChannelId, setCurrentChannel], + ); + + const cycle = useCallback( + (delta: 1 | -1) => { + if (spaces.length === 0) return; + const from = currentIndex === -1 ? 0 : currentIndex; + const next = spaces[(from + delta + spaces.length) % spaces.length]; + if (next) switchTo(next); + }, + [spaces, currentIndex, switchTo], + ); + + return { spaces, currentChannelId, currentIndex, switchTo, cycle }; +} diff --git a/packages/ui/src/features/canvas/stores/spaceStore.ts b/packages/ui/src/features/canvas/stores/spaceStore.ts new file mode 100644 index 0000000000..5c04020aa1 --- /dev/null +++ b/packages/ui/src/features/canvas/stores/spaceStore.ts @@ -0,0 +1,26 @@ +import { create } from "zustand"; + +/** + * View state for the Arc-style "spaces" chrome (layout prototype): which + * channel the sidebar is currently scoped to, and which direction the last + * switch moved so the sidebar body can slide accordingly. The current space + * survives navigating to channel-less routes (inbox, activity, settings) so + * the sidebar keeps its scope until the user explicitly leaves via the + * all-channels landing. + */ +interface SpaceState { + currentChannelId: string | null; + /** +1 = moved right in the dot order, -1 = left. Drives the slide-in. */ + direction: 1 | -1; + setCurrentChannel: (channelId: string | null, direction?: 1 | -1) => void; +} + +export const useSpaceStore = create()((set) => ({ + currentChannelId: null, + direction: 1, + setCurrentChannel: (currentChannelId, direction) => + set((state) => ({ + currentChannelId, + direction: direction ?? state.direction, + })), +})); diff --git a/packages/ui/src/features/panels/components/TabbedPanel.tsx b/packages/ui/src/features/panels/components/TabbedPanel.tsx index d34ad048ed..e7cdd86c47 100644 --- a/packages/ui/src/features/panels/components/TabbedPanel.tsx +++ b/packages/ui/src/features/panels/components/TabbedPanel.tsx @@ -1,6 +1,18 @@ import { useDroppable } from "@dnd-kit/react"; -import { Plus, SquareSplitHorizontalIcon } from "@phosphor-icons/react"; +import { + ChartBarIcon, + ChatCircleIcon, + Plus, + SquareSplitHorizontalIcon, + TerminalWindowIcon, +} from "@phosphor-icons/react"; import { useHostTRPCClient } from "@posthog/host-router/react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@posthog/quill"; import { PanelDropZones } from "@posthog/ui/features/panels/components/PanelDropZones"; import type { SplitDirection } from "@posthog/ui/features/panels/panelLayoutStore"; import type { PanelContent } from "@posthog/ui/features/panels/panelTypes"; @@ -218,12 +230,39 @@ export const TabbedPanel: React.FC = ({ badge={tab.badge} /> ))} + {/* "+" adds a tab. Only terminals work today; chat and canvas + tabs are the layout prototype's declared direction, shown + disabled so the menu communicates where this is going. */} {content.droppable && onAddTerminal && ( - - - - - + + {}}> + + + } + /> + + + + New chat + + Soon + + + + + New terminal + + + + New canvas + + Soon + + + + )} {/* Spacer to increase DND area */} {content.droppable && ( diff --git a/packages/ui/src/router/routes/__root.tsx b/packages/ui/src/router/routes/__root.tsx index 6ecb9a0032..f45d5e5f92 100644 --- a/packages/ui/src/router/routes/__root.tsx +++ b/packages/ui/src/router/routes/__root.tsx @@ -28,6 +28,10 @@ import { FeedbackModal, type FeedbackModalMode, } from "@posthog/ui/features/canvas/components/FeedbackModal"; +import { + SpacesSearchField, + SpacesTitleBarActions, +} from "@posthog/ui/features/canvas/components/SpacesTitleBar"; import { useCanvasDeepLink } from "@posthog/ui/features/canvas/hooks/useCanvasDeepLink"; import { useChannelDeepLink } from "@posthog/ui/features/canvas/hooks/useChannelDeepLink"; import { CommandMenu } from "@posthog/ui/features/command/CommandMenu"; @@ -409,16 +413,17 @@ function RootLayout() {
)} - {/* Tabs work in both spaces: channel tabs under /website and plain - task tabs in the Code experience. The strip's route→tab effect - noops on param-less routes (inbox, agents, new-task), so it's safe - to mount everywhere. */} - + {/* Spaces layout (prototype): with channels on, the title-bar center + is a persistent search pill (tabs live inside the task view + instead of a global strip). Otherwise the browser-tab strip works + as before: channel tabs under /website, plain task tabs in Code. */} + {channelsEnabled ? : } {/* Gated so an empty right-side group can't claim a no-drag rect in the title bar for nothing — every pixel without controls should drag the window. */} {(billingEnabled || channelsEnabled) && ( + {channelsEnabled && } {channelsEnabled && ( - ); } diff --git a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx index 17a3a15015..83b298d38d 100644 --- a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx @@ -1,5 +1,5 @@ -import { ArchiveIcon } from "@phosphor-icons/react"; -import { Separator } from "@posthog/quill"; +import { ArchiveIcon, GearSixIcon } from "@phosphor-icons/react"; +import { Button, Separator } from "@posthog/quill"; import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; import { useArchivedTaskIds } from "@posthog/ui/features/archive/useArchivedTaskIds"; import { ChannelsFab } from "@posthog/ui/features/canvas/components/ChannelsFab"; @@ -7,10 +7,13 @@ import { ChannelsList } from "@posthog/ui/features/canvas/components/ChannelsLis import { useChannelsSidebarStore } from "@posthog/ui/features/canvas/components/channelsSidebarStore"; import { SpaceDots } from "@posthog/ui/features/canvas/components/SpaceDots"; import { SpaceSidebar } from "@posthog/ui/features/canvas/components/SpaceSidebar"; +import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; import { useSpaceStore } from "@posthog/ui/features/canvas/stores/spaceStore"; 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"; +import { openSettings } from "@posthog/ui/features/settings/hooks/useOpenSettings"; import { ProjectSwitcher } from "@posthog/ui/features/sidebar/components/ProjectSwitcher"; import { SidebarMenu } from "@posthog/ui/features/sidebar/components/SidebarMenu"; import { SidebarNavSection } from "@posthog/ui/features/sidebar/components/SidebarNavSection"; @@ -28,7 +31,7 @@ import { ResizableSidebar } from "@posthog/ui/primitives/ResizableSidebar"; import { navigateToArchived } from "@posthog/ui/router/navigationBridge"; import { Box, Flex } from "@radix-ui/themes"; import { useParams } from "@tanstack/react-router"; -import { useDeferredValue, useEffect } from "react"; +import { useDeferredValue, useEffect, useRef } from "react"; // The unified app sidebar (Code merged into the Bluebird chrome). Top to // bottom: workspace switcher, the merged global nav, the "Enable channels" @@ -105,6 +108,23 @@ export function ChannelsSidebar() { }, [routeChannelId, setCurrentChannel]); const inSpace = channelsEnabled && currentChannelId != null; + // The personal "#me" space is the default: on first load with nothing + // scoped, land there. Once any space has been current (including via the + // route), never auto-scope again — "Show channel list" (null) must stick. + const { channels } = useChannels({ enabled: channelsEnabled }); + const autoScopedRef = useRef(false); + useEffect(() => { + if (currentChannelId) autoScopedRef.current = true; + }, [currentChannelId]); + useEffect(() => { + if (!channelsEnabled || autoScopedRef.current || currentChannelId) return; + const me = channels.find((c) => c.name === PERSONAL_CHANNEL_NAME); + if (me) { + autoScopedRef.current = true; + setCurrentChannel(me.id); + } + }, [channelsEnabled, channels, currentChannelId, setCurrentChannel]); + return ( } - {/* Workspace switcher pinned to the bottom. Its dropdown carries the - Settings entry, so there's no separate Settings row. */} + {/* Workspace switcher pinned to the bottom. In spaces mode a Configure + gear sits beside it (per the mockup); otherwise its dropdown still + carries the Settings entry. */} - + {channelsEnabled ? ( + + + + + + + ) : ( + + )}
diff --git a/packages/ui/src/features/canvas/components/SpaceDots.tsx b/packages/ui/src/features/canvas/components/SpaceDots.tsx index bcbb0cc238..278e299f25 100644 --- a/packages/ui/src/features/canvas/components/SpaceDots.tsx +++ b/packages/ui/src/features/canvas/components/SpaceDots.tsx @@ -1,27 +1,57 @@ -import { HashIcon, PlusIcon } from "@phosphor-icons/react"; +import { + HashIcon, + PlusIcon, + SidebarSimpleIcon, + StarIcon, + UserIcon, +} from "@phosphor-icons/react"; import { cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@posthog/quill"; import { CreateChannelModal } from "@posthog/ui/features/canvas/components/CreateChannelModal"; +import { ensurePersonalChannel } from "@posthog/ui/features/canvas/ensurePersonalChannel"; +import { + useChannelStarMutations, + useChannelStars, +} from "@posthog/ui/features/canvas/hooks/useChannelStars"; +import { + type Channel, + useChannelMutations, + useChannels, +} from "@posthog/ui/features/canvas/hooks/useChannels"; import { useSpaces } from "@posthog/ui/features/canvas/hooks/useSpaces"; +import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; import { useIsChannelUnread } from "@posthog/ui/features/canvas/hooks/useUnreadChannels"; import { useSpaceStore } from "@posthog/ui/features/canvas/stores/spaceStore"; -import { useNavigate } from "@tanstack/react-router"; +import { toast } from "@posthog/ui/primitives/toast"; +import { navigateToCode } from "@posthog/ui/router/navigationBridge"; import { motion } from "framer-motion"; import { useEffect, useState } from "react"; +const RAIL_BUTTON_CLASS = + "flex size-5 shrink-0 items-center justify-center rounded text-gray-10 transition-colors hover:bg-gray-3 hover:text-gray-12"; + /** - * The Arc-style space switcher pinned above the sidebar footer: one dot per - * space (starred channel), a "#" escape hatch to the all-channels landing, and - * a "+" that creates a new channel (= a new space). Ctrl+Alt+←/→ cycles. + * The Arc-style space switcher pinned above the sidebar footer. The personal + * "#me" space is always first; then one dot per space. The "#" opens a + * browse-all-channels menu (pick one to open it as a space); the "+" adds a + * space — star an existing channel or create a new one. Ctrl+Alt+←/→ cycles. */ export function SpaceDots() { - const navigate = useNavigate(); const { spaces, currentChannelId, switchTo, cycle } = useSpaces(); + const { channels } = useChannels(); + const { createChannel } = useChannelMutations(); + const { starredRefToShortcutId } = useChannelStars(); + const { star } = useChannelStarMutations(); const setCurrentChannel = useSpaceStore((s) => s.setCurrentChannel); const isUnread = useIsChannelUnread(); const [createOpen, setCreateOpen] = useState(false); @@ -41,35 +71,118 @@ export function SpaceDots() { return () => window.removeEventListener("keydown", onKeyDown); }, [cycle]); - const goToLanding = () => { + const meChannel = channels.find((c) => c.name === PERSONAL_CHANNEL_NAME); + const meActive = !!meChannel && meChannel.id === currentChannelId; + const dotSpaces = spaces.filter((c) => c.name !== PERSONAL_CHANNEL_NAME); + const browsable = channels + .filter((c) => c.name !== PERSONAL_CHANNEL_NAME) + .sort((a, b) => a.name.localeCompare(b.name)); + // Channels that aren't a space yet — what "+" offers to add. + const addable = browsable.filter((c) => !spaces.some((s) => s.id === c.id)); + + // Open (creating on first use) the personal space. + const openPersonalSpace = () => { + ensurePersonalChannel(channels, createChannel) + .then((me) => switchTo(me)) + .catch((error: unknown) => { + toast.error("Couldn't open your personal space", { + description: error instanceof Error ? error.message : String(error), + }); + }); + }; + + // Adding a space stars the channel so its dot persists (spaces = starred). + const addSpace = (channel: Channel) => { + star(channel).catch((error: unknown) => { + toast.error("Couldn't add space", { + description: error instanceof Error ? error.message : String(error), + }); + }); + switchTo(channel); + }; + + // Escape hatch back to the classic sidebar (channel list + nav, where the + // Channels toggle lives). Client-side only — navigating to /website would + // redirect into the first channel and immediately re-scope the sidebar. + const showChannelList = () => { setCurrentChannel(null); - void navigate({ to: "/website" }); + navigateToCode(); }; return (
+ {/* Browse all channels; picking one opens it as a space. */} + + + + + + } + /> + } + /> + All channels + + + {browsable.length === 0 && ( + No channels yet + )} + {browsable.map((channel) => ( + switchTo(channel)} + > + + {channel.name} + {starredRefToShortcutId.has(channel.path) && ( + + )} + + ))} + + + + Show channel list + + + + + {/* The personal space — always present, always first. */} - + } /> - All channels + # me
- {spaces.map((channel) => { + {dotSpaces.map((channel) => { const active = channel.id === currentChannelId; return ( @@ -106,21 +219,48 @@ export function SpaceDots() { })}
- - setCreateOpen(true)} - className="flex size-5 shrink-0 items-center justify-center rounded text-gray-10 transition-colors hover:bg-gray-3 hover:text-gray-12" + {/* Add a space: star an existing channel, or create a new one. */} + + + + + + } + /> + } + /> + New space + + + {addable.map((channel) => ( + addSpace(channel)} > - - - } - /> - New space (channel) - + + {channel.name} + + ))} + {addable.length > 0 && } + setCreateOpen(true)}> + + New channel… + + +
diff --git a/packages/ui/src/features/canvas/components/SpaceSidebar.tsx b/packages/ui/src/features/canvas/components/SpaceSidebar.tsx index c1057197a8..13dd1a6e53 100644 --- a/packages/ui/src/features/canvas/components/SpaceSidebar.tsx +++ b/packages/ui/src/features/canvas/components/SpaceSidebar.tsx @@ -8,8 +8,14 @@ import { } from "@phosphor-icons/react"; import type { ChannelTaskRecord } from "@posthog/core/canvas/channelTaskSchemas"; import type { DashboardSummary } from "@posthog/core/canvas/dashboardSchemas"; -import { MenuLabel } from "@posthog/quill"; -import { LOOPS_FLAG } from "@posthog/shared"; +import { + MenuLabel, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@posthog/quill"; +import { formatRelativeTimeShort, LOOPS_FLAG } from "@posthog/shared"; import { useArchivedTaskIds } from "@posthog/ui/features/archive/useArchivedTaskIds"; import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; @@ -17,6 +23,7 @@ import { useChannelTasks } from "@posthog/ui/features/canvas/hooks/useChannelTas import { useDashboards } from "@posthog/ui/features/canvas/hooks/useDashboards"; import { useSpaces } from "@posthog/ui/features/canvas/hooks/useSpaces"; import { useSpaceStore } from "@posthog/ui/features/canvas/stores/spaceStore"; +import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { SidebarItem } from "@posthog/ui/features/sidebar/components/SidebarItem"; import { usePinnedTasks } from "@posthog/ui/features/sidebar/usePinnedTasks"; @@ -34,9 +41,53 @@ interface SpaceItem { pinned: boolean; icon: ReactNode; isActive: boolean; + /** Run status ("In progress", …) for the hover card; tasks only. */ + status: string | null; + /** Who created it, for the hover card. */ + person: string | null; onClick: () => void; } +function humanizeStatus(status: string | null | undefined): string | null { + if (!status) return null; + const text = status.replace(/_/g, " "); + return text.charAt(0).toUpperCase() + text.slice(1); +} + +// A pinned/recent row with its hover card: kind, status, author, last update. +function SpaceItemRow({ item }: { item: SpaceItem }) { + return ( + + + + + } + /> + +
+ {item.title} + + {item.kind === "canvas" ? "Canvas" : "Task"} + {item.status ? ` · ${item.status}` : ""} ·{" "} + {formatRelativeTimeShort(item.ts)} + + {item.person && ( + By {item.person} + )} +
+
+
+ ); +} + const RECENTS_CAP = 30; // How much horizontal trackpad travel counts as a space swipe, and how long @@ -81,6 +132,8 @@ export function SpaceSidebar({ channelId }: { channelId: string }) { size: 15, className: "text-violet-9", }), + status: null, + person: d.createdBy ?? null, isActive: pathname === `${base}/dashboards/${d.id}`, onClick: () => void navigate({ @@ -102,6 +155,8 @@ export function SpaceSidebar({ channelId }: { channelId: string }) { ts: Date.parse(task.updated_at) || 0, pinned: pinnedTaskIds.has(f.taskId), icon: , + status: humanizeStatus(task.latest_run?.status), + person: task.created_by ? userDisplayName(task.created_by) : null, isActive: pathname === `${base}/tasks/${f.taskId}`, onClick: () => void navigate({ @@ -235,49 +290,37 @@ export function SpaceSidebar({ channelId }: { channelId: string }) { )} -
- {pinnedItems.length > 0 && ( - <> - Pinned -
- {pinnedItems.map((item) => ( - - ))} -
- - )} + +
+ {pinnedItems.length > 0 && ( + <> + Pinned +
+ {pinnedItems.map((item) => ( + + ))} +
+ + )} - {recentItems.length > 0 && ( - <> - Recent -
- {recentItems.map((item) => ( - - ))} -
- - )} + {recentItems.length > 0 && ( + <> + Recent +
+ {recentItems.map((item) => ( + + ))} +
+ + )} - {pinnedItems.length === 0 && recentItems.length === 0 && ( -

- Tasks and canvases you create in this space show up here. -

- )} -
+ {pinnedItems.length === 0 && recentItems.length === 0 && ( +

+ Tasks and canvases you create in this space show up here. +

+ )} +
+ ); } diff --git a/packages/ui/src/features/canvas/hooks/useSpaces.ts b/packages/ui/src/features/canvas/hooks/useSpaces.ts index 07da151dfe..b6f159a09d 100644 --- a/packages/ui/src/features/canvas/hooks/useSpaces.ts +++ b/packages/ui/src/features/canvas/hooks/useSpaces.ts @@ -3,14 +3,16 @@ import { type Channel, useChannels, } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; import { useSpaceStore } from "@posthog/ui/features/canvas/stores/spaceStore"; import { navigateToChannel } from "@posthog/ui/router/navigationBridge"; import { useCallback, useMemo } from "react"; /** - * The spaces in the Arc-style dot switcher: the user's starred channels (their - * curated set), plus the current channel appended when it isn't starred — a - * temporarily visited space, like opening an unstarred Arc space via search. + * The spaces in the Arc-style dot switcher: the personal "#me" channel always + * first, then the user's starred channels (their curated set), plus the + * current channel appended when it isn't one of those — a temporarily visited + * space, like opening an unstarred Arc space via search. */ export function useSpaces(): { spaces: Channel[]; @@ -25,12 +27,17 @@ export function useSpaces(): { const setCurrentChannel = useSpaceStore((s) => s.setCurrentChannel); const spaces = useMemo(() => { - const starred = channels.filter((c) => starredRefToShortcutId.has(c.path)); + const me = channels.find((c) => c.name === PERSONAL_CHANNEL_NAME); + const starred = channels.filter( + (c) => + c.name !== PERSONAL_CHANNEL_NAME && starredRefToShortcutId.has(c.path), + ); + const list = me ? [me, ...starred] : starred; const current = channels.find((c) => c.id === currentChannelId); - if (current && !starred.some((c) => c.id === current.id)) { - starred.push(current); + if (current && !list.some((c) => c.id === current.id)) { + list.push(current); } - return starred; + return list; }, [channels, starredRefToShortcutId, currentChannelId]); const currentIndex = spaces.findIndex((c) => c.id === currentChannelId); From f0b187115d1ef9ffe7177b12511324763182eb65 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 25 Jul 2026 15:44:13 +0200 Subject: [PATCH 04/57] fix(canvas): in-place channel browsing and Arc-style draft new space MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "#" dot no longer opens a popover: it toggles the sidebar body into the all-channels list in place; picking a channel opens it as a space. The "+" now opens a temporary draft space, Arc/Zen-style — a hollow dashed dot appears in the switcher and the sidebar becomes a chooser to attach an existing channel (starring it so the dot persists) or create a new channel. Selecting any space dismisses the overrides; any navigation while a picker is open also dismisses it, covering re-selecting the current channel where the route doesn't change. Generated-By: PostHog Code Task-Id: c401c7cb-e436-4da3-80bf-1d3008ee5d38 --- docs/plans/spaces-layout-prototype.md | 2 +- .../canvas/components/ChannelsSidebar.tsx | 61 ++++-- .../canvas/components/NewSpaceDraft.tsx | 107 +++++++++ .../features/canvas/components/SpaceDots.tsx | 207 ++++++------------ .../ui/src/features/canvas/hooks/useSpaces.ts | 3 +- .../src/features/canvas/stores/spaceStore.ts | 32 ++- 6 files changed, 250 insertions(+), 162 deletions(-) create mode 100644 packages/ui/src/features/canvas/components/NewSpaceDraft.tsx diff --git a/docs/plans/spaces-layout-prototype.md b/docs/plans/spaces-layout-prototype.md index 78422bfde3..93006c4bf1 100644 --- a/docs/plans/spaces-layout-prototype.md +++ b/docs/plans/spaces-layout-prototype.md @@ -117,6 +117,6 @@ Replace the docked `ThreadPanel` (in spaces mode) with an `ActivityPanel` in the ## How to try it 1. Enable the **Channels** alpha switch in the sidebar (Bluebird flag is default-on in dev). You land in your personal `#me` space — it is always the first item in the dot row. -2. In the dot row: `#` opens a browse-all-channels menu (pick one to open it as a space); `+` adds a space — choose an existing channel (stars it, so the dot persists) or create a new one. A gear beside the account switcher opens Settings. +2. In the dot row: `#` toggles the sidebar body into the all-channels list (pick one to open it as a space); `+` opens a draft new space, Arc-style — a hollow dot appears and the sidebar becomes a chooser: attach an existing channel (stars it, so the dot persists) or create a new one. A gear beside the account switcher opens Settings. 3. Switch spaces by clicking dots, swiping horizontally on the sidebar, or `Ctrl+Alt+←/→`. Hovering a pinned/recent item shows a hover card with kind, status, author, and last update. 4. Open a task in the space: the right Activity panel has Timeline / Artifacts / Comments; clicking a PR artifact opens its diff. The `+` on the task tab strip shows the new chat/terminal/canvas menu. diff --git a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx index 83b298d38d..9995370d3c 100644 --- a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx @@ -5,6 +5,7 @@ import { useArchivedTaskIds } from "@posthog/ui/features/archive/useArchivedTask 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 { NewSpaceDraft } from "@posthog/ui/features/canvas/components/NewSpaceDraft"; import { SpaceDots } from "@posthog/ui/features/canvas/components/SpaceDots"; import { SpaceSidebar } from "@posthog/ui/features/canvas/components/SpaceSidebar"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; @@ -30,7 +31,7 @@ import { useSidebarEdgeHoverPeek } from "@posthog/ui/primitives/hooks/useSidebar import { ResizableSidebar } from "@posthog/ui/primitives/ResizableSidebar"; import { navigateToArchived } from "@posthog/ui/router/navigationBridge"; import { Box, Flex } from "@radix-ui/themes"; -import { useParams } from "@tanstack/react-router"; +import { useParams, useRouterState } from "@tanstack/react-router"; import { useDeferredValue, useEffect, useRef } from "react"; // The unified app sidebar (Code merged into the Bluebird chrome). Top to @@ -97,20 +98,40 @@ export function ChannelsSidebar() { // Spaces layout (prototype): while a channel is active the sidebar scopes to // it (Arc-style space). The route is the source of truth — visiting any // /website/$channelId page adopts that channel as the current space, and it - // sticks across channel-less routes (inbox, activity) until the user leaves - // via the all-channels landing (the "#" in the dot row). + // sticks across channel-less routes (inbox, activity). Two transient body + // overrides sit on top: browsing the full channel list ("#") and the draft + // new-space chooser ("+"); picking any space dismisses them. const params = useParams({ strict: false }); const routeChannelId = params.channelId; const currentChannelId = useSpaceStore((s) => s.currentChannelId); const setCurrentChannel = useSpaceStore((s) => s.setCurrentChannel); + const browsing = useSpaceStore((s) => s.browsing); + const draftSpace = useSpaceStore((s) => s.draftSpace); useEffect(() => { if (routeChannelId) setCurrentChannel(routeChannelId); }, [routeChannelId, setCurrentChannel]); - const inSpace = channelsEnabled && currentChannelId != null; + const inSpace = + channelsEnabled && currentChannelId != null && !browsing && !draftSpace; + + // Any navigation while a picker override is open dismisses it — the user + // moved somewhere, so the picker's moment has passed. This also covers + // re-selecting the channel you're already in (the route doesn't change, so + // the sync effect above never fires). + const historyIndex = useRouterState({ + select: (s) => s.location.state.__TSR_index, + }); + const prevHistoryIndexRef = useRef(historyIndex); + useEffect(() => { + if (prevHistoryIndexRef.current === historyIndex) return; + prevHistoryIndexRef.current = historyIndex; + const state = useSpaceStore.getState(); + if (state.browsing) state.setBrowsing(false); + if (state.draftSpace) state.setDraftSpace(false); + }, [historyIndex]); // The personal "#me" space is the default: on first load with nothing // scoped, land there. Once any space has been current (including via the - // route), never auto-scope again — "Show channel list" (null) must stick. + // route), never auto-scope again, so the pickers can't be hijacked. const { channels } = useChannels({ enabled: channelsEnabled }); const autoScopedRef = useRef(false); useEffect(() => { @@ -118,12 +139,20 @@ export function ChannelsSidebar() { }, [currentChannelId]); useEffect(() => { if (!channelsEnabled || autoScopedRef.current || currentChannelId) return; + if (browsing || draftSpace) return; const me = channels.find((c) => c.name === PERSONAL_CHANNEL_NAME); if (me) { autoScopedRef.current = true; setCurrentChannel(me.id); } - }, [channelsEnabled, channels, currentChannelId, setCurrentChannel]); + }, [ + channelsEnabled, + channels, + currentChannelId, + browsing, + draftSpace, + setCurrentChannel, + ]); return ( {/* The nav owns the "Enable channels" toggle + Canvas rows (gated by the same flag), so this section carries the whole merged nav. - Hidden while a space is active: the space sidebar is the whole - body then (search/inbox/activity live in the title bar). */} - {!inSpace && } + Hidden while a space or the new-space draft is active: those own + the whole body (search/inbox/activity live in the title bar). */} + {!inSpace && !draftSpace && } - {/* Body: inside a space, the space-scoped sidebar; otherwise the - channel tree when channels are on, else the task list. Each owns - its own scroll region. Gated on the deferred value so the toggle - paints before this heavy swap. */} - {bodyChannelsEnabled && inSpace && currentChannelId ? ( + {/* Body precedence: the draft new-space chooser, then the active + space, then the channel list (browse mode / landing), else the + task list. Each owns its own scroll region. Gated on the deferred + value so the toggle paints before this heavy swap. */} + {bodyChannelsEnabled && draftSpace ? ( + + + + ) : bodyChannelsEnabled && inSpace && currentChannelId ? ( diff --git a/packages/ui/src/features/canvas/components/NewSpaceDraft.tsx b/packages/ui/src/features/canvas/components/NewSpaceDraft.tsx new file mode 100644 index 0000000000..a96d26caa2 --- /dev/null +++ b/packages/ui/src/features/canvas/components/NewSpaceDraft.tsx @@ -0,0 +1,107 @@ +import { HashIcon, PlusIcon, XIcon } from "@phosphor-icons/react"; +import { Button, MenuLabel } from "@posthog/quill"; +import { CreateChannelModal } from "@posthog/ui/features/canvas/components/CreateChannelModal"; +import { useChannelStarMutations } from "@posthog/ui/features/canvas/hooks/useChannelStars"; +import { + type Channel, + useChannels, +} from "@posthog/ui/features/canvas/hooks/useChannels"; +import { useSpaces } from "@posthog/ui/features/canvas/hooks/useSpaces"; +import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; +import { useSpaceStore } from "@posthog/ui/features/canvas/stores/spaceStore"; +import { SidebarItem } from "@posthog/ui/features/sidebar/components/SidebarItem"; +import { toast } from "@posthog/ui/primitives/toast"; +import { motion } from "framer-motion"; +import { useState } from "react"; + +/** + * The draft "new space" chooser, Arc/Zen-style: pressing "+" opens a temporary + * space in the sidebar where you either attach an existing channel (starring + * it, so its dot persists) or create a new channel. Cancelling returns to the + * previous space. + */ +export function NewSpaceDraft() { + const { spaces, switchTo } = useSpaces(); + const { channels } = useChannels(); + const { star } = useChannelStarMutations(); + const setDraftSpace = useSpaceStore((s) => s.setDraftSpace); + const [createOpen, setCreateOpen] = useState(false); + + // Channels that aren't a space yet — candidates to attach to this draft. + const addable = channels + .filter( + (c) => + c.name !== PERSONAL_CHANNEL_NAME && !spaces.some((s) => s.id === c.id), + ) + .sort((a, b) => a.name.localeCompare(b.name)); + + const attach = (channel: Channel) => { + star(channel).catch((error: unknown) => { + toast.error("Couldn't add space", { + description: error instanceof Error ? error.message : String(error), + }); + }); + // Clears the draft too (setCurrentChannel dismisses the overrides). + switchTo(channel); + }; + + return ( + +
+ + New space + + +
+

+ Pick a channel for this space, or create a new one. +

+ +
+ {addable.length > 0 && ( + <> + Channels +
+ {addable.map((channel) => ( + } + label={channel.name} + onClick={() => attach(channel)} + /> + ))} +
+ + )} + {addable.length === 0 && ( +

+ Every channel is already a space — create a new one. +

+ )} +
+ } + label="New channel…" + onClick={() => setCreateOpen(true)} + /> +
+
+ + +
+ ); +} diff --git a/packages/ui/src/features/canvas/components/SpaceDots.tsx b/packages/ui/src/features/canvas/components/SpaceDots.tsx index 278e299f25..54460c191c 100644 --- a/packages/ui/src/features/canvas/components/SpaceDots.tsx +++ b/packages/ui/src/features/canvas/components/SpaceDots.tsx @@ -1,30 +1,13 @@ -import { - HashIcon, - PlusIcon, - SidebarSimpleIcon, - StarIcon, - UserIcon, -} from "@phosphor-icons/react"; +import { HashIcon, PlusIcon, UserIcon } from "@phosphor-icons/react"; import { cn, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@posthog/quill"; -import { CreateChannelModal } from "@posthog/ui/features/canvas/components/CreateChannelModal"; import { ensurePersonalChannel } from "@posthog/ui/features/canvas/ensurePersonalChannel"; import { - useChannelStarMutations, - useChannelStars, -} from "@posthog/ui/features/canvas/hooks/useChannelStars"; -import { - type Channel, useChannelMutations, useChannels, } from "@posthog/ui/features/canvas/hooks/useChannels"; @@ -33,28 +16,28 @@ import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTask import { useIsChannelUnread } from "@posthog/ui/features/canvas/hooks/useUnreadChannels"; import { useSpaceStore } from "@posthog/ui/features/canvas/stores/spaceStore"; import { toast } from "@posthog/ui/primitives/toast"; -import { navigateToCode } from "@posthog/ui/router/navigationBridge"; import { motion } from "framer-motion"; -import { useEffect, useState } from "react"; +import { useEffect } from "react"; const RAIL_BUTTON_CLASS = "flex size-5 shrink-0 items-center justify-center rounded text-gray-10 transition-colors hover:bg-gray-3 hover:text-gray-12"; /** * The Arc-style space switcher pinned above the sidebar footer. The personal - * "#me" space is always first; then one dot per space. The "#" opens a - * browse-all-channels menu (pick one to open it as a space); the "+" adds a - * space — star an existing channel or create a new one. Ctrl+Alt+←/→ cycles. + * "#me" space is always first; then one dot per space. The "#" toggles the + * sidebar body into the all-channels list; the "+" opens a draft new space + * (choose a channel or create one) with its own temporary dot. Ctrl+Alt+←/→ + * cycles spaces. */ export function SpaceDots() { const { spaces, currentChannelId, switchTo, cycle } = useSpaces(); const { channels } = useChannels(); const { createChannel } = useChannelMutations(); - const { starredRefToShortcutId } = useChannelStars(); - const { star } = useChannelStarMutations(); - const setCurrentChannel = useSpaceStore((s) => s.setCurrentChannel); + const browsing = useSpaceStore((s) => s.browsing); + const draftSpace = useSpaceStore((s) => s.draftSpace); + const setBrowsing = useSpaceStore((s) => s.setBrowsing); + const setDraftSpace = useSpaceStore((s) => s.setDraftSpace); const isUnread = useIsChannelUnread(); - const [createOpen, setCreateOpen] = useState(false); useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { @@ -72,13 +55,12 @@ export function SpaceDots() { }, [cycle]); const meChannel = channels.find((c) => c.name === PERSONAL_CHANNEL_NAME); - const meActive = !!meChannel && meChannel.id === currentChannelId; + const meActive = + !!meChannel && + meChannel.id === currentChannelId && + !browsing && + !draftSpace; const dotSpaces = spaces.filter((c) => c.name !== PERSONAL_CHANNEL_NAME); - const browsable = channels - .filter((c) => c.name !== PERSONAL_CHANNEL_NAME) - .sort((a, b) => a.name.localeCompare(b.name)); - // Channels that aren't a space yet — what "+" offers to add. - const addable = browsable.filter((c) => !spaces.some((s) => s.id === c.id)); // Open (creating on first use) the personal space. const openPersonalSpace = () => { @@ -91,75 +73,30 @@ export function SpaceDots() { }); }; - // Adding a space stars the channel so its dot persists (spaces = starred). - const addSpace = (channel: Channel) => { - star(channel).catch((error: unknown) => { - toast.error("Couldn't add space", { - description: error instanceof Error ? error.message : String(error), - }); - }); - switchTo(channel); - }; - - // Escape hatch back to the classic sidebar (channel list + nav, where the - // Channels toggle lives). Client-side only — navigating to /website would - // redirect into the first channel and immediately re-scope the sidebar. - const showChannelList = () => { - setCurrentChannel(null); - navigateToCode(); - }; - return (
- {/* Browse all channels; picking one opens it as a space. */} - - - - - - } - /> - } - /> - All channels - - - {browsable.length === 0 && ( - No channels yet - )} - {browsable.map((channel) => ( - switchTo(channel)} - > - - {channel.name} - {starredRefToShortcutId.has(channel.path) && ( - + {/* Toggle the sidebar body into the all-channels list (in place — no + popover, no navigation). */} + + setBrowsing(!browsing)} + className={cn( + RAIL_BUTTON_CLASS, + browsing && "bg-gray-3 text-gray-12", )} - - ))} - - - - Show channel list - - - + > + + + } + /> + All channels + {/* The personal space — always present, always first. */} @@ -183,7 +120,8 @@ export function SpaceDots() {
{dotSpaces.map((channel) => { - const active = channel.id === currentChannelId; + const active = + channel.id === currentChannelId && !browsing && !draftSpace; return ( ); })} + {/* The draft space's temporary dot — a hollow placeholder until a + channel is chosen or created. */} + {draftSpace && ( + + )}
- {/* Add a space: star an existing channel, or create a new one. */} - - - - - - } - /> - } - /> - New space - - - {addable.map((channel) => ( - addSpace(channel)} + {/* Open a draft new space (Arc-style): its dot appears immediately; + the sidebar body becomes the choose-or-create view. */} + + setDraftSpace(!draftSpace)} + className={cn( + RAIL_BUTTON_CLASS, + draftSpace && "bg-gray-3 text-gray-12", + )} > - - {channel.name} - - ))} - {addable.length > 0 && } - setCreateOpen(true)}> - - New channel… - - - + + + } + /> + New space +
-
); } diff --git a/packages/ui/src/features/canvas/hooks/useSpaces.ts b/packages/ui/src/features/canvas/hooks/useSpaces.ts index b6f159a09d..3ca90e3a94 100644 --- a/packages/ui/src/features/canvas/hooks/useSpaces.ts +++ b/packages/ui/src/features/canvas/hooks/useSpaces.ts @@ -44,7 +44,8 @@ export function useSpaces(): { const switchTo = useCallback( (channel: Channel) => { - if (channel.id === currentChannelId) return; + // Re-selecting the current space still goes through setCurrentChannel: + // it dismisses the browse/draft overrides and lands on the channel home. const from = spaces.findIndex((c) => c.id === currentChannelId); const to = spaces.findIndex((c) => c.id === channel.id); // Unknown positions (entering from the landing) read as moving right. diff --git a/packages/ui/src/features/canvas/stores/spaceStore.ts b/packages/ui/src/features/canvas/stores/spaceStore.ts index 5c04020aa1..fd892d0738 100644 --- a/packages/ui/src/features/canvas/stores/spaceStore.ts +++ b/packages/ui/src/features/canvas/stores/spaceStore.ts @@ -2,25 +2,47 @@ import { create } from "zustand"; /** * View state for the Arc-style "spaces" chrome (layout prototype): which - * channel the sidebar is currently scoped to, and which direction the last - * switch moved so the sidebar body can slide accordingly. The current space - * survives navigating to channel-less routes (inbox, activity, settings) so - * the sidebar keeps its scope until the user explicitly leaves via the - * all-channels landing. + * channel the sidebar is currently scoped to, which direction the last switch + * moved (drives the slide), and the two sidebar-body overrides — browsing the + * full channel list, or holding a draft "new space" chooser. The current space + * survives navigating to channel-less routes (inbox, activity, settings); + * selecting any space clears the overrides. */ interface SpaceState { currentChannelId: string | null; /** +1 = moved right in the dot order, -1 = left. Drives the slide-in. */ direction: 1 | -1; + /** Sidebar body shows the all-channels list (the "#" toggle). */ + browsing: boolean; + /** Sidebar body shows the new-space chooser (the "+" draft, Arc-style). */ + draftSpace: boolean; setCurrentChannel: (channelId: string | null, direction?: 1 | -1) => void; + setBrowsing: (browsing: boolean) => void; + setDraftSpace: (draftSpace: boolean) => void; } export const useSpaceStore = create()((set) => ({ currentChannelId: null, direction: 1, + browsing: false, + draftSpace: false, + // Landing in a space always dismisses the overrides — they are transient + // pickers, not places. setCurrentChannel: (currentChannelId, direction) => set((state) => ({ currentChannelId, direction: direction ?? state.direction, + browsing: false, + draftSpace: false, + })), + setBrowsing: (browsing) => + set((state) => ({ + browsing, + draftSpace: browsing ? false : state.draftSpace, + })), + setDraftSpace: (draftSpace) => + set((state) => ({ + draftSpace, + browsing: draftSpace ? false : state.browsing, })), })); From 21fb49302650ce5af7cee7c7bea033472288b2a7 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 25 Jul 2026 15:44:15 +0200 Subject: [PATCH 05/57] =?UTF-8?q?fix(canvas):=20dot-row=20polish=20?= =?UTF-8?q?=E2=80=94=20emoji=20context=20menu,=20one-per-gesture=20swipe,?= =?UTF-8?q?=20chrome-free=20browse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browse mode ("#") now shows purely the channel list with no nav chrome. The "#me" glyph moves into the dot group as its leftmost member; added spaces keep star-creation order so new ones always append on the right. Right-clicking a dot opens a context menu with an emoji grid (the emoji replaces the dot, persisted locally) and a Remove space action for starred channels. Horizontal swipe now works anywhere on the sidebar (space, browse, or draft view) and moves strictly one space per gesture — inertia events extend a quiet-period lock instead of re-firing. The new-space chooser drops the "Channels" subheader for a search field. Generated-By: PostHog Code Task-Id: c401c7cb-e436-4da3-80bf-1d3008ee5d38 --- docs/plans/spaces-layout-prototype.md | 4 +- .../canvas/components/ChannelsSidebar.tsx | 23 +- .../canvas/components/NewSpaceDraft.tsx | 49 ++-- .../features/canvas/components/SpaceDots.tsx | 268 +++++++++++++----- .../canvas/components/SpaceSidebar.tsx | 34 +-- .../ui/src/features/canvas/hooks/useSpaces.ts | 60 +++- .../features/canvas/stores/spaceEmojiStore.ts | 30 ++ 7 files changed, 338 insertions(+), 130 deletions(-) create mode 100644 packages/ui/src/features/canvas/stores/spaceEmojiStore.ts diff --git a/docs/plans/spaces-layout-prototype.md b/docs/plans/spaces-layout-prototype.md index 93006c4bf1..abdd8365be 100644 --- a/docs/plans/spaces-layout-prototype.md +++ b/docs/plans/spaces-layout-prototype.md @@ -117,6 +117,6 @@ Replace the docked `ThreadPanel` (in spaces mode) with an `ActivityPanel` in the ## How to try it 1. Enable the **Channels** alpha switch in the sidebar (Bluebird flag is default-on in dev). You land in your personal `#me` space — it is always the first item in the dot row. -2. In the dot row: `#` toggles the sidebar body into the all-channels list (pick one to open it as a space); `+` opens a draft new space, Arc-style — a hollow dot appears and the sidebar becomes a chooser: attach an existing channel (stars it, so the dot persists) or create a new one. A gear beside the account switcher opens Settings. -3. Switch spaces by clicking dots, swiping horizontally on the sidebar, or `Ctrl+Alt+←/→`. Hovering a pinned/recent item shows a hover card with kind, status, author, and last update. +2. In the dot row: `#` toggles the sidebar body into the all-channels list, with no nav chrome (pick one to open it as a space); `+` opens a draft new space, Arc-style — a hollow dot appears and the sidebar becomes a searchable chooser: attach an existing channel (stars it, so the dot persists) or create a new one. New spaces always append on the right; `#me` is always the leftmost space. A gear beside the account switcher opens Settings. +3. Switch spaces by clicking dots, swiping horizontally anywhere on the sidebar (strictly one space per gesture, however fast), or `Ctrl+Alt+←/→`. Right-click a dot to set an emoji for the space or remove it. Hovering a pinned/recent item shows a hover card with kind, status, author, and last update. 4. Open a task in the space: the right Activity panel has Timeline / Artifacts / Comments; clicking a PR artifact opens its diff. The `+` on the task tab strip shows the new chat/terminal/canvas menu. diff --git a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx index 9995370d3c..fde21710a2 100644 --- a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx @@ -9,6 +9,7 @@ import { NewSpaceDraft } from "@posthog/ui/features/canvas/components/NewSpaceDr import { SpaceDots } from "@posthog/ui/features/canvas/components/SpaceDots"; import { SpaceSidebar } from "@posthog/ui/features/canvas/components/SpaceSidebar"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { useSpaceSwipe } from "@posthog/ui/features/canvas/hooks/useSpaces"; import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; import { useSpaceStore } from "@posthog/ui/features/canvas/stores/spaceStore"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; @@ -154,6 +155,10 @@ export function ChannelsSidebar() { setCurrentChannel, ]); + // Horizontal trackpad swipe anywhere on the sidebar cycles spaces — from a + // space, the channel list, or the draft view alike. One space per gesture. + const handleSpaceSwipe = useSpaceSwipe(channelsEnabled); + return ( endSidebarPeek()} onPeekDismiss={cancelSidebarPeek} > - + {/* The nav owns the "Enable channels" toggle + Canvas rows (gated by - the same flag), so this section carries the whole merged nav. - Hidden while a space or the new-space draft is active: those own - the whole body (search/inbox/activity live in the title bar). */} - {!inSpace && !draftSpace && } + the same flag), so this section carries the whole merged nav. In + channels mode the spaces chrome owns the sidebar (search / inbox / + activity live in the title bar) — browse mode shows purely the + channel list — so the nav only renders on the pre-scope landing. */} + {(!channelsEnabled || + (currentChannelId == null && !browsing && !draftSpace)) && ( + + )} {/* Body precedence: the draft new-space chooser, then the active space, then the channel list (browse mode / landing), else the diff --git a/packages/ui/src/features/canvas/components/NewSpaceDraft.tsx b/packages/ui/src/features/canvas/components/NewSpaceDraft.tsx index a96d26caa2..b15ed466f3 100644 --- a/packages/ui/src/features/canvas/components/NewSpaceDraft.tsx +++ b/packages/ui/src/features/canvas/components/NewSpaceDraft.tsx @@ -1,5 +1,5 @@ import { HashIcon, PlusIcon, XIcon } from "@phosphor-icons/react"; -import { Button, MenuLabel } from "@posthog/quill"; +import { Button, Input } from "@posthog/quill"; import { CreateChannelModal } from "@posthog/ui/features/canvas/components/CreateChannelModal"; import { useChannelStarMutations } from "@posthog/ui/features/canvas/hooks/useChannelStars"; import { @@ -26,6 +26,7 @@ export function NewSpaceDraft() { const { star } = useChannelStarMutations(); const setDraftSpace = useSpaceStore((s) => s.setDraftSpace); const [createOpen, setCreateOpen] = useState(false); + const [query, setQuery] = useState(""); // Channels that aren't a space yet — candidates to attach to this draft. const addable = channels @@ -34,6 +35,10 @@ export function NewSpaceDraft() { c.name !== PERSONAL_CHANNEL_NAME && !spaces.some((s) => s.id === c.id), ) .sort((a, b) => a.name.localeCompare(b.name)); + const normalizedQuery = query.trim().toLowerCase(); + const filtered = normalizedQuery + ? addable.filter((c) => c.name.toLowerCase().includes(normalizedQuery)) + : addable; const attach = (channel: Channel) => { star(channel).catch((error: unknown) => { @@ -69,22 +74,34 @@ export function NewSpaceDraft() { Pick a channel for this space, or create a new one.

+
+ setQuery(event.target.value)} + placeholder="Search channels…" + aria-label="Search channels" + className="h-7 text-[13px]" + /> +
+
- {addable.length > 0 && ( - <> - Channels -
- {addable.map((channel) => ( - } - label={channel.name} - onClick={() => attach(channel)} - /> - ))} -
- + {filtered.length > 0 && ( +
+ {filtered.map((channel) => ( + } + label={channel.name} + onClick={() => attach(channel)} + /> + ))} +
+ )} + {filtered.length === 0 && addable.length > 0 && ( +

+ No channels match "{query.trim()}". +

)} {addable.length === 0 && (

diff --git a/packages/ui/src/features/canvas/components/SpaceDots.tsx b/packages/ui/src/features/canvas/components/SpaceDots.tsx index 54460c191c..8c0fbb878f 100644 --- a/packages/ui/src/features/canvas/components/SpaceDots.tsx +++ b/packages/ui/src/features/canvas/components/SpaceDots.tsx @@ -1,5 +1,10 @@ -import { HashIcon, PlusIcon, UserIcon } from "@phosphor-icons/react"; +import { HashIcon, PlusIcon, UserIcon, XIcon } from "@phosphor-icons/react"; import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, cn, Tooltip, TooltipContent, @@ -8,12 +13,18 @@ import { } from "@posthog/quill"; import { ensurePersonalChannel } from "@posthog/ui/features/canvas/ensurePersonalChannel"; import { + useChannelStarMutations, + useChannelStars, +} from "@posthog/ui/features/canvas/hooks/useChannelStars"; +import { + type Channel, useChannelMutations, useChannels, } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useSpaces } from "@posthog/ui/features/canvas/hooks/useSpaces"; import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; import { useIsChannelUnread } from "@posthog/ui/features/canvas/hooks/useUnreadChannels"; +import { useSpaceEmojiStore } from "@posthog/ui/features/canvas/stores/spaceEmojiStore"; import { useSpaceStore } from "@posthog/ui/features/canvas/stores/spaceStore"; import { toast } from "@posthog/ui/primitives/toast"; import { motion } from "framer-motion"; @@ -22,12 +33,159 @@ import { useEffect } from "react"; const RAIL_BUTTON_CLASS = "flex size-5 shrink-0 items-center justify-center rounded text-gray-10 transition-colors hover:bg-gray-3 hover:text-gray-12"; +// Curated emoji choices for a space's dot (Arc-style space icons). +const SPACE_EMOJIS = [ + "🦔", + "🚀", + "🔥", + "⭐", + "🎨", + "📊", + "🧪", + "🔧", + "📦", + "🤖", + "💬", + "🧠", + "📈", + "🐛", + "🎯", + "⚡", + "🌊", + "📝", + "🔒", + "❤️", +] as const; + +// One space in the dot row: an emoji (if set), the person glyph for "#me", or +// a plain dot. Right-click offers emoji selection and, for starred channels, +// removing the space. +function SpaceDot({ + channel, + active, + unread, + onSelect, +}: { + channel: Channel; + active: boolean; + unread: boolean; + onSelect: () => void; +}) { + const isMe = channel.name === PERSONAL_CHANNEL_NAME; + const emoji = useSpaceEmojiStore((s) => s.emojiByChannelId[channel.id]); + const setEmoji = useSpaceEmojiStore((s) => s.setEmoji); + const { starredRefToShortcutId } = useChannelStars(); + const { unstar } = useChannelStarMutations(); + const shortcutId = starredRefToShortcutId.get(channel.path); + + const removeSpace = () => { + if (!shortcutId) return; + unstar(shortcutId).catch((error: unknown) => { + toast.error("Couldn't remove space", { + description: error instanceof Error ? error.message : String(error), + }); + }); + }; + + const glyph = emoji ? ( + + {emoji} + + ) : isMe ? ( + + ) : ( + + ); + + return ( + + + + {glyph} + + } + /> + } + /> + # {channel.name} + + + {/* Emoji grid — plain buttons inside the menu, not menu items, so a + pick doesn't need per-emoji rows. */} +

+ {SPACE_EMOJIS.map((option) => ( + + ))} +
+ {(emoji || (!isMe && shortcutId)) && } + {emoji && ( + setEmoji(channel.id, null)}> + + Remove emoji + + )} + {!isMe && shortcutId && ( + + + Remove space + + )} + + + ); +} + /** - * The Arc-style space switcher pinned above the sidebar footer. The personal - * "#me" space is always first; then one dot per space. The "#" toggles the - * sidebar body into the all-channels list; the "+" opens a draft new space - * (choose a channel or create one) with its own temporary dot. Ctrl+Alt+←/→ - * cycles spaces. + * The Arc-style space switcher pinned above the sidebar footer. "#me" is + * always the first space in the row; added spaces append to the right. The + * "#" toggles the sidebar body into the all-channels list; the "+" opens a + * draft new space with its own temporary dot. Ctrl+Alt+←/→ and horizontal + * swipes cycle spaces. */ export function SpaceDots() { const { spaces, currentChannelId, switchTo, cycle } = useSpaces(); @@ -54,15 +212,11 @@ export function SpaceDots() { return () => window.removeEventListener("keydown", onKeyDown); }, [cycle]); - const meChannel = channels.find((c) => c.name === PERSONAL_CHANNEL_NAME); - const meActive = - !!meChannel && - meChannel.id === currentChannelId && - !browsing && - !draftSpace; - const dotSpaces = spaces.filter((c) => c.name !== PERSONAL_CHANNEL_NAME); + const hasMe = spaces.some((c) => c.name === PERSONAL_CHANNEL_NAME); + const overrideOpen = browsing || draftSpace; - // Open (creating on first use) the personal space. + // Open (creating on first use) the personal space — only needed while the + // "#me" channel doesn't exist yet; afterwards it's a regular dot. const openPersonalSpace = () => { ensurePersonalChannel(channels, createChannel) .then((me) => switchTo(me)) @@ -98,63 +252,37 @@ export function SpaceDots() { All channels - {/* The personal space — always present, always first. */} - - - - - } - /> - # me - -
- {dotSpaces.map((channel) => { - const active = - channel.id === currentChannelId && !browsing && !draftSpace; - return ( - - switchTo(channel)} - className="group flex size-4 shrink-0 items-center justify-center" - > - - - } - /> - # {channel.name} - - ); - })} + {/* "#me" not provisioned yet: show its slot anyway, first. */} + {!hasMe && ( + + + + + } + /> + # me + + )} + {spaces.map((channel) => ( + switchTo(channel)} + /> + ))} {/* The draft space's temporary dot — a hollow placeholder until a channel is chosen or created. */} {draftSpace && ( diff --git a/packages/ui/src/features/canvas/components/SpaceSidebar.tsx b/packages/ui/src/features/canvas/components/SpaceSidebar.tsx index 13dd1a6e53..4f87fac234 100644 --- a/packages/ui/src/features/canvas/components/SpaceSidebar.tsx +++ b/packages/ui/src/features/canvas/components/SpaceSidebar.tsx @@ -21,7 +21,6 @@ import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTe import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useChannelTasks } from "@posthog/ui/features/canvas/hooks/useChannelTasks"; import { useDashboards } from "@posthog/ui/features/canvas/hooks/useDashboards"; -import { useSpaces } from "@posthog/ui/features/canvas/hooks/useSpaces"; import { useSpaceStore } from "@posthog/ui/features/canvas/stores/spaceStore"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; @@ -31,7 +30,7 @@ import { useTasks } from "@posthog/ui/features/tasks/useTasks"; import { navigateToChannelNewTask } from "@posthog/ui/router/navigationBridge"; import { useNavigate, useRouterState } from "@tanstack/react-router"; import { motion } from "framer-motion"; -import { type ReactNode, useMemo, useRef } from "react"; +import { type ReactNode, useMemo } from "react"; interface SpaceItem { key: string; @@ -90,23 +89,16 @@ function SpaceItemRow({ item }: { item: SpaceItem }) { const RECENTS_CAP = 30; -// How much horizontal trackpad travel counts as a space swipe, and how long -// the switcher stays locked afterwards so one gesture moves one space. -const SWIPE_THRESHOLD = 90; -const SWIPE_LOCK_MS = 500; -const SWIPE_RESET_MS = 300; - /** * The sidebar body while a space (channel) is active — the Arc-style layout: * New task, the channel header, the channel's sections (Context / Loops / * Artifacts) as nav rows, then pinned and recent tasks & canvases. Slides in - * on space switches; horizontal trackpad swipes cycle spaces. + * on space switches; the sidebar-wide swipe handler lives in ChannelsSidebar. */ export function SpaceSidebar({ channelId }: { channelId: string }) { const navigate = useNavigate(); const pathname = useRouterState({ select: (s) => s.location.pathname }); const direction = useSpaceStore((s) => s.direction); - const { cycle } = useSpaces(); const loopsEnabled = useFeatureFlag(LOOPS_FLAG, import.meta.env.DEV); const { channels } = useChannels(); @@ -184,27 +176,6 @@ export function SpaceSidebar({ channelId }: { channelId: string }) { const pinnedItems = items.filter((i) => i.pinned); const recentItems = items.filter((i) => !i.pinned).slice(0, RECENTS_CAP); - // Horizontal trackpad swipe cycles spaces, Arc-style. Accumulate deltaX and - // fire once past the threshold; ignore mostly-vertical wheel events so list - // scrolling never switches spaces. - const swipeAccum = useRef(0); - const swipeLockUntil = useRef(0); - const swipeLastEvent = useRef(0); - const onWheel = (event: React.WheelEvent) => { - if (Math.abs(event.deltaX) <= Math.abs(event.deltaY)) return; - const now = Date.now(); - if (now < swipeLockUntil.current) return; - if (now - swipeLastEvent.current > SWIPE_RESET_MS) swipeAccum.current = 0; - swipeLastEvent.current = now; - swipeAccum.current += event.deltaX; - if (Math.abs(swipeAccum.current) >= SWIPE_THRESHOLD) { - const delta = swipeAccum.current > 0 ? 1 : -1; - swipeAccum.current = 0; - swipeLockUntil.current = now + SWIPE_LOCK_MS; - cycle(delta); - } - }; - const sectionRow = ( label: string, icon: ReactNode, @@ -227,7 +198,6 @@ export function SpaceSidebar({ channelId }: { channelId: string }) { animate={{ x: 0, opacity: 1 }} transition={{ duration: 0.18, ease: [0, 0, 0.2, 1] }} className="flex h-full min-h-0 flex-col" - onWheel={onWheel} >
{ const me = channels.find((c) => c.name === PERSONAL_CHANNEL_NAME); - const starred = channels.filter( - (c) => - c.name !== PERSONAL_CHANNEL_NAME && starredRefToShortcutId.has(c.path), - ); + // Star order, not name order: the shortcuts map preserves creation order, + // so a newly added space always lands at the right end of the dot row. + const byPath = new Map(channels.map((c) => [c.path, c])); + const starred: Channel[] = []; + for (const ref of starredRefToShortcutId.keys()) { + const channel = byPath.get(ref); + if (channel && channel.name !== PERSONAL_CHANNEL_NAME) { + starred.push(channel); + } + } const list = me ? [me, ...starred] : starred; const current = channels.find((c) => c.id === currentChannelId); if (current && !list.some((c) => c.id === current.id)) { @@ -68,3 +74,47 @@ export function useSpaces(): { return { spaces, currentChannelId, currentIndex, switchTo, cycle }; } + +// How much horizontal trackpad travel counts as a space swipe, and how long +// the gesture must go quiet before another swipe can fire. +const SWIPE_THRESHOLD = 90; +const SWIPE_QUIET_MS = 300; +const SWIPE_RESET_MS = 300; + +/** + * Horizontal trackpad swipe → cycle spaces, strictly one space per gesture: + * after a move fires, every further wheel event of the same gesture (macOS + * inertia included) keeps a quiet-period lock alive, so even a very fast + * fling moves exactly one space. Attach the returned handler via `onWheel`. + */ +export function useSpaceSwipe( + enabled: boolean, +): (event: React.WheelEvent) => void { + const { cycle } = useSpaces(); + const accum = useRef(0); + const lockUntil = useRef(0); + const lastEvent = useRef(0); + + return useCallback( + (event: React.WheelEvent) => { + if (!enabled) return; + // Mostly-vertical wheel = list scrolling, never a space switch. + if (Math.abs(event.deltaX) <= Math.abs(event.deltaY)) return; + const now = Date.now(); + if (now < lockUntil.current) { + // Same gesture still rolling — extend the lock so it can't re-fire. + lockUntil.current = now + SWIPE_QUIET_MS; + return; + } + if (now - lastEvent.current > SWIPE_RESET_MS) accum.current = 0; + lastEvent.current = now; + accum.current += event.deltaX; + if (Math.abs(accum.current) >= SWIPE_THRESHOLD) { + cycle(accum.current > 0 ? 1 : -1); + accum.current = 0; + lockUntil.current = now + SWIPE_QUIET_MS; + } + }, + [enabled, cycle], + ); +} diff --git a/packages/ui/src/features/canvas/stores/spaceEmojiStore.ts b/packages/ui/src/features/canvas/stores/spaceEmojiStore.ts new file mode 100644 index 0000000000..8f5d1a8989 --- /dev/null +++ b/packages/ui/src/features/canvas/stores/spaceEmojiStore.ts @@ -0,0 +1,30 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +/** + * Per-space emoji, set from a dot's right-click menu (Arc-style space icons). + * Pure view state persisted to localStorage, keyed by channel id. + */ +interface SpaceEmojiState { + emojiByChannelId: Record; + setEmoji: (channelId: string, emoji: string | null) => void; +} + +export const useSpaceEmojiStore = create()( + persist( + (set) => ({ + emojiByChannelId: {}, + setEmoji: (channelId, emoji) => + set((state) => { + const next = { ...state.emojiByChannelId }; + if (emoji) { + next[channelId] = emoji; + } else { + delete next[channelId]; + } + return { emojiByChannelId: next }; + }), + }), + { name: "space-emoji" }, + ), +); From 8c272d1323fe2f3659cac40385f74285af729756 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 25 Jul 2026 15:44:16 +0200 Subject: [PATCH 06/57] fix(canvas): real hover card for sidebar items, no duplicate tooltip Pinned/recent rows now use a Base UI PreviewCard instead of a tooltip: a panel-styled card with the item's icon, full title (so the row's built-in truncation tooltip is suppressed by passing a non-string label), kind and freshness line, a colored status badge, and the author with their avatar (gravatar for task creators, initial fallback for canvas authors). Generated-By: PostHog Code Task-Id: c401c7cb-e436-4da3-80bf-1d3008ee5d38 --- .../canvas/components/SpaceSidebar.tsx | 178 ++++++++++++------ 1 file changed, 120 insertions(+), 58 deletions(-) diff --git a/packages/ui/src/features/canvas/components/SpaceSidebar.tsx b/packages/ui/src/features/canvas/components/SpaceSidebar.tsx index 4f87fac234..4709f486a1 100644 --- a/packages/ui/src/features/canvas/components/SpaceSidebar.tsx +++ b/packages/ui/src/features/canvas/components/SpaceSidebar.tsx @@ -1,3 +1,4 @@ +import { PreviewCard } from "@base-ui/react/preview-card"; import { BookOpenTextIcon, FileTextIcon, @@ -8,15 +9,11 @@ import { } from "@phosphor-icons/react"; import type { ChannelTaskRecord } from "@posthog/core/canvas/channelTaskSchemas"; import type { DashboardSummary } from "@posthog/core/canvas/dashboardSchemas"; -import { - MenuLabel, - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@posthog/quill"; +import { Avatar, AvatarFallback, Badge, MenuLabel } from "@posthog/quill"; import { formatRelativeTimeShort, LOOPS_FLAG } from "@posthog/shared"; +import type { UserBasic } from "@posthog/shared/domain-types"; import { useArchivedTaskIds } from "@posthog/ui/features/archive/useArchivedTaskIds"; +import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar"; import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useChannelTasks } from "@posthog/ui/features/canvas/hooks/useChannelTasks"; @@ -32,6 +29,8 @@ import { useNavigate, useRouterState } from "@tanstack/react-router"; import { motion } from "framer-motion"; import { type ReactNode, useMemo } from "react"; +type StatusVariant = "default" | "destructive" | "info" | "success" | "warning"; + interface SpaceItem { key: string; kind: "task" | "canvas"; @@ -42,8 +41,10 @@ interface SpaceItem { isActive: boolean; /** Run status ("In progress", …) for the hover card; tasks only. */ status: string | null; - /** Who created it, for the hover card. */ - person: string | null; + statusVariant: StatusVariant; + /** Who created it — the full user when known (tasks), else a name. */ + authorUser: UserBasic | null; + authorName: string | null; onClick: () => void; } @@ -53,37 +54,94 @@ function humanizeStatus(status: string | null | undefined): string | null { return text.charAt(0).toUpperCase() + text.slice(1); } -// A pinned/recent row with its hover card: kind, status, author, last update. +function statusVariantFor(status: string | null | undefined): StatusVariant { + if (!status) return "default"; + const value = status.toLowerCase(); + if (value.includes("complete")) return "success"; + if (value.includes("fail") || value.includes("error")) return "destructive"; + if ( + value.includes("progress") || + value.includes("running") || + value.includes("pending") || + value.includes("start") + ) { + return "info"; + } + return "default"; +} + +// A pinned/recent row with its hover card (Base UI PreviewCard): icon, full +// title, kind + freshness, status badge, and the author with their avatar. function SpaceItemRow({ item }: { item: SpaceItem }) { return ( - - + {item.title}} isActive={item.isActive} onClick={item.onClick} />
} /> - -
- {item.title} - - {item.kind === "canvas" ? "Canvas" : "Task"} - {item.status ? ` · ${item.status}` : ""} ·{" "} - {formatRelativeTimeShort(item.ts)} - - {item.person && ( - By {item.person} - )} -
-
- + + + +
+ + {item.icon} + +
+

+ {item.title} +

+

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

+
+
+ {item.status && ( +
+ {item.status} +
+ )} + {(item.authorUser || item.authorName) && ( +
+ {item.authorUser ? ( + + ) : ( + + + {(item.authorName ?? "?").charAt(0).toUpperCase()} + + + )} +
+

+ {item.authorName ?? "Unknown"} +

+

Created by

+
+
+ )} +
+
+
+ ); } @@ -125,7 +183,9 @@ export function SpaceSidebar({ channelId }: { channelId: string }) { className: "text-violet-9", }), status: null, - person: d.createdBy ?? null, + statusVariant: "default" as const, + authorUser: null, + authorName: d.createdBy ?? null, isActive: pathname === `${base}/dashboards/${d.id}`, onClick: () => void navigate({ @@ -148,7 +208,11 @@ export function SpaceSidebar({ channelId }: { channelId: string }) { pinned: pinnedTaskIds.has(f.taskId), icon: , status: humanizeStatus(task.latest_run?.status), - person: task.created_by ? userDisplayName(task.created_by) : null, + statusVariant: statusVariantFor(task.latest_run?.status), + authorUser: task.created_by ?? null, + authorName: task.created_by + ? userDisplayName(task.created_by) + : null, isActive: pathname === `${base}/tasks/${f.taskId}`, onClick: () => void navigate({ @@ -260,37 +324,35 @@ export function SpaceSidebar({ channelId }: { channelId: string }) { )}
- -
- {pinnedItems.length > 0 && ( - <> - Pinned -
- {pinnedItems.map((item) => ( - - ))} -
- - )} +
+ {pinnedItems.length > 0 && ( + <> + Pinned +
+ {pinnedItems.map((item) => ( + + ))} +
+ + )} - {recentItems.length > 0 && ( - <> - Recent -
- {recentItems.map((item) => ( - - ))} -
- - )} + {recentItems.length > 0 && ( + <> + Recent +
+ {recentItems.map((item) => ( + + ))} +
+ + )} - {pinnedItems.length === 0 && recentItems.length === 0 && ( -

- Tasks and canvases you create in this space show up here. -

- )} -
- + {pinnedItems.length === 0 && recentItems.length === 0 && ( +

+ Tasks and canvases you create in this space show up here. +

+ )} +
); } From f3b586118891fd4282e758c8b064c5c9b172e31b Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 25 Jul 2026 15:44:18 +0200 Subject: [PATCH 07/57] feat(canvas): recent-section search/filter and task-style hover actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The space sidebar's Recent section gains the old task-list header controls (minus Add folder): a search toggle with inline input, and a filter menu for created-by (anyone / me / other people) and run status. Rows now show the relative time at rest and swap to pin + archive hover actions exactly like the old task rows — pin works for tasks and canvases (pin-to-channel), archive for tasks only, using the existing archive flow with its undo toast. Generated-By: PostHog Code Task-Id: c401c7cb-e436-4da3-80bf-1d3008ee5d38 --- docs/plans/spaces-layout-prototype.md | 2 +- .../canvas/components/SpaceSidebar.tsx | 244 +++++++++++++++++- 2 files changed, 233 insertions(+), 13 deletions(-) diff --git a/docs/plans/spaces-layout-prototype.md b/docs/plans/spaces-layout-prototype.md index abdd8365be..9651ee4ed5 100644 --- a/docs/plans/spaces-layout-prototype.md +++ b/docs/plans/spaces-layout-prototype.md @@ -118,5 +118,5 @@ Replace the docked `ThreadPanel` (in spaces mode) with an `ActivityPanel` in the 1. Enable the **Channels** alpha switch in the sidebar (Bluebird flag is default-on in dev). You land in your personal `#me` space — it is always the first item in the dot row. 2. In the dot row: `#` toggles the sidebar body into the all-channels list, with no nav chrome (pick one to open it as a space); `+` opens a draft new space, Arc-style — a hollow dot appears and the sidebar becomes a searchable chooser: attach an existing channel (stars it, so the dot persists) or create a new one. New spaces always append on the right; `#me` is always the leftmost space. A gear beside the account switcher opens Settings. -3. Switch spaces by clicking dots, swiping horizontally anywhere on the sidebar (strictly one space per gesture, however fast), or `Ctrl+Alt+←/→`. Right-click a dot to set an emoji for the space or remove it. Hovering a pinned/recent item shows a hover card with kind, status, author, and last update. +3. Switch spaces by clicking dots, swiping horizontally anywhere on the sidebar (strictly one space per gesture, however fast), or `Ctrl+Alt+←/→`. Right-click a dot to set an emoji for the space or remove it. The Recent section has search and filters (created by me/others, status) like the old task list header; rows show the relative time and swap to pin/archive actions on hover; hovering shows a preview card with kind, status badge, author avatar, and last update. 4. Open a task in the space: the right Activity panel has Timeline / Artifacts / Comments; clicking a PR artifact opens its diff. The `+` on the task tab strip shows the new chat/terminal/canvas menu. diff --git a/packages/ui/src/features/canvas/components/SpaceSidebar.tsx b/packages/ui/src/features/canvas/components/SpaceSidebar.tsx index 4709f486a1..7106387501 100644 --- a/packages/ui/src/features/canvas/components/SpaceSidebar.tsx +++ b/packages/ui/src/features/canvas/components/SpaceSidebar.tsx @@ -1,33 +1,59 @@ import { PreviewCard } from "@base-ui/react/preview-card"; import { + Archive, BookOpenTextIcon, + CheckIcon, FileTextIcon, + FunnelSimple as FunnelSimpleIcon, HashIcon, + MagnifyingGlass, PackageIcon, PlusIcon, + PushPin, RepeatIcon, } from "@phosphor-icons/react"; import type { ChannelTaskRecord } from "@posthog/core/canvas/channelTaskSchemas"; import type { DashboardSummary } from "@posthog/core/canvas/dashboardSchemas"; -import { Avatar, AvatarFallback, Badge, MenuLabel } from "@posthog/quill"; +import { + Avatar, + AvatarFallback, + Badge, + cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + Input, + MenuLabel, +} from "@posthog/quill"; import { formatRelativeTimeShort, LOOPS_FLAG } from "@posthog/shared"; import type { UserBasic } from "@posthog/shared/domain-types"; import { useArchivedTaskIds } from "@posthog/ui/features/archive/useArchivedTaskIds"; +import { useArchiveTask } from "@posthog/ui/features/archive/useArchiveTask"; +import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar"; +import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useChannelTasks } from "@posthog/ui/features/canvas/hooks/useChannelTasks"; -import { useDashboards } from "@posthog/ui/features/canvas/hooks/useDashboards"; +import { + useDashboardMutations, + useDashboards, +} from "@posthog/ui/features/canvas/hooks/useDashboards"; import { useSpaceStore } from "@posthog/ui/features/canvas/stores/spaceStore"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { SidebarItem } from "@posthog/ui/features/sidebar/components/SidebarItem"; import { usePinnedTasks } from "@posthog/ui/features/sidebar/usePinnedTasks"; import { useTasks } from "@posthog/ui/features/tasks/useTasks"; +import { NestedButton } from "@posthog/ui/primitives/NestedButton"; +import { Tooltip } from "@posthog/ui/primitives/Tooltip"; +import { toast } from "@posthog/ui/primitives/toast"; import { navigateToChannelNewTask } from "@posthog/ui/router/navigationBridge"; import { useNavigate, useRouterState } from "@tanstack/react-router"; import { motion } from "framer-motion"; -import { type ReactNode, useMemo } from "react"; +import { type ReactNode, useMemo, useState } from "react"; type StatusVariant = "default" | "destructive" | "info" | "success" | "warning"; @@ -41,13 +67,48 @@ interface SpaceItem { isActive: boolean; /** Run status ("In progress", …) for the hover card; tasks only. */ status: string | null; + /** Raw run status value, for the status filter. */ + rawStatus: string | null; statusVariant: StatusVariant; /** Who created it — the full user when known (tasks), else a name. */ authorUser: UserBasic | null; authorName: string | null; onClick: () => void; + onTogglePin: () => void; + /** Tasks only — canvases can't be archived. */ + onArchive?: () => void; } +type CreatedByFilter = "anyone" | "me" | "others"; + +const STATUS_FILTER_OPTIONS: readonly { + value: string | null; + label: string; +}[] = [ + { value: null, label: "Any status" }, + { value: "in_progress", label: "In progress" }, + { value: "completed", label: "Completed" }, + { value: "failed", label: "Failed" }, + { value: "cancelled", label: "Cancelled" }, +] as const; + +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-gray-10 transition-colors hover:bg-gray-3 hover:text-gray-12"; +const HOVER_ACTION_CLASS = + "flex h-5 w-5 cursor-pointer items-center justify-center rounded text-gray-10 transition-colors hover:bg-gray-4 hover:text-gray-12"; + +const cnHeaderButton = (active: boolean) => + cn(HEADER_ICON_BUTTON_CLASS, active && "bg-gray-3 text-gray-12"); + function humanizeStatus(status: string | null | undefined): string | null { if (!status) return null; const text = status.replace(/_/g, " "); @@ -88,6 +149,40 @@ function SpaceItemRow({ item }: { item: SpaceItem }) { label={<>{item.title}} isActive={item.isActive} onClick={item.onClick} + // At rest: the relative time. On hover: pin + archive, like the + // old task rows. + endContent={ + <> + + {formatRelativeTimeShort(item.ts)} + + + + + + + + {item.onArchive && ( + + + + + + )} + + + } />
} @@ -167,7 +262,20 @@ export function SpaceSidebar({ channelId }: { channelId: string }) { const { tasks: filedTasks } = useChannelTasks(channelId); const { data: tasks } = useTasks(); const archivedTaskIds = useArchivedTaskIds(); - const { pinnedTaskIds } = usePinnedTasks(); + const { pinnedTaskIds, togglePin } = usePinnedTasks(); + const { archiveTask } = useArchiveTask({ navigateSpace: "website" }); + const { setPinned: setCanvasPinned } = useDashboardMutations(); + const client = useOptionalAuthenticatedClient(); + const { data: currentUser } = useCurrentUser({ client }); + + // Recent-section controls: title search plus created-by / status filters, + // mirroring the old task list's header (minus "Add folder"). + 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}`; @@ -183,6 +291,7 @@ export function SpaceSidebar({ channelId }: { channelId: string }) { className: "text-violet-9", }), status: null, + rawStatus: null, statusVariant: "default" as const, authorUser: null, authorName: d.createdBy ?? null, @@ -192,6 +301,11 @@ export function SpaceSidebar({ channelId }: { channelId: string }) { to: "/website/$channelId/dashboards/$dashboardId", params: { channelId, dashboardId: d.id }, }), + onTogglePin: () => { + setCanvasPinned(d.id, d.pinnedAt == null).catch(() => { + toast.error("Couldn't update pin"); + }); + }, })); const taskById = new Map(tasks?.map((t) => [t.id, t]) ?? []); @@ -208,6 +322,7 @@ export function SpaceSidebar({ channelId }: { channelId: string }) { pinned: pinnedTaskIds.has(f.taskId), icon: , status: humanizeStatus(task.latest_run?.status), + rawStatus: task.latest_run?.status ?? null, statusVariant: statusVariantFor(task.latest_run?.status), authorUser: task.created_by ?? null, authorName: task.created_by @@ -219,6 +334,14 @@ export function SpaceSidebar({ channelId }: { channelId: string }) { to: "/website/$channelId/tasks/$taskId", params: { channelId, taskId: f.taskId }, }), + onTogglePin: () => { + togglePin(f.taskId).catch(() => { + toast.error("Couldn't update pin"); + }); + }, + onArchive: () => { + void archiveTask({ taskId: f.taskId }); + }, }, ]; }, @@ -235,10 +358,35 @@ export function SpaceSidebar({ channelId }: { channelId: string }) { base, channelId, navigate, + togglePin, + archiveTask, + setCanvasPinned, ]); const pinnedItems = items.filter((i) => i.pinned); - const recentItems = items.filter((i) => !i.pinned).slice(0, RECENTS_CAP); + + // The Recent list honors the header's search + filters; Pinned stays as-is. + const meUuid = currentUser?.uuid ?? null; + const meName = currentUser ? userDisplayName(currentUser) : null; + const normalizedQuery = query.trim().toLowerCase(); + const recentItems = items + .filter((i) => !i.pinned) + .filter((i) => { + if (normalizedQuery && !i.title.toLowerCase().includes(normalizedQuery)) { + return false; + } + if (createdByFilter !== "anyone") { + // Tasks carry the author's uuid; canvases only a display name. + const isMine = i.authorUser + ? i.authorUser.uuid === meUuid + : i.authorName != null && meName != null && i.authorName === meName; + if (createdByFilter === "me" && !isMine) return false; + if (createdByFilter === "others" && isMine) return false; + } + if (statusFilter && i.rawStatus !== statusFilter) return false; + return true; + }) + .slice(0, RECENTS_CAP); const sectionRow = ( label: string, @@ -336,18 +484,90 @@ export function SpaceSidebar({ channelId }: { channelId: string }) { )} - {recentItems.length > 0 && ( + {(items.some((i) => !i.pinned) || filtersActive || searchOpen) && ( <> - Recent -
- {recentItems.map((item) => ( - - ))} +
+
+ Recent +
+ + + + + + } + /> + + Created by + {CREATED_BY_OPTIONS.map((option) => ( + setCreatedByFilter(option.value)} + > + {option.label} + {createdByFilter === option.value && ( + + )} + + ))} + + Status + {STATUS_FILTER_OPTIONS.map((option) => ( + setStatusFilter(option.value)} + > + {option.label} + {statusFilter === option.value && } + + ))} + +
+ {searchOpen && ( +
+ setQuery(event.target.value)} + placeholder="Search…" + aria-label="Search recent items" + className="h-6 text-[12px]" + /> +
+ )} + {recentItems.length > 0 ? ( +
+ {recentItems.map((item) => ( + + ))} +
+ ) : ( +

+ Nothing matches the current search or filters. +

+ )} )} - {pinnedItems.length === 0 && recentItems.length === 0 && ( + {items.length === 0 && (

Tasks and canvases you create in this space show up here.

From c00dc134548ea4ce0ac2c6f9da38948f1f6f2d21 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 25 Jul 2026 15:44:19 +0200 Subject: [PATCH 08/57] fix(canvas): me-space ownership, full task history in Timeline, gesture-session swipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from prototype feedback. The personal space now shows only your items: the shared "me" folder can hold other people's canvases/tasks, so anything with a known non-you author is dropped (unknown authors stay, and nothing filters until the current user loads). The Activity Timeline becomes the task's full history — creation, the user messages sent to the agent (derived from the session, what used to live "inside the thread"), human comments, artifact announcements, the run-output PR fallback, and the run's terminal status — instead of only the sparse human thread. The swipe handler is rewritten as a gesture-session latch (the approach use-gesture's own docs recommend for wheel inertia, without taking the dependency): events under 250ms apart form one session, the axis is decided once per session so diagonal scrolling can't flap, and each session fires at most once — a fast fling with a long inertia tail moves exactly one space. Generated-By: PostHog Code Task-Id: c401c7cb-e436-4da3-80bf-1d3008ee5d38 --- docs/plans/spaces-layout-prototype.md | 2 +- .../canvas/components/SpaceSidebar.tsx | 22 +- .../canvas/components/ThreadPanel.tsx | 224 ++++++++++++++++-- .../ui/src/features/canvas/hooks/useSpaces.ts | 79 ++++-- 4 files changed, 282 insertions(+), 45 deletions(-) diff --git a/docs/plans/spaces-layout-prototype.md b/docs/plans/spaces-layout-prototype.md index 9651ee4ed5..a5282b859d 100644 --- a/docs/plans/spaces-layout-prototype.md +++ b/docs/plans/spaces-layout-prototype.md @@ -89,7 +89,7 @@ Replace the docked `ThreadPanel` (in spaces mode) with an `ActivityPanel` in the **Shell.** With Channels on: the all-channels list is the landing; entering a channel scopes the sidebar to that space (`SpaceSidebar.tsx`: New task, `#channel` header, Context/Loops/Artifacts rows, Pinned, Recent). `SpaceDots.tsx` renders the Arc-style dot row above the account footer (starred channels = spaces, `#` = back to landing, `+` = create channel); switching slides the sidebar in the travel direction (framer-motion), horizontal trackpad swipe and `Ctrl+Alt+←/→` cycle spaces. The current space persists across channel-less routes (inbox, activity) via `stores/spaceStore.ts`. Title bar (`SpacesTitleBar.tsx` + `__root.tsx`): the browser-tab strip is replaced by a centered search pill (opens the ⌘K palette) plus Activity-bell (unread-mention dot) and Inbox icons. -**Activity panel.** `ThreadPanel.tsx` is now the Activity panel: header renamed, with Timeline / Artifacts / Comments tabs. Timeline = the existing thread untouched; Comments = the human rows of the same thread + the existing composer; Artifacts = `TaskArtifactsList.tsx`, derived with no new backend (thread `pr_created`/`canvas_created` events + run output PR + `slack_thread_url`), with live PR state + comment counts from GitHub. PR rows open the diff in the review pane; canvas rows open the canvas; Slack opens externally. +**Activity panel.** `ThreadPanel.tsx` is now the Activity panel: header renamed, with Timeline / Artifacts / Comments tabs. Timeline = the task's full history: creation, the user messages sent to the agent (from the session), the thread's human comments and artifact announcements, the run-output PR, and the run's terminal status. Comments = the human rows of the thread + the existing composer; Artifacts = `TaskArtifactsList.tsx`, derived with no new backend (thread `pr_created`/`canvas_created` events + run output PR + `slack_thread_url`), with live PR state + comment counts from GitHub. PR rows open the diff in the review pane; canvas rows open the canvas; Slack opens externally. **Task tabs.** The panel tab strip's `+` is a dropdown: New terminal (works), New chat / New canvas disabled with a "Soon" hint (`TabbedPanel.tsx`) — per the mockup's "only what's available" note. diff --git a/packages/ui/src/features/canvas/components/SpaceSidebar.tsx b/packages/ui/src/features/canvas/components/SpaceSidebar.tsx index 7106387501..0fb2824004 100644 --- a/packages/ui/src/features/canvas/components/SpaceSidebar.tsx +++ b/packages/ui/src/features/canvas/components/SpaceSidebar.tsx @@ -41,6 +41,7 @@ import { useDashboardMutations, useDashboards, } from "@posthog/ui/features/canvas/hooks/useDashboards"; +import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; import { useSpaceStore } from "@posthog/ui/features/canvas/stores/spaceStore"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; @@ -278,6 +279,9 @@ export function SpaceSidebar({ channelId }: { channelId: string }) { const filtersActive = createdByFilter !== "anyone" || statusFilter !== null; const base = `/website/${channelId}`; + const isPersonal = channelName === PERSONAL_CHANNEL_NAME; + const meUuid = currentUser?.uuid ?? null; + const meName = currentUser ? userDisplayName(currentUser) : null; const items = useMemo(() => { const canvasItems: SpaceItem[] = dashboards.map((d: DashboardSummary) => ({ @@ -347,7 +351,18 @@ export function SpaceSidebar({ channelId }: { channelId: string }) { }, ); - return [...canvasItems, ...taskItems].sort((a, b) => b.ts - a.ts); + const all = [...canvasItems, ...taskItems].sort((a, b) => b.ts - a.ts); + + // The personal space is *yours*: the shared "me" folder can hold other + // people's items, so drop anything whose author is known and isn't you. + // Unknown authors stay (better to over-show than hide your own work), and + // nothing is dropped until the current user has actually loaded. + if (!isPersonal || (!meUuid && !meName)) return all; + return all.filter((item) => { + if (item.authorUser) return item.authorUser.uuid === meUuid; + if (item.authorName && meName) return item.authorName === meName; + return true; + }); }, [ dashboards, filedTasks, @@ -361,13 +376,14 @@ export function SpaceSidebar({ channelId }: { channelId: string }) { togglePin, archiveTask, setCanvasPinned, + isPersonal, + meUuid, + meName, ]); const pinnedItems = items.filter((i) => i.pinned); // The Recent list honors the header's search + filters; Pinned stays as-is. - const meUuid = currentUser?.uuid ?? null; - const meName = currentUser ? userDisplayName(currentUser) : null; const normalizedQuery = query.trim().toLowerCase(); const recentItems = items .filter((i) => !i.pinned) diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.tsx index b6ad4bbb96..7e821bd3a2 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.tsx @@ -1,10 +1,14 @@ import { ArrowSquareOutIcon, CaretRightIcon, + CheckCircleIcon, DotsThreeIcon, PaperPlaneRightIcon, + PlusCircleIcon, RobotIcon, TrashIcon, + UserIcon, + XCircleIcon, XIcon, } from "@phosphor-icons/react"; import { @@ -75,6 +79,7 @@ import { import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; import { getPrVisualIcon } from "@posthog/ui/features/git-interaction/prIcon"; import { usePrDetails } from "@posthog/ui/features/git-interaction/usePrDetails"; +import { buildConversationItems } from "@posthog/ui/features/sessions/components/buildConversationItems"; import { useSessionConnection } from "@posthog/ui/features/sessions/hooks/useSessionConnection"; import { useSessionViewState } from "@posthog/ui/features/sessions/hooks/useSessionViewState"; import { usePendingPermissionsForTask } from "@posthog/ui/features/sessions/sessionStore"; @@ -86,7 +91,15 @@ import { parseShareLink } from "@posthog/ui/utils/posthogLinks"; import { navigateToShareTarget } from "@posthog/ui/utils/shareLinks"; import { getPostHogUrl } from "@posthog/ui/utils/urls"; import { useQuery } from "@tanstack/react-query"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Fragment, + type ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; export function ThreadMessageRow({ message, @@ -321,6 +334,44 @@ export function ThreadArtifactRow({ ); } +// A condensed history row in the Activity timeline (task created, user +// message, run finished, …) — the "what happened" events the mockup asks for, +// derived from the task and its session rather than the human thread. +function ActivityEventRow({ + icon, + label, + detail, + timestamp, +}: { + icon: ReactNode; + label: string; + detail?: string | null; + timestamp: string; +}) { + return ( + + + + {icon} + + + + + {label} + + + {detail && ( + + + {detail} + + + )} + + + ); +} + function ThreadLoadingState() { return ( @@ -636,7 +687,7 @@ function ThreadConversation({ // biome-ignore lint/correctness/useExhaustiveDependencies: scroll when rendered thread content changes useEffect(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }); - }, [timeline, agentStatus?.phase]); + }, [timeline, events.length, agentStatus?.phase, tab]); const isTaskAuthor = !!currentUser?.uuid && currentUser.uuid === task.created_by?.uuid; @@ -679,24 +730,153 @@ function ThreadConversation({ } }; - const handleSendToAgent = (messageId: string) => { - sendToAgent(messageId).catch((error: unknown) => { - toast.error("Couldn't send message to agent", { - description: error instanceof Error ? error.message : String(error), + const handleSendToAgent = useCallback( + (messageId: string) => { + sendToAgent(messageId).catch((error: unknown) => { + toast.error("Couldn't send message to agent", { + description: error instanceof Error ? error.message : String(error), + }); }); - }); - }; + }, + [sendToAgent], + ); - const handleDelete = (messageId: string) => { - deleteMessage(messageId).catch((error: unknown) => { - toast.error("Couldn't delete message", { - description: error instanceof Error ? error.message : String(error), + const handleDelete = useCallback( + (messageId: string) => { + deleteMessage(messageId).catch((error: unknown) => { + toast.error("Couldn't delete message", { + description: error instanceof Error ? error.message : String(error), + }); }); - }); - }; + }, + [deleteMessage], + ); const isReady = !isInitializing && !isLoading; + // The Timeline tab is the task's full history, not just the human thread: + // creation, the user messages sent to the agent (from the session — what + // used to live "inside the thread" of a task), the thread's human comments + // and artifact announcements, the run-output PR as a fallback, and the + // run's terminal status. + const conversationItems = useMemo( + () => buildConversationItems(events, isPromptPending).items, + [events, isPromptPending], + ); + const activityNodes = useMemo(() => { + const nodes: { key: string; ts: number; node: ReactNode }[] = []; + const createdTs = Date.parse(task.created_at) || 0; + nodes.push({ + key: "task-created", + ts: createdTs, + node: ( + } + label={`${ + task.created_by ? userDisplayName(task.created_by) : "Someone" + } created this task`} + detail={task.title || null} + timestamp={task.created_at} + /> + ), + }); + + for (const item of conversationItems) { + if (item.type !== "user_message") continue; + nodes.push({ + key: `user-message-${item.id}`, + ts: item.timestamp, + node: ( + } + label="User message" + detail={item.content} + timestamp={new Date(item.timestamp).toISOString()} + /> + ), + }); + } + + let hasPrArtifact = false; + for (const row of timeline) { + if (row.kind === "artifact" && row.artifact.kind === "pr") { + hasPrArtifact = true; + } + nodes.push({ + key: `thread-${row.message.id}`, + ts: row.timestamp, + node: + row.kind === "human" ? ( + handleSendToAgent(row.message.id)} + onDelete={() => handleDelete(row.message.id)} + /> + ) : ( + + ), + }); + } + + const updatedTs = Date.parse(task.updated_at) || createdTs; + const outputPr = task.latest_run?.output?.pr_url; + if (typeof outputPr === "string" && outputPr && !hasPrArtifact) { + nodes.push({ + key: "output-pr", + ts: updatedTs, + node: ( + + ), + }); + } + + const runStatus = task.latest_run?.status; + if (runStatus && isTerminalStatus(runStatus)) { + nodes.push({ + key: "run-status", + ts: updatedTs + 1, + node: ( + + ) : ( + + ) + } + label={`Task ${runStatus.replace(/_/g, " ")}`} + timestamp={task.updated_at} + /> + ), + }); + } + + return nodes.sort((a, b) => a.ts - b.ts); + }, [ + conversationItems, + timeline, + task, + isTaskAuthor, + canForward, + currentUser?.uuid, + currentUser?.email, + handleSendToAgent, + handleDelete, + ]); + return (
- ) : ( + ) : tab === "comments" ? (
+ ) : ( +
+ {!isReady ? ( + + ) : ( + + {activityNodes.map((entry) => ( + {entry.node} + ))} + + )} +
)} {tab !== "artifacts" && agentStatus && ( diff --git a/packages/ui/src/features/canvas/hooks/useSpaces.ts b/packages/ui/src/features/canvas/hooks/useSpaces.ts index 97c0417c10..f86a6b100d 100644 --- a/packages/ui/src/features/canvas/hooks/useSpaces.ts +++ b/packages/ui/src/features/canvas/hooks/useSpaces.ts @@ -75,44 +75,73 @@ export function useSpaces(): { return { spaces, currentChannelId, currentIndex, switchTo, cycle }; } -// How much horizontal trackpad travel counts as a space swipe, and how long -// the gesture must go quiet before another swipe can fire. -const SWIPE_THRESHOLD = 90; -const SWIPE_QUIET_MS = 300; -const SWIPE_RESET_MS = 300; +// Gesture-session swipe tuning. A "gesture" is a run of wheel events with no +// gap longer than GESTURE_GAP_MS between them — macOS inertia events arrive +// well inside that gap, so a whole fling (fingers + momentum) is one session. +const GESTURE_GAP_MS = 250; +// Travel before the gesture commits to an axis (horizontal vs vertical). +const AXIS_INTENT_PX = 12; +// Horizontal travel that triggers the switch. +const SWIPE_FIRE_PX = 60; + +interface SwipeSession { + lastEventAt: number; + totalX: number; + totalY: number; + axis: "x" | "y" | null; + fired: boolean; +} /** - * Horizontal trackpad swipe → cycle spaces, strictly one space per gesture: - * after a move fires, every further wheel event of the same gesture (macOS - * inertia included) keeps a quiet-period lock alive, so even a very fast - * fling moves exactly one space. Attach the returned handler via `onWheel`. + * Horizontal trackpad swipe → cycle spaces, strictly one space per gesture. + * + * Wheel events carry no native intent-vs-inertia signal (the same problem + * use-gesture's docs point at Lethargy for), so this uses a gesture-session + * latch: events separated by less than GESTURE_GAP_MS belong to one session; + * each session decides its axis exactly once (so diagonal scrolling can't + * flap between "scroll" and "swipe"), fires at most once, and stays latched — + * inertia included — until true quiet ends the session. */ export function useSpaceSwipe( enabled: boolean, ): (event: React.WheelEvent) => void { const { cycle } = useSpaces(); - const accum = useRef(0); - const lockUntil = useRef(0); - const lastEvent = useRef(0); + const session = useRef({ + lastEventAt: 0, + totalX: 0, + totalY: 0, + axis: null, + fired: false, + }); return useCallback( (event: React.WheelEvent) => { if (!enabled) return; - // Mostly-vertical wheel = list scrolling, never a space switch. - if (Math.abs(event.deltaX) <= Math.abs(event.deltaY)) return; const now = Date.now(); - if (now < lockUntil.current) { - // Same gesture still rolling — extend the lock so it can't re-fire. - lockUntil.current = now + SWIPE_QUIET_MS; - return; + const s = session.current; + if (now - s.lastEventAt > GESTURE_GAP_MS) { + s.totalX = 0; + s.totalY = 0; + s.axis = null; + s.fired = false; + } + s.lastEventAt = now; + s.totalX += event.deltaX; + s.totalY += event.deltaY; + if (s.fired) return; + if (s.axis === null) { + if ( + Math.abs(s.totalX) < AXIS_INTENT_PX && + Math.abs(s.totalY) < AXIS_INTENT_PX + ) { + return; + } + s.axis = Math.abs(s.totalX) > Math.abs(s.totalY) * 1.2 ? "x" : "y"; } - if (now - lastEvent.current > SWIPE_RESET_MS) accum.current = 0; - lastEvent.current = now; - accum.current += event.deltaX; - if (Math.abs(accum.current) >= SWIPE_THRESHOLD) { - cycle(accum.current > 0 ? 1 : -1); - accum.current = 0; - lockUntil.current = now + SWIPE_QUIET_MS; + if (s.axis !== "x") return; + if (Math.abs(s.totalX) >= SWIPE_FIRE_PX) { + s.fired = true; + cycle(s.totalX > 0 ? 1 : -1); } }, [enabled, cycle], From e96ddc7e0e9333cdd36905c41c618b95f4eeaa73 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 25 Jul 2026 15:44:20 +0200 Subject: [PATCH 09/57] fix(canvas): clamp space cycling, snappier swipe re-arm, native filter menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swiping/cycling no longer wraps around the ends — running past the last space clamps instead of jumping back to the first, which read as the switcher "swiping all around". The gesture gap drops to 150ms so two deliberate back-to-back swipes register as two moves while inertia tails still can't re-fire (their tiny deltas never reach the threshold). The Recent filter menu is rebuilt with the same DropdownMenuRadioGroup construction as the task list's filter menu, so the rows are compact, left-aligned, and consistent with every other menu. Generated-By: PostHog Code Task-Id: c401c7cb-e436-4da3-80bf-1d3008ee5d38 --- .../canvas/components/SpaceSidebar.tsx | 63 ++++++++++++------- .../ui/src/features/canvas/hooks/useSpaces.ts | 13 +++- 2 files changed, 50 insertions(+), 26 deletions(-) diff --git a/packages/ui/src/features/canvas/components/SpaceSidebar.tsx b/packages/ui/src/features/canvas/components/SpaceSidebar.tsx index 0fb2824004..5f2e06cd21 100644 --- a/packages/ui/src/features/canvas/components/SpaceSidebar.tsx +++ b/packages/ui/src/features/canvas/components/SpaceSidebar.tsx @@ -2,7 +2,6 @@ import { PreviewCard } from "@base-ui/react/preview-card"; import { Archive, BookOpenTextIcon, - CheckIcon, FileTextIcon, FunnelSimple as FunnelSimpleIcon, HashIcon, @@ -21,7 +20,8 @@ import { cn, DropdownMenu, DropdownMenuContent, - DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger, Input, @@ -530,30 +530,47 @@ export function SpaceSidebar({ channelId }: { channelId: string }) { } /> - + {/* Same construction as the task list's filter menu so the + two read identically. */} + Created by - {CREATED_BY_OPTIONS.map((option) => ( - setCreatedByFilter(option.value)} - > - {option.label} - {createdByFilter === option.value && ( - - )} - - ))} + + setCreatedByFilter(value as CreatedByFilter) + } + > + {CREATED_BY_OPTIONS.map((option) => ( + + {option.label} + + ))} + Status - {STATUS_FILTER_OPTIONS.map((option) => ( - setStatusFilter(option.value)} - > - {option.label} - {statusFilter === option.value && } - - ))} + + setStatusFilter(value === "any" ? null : value) + } + > + {STATUS_FILTER_OPTIONS.map((option) => ( + + {option.label} + + ))} +
diff --git a/packages/ui/src/features/canvas/hooks/useSpaces.ts b/packages/ui/src/features/canvas/hooks/useSpaces.ts index f86a6b100d..f7ee0de15c 100644 --- a/packages/ui/src/features/canvas/hooks/useSpaces.ts +++ b/packages/ui/src/features/canvas/hooks/useSpaces.ts @@ -65,8 +65,12 @@ export function useSpaces(): { const cycle = useCallback( (delta: 1 | -1) => { if (spaces.length === 0) return; - const from = currentIndex === -1 ? 0 : currentIndex; - const next = spaces[(from + delta + spaces.length) % spaces.length]; + // Clamp at the ends — wrapping around reads as the switcher jumping + // randomly when you swipe past the last space. + const from = currentIndex === -1 ? (delta > 0 ? -1 : 1) : currentIndex; + const nextIndex = Math.max(0, Math.min(spaces.length - 1, from + delta)); + if (nextIndex === currentIndex) return; + const next = spaces[nextIndex]; if (next) switchTo(next); }, [spaces, currentIndex, switchTo], @@ -78,7 +82,10 @@ export function useSpaces(): { // Gesture-session swipe tuning. A "gesture" is a run of wheel events with no // gap longer than GESTURE_GAP_MS between them — macOS inertia events arrive // well inside that gap, so a whole fling (fingers + momentum) is one session. -const GESTURE_GAP_MS = 250; +// Kept short enough that two deliberate back-to-back swipes register as two +// gestures; a dying inertia tail that sneaks past the gap can't re-fire +// because its tiny deltas never reach the fire threshold. +const GESTURE_GAP_MS = 150; // Travel before the gesture commits to an axis (horizontal vs vertical). const AXIS_INTENT_PX = 12; // Horizontal travel that triggers the switch. From 50784f90db03437d981ce4e2f8cecde8542beca6 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 25 Jul 2026 15:44:22 +0200 Subject: [PATCH 10/57] fix(canvas): move New task below the space nav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The New task row moves from the top of the space sidebar to below the channel header and Context/Loops/Artifacts rows, sitting just above Pinned — per the annotated screenshot. Generated-By: PostHog Code Task-Id: c401c7cb-e436-4da3-80bf-1d3008ee5d38 --- .../canvas/components/SpaceSidebar.tsx | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/ui/src/features/canvas/components/SpaceSidebar.tsx b/packages/ui/src/features/canvas/components/SpaceSidebar.tsx index 5f2e06cd21..66224d997c 100644 --- a/packages/ui/src/features/canvas/components/SpaceSidebar.tsx +++ b/packages/ui/src/features/canvas/components/SpaceSidebar.tsx @@ -427,16 +427,6 @@ export function SpaceSidebar({ channelId }: { channelId: string }) { transition={{ duration: 0.18, ease: [0, 0, 0.2, 1] }} className="flex h-full min-h-0 flex-col" > -
- } - label="New task" - isActive={pathname === `${base}/new`} - onClick={() => navigateToChannelNewTask(channelId)} - /> -
- {/* Channel header: the space's identity, click = channel home. */} - - - -
- ) : ( - - )} +
From 5d8bb23fe4c3984e120a107103be9003788a74b3 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 25 Jul 2026 15:44:27 +0200 Subject: [PATCH 13/57] fix(canvas): show teammates' tasks in space lists, stop multi-space swipes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Channel Pinned/Recent rows resolved filed tasks against the mine-only task list, silently dropping every teammate's task while their canvases (project-wide query) showed — resolve against all users' tasks instead (the personal space's ownership filter still applies). The swipe re-arm gains two guards against one fling moving several spaces: a fired session can only re-arm after a 250ms cooldown (inside that window the fling's own ramp/peak deltas could pass the burst test), and for 800ms after a fire, a "new" session opened by a sparse inertia tail that outlasted the gesture gap parks on the vertical axis — only a genuine burst can turn it back into a swipe. Generated-By: PostHog Code Task-Id: c401c7cb-e436-4da3-80bf-1d3008ee5d38 --- .../canvas/components/SpaceSidebar.tsx | 5 ++- .../ui/src/features/canvas/hooks/useSpaces.ts | 31 ++++++++++++++++--- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/features/canvas/components/SpaceSidebar.tsx b/packages/ui/src/features/canvas/components/SpaceSidebar.tsx index 66224d997c..92ca5828bf 100644 --- a/packages/ui/src/features/canvas/components/SpaceSidebar.tsx +++ b/packages/ui/src/features/canvas/components/SpaceSidebar.tsx @@ -261,7 +261,10 @@ export function SpaceSidebar({ channelId }: { channelId: string }) { const { dashboards } = useDashboards(channelId); const { tasks: filedTasks } = useChannelTasks(channelId); - const { data: tasks } = useTasks(); + // All users' tasks, not just mine: a channel's filed tasks belong to the + // whole team, and rows are dropped when the task can't be resolved — the + // default mine-only list silently hid everyone else's work. + const { data: tasks } = useTasks({ showAllUsers: true }); const archivedTaskIds = useArchivedTaskIds(); const { pinnedTaskIds, togglePin } = usePinnedTasks(); const { archiveTask } = useArchiveTask({ navigateSpace: "website" }); diff --git a/packages/ui/src/features/canvas/hooks/useSpaces.ts b/packages/ui/src/features/canvas/hooks/useSpaces.ts index 336ee7e243..b4108cffa7 100644 --- a/packages/ui/src/features/canvas/hooks/useSpaces.ts +++ b/packages/ui/src/features/canvas/hooks/useSpaces.ts @@ -94,6 +94,16 @@ const SWIPE_FIRE_PX = 50; const REARM_MIN_PX = 15; const REARM_FACTOR = 2.5; const RECENT_SAMPLES = 4; +// A fired session may only re-arm after this much time: within it, a single +// fling is still ramping/peaking and its own deltas can spike past the burst +// test — which read as one swipe moving several spaces. A human re-swipe +// (lift, reposition, swipe) always takes longer than this. +const REARM_COOLDOWN_MS = 250; +// After a fire, a sparse inertia tail can pause longer than GESTURE_GAP_MS +// and come back — a technically "new" session that would crawl back to the +// threshold. Within this window a new session opening on a small delta is +// parked (treated as leftover noise) until a real burst re-arms it. +const POST_FIRE_GUARD_MS = 800; interface SwipeSession { lastEventAt: number; @@ -101,6 +111,7 @@ interface SwipeSession { totalY: number; axis: "x" | "y" | null; fired: boolean; + firedAt: number; /** Recent |deltaX| samples — the stream level re-arm bursts are judged against. */ recentX: number[]; } @@ -131,6 +142,7 @@ export function useSpaceSwipe( totalY: 0, axis: null, fired: false, + firedAt: 0, recentX: [], }); @@ -150,12 +162,22 @@ export function useSpaceSwipe( s.recentX = []; }; + // A spent session (fired, past its cooldown) or one committed to + // vertical scrolling can re-open when a horizontal burst far above the + // recent level shows up — that's the user swiping again right now. + const canRearm = + (s.fired && now - s.firedAt >= REARM_COOLDOWN_MS) || + (!s.fired && s.axis === "y"); + if (now - s.lastEventAt > GESTURE_GAP_MS) { resetSession(); - } else if (s.fired || s.axis === "y") { - // The session is spent (fired) or committed to vertical scrolling — - // but a horizontal burst far above the recent level means the user is - // swiping again right now. Re-open for it. + // Leftover inertia shortly after a fire opens weak — park the session + // on the vertical axis so only a genuine burst (via re-arm below, on + // a later event) can turn it into a swipe. + if (now - s.firedAt < POST_FIRE_GUARD_MS && magnitudeX < REARM_MIN_PX) { + s.axis = "y"; + } + } else if (canRearm) { const recent = s.recentX.slice(-RECENT_SAMPLES); const average = recent.length ? recent.reduce((sum, value) => sum + value, 0) / recent.length @@ -187,6 +209,7 @@ export function useSpaceSwipe( if (s.axis !== "x") return; if (Math.abs(s.totalX) >= SWIPE_FIRE_PX) { s.fired = true; + s.firedAt = now; cycle(s.totalX > 0 ? 1 : -1); } }, From c011034e957812f164f7017d3fb1f0ccf67e25e3 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 25 Jul 2026 15:44:29 +0200 Subject: [PATCH 14/57] fix(canvas): source space tasks from the channel feed so all of them show MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix (all-users task query) returned zero tasks for regular members, so Recent showed only canvases. Switch the space sidebar to the same source the channel home uses: resolve the folder channel to its backend channel and read useChannelFeed (server-side getTasks({ channel })), which returns every filed task as a full object regardless of author — no fragile join against a separately-filtered task list. The personal #me ownership filter still applies on top. Generated-By: PostHog Code Task-Id: c401c7cb-e436-4da3-80bf-1d3008ee5d38 --- .../canvas/components/SpaceSidebar.tsx | 93 +++++++++---------- 1 file changed, 44 insertions(+), 49 deletions(-) diff --git a/packages/ui/src/features/canvas/components/SpaceSidebar.tsx b/packages/ui/src/features/canvas/components/SpaceSidebar.tsx index 92ca5828bf..ca00015651 100644 --- a/packages/ui/src/features/canvas/components/SpaceSidebar.tsx +++ b/packages/ui/src/features/canvas/components/SpaceSidebar.tsx @@ -11,7 +11,6 @@ import { PushPin, RepeatIcon, } from "@phosphor-icons/react"; -import type { ChannelTaskRecord } from "@posthog/core/canvas/channelTaskSchemas"; import type { DashboardSummary } from "@posthog/core/canvas/dashboardSchemas"; import { Avatar, @@ -35,19 +34,21 @@ import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authCl import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar"; import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon"; +import { useChannelFeed } from "@posthog/ui/features/canvas/hooks/useChannelFeed"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; -import { useChannelTasks } from "@posthog/ui/features/canvas/hooks/useChannelTasks"; import { useDashboardMutations, useDashboards, } from "@posthog/ui/features/canvas/hooks/useDashboards"; -import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; +import { + PERSONAL_CHANNEL_NAME, + useBackendChannel, +} from "@posthog/ui/features/canvas/hooks/useTaskChannels"; import { useSpaceStore } from "@posthog/ui/features/canvas/stores/spaceStore"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { SidebarItem } from "@posthog/ui/features/sidebar/components/SidebarItem"; import { usePinnedTasks } from "@posthog/ui/features/sidebar/usePinnedTasks"; -import { useTasks } from "@posthog/ui/features/tasks/useTasks"; import { NestedButton } from "@posthog/ui/primitives/NestedButton"; import { Tooltip } from "@posthog/ui/primitives/Tooltip"; import { toast } from "@posthog/ui/primitives/toast"; @@ -147,7 +148,7 @@ function SpaceItemRow({ item }: { item: SpaceItem }) { icon={item.icon} // A non-string label opts out of SidebarItem's built-in // truncation tooltip — the hover card carries the full title. - label={<>{item.title}} + label={{item.title}} isActive={item.isActive} onClick={item.onClick} // At rest: the relative time. On hover: pin + archive, like the @@ -260,11 +261,12 @@ export function SpaceSidebar({ channelId }: { channelId: string }) { channels.find((c) => c.id === channelId)?.name ?? "channel"; const { dashboards } = useDashboards(channelId); - const { tasks: filedTasks } = useChannelTasks(channelId); - // All users' tasks, not just mine: a channel's filed tasks belong to the - // whole team, and rows are dropped when the task can't be resolved — the - // default mine-only list silently hid everyone else's work. - const { data: tasks } = useTasks({ showAllUsers: true }); + // The channel's full task feed (server-side `getTasks({ channel })`) — every + // filed task with its full object, regardless of author. Joining the id-only + // channelTasks list against the mine-only task query silently dropped every + // teammate's task; the feed is the same source the channel home renders. + const { channel: backendChannel } = useBackendChannel(channelName); + const { tasks: feedTasks } = useChannelFeed(backendChannel?.id); const archivedTaskIds = useArchivedTaskIds(); const { pinnedTaskIds, togglePin } = usePinnedTasks(); const { archiveTask } = useArchiveTask({ navigateSpace: "website" }); @@ -315,44 +317,38 @@ export function SpaceSidebar({ channelId }: { channelId: string }) { }, })); - const taskById = new Map(tasks?.map((t) => [t.id, t]) ?? []); - const taskItems: SpaceItem[] = filedTasks.flatMap( - (f: ChannelTaskRecord) => { - const task = taskById.get(f.taskId); - if (archivedTaskIds.has(f.taskId) || !task) return []; - return [ - { - key: `task:${f.id}`, - kind: "task" as const, - title: task.title || "Untitled task", - ts: Date.parse(task.updated_at) || 0, - pinned: pinnedTaskIds.has(f.taskId), - icon: , - status: humanizeStatus(task.latest_run?.status), - rawStatus: task.latest_run?.status ?? null, - statusVariant: statusVariantFor(task.latest_run?.status), - authorUser: task.created_by ?? null, - authorName: task.created_by - ? userDisplayName(task.created_by) - : null, - isActive: pathname === `${base}/tasks/${f.taskId}`, - onClick: () => - void navigate({ - to: "/website/$channelId/tasks/$taskId", - params: { channelId, taskId: f.taskId }, - }), - onTogglePin: () => { - togglePin(f.taskId).catch(() => { - toast.error("Couldn't update pin"); - }); - }, - onArchive: () => { - void archiveTask({ taskId: f.taskId }); - }, + const taskItems: SpaceItem[] = feedTasks.flatMap((task) => { + if (archivedTaskIds.has(task.id)) return []; + return [ + { + key: `task:${task.id}`, + kind: "task" as const, + title: task.title || "Untitled task", + ts: Date.parse(task.updated_at) || 0, + pinned: pinnedTaskIds.has(task.id), + icon: , + status: humanizeStatus(task.latest_run?.status), + rawStatus: task.latest_run?.status ?? null, + statusVariant: statusVariantFor(task.latest_run?.status), + authorUser: task.created_by ?? null, + authorName: task.created_by ? userDisplayName(task.created_by) : null, + isActive: pathname === `${base}/tasks/${task.id}`, + onClick: () => + void navigate({ + to: "/website/$channelId/tasks/$taskId", + params: { channelId, taskId: task.id }, + }), + onTogglePin: () => { + togglePin(task.id).catch(() => { + toast.error("Couldn't update pin"); + }); }, - ]; - }, - ); + onArchive: () => { + void archiveTask({ taskId: task.id }); + }, + }, + ]; + }); const all = [...canvasItems, ...taskItems].sort((a, b) => b.ts - a.ts); @@ -368,8 +364,7 @@ export function SpaceSidebar({ channelId }: { channelId: string }) { }); }, [ dashboards, - filedTasks, - tasks, + feedTasks, archivedTaskIds, pinnedTaskIds, pathname, From 714c7e3a89b505b5bfdf5c0626289367aa36e898 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 25 Jul 2026 15:44:30 +0200 Subject: [PATCH 15/57] fix(canvas): dead-simple one-swipe-one-space gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the whole gesture-session + burst-re-arm machinery (which could double-fire) with macOS-Spaces semantics: accumulate horizontal wheel delta, fire once when it crosses 40px, then lock until the wheel is quiet for 250ms. Inertia keeps the lock alive (it never re-fires); the lock releases only after the fling fully stops, and the next swipe re-arms. No speed dependence, no heuristics — one swipe moves exactly one space, clamped at the ends. Generated-By: PostHog Code Task-Id: c401c7cb-e436-4da3-80bf-1d3008ee5d38 --- docs/plans/spaces-layout-prototype.md | 2 +- .../ui/src/features/canvas/hooks/useSpaces.ts | 147 +++++------------- 2 files changed, 37 insertions(+), 112 deletions(-) diff --git a/docs/plans/spaces-layout-prototype.md b/docs/plans/spaces-layout-prototype.md index f682b43184..ff92bb3edc 100644 --- a/docs/plans/spaces-layout-prototype.md +++ b/docs/plans/spaces-layout-prototype.md @@ -118,5 +118,5 @@ Replace the docked `ThreadPanel` (in spaces mode) with an `ActivityPanel` in the 1. Enable the **Channels** alpha switch in the sidebar (Bluebird flag is default-on in dev). You land in your personal `#me` space — it is always the first item in the dot row. 2. In the dot row: `#` toggles the sidebar body into the all-channels list, with no nav chrome (pick one to open it as a space); `+` opens a draft new space, Arc-style — a hollow dot appears and the sidebar becomes a searchable chooser: attach an existing channel (stars it, so the dot persists) or create a new one. New spaces always append on the right; `#me` is always the leftmost space. -3. Switch spaces by clicking dots, swiping horizontally anywhere on the sidebar (strictly one space per gesture, however fast), or `Ctrl+Alt+←/→`. Right-click a dot to set an emoji for the space or remove it. The Recent section has search and filters (created by me/others, status) like the old task list header; rows show the relative time and swap to pin/archive actions on hover; hovering shows a preview card with kind, status badge, author avatar, and last update. +3. Switch spaces by clicking dots, swiping horizontally anywhere on the sidebar (macOS-Spaces semantics: one swipe = one space, speed-independent — fire-once-then-lock-until-quiet gate), or `Ctrl+Alt+←/→`. Right-click a dot to set an emoji for the space or remove it. The Recent section has search and filters (created by me/others, status) like the old task list header; rows show the relative time and swap to pin/archive actions on hover; hovering shows a preview card with kind, status badge, author avatar, and last update. 4. Open a task in the space: the right Activity panel has Timeline / Artifacts / Comments; clicking a PR artifact opens its diff. The `+` on the task tab strip shows the new chat/terminal/canvas menu. diff --git a/packages/ui/src/features/canvas/hooks/useSpaces.ts b/packages/ui/src/features/canvas/hooks/useSpaces.ts index b4108cffa7..066534f72b 100644 --- a/packages/ui/src/features/canvas/hooks/useSpaces.ts +++ b/packages/ui/src/features/canvas/hooks/useSpaces.ts @@ -79,138 +79,63 @@ export function useSpaces(): { return { spaces, currentChannelId, currentIndex, switchTo, cycle }; } -// Gesture-session swipe tuning. A "gesture" is a run of wheel events with no -// gap longer than GESTURE_GAP_MS between them — macOS inertia events arrive -// well inside that gap, so a whole fling (fingers + momentum) is one session. -const GESTURE_GAP_MS = 150; -// Travel before the gesture commits to an axis (horizontal vs vertical). -const AXIS_INTENT_PX = 12; -// Horizontal travel that triggers the switch. -const SWIPE_FIRE_PX = 50; -// A new intentional burst inside a still-live session: the horizontal delta -// must reach this many px AND this multiple of the recent horizontal level. -// Inertia decays smoothly, so it can never satisfy both; a fresh finger swipe -// spikes well past them. -const REARM_MIN_PX = 15; -const REARM_FACTOR = 2.5; -const RECENT_SAMPLES = 4; -// A fired session may only re-arm after this much time: within it, a single -// fling is still ramping/peaking and its own deltas can spike past the burst -// test — which read as one swipe moving several spaces. A human re-swipe -// (lift, reposition, swipe) always takes longer than this. -const REARM_COOLDOWN_MS = 250; -// After a fire, a sparse inertia tail can pause longer than GESTURE_GAP_MS -// and come back — a technically "new" session that would crawl back to the -// threshold. Within this window a new session opening on a small delta is -// parked (treated as leftover noise) until a real burst re-arms it. -const POST_FIRE_GUARD_MS = 800; +// Swipe tuning. macOS "switch desktop" semantics: one swipe = one space, +// regardless of speed. A trackpad swipe is a burst of wheel events (fingers + +// inertia tail); the whole burst must move exactly one space. +// +// The gate: fire once when a horizontal swipe crosses the threshold, then lock +// until the wheel goes QUIET for END_GAP_MS. Inertia streams events with small +// gaps, so it keeps the lock alive and can never fire again; the lock only +// releases after the fling (fingers + momentum) has fully stopped. A fresh +// swipe then re-arms. No speed dependence, no re-arm heuristics. +const END_GAP_MS = 250; +// Horizontal travel within one gesture that triggers the switch. +const FIRE_PX = 40; -interface SwipeSession { +interface SwipeGate { lastEventAt: number; - totalX: number; - totalY: number; - axis: "x" | "y" | null; - fired: boolean; - firedAt: number; - /** Recent |deltaX| samples — the stream level re-arm bursts are judged against. */ - recentX: number[]; + accumX: number; + locked: boolean; } /** - * Horizontal trackpad swipe → cycle spaces: exactly one space per swipe, and - * every swipe counts. - * - * Wheel events carry no native intent-vs-inertia signal (the problem - * use-gesture's docs point at Lethargy for), so this combines two mechanisms: - * - * 1. A gesture-session latch — events under GESTURE_GAP_MS apart form one - * session; the axis is decided once per session (diagonal scrolling can't - * flap) and it fires at most once, so an inertia tail can never re-fire. - * 2. Lethargy-style burst re-arm — a horizontal delta that spikes well above - * the session's recent stream level is a NEW intent, so a swipe made while - * the previous one's inertia is still rolling (or right after vertical - * scrolling locked the session to "y") re-opens the session instead of - * being swallowed. + * Horizontal trackpad swipe → cycle spaces, exactly one space per swipe. + * Attach the returned handler via `onWheel`. */ export function useSpaceSwipe( enabled: boolean, ): (event: React.WheelEvent) => void { const { cycle } = useSpaces(); - const session = useRef({ + const gate = useRef({ lastEventAt: 0, - totalX: 0, - totalY: 0, - axis: null, - fired: false, - firedAt: 0, - recentX: [], + accumX: 0, + locked: false, }); return useCallback( (event: React.WheelEvent) => { if (!enabled) return; const now = Date.now(); - const s = session.current; - const deltaX = event.deltaX; - const magnitudeX = Math.abs(deltaX); + const g = gate.current; - const resetSession = () => { - s.totalX = 0; - s.totalY = 0; - s.axis = null; - s.fired = false; - s.recentX = []; - }; - - // A spent session (fired, past its cooldown) or one committed to - // vertical scrolling can re-open when a horizontal burst far above the - // recent level shows up — that's the user swiping again right now. - const canRearm = - (s.fired && now - s.firedAt >= REARM_COOLDOWN_MS) || - (!s.fired && s.axis === "y"); - - if (now - s.lastEventAt > GESTURE_GAP_MS) { - resetSession(); - // Leftover inertia shortly after a fire opens weak — park the session - // on the vertical axis so only a genuine burst (via re-arm below, on - // a later event) can turn it into a swipe. - if (now - s.firedAt < POST_FIRE_GUARD_MS && magnitudeX < REARM_MIN_PX) { - s.axis = "y"; - } - } else if (canRearm) { - const recent = s.recentX.slice(-RECENT_SAMPLES); - const average = recent.length - ? recent.reduce((sum, value) => sum + value, 0) / recent.length - : 0; - if ( - magnitudeX >= REARM_MIN_PX && - magnitudeX >= REARM_FACTOR * Math.max(average, 1) - ) { - resetSession(); - } + // A quiet stretch ends the previous gesture (fingers + inertia have + // stopped), so the next event starts fresh and re-arms. + if (now - g.lastEventAt > END_GAP_MS) { + g.accumX = 0; + g.locked = false; } + g.lastEventAt = now; - s.lastEventAt = now; - s.recentX.push(magnitudeX); - if (s.recentX.length > RECENT_SAMPLES * 2) s.recentX.shift(); - s.totalX += deltaX; - s.totalY += event.deltaY; + // Already fired this gesture — swallow the rest of the burst (inertia). + if (g.locked) return; - if (s.fired) return; - if (s.axis === null) { - if ( - Math.abs(s.totalX) < AXIS_INTENT_PX && - Math.abs(s.totalY) < AXIS_INTENT_PX - ) { - return; - } - s.axis = Math.abs(s.totalX) > Math.abs(s.totalY) * 1.2 ? "x" : "y"; - } - if (s.axis !== "x") return; - if (Math.abs(s.totalX) >= SWIPE_FIRE_PX) { - s.fired = true; - s.firedAt = now; - cycle(s.totalX > 0 ? 1 : -1); + // Only horizontal-dominant events count; vertical scrolling is ignored. + if (Math.abs(event.deltaX) <= Math.abs(event.deltaY)) return; + + g.accumX += event.deltaX; + if (Math.abs(g.accumX) >= FIRE_PX) { + g.locked = true; + cycle(g.accumX > 0 ? 1 : -1); } }, [enabled, cycle], From 7defeace50a3e685195be6fb1170e2c39b5ce0a6 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 25 Jul 2026 15:44:31 +0200 Subject: [PATCH 16/57] fix(canvas): refine Timeline rows, pin channel on browse-click, harden swipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes. Timeline aesthetics: lifecycle events (task created / completed / failed) become compact markers — a small real person avatar for "created", a small colored status glyph for the terminal state — instead of oversized gray avatar boxes. User messages now render as proper message rows with the author's real avatar (gravatar/initials) rather than a generic person icon. Browse-to-pin: clicking a channel from the all-channels browse list now stars it, so it becomes a permanent space dot rather than a transient visit; captured from the store before setCurrentChannel clears the browsing flag. Swipe: add a hard minimum interval between switches on top of the quiet-gap release, so a sparse inertia tail whose gaps exceed the quiet threshold still can't fire a second switch — one physical swipe moves exactly one space regardless of the tail's shape. Generated-By: PostHog Code Task-Id: c401c7cb-e436-4da3-80bf-1d3008ee5d38 --- .../canvas/components/ChannelsSidebar.tsx | 38 ++++- .../canvas/components/ThreadPanel.tsx | 134 +++++++++++++----- .../ui/src/features/canvas/hooks/useSpaces.ts | 49 ++++--- 3 files changed, 165 insertions(+), 56 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx index f8d2626b26..23613b9af3 100644 --- a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx @@ -8,6 +8,10 @@ import { useChannelsSidebarStore } from "@posthog/ui/features/canvas/components/ import { NewSpaceDraft } from "@posthog/ui/features/canvas/components/NewSpaceDraft"; import { SpaceDots } from "@posthog/ui/features/canvas/components/SpaceDots"; import { SpaceSidebar } from "@posthog/ui/features/canvas/components/SpaceSidebar"; +import { + useChannelStarMutations, + useChannelStars, +} from "@posthog/ui/features/canvas/hooks/useChannelStars"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useSpaceSwipe } from "@posthog/ui/features/canvas/hooks/useSpaces"; import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; @@ -107,9 +111,38 @@ export function ChannelsSidebar() { const setCurrentChannel = useSpaceStore((s) => s.setCurrentChannel); const browsing = useSpaceStore((s) => s.browsing); const draftSpace = useSpaceStore((s) => s.draftSpace); + const { channels } = useChannels({ enabled: channelsEnabled }); + const { starredRefToShortcutId } = useChannelStars({ + enabled: channelsEnabled, + }); + const { star } = useChannelStarMutations(); useEffect(() => { - if (routeChannelId) setCurrentChannel(routeChannelId); - }, [routeChannelId, setCurrentChannel]); + if (!routeChannelId) return; + // Read the store fresh: setCurrentChannel clears `browsing` below, so the + // closure value would already be stale. Picking a channel from the + // all-channels browse list adopts it as a pinned space (a permanent dot), + // not just a transient visit. + const wasBrowsing = useSpaceStore.getState().browsing; + setCurrentChannel(routeChannelId); + if (wasBrowsing) { + const channel = channels.find((c) => c.id === routeChannelId); + if ( + channel && + channel.name !== PERSONAL_CHANNEL_NAME && + !starredRefToShortcutId.has(channel.path) + ) { + star(channel).catch(() => { + // Non-fatal: the space still opens, it just isn't pinned. + }); + } + } + }, [ + routeChannelId, + setCurrentChannel, + channels, + starredRefToShortcutId, + star, + ]); const inSpace = channelsEnabled && currentChannelId != null && !browsing && !draftSpace; @@ -132,7 +165,6 @@ export function ChannelsSidebar() { // The personal "#me" space is the default: on first load with nothing // scoped, land there. Once any space has been current (including via the // route), never auto-scope again, so the pickers can't be hijacked. - const { channels } = useChannels({ enabled: channelsEnabled }); const autoScopedRef = useRef(false); useEffect(() => { if (currentChannelId) autoScopedRef.current = true; diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.tsx index 7e821bd3a2..e5d456407b 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.tsx @@ -7,7 +7,6 @@ import { PlusCircleIcon, RobotIcon, TrashIcon, - UserIcon, XCircleIcon, XIcon, } from "@phosphor-icons/react"; @@ -334,39 +333,83 @@ export function ThreadArtifactRow({ ); } -// A condensed history row in the Activity timeline (task created, user -// message, run finished, …) — the "what happened" events the mockup asks for, -// derived from the task and its session rather than the human thread. -function ActivityEventRow({ - icon, +// A compact lifecycle marker in the Activity timeline (task created / run +// finished): a small leading glyph — a person's avatar or a status icon — then +// a muted label and the time. Deliberately lighter than a full message row so +// the timeline reads as events, not a wall of chunky avatars. +function LifecycleMarker({ + leading, label, - detail, timestamp, +}: { + leading: ReactNode; + label: ReactNode; + timestamp: string; +}) { + return ( +
+ + {leading} + + + {label} + + +
+ ); +} + +// The small circular status glyph for lifecycle markers with no person. +function StatusGlyph({ + icon, + tone, }: { icon: ReactNode; - label: string; - detail?: string | null; + tone: "muted" | "success" | "danger"; +}) { + const toneClass = + tone === "success" + ? "text-green-9" + : tone === "danger" + ? "text-red-9" + : "text-gray-10"; + return ( + + {icon} + + ); +} + +// A user message sent to the agent — a real message row with the author's +// avatar, matching the human comment rows so the two read alike. +function UserMessageRow({ + author, + content, + timestamp, +}: { + author?: UserBasic | null; + content: string; timestamp: string; }) { return ( - - {icon} - + - {label} + + {author ? userDisplayName(author) : "You"} + - {detail && ( - - - {detail} - - - )} + + + {content} + + ); @@ -770,12 +813,22 @@ function ThreadConversation({ key: "task-created", ts: createdTs, node: ( - } - label={`${ - task.created_by ? userDisplayName(task.created_by) : "Someone" - } created this task`} - detail={task.title || null} + + ) : ( + } tone="muted" /> + ) + } + label={ + <> + + {task.created_by ? userDisplayName(task.created_by) : "Someone"} + {" "} + created this task + + } timestamp={task.created_at} /> ), @@ -787,10 +840,11 @@ function ThreadConversation({ key: `user-message-${item.id}`, ts: item.timestamp, node: ( - } - label="User message" - detail={item.content} + // The session carries no per-message author; the task owner drives + // it, so attribute their avatar rather than a generic glyph. + ), @@ -845,17 +899,23 @@ function ThreadConversation({ const runStatus = task.latest_run?.status; if (runStatus && isTerminalStatus(runStatus)) { + const succeeded = runStatus === "completed"; nodes.push({ key: "run-status", ts: updatedTs + 1, node: ( - - ) : ( - - ) + + ) : ( + + ) + } + tone={succeeded ? "success" : "danger"} + /> } label={`Task ${runStatus.replace(/_/g, " ")}`} timestamp={task.updated_at} diff --git a/packages/ui/src/features/canvas/hooks/useSpaces.ts b/packages/ui/src/features/canvas/hooks/useSpaces.ts index 066534f72b..032f84bc51 100644 --- a/packages/ui/src/features/canvas/hooks/useSpaces.ts +++ b/packages/ui/src/features/canvas/hooks/useSpaces.ts @@ -81,19 +81,27 @@ export function useSpaces(): { // Swipe tuning. macOS "switch desktop" semantics: one swipe = one space, // regardless of speed. A trackpad swipe is a burst of wheel events (fingers + -// inertia tail); the whole burst must move exactly one space. +// a long, decaying inertia tail); the whole burst must move exactly one space. // -// The gate: fire once when a horizontal swipe crosses the threshold, then lock -// until the wheel goes QUIET for END_GAP_MS. Inertia streams events with small -// gaps, so it keeps the lock alive and can never fire again; the lock only -// releases after the fling (fingers + momentum) has fully stopped. A fresh -// swipe then re-arms. No speed dependence, no re-arm heuristics. -const END_GAP_MS = 250; +// Two guards together make a single fire impossible to beat, without trying to +// classify inertia vs. intent (which timing alone can't do reliably): +// 1. QUIET_GAP — after firing, stay locked until the wheel is silent this +// long. A continuous inertia tail never goes silent, so it stays locked. +// 2. MIN_FIRE_INTERVAL — a hard floor between two switches. Even if the tail +// *does* have a gap longer than QUIET_GAP (some trackpads emit sparse +// late-inertia events), a second switch can't fire until this has passed, +// and by then the tail's velocity is spent — with the accumulator reset +// on every fire, the dregs can't re-reach the threshold. +// The cost is that two *deliberate* swipes need ~half a second between them, +// which is fine for discrete space switching and the price of never over-shooting. +const QUIET_GAP_MS = 400; +const MIN_FIRE_INTERVAL_MS = 500; // Horizontal travel within one gesture that triggers the switch. const FIRE_PX = 40; interface SwipeGate { lastEventAt: number; + firedAt: number; accumX: number; locked: boolean; } @@ -108,6 +116,7 @@ export function useSpaceSwipe( const { cycle } = useSpaces(); const gate = useRef({ lastEventAt: 0, + firedAt: 0, accumX: 0, locked: false, }); @@ -117,25 +126,33 @@ export function useSpaceSwipe( if (!enabled) return; const now = Date.now(); const g = gate.current; + const gap = now - g.lastEventAt; + g.lastEventAt = now; - // A quiet stretch ends the previous gesture (fingers + inertia have - // stopped), so the next event starts fresh and re-arms. - if (now - g.lastEventAt > END_GAP_MS) { + if (g.locked) { + // Release only once the wheel has gone quiet AND the hard floor since + // the last switch has passed — inertia can satisfy neither. + if (gap > QUIET_GAP_MS && now - g.firedAt > MIN_FIRE_INTERVAL_MS) { + g.locked = false; + g.accumX = 0; + } else { + return; + } + } else if (gap > QUIET_GAP_MS) { + // Fresh gesture after a real pause. g.accumX = 0; - g.locked = false; } - g.lastEventAt = now; - - // Already fired this gesture — swallow the rest of the burst (inertia). - if (g.locked) return; // Only horizontal-dominant events count; vertical scrolling is ignored. if (Math.abs(event.deltaX) <= Math.abs(event.deltaY)) return; g.accumX += event.deltaX; if (Math.abs(g.accumX) >= FIRE_PX) { + const direction = g.accumX > 0 ? 1 : -1; g.locked = true; - cycle(g.accumX > 0 ? 1 : -1); + g.firedAt = now; + g.accumX = 0; + cycle(direction); } }, [enabled, cycle], From f84e59b0376c87e5384a5b3431d71e16ff543951 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 25 Jul 2026 15:44:33 +0200 Subject: [PATCH 17/57] fix(canvas): all-channels is a preview directory, not a pin/space action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking a channel in the "#" all-channels list now just shows that channel's activity in the main view — it no longer scopes the sidebar into a space or stars/pins the channel. The browse directory stays open in the sidebar while you preview channels; only non-browse navigation (a dot, deep link, in-space nav) adopts a channel as the current space. Closing the directory ("#" again) returns to the current space (or the landing) so the preview leaves no trace. Channels still become spaces only via "+" (or #me). Generated-By: PostHog Code Task-Id: c401c7cb-e436-4da3-80bf-1d3008ee5d38 --- docs/plans/spaces-layout-prototype.md | 2 +- .../canvas/components/ChannelsSidebar.tsx | 48 +++++-------------- .../features/canvas/components/SpaceDots.tsx | 22 ++++++++- 3 files changed, 33 insertions(+), 39 deletions(-) diff --git a/docs/plans/spaces-layout-prototype.md b/docs/plans/spaces-layout-prototype.md index ff92bb3edc..6fdca2daaa 100644 --- a/docs/plans/spaces-layout-prototype.md +++ b/docs/plans/spaces-layout-prototype.md @@ -117,6 +117,6 @@ Replace the docked `ThreadPanel` (in spaces mode) with an `ActivityPanel` in the ## How to try it 1. Enable the **Channels** alpha switch in the sidebar (Bluebird flag is default-on in dev). You land in your personal `#me` space — it is always the first item in the dot row. -2. In the dot row: `#` toggles the sidebar body into the all-channels list, with no nav chrome (pick one to open it as a space); `+` opens a draft new space, Arc-style — a hollow dot appears and the sidebar becomes a searchable chooser: attach an existing channel (stars it, so the dot persists) or create a new one. New spaces always append on the right; `#me` is always the leftmost space. +2. In the dot row: `#` toggles the sidebar body into the all-channels list — a preview directory: clicking a channel shows its activity in the main view but does NOT scope to it or pin it, and closing the directory returns you to your current space. `+` opens a draft new space, Arc-style — a hollow dot appears and the sidebar becomes a searchable chooser: attach an existing channel (stars it, so the dot persists) or create a new one. New spaces always append on the right; `#me` is always the leftmost space. 3. Switch spaces by clicking dots, swiping horizontally anywhere on the sidebar (macOS-Spaces semantics: one swipe = one space, speed-independent — fire-once-then-lock-until-quiet gate), or `Ctrl+Alt+←/→`. Right-click a dot to set an emoji for the space or remove it. The Recent section has search and filters (created by me/others, status) like the old task list header; rows show the relative time and swap to pin/archive actions on hover; hovering shows a preview card with kind, status badge, author avatar, and last update. 4. Open a task in the space: the right Activity panel has Timeline / Artifacts / Comments; clicking a PR artifact opens its diff. The `+` on the task tab strip shows the new chat/terminal/canvas menu. diff --git a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx index 23613b9af3..b781793d2e 100644 --- a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx @@ -8,10 +8,6 @@ import { useChannelsSidebarStore } from "@posthog/ui/features/canvas/components/ import { NewSpaceDraft } from "@posthog/ui/features/canvas/components/NewSpaceDraft"; import { SpaceDots } from "@posthog/ui/features/canvas/components/SpaceDots"; import { SpaceSidebar } from "@posthog/ui/features/canvas/components/SpaceSidebar"; -import { - useChannelStarMutations, - useChannelStars, -} from "@posthog/ui/features/canvas/hooks/useChannelStars"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useSpaceSwipe } from "@posthog/ui/features/canvas/hooks/useSpaces"; import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; @@ -112,44 +108,21 @@ export function ChannelsSidebar() { const browsing = useSpaceStore((s) => s.browsing); const draftSpace = useSpaceStore((s) => s.draftSpace); const { channels } = useChannels({ enabled: channelsEnabled }); - const { starredRefToShortcutId } = useChannelStars({ - enabled: channelsEnabled, - }); - const { star } = useChannelStarMutations(); useEffect(() => { if (!routeChannelId) return; - // Read the store fresh: setCurrentChannel clears `browsing` below, so the - // closure value would already be stale. Picking a channel from the - // all-channels browse list adopts it as a pinned space (a permanent dot), - // not just a transient visit. - const wasBrowsing = useSpaceStore.getState().browsing; + // Browsing the all-channels list is a preview: clicking a channel shows its + // activity in the main view but keeps the directory open in the sidebar and + // does NOT scope to (or pin) it. Only non-browse navigation (a dot, a deep + // link, in-space nav) adopts a channel as the current space. + if (useSpaceStore.getState().browsing) return; setCurrentChannel(routeChannelId); - if (wasBrowsing) { - const channel = channels.find((c) => c.id === routeChannelId); - if ( - channel && - channel.name !== PERSONAL_CHANNEL_NAME && - !starredRefToShortcutId.has(channel.path) - ) { - star(channel).catch(() => { - // Non-fatal: the space still opens, it just isn't pinned. - }); - } - } - }, [ - routeChannelId, - setCurrentChannel, - channels, - starredRefToShortcutId, - star, - ]); + }, [routeChannelId, setCurrentChannel]); const inSpace = channelsEnabled && currentChannelId != null && !browsing && !draftSpace; - // Any navigation while a picker override is open dismisses it — the user - // moved somewhere, so the picker's moment has passed. This also covers - // re-selecting the channel you're already in (the route doesn't change, so - // the sync effect above never fires). + // Leaving the channels world (navigating to a non-channel route) closes the + // pickers. Previewing a channel from the browse list keeps the directory + // open, so a navigation that lands on a channel route is left alone. const historyIndex = useRouterState({ select: (s) => s.location.state.__TSR_index, }); @@ -157,10 +130,11 @@ export function ChannelsSidebar() { useEffect(() => { if (prevHistoryIndexRef.current === historyIndex) return; prevHistoryIndexRef.current = historyIndex; + if (routeChannelId) return; const state = useSpaceStore.getState(); if (state.browsing) state.setBrowsing(false); if (state.draftSpace) state.setDraftSpace(false); - }, [historyIndex]); + }, [historyIndex, routeChannelId]); // The personal "#me" space is the default: on first load with nothing // scoped, land there. Once any space has been current (including via the diff --git a/packages/ui/src/features/canvas/components/SpaceDots.tsx b/packages/ui/src/features/canvas/components/SpaceDots.tsx index 8c0fbb878f..4c0793f210 100644 --- a/packages/ui/src/features/canvas/components/SpaceDots.tsx +++ b/packages/ui/src/features/canvas/components/SpaceDots.tsx @@ -27,6 +27,7 @@ import { useIsChannelUnread } from "@posthog/ui/features/canvas/hooks/useUnreadC import { useSpaceEmojiStore } from "@posthog/ui/features/canvas/stores/spaceEmojiStore"; import { useSpaceStore } from "@posthog/ui/features/canvas/stores/spaceStore"; import { toast } from "@posthog/ui/primitives/toast"; +import { useNavigate } from "@tanstack/react-router"; import { motion } from "framer-motion"; import { useEffect } from "react"; @@ -188,6 +189,7 @@ function SpaceDot({ * swipes cycle spaces. */ export function SpaceDots() { + const navigate = useNavigate(); const { spaces, currentChannelId, switchTo, cycle } = useSpaces(); const { channels } = useChannels(); const { createChannel } = useChannelMutations(); @@ -215,6 +217,24 @@ export function SpaceDots() { const hasMe = spaces.some((c) => c.name === PERSONAL_CHANNEL_NAME); const overrideOpen = browsing || draftSpace; + // The "#" directory is a preview: while it's open the sidebar lists every + // channel and clicking one just shows its activity in the main view. Closing + // it returns to the current space (or the landing) so the preview leaves no + // trace — no space scoped, nothing pinned. + const toggleBrowsing = () => { + if (!browsing) { + setBrowsing(true); + return; + } + const current = channels.find((c) => c.id === currentChannelId); + if (current) { + switchTo(current); + } else { + setBrowsing(false); + void navigate({ to: "/website" }); + } + }; + // Open (creating on first use) the personal space — only needed while the // "#me" channel doesn't exist yet; afterwards it's a regular dot. const openPersonalSpace = () => { @@ -239,7 +259,7 @@ export function SpaceDots() { type="button" aria-label="All channels" aria-pressed={browsing} - onClick={() => setBrowsing(!browsing)} + onClick={toggleBrowsing} className={cn( RAIL_BUTTON_CLASS, browsing && "bg-gray-3 text-gray-12", From f53bee255e21d9049dc19e457f11108278922809 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 25 Jul 2026 15:44:34 +0200 Subject: [PATCH 18/57] fix(canvas): unify Activity timeline rows to one shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "created" and "completed/failed" lifecycle rows used a compact small-glyph marker while messages and the PR row used the full ThreadItem avatar layout, so the timeline looked mixed. All rows now use the same ThreadItem shape: a large gutter avatar (the person's real avatar for "created", a neutral status-icon avatar for terminal states — the same avatar the artifact rows use) plus a header with the title, a muted action suffix, and the timestamp. Every row in the timeline now reads alike. Generated-By: PostHog Code Task-Id: c401c7cb-e436-4da3-80bf-1d3008ee5d38 --- .../canvas/components/ThreadPanel.tsx | 112 ++++++++---------- 1 file changed, 52 insertions(+), 60 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.tsx index e5d456407b..9b74e6f143 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.tsx @@ -4,7 +4,6 @@ import { CheckCircleIcon, DotsThreeIcon, PaperPlaneRightIcon, - PlusCircleIcon, RobotIcon, TrashIcon, XCircleIcon, @@ -333,52 +332,44 @@ export function ThreadArtifactRow({ ); } -// A compact lifecycle marker in the Activity timeline (task created / run -// finished): a small leading glyph — a person's avatar or a status icon — then -// a muted label and the time. Deliberately lighter than a full message row so -// the timeline reads as events, not a wall of chunky avatars. -function LifecycleMarker({ - leading, - label, +// A lifecycle event in the Activity timeline (task created / run finished) — +// the same ThreadItem shape as message and artifact rows (gutter avatar + +// header) so every row in the timeline reads alike. `action` is the muted +// suffix after the bold title ("created this task"). +function ActivityEventRow({ + avatar, + title, + action, timestamp, }: { - leading: ReactNode; - label: ReactNode; + avatar: ReactNode; + title: string; + action?: string; timestamp: string; }) { return ( -
- - {leading} - - - {label} - - -
+ + {avatar} + + + {title} + {action && ( + {action} + )} + + + + ); } -// The small circular status glyph for lifecycle markers with no person. -function StatusGlyph({ - icon, - tone, -}: { - icon: ReactNode; - tone: "muted" | "success" | "danger"; -}) { - const toneClass = - tone === "success" - ? "text-green-9" - : tone === "danger" - ? "text-red-9" - : "text-gray-10"; +// The gutter avatar for a system lifecycle event (no person): an icon on the +// same neutral avatar the artifact rows use, so its size matches every row. +function SystemAvatar({ icon }: { icon: ReactNode }) { return ( - - {icon} - + + {icon} + ); } @@ -813,22 +804,16 @@ function ThreadConversation({ key: "task-created", ts: createdTs, node: ( - - ) : ( - } tone="muted" /> - ) - } - label={ - <> - - {task.created_by ? userDisplayName(task.created_by) : "Someone"} - {" "} - created this task - + } + title={task.created_by ? userDisplayName(task.created_by) : "Someone"} + action="created this task" timestamp={task.created_at} /> ), @@ -904,20 +889,27 @@ function ThreadConversation({ key: "run-status", ts: updatedTs + 1, node: ( - + ) : ( - + ) } - tone={succeeded ? "success" : "danger"} /> } - label={`Task ${runStatus.replace(/_/g, " ")}`} + title={`Task ${runStatus.replace(/_/g, " ")}`} timestamp={task.updated_at} /> ), From af4d86a7a03f791b7cdb8f34f8925db2d1b72dec Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 25 Jul 2026 15:44:35 +0200 Subject: [PATCH 19/57] feat(canvas): collect run-uploaded files in the task Artifacts tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Artifacts tab already gathered the PR (from thread pr_created events / run output — the same PR shown in the Timeline), canvases, and the Slack thread. Add the files the agent uploads during the run (plans, outputs, references, skill bundles) — read defensively off the run JSON since they aren't on the typed TaskRun — each opening via a freshly presigned URL. User attachments (task inputs, not generated output) are skipped. So the tab now collects everything produced during the session. Generated-By: PostHog Code Task-Id: c401c7cb-e436-4da3-80bf-1d3008ee5d38 --- .../canvas/components/TaskArtifactsList.tsx | 118 +++++++++++++++++- 1 file changed, 115 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/features/canvas/components/TaskArtifactsList.tsx b/packages/ui/src/features/canvas/components/TaskArtifactsList.tsx index 2668c9814d..7c28740f6c 100644 --- a/packages/ui/src/features/canvas/components/TaskArtifactsList.tsx +++ b/packages/ui/src/features/canvas/components/TaskArtifactsList.tsx @@ -1,5 +1,6 @@ import { ArrowSquareOutIcon, + FileTextIcon, PackageIcon, SlackLogoIcon, } from "@phosphor-icons/react"; @@ -16,12 +17,14 @@ import { EmptyTitle, } from "@posthog/quill"; import type { Task, TaskThreadMessage } from "@posthog/shared/domain-types"; +import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon"; import { useReviewNavigationStore } from "@posthog/ui/features/code-review/reviewNavigationStore"; import { getPrVisualIcon } from "@posthog/ui/features/git-interaction/prIcon"; import { usePrDetails } from "@posthog/ui/features/git-interaction/usePrDetails"; import { usePrComments } from "@posthog/ui/features/pr-review/usePrComments"; import { usePrReviewThreads } from "@posthog/ui/features/pr-review/usePrReviewThreads"; +import { toast } from "@posthog/ui/primitives/toast"; import { openExternalUrl } from "@posthog/ui/shell/openExternal"; import { parseShareLink } from "@posthog/ui/utils/posthogLinks"; import { navigateToShareTarget } from "@posthog/ui/utils/shareLinks"; @@ -31,8 +34,47 @@ import { type ReactNode, useMemo } from "react"; type ArtifactRow = | { kind: "pr"; key: string; url: string } | { kind: "canvas"; key: string; name: string; url: string | null } + | { + kind: "file"; + key: string; + name: string; + typeLabel: string; + storagePath: string | null; + runId: string | null; + } | { kind: "slack"; key: string; url: string }; +// Human labels for the artifact types the agent uploads during a run. +const ARTIFACT_TYPE_LABELS: Record = { + plan: "Plan", + context: "Context", + reference: "Reference", + output: "Output", + artifact: "File", + skill_bundle: "Skill", +}; + +interface RunArtifact { + id?: string; + name?: string; + type?: string; + storage_path?: string; +} + +// Run-uploaded artifacts live on the run JSON but aren't in the typed TaskRun, +// so read them past the type. Skip user attachments (those are task inputs, +// not things generated during the session). +function readRunArtifacts(task: Task): RunArtifact[] { + const run = task.latest_run as { artifacts?: unknown } | undefined; + if (!run || !Array.isArray(run.artifacts)) return []; + return (run.artifacts as RunArtifact[]).filter( + (artifact) => + artifact && + typeof artifact.name === "string" && + artifact.type !== "user_attachment", + ); +} + function parseHttpsUrl(url: string): URL | null { try { const parsed = new URL(url); @@ -43,9 +85,10 @@ function parseHttpsUrl(url: string): URL | null { } /** - * Everything this task produced or is anchored to, derived with no new - * backend: PRs and canvases the agent announced on the task thread, the run - * output's PR as a fallback, and the originating Slack thread (external link). + * Everything this task produced or is anchored to during the session: PRs and + * canvases the agent announced on the task thread, the run output's PR as a + * fallback, the files the agent uploaded on the run, and the originating Slack + * thread (external link). */ function buildRows( task: Task, @@ -75,6 +118,21 @@ function buildRows( rows.push({ kind: "pr", key: `output-pr:${outputPr}`, url: outputPr }); } + // Files the agent uploaded during the run (plans, outputs, references, …). + const runId = task.latest_run?.id ?? null; + for (const artifact of readRunArtifacts(task)) { + rows.push({ + kind: "file", + key: `artifact:${artifact.id ?? artifact.storage_path ?? artifact.name}`, + name: artifact.name ?? "Artifact", + typeLabel: artifact.type + ? (ARTIFACT_TYPE_LABELS[artifact.type] ?? "File") + : "File", + storagePath: artifact.storage_path ?? null, + runId, + }); + } + const slackUrl = task.latest_run?.state?.slack_thread_url; if (typeof slackUrl === "string" && slackUrl) { rows.push({ kind: "slack", key: "slack-thread", url: slackUrl }); @@ -188,6 +246,51 @@ function CanvasRow({ name, url }: { name: string; url: string | null }) { ); } +// A file the agent uploaded during the run. Clicking presigns a fresh URL and +// opens it (download) — the presign endpoint needs the task + run id + path. +function FileRow({ + taskId, + runId, + name, + typeLabel, + storagePath, +}: { + taskId: string; + runId: string | null; + name: string; + typeLabel: string; + storagePath: string | null; +}) { + const client = useOptionalAuthenticatedClient(); + const canOpen = !!client && !!runId && !!storagePath; + const onOpen = canOpen + ? () => { + client + .presignTaskRunArtifact( + taskId, + runId as string, + storagePath as string, + ) + .then((url) => openExternalUrl(url)) + .catch((error: unknown) => { + toast.error("Couldn't open artifact", { + description: + error instanceof Error ? error.message : String(error), + }); + }); + } + : undefined; + return ( + } + title={name} + detail={typeLabel} + external={canOpen} + onOpen={onOpen} + /> + ); +} + export function TaskArtifactsList({ task, timeline, @@ -221,6 +324,15 @@ export function TaskArtifactsList({ ) : row.kind === "canvas" ? ( + ) : row.kind === "file" ? ( + ) : ( Date: Sat, 25 Jul 2026 15:44:37 +0200 Subject: [PATCH 20/57] feat(canvas): timeline rail (direction A) + curate Artifacts to PRs/canvases/plans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Timeline (direction A): lifecycle events (created / run finished) are now slim nodes sitting on a continuous vertical rail down the avatar column, while messages/comments/artifacts stay full avatar rows hanging off the same rail — so the bodyless events no longer look like empty chunky rows. Timestamps are right-aligned on every row. Artifacts (critical): stop listing internal upload blobs (the UUID-named .pack/skill/output files). The tab now shows a curated set gathered across ALL runs of the task — PRs (thread events + each run's output PR), canvases, and plans (run-uploaded artifacts of type "plan"), plus the originating Slack thread. Adds useTaskRuns to fetch every run; plan names that are raw UUIDs fall back to "Plan". Generated-By: PostHog Code Task-Id: c401c7cb-e436-4da3-80bf-1d3008ee5d38 --- docs/plans/spaces-layout-prototype.md | 2 +- .../canvas/components/TaskArtifactsList.tsx | 135 ++++++++++-------- .../canvas/components/ThreadPanel.tsx | 103 ++++++++----- .../src/features/canvas/hooks/useTaskRuns.ts | 24 ++++ 4 files changed, 163 insertions(+), 101 deletions(-) create mode 100644 packages/ui/src/features/canvas/hooks/useTaskRuns.ts diff --git a/docs/plans/spaces-layout-prototype.md b/docs/plans/spaces-layout-prototype.md index 6fdca2daaa..4cd208093f 100644 --- a/docs/plans/spaces-layout-prototype.md +++ b/docs/plans/spaces-layout-prototype.md @@ -89,7 +89,7 @@ Replace the docked `ThreadPanel` (in spaces mode) with an `ActivityPanel` in the **Shell.** With Channels on: the all-channels list is the landing; entering a channel scopes the sidebar to that space (`SpaceSidebar.tsx`: New task, `#channel` header, Context/Loops/Artifacts rows, Pinned, Recent). `SpaceDots.tsx` renders the Arc-style dot row above the account footer (starred channels = spaces, `#` = back to landing, `+` = create channel); switching slides the sidebar in the travel direction (framer-motion), horizontal trackpad swipe and `Ctrl+Alt+←/→` cycle spaces. The current space persists across channel-less routes (inbox, activity) via `stores/spaceStore.ts`. Title bar (`SpacesTitleBar.tsx` + `__root.tsx`): the browser-tab strip is replaced by a centered search pill (opens the ⌘K palette) plus Activity-bell (unread-mention dot) and Inbox icons. -**Activity panel.** `ThreadPanel.tsx` is now the Activity panel: header renamed, with Timeline / Artifacts / Comments tabs. Timeline = the task's full history: creation, the user messages sent to the agent (from the session), the thread's human comments and artifact announcements, the run-output PR, and the run's terminal status. Comments = the human rows of the thread + the existing composer; Artifacts = `TaskArtifactsList.tsx`, derived with no new backend (thread `pr_created`/`canvas_created` events + run output PR + `slack_thread_url`), with live PR state + comment counts from GitHub. PR rows open the diff in the review pane; canvas rows open the canvas; Slack opens externally. +**Activity panel.** `ThreadPanel.tsx` is now the Activity panel: header renamed, with Timeline / Artifacts / Comments tabs. Timeline = the task's full history on a GitHub/Linear-style **rail**: creation and run-status are slim event nodes on the spine; user messages, human comments, and artifact announcements are full avatar rows hanging off it. Timestamps are right-aligned on every row. Comments = the human rows of the thread + the existing composer. Artifacts (`TaskArtifactsList.tsx`) = a curated set gathered across **all runs** of the task: PRs (thread `pr_created` + each run's output PR, with live state + comment counts), canvases (`canvas_created`), plans (run-uploaded artifacts of type `plan`), and the originating Slack thread. Internal upload blobs (skill packs, raw outputs with UUID names) are deliberately excluded. **Task tabs.** The panel tab strip's `+` is a dropdown: New terminal (works), New chat / New canvas disabled with a "Soon" hint (`TabbedPanel.tsx`) — per the mockup's "only what's available" note. diff --git a/packages/ui/src/features/canvas/components/TaskArtifactsList.tsx b/packages/ui/src/features/canvas/components/TaskArtifactsList.tsx index 7c28740f6c..aab524f679 100644 --- a/packages/ui/src/features/canvas/components/TaskArtifactsList.tsx +++ b/packages/ui/src/features/canvas/components/TaskArtifactsList.tsx @@ -1,6 +1,6 @@ import { ArrowSquareOutIcon, - FileTextIcon, + ClipboardTextIcon, PackageIcon, SlackLogoIcon, } from "@phosphor-icons/react"; @@ -16,9 +16,14 @@ import { EmptyMedia, EmptyTitle, } from "@posthog/quill"; -import type { Task, TaskThreadMessage } from "@posthog/shared/domain-types"; +import type { + Task, + TaskRun, + TaskThreadMessage, +} from "@posthog/shared/domain-types"; import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon"; +import { useTaskRuns } from "@posthog/ui/features/canvas/hooks/useTaskRuns"; import { useReviewNavigationStore } from "@posthog/ui/features/code-review/reviewNavigationStore"; import { getPrVisualIcon } from "@posthog/ui/features/git-interaction/prIcon"; import { usePrDetails } from "@posthog/ui/features/git-interaction/usePrDetails"; @@ -35,25 +40,14 @@ type ArtifactRow = | { kind: "pr"; key: string; url: string } | { kind: "canvas"; key: string; name: string; url: string | null } | { - kind: "file"; + kind: "plan"; key: string; name: string; - typeLabel: string; storagePath: string | null; runId: string | null; } | { kind: "slack"; key: string; url: string }; -// Human labels for the artifact types the agent uploads during a run. -const ARTIFACT_TYPE_LABELS: Record = { - plan: "Plan", - context: "Context", - reference: "Reference", - output: "Output", - artifact: "File", - skill_bundle: "Skill", -}; - interface RunArtifact { id?: string; name?: string; @@ -61,20 +55,26 @@ interface RunArtifact { storage_path?: string; } -// Run-uploaded artifacts live on the run JSON but aren't in the typed TaskRun, -// so read them past the type. Skip user attachments (those are task inputs, -// not things generated during the session). -function readRunArtifacts(task: Task): RunArtifact[] { - const run = task.latest_run as { artifacts?: unknown } | undefined; - if (!run || !Array.isArray(run.artifacts)) return []; - return (run.artifacts as RunArtifact[]).filter( - (artifact) => - artifact && - typeof artifact.name === "string" && - artifact.type !== "user_attachment", +// A run's uploaded artifacts live on the run JSON but aren't in the typed +// TaskRun, so read them past the type. Only PLANS are surfaced — the other +// types are internal blobs (skill packs, raw outputs) with opaque UUID names +// that don't belong in a human artifacts list. +function readRunPlans(run: TaskRun): RunArtifact[] { + const raw = (run as { artifacts?: unknown }).artifacts; + if (!Array.isArray(raw)) return []; + return (raw as RunArtifact[]).filter( + (artifact) => artifact && artifact.type === "plan", ); } +// UUID-ish storage names make poor titles; fall back to a friendly label. +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i; +function planTitle(name: string | undefined): string { + if (!name || UUID_RE.test(name)) return "Plan"; + return name; +} + function parseHttpsUrl(url: string): URL | null { try { const parsed = new URL(url); @@ -85,24 +85,31 @@ function parseHttpsUrl(url: string): URL | null { } /** - * Everything this task produced or is anchored to during the session: PRs and - * canvases the agent announced on the task thread, the run output's PR as a - * fallback, the files the agent uploaded on the run, and the originating Slack - * thread (external link). + * A task's artifacts, gathered across ALL of its runs: the PRs and canvases the + * agent announced on the task thread, every run's output PR, the plans the + * agent produced, and the originating Slack thread. Deliberately curated — + * internal upload blobs (skill packs, raw outputs) are excluded. */ function buildRows( task: Task, timeline: ThreadTimelineRow[], + runs: TaskRun[], ): ArtifactRow[] { const rows: ArtifactRow[] = []; const seenPrUrls = new Set(); + const seenPlanKeys = new Set(); + const addPr = (url: string, key: string) => { + if (seenPrUrls.has(url)) return; + seenPrUrls.add(url); + rows.push({ kind: "pr", key, url }); + }; + + // Canvases + PRs the agent announced on the (task-level) thread. for (const row of timeline) { if (row.kind !== "artifact") continue; if (row.artifact.kind === "pr") { - if (seenPrUrls.has(row.artifact.url)) continue; - seenPrUrls.add(row.artifact.url); - rows.push({ kind: "pr", key: row.message.id, url: row.artifact.url }); + addPr(row.artifact.url, row.message.id); } else { rows.push({ kind: "canvas", @@ -113,24 +120,27 @@ function buildRows( } } - const outputPr = task.latest_run?.output?.pr_url; - if (typeof outputPr === "string" && outputPr && !seenPrUrls.has(outputPr)) { - rows.push({ kind: "pr", key: `output-pr:${outputPr}`, url: outputPr }); - } - - // Files the agent uploaded during the run (plans, outputs, references, …). - const runId = task.latest_run?.id ?? null; - for (const artifact of readRunArtifacts(task)) { - rows.push({ - kind: "file", - key: `artifact:${artifact.id ?? artifact.storage_path ?? artifact.name}`, - name: artifact.name ?? "Artifact", - typeLabel: artifact.type - ? (ARTIFACT_TYPE_LABELS[artifact.type] ?? "File") - : "File", - storagePath: artifact.storage_path ?? null, - runId, - }); + // PRs and plans from every run of the task (fall back to latest_run while + // the runs list is still loading). + const allRuns = + runs.length > 0 ? runs : task.latest_run ? [task.latest_run] : []; + for (const run of allRuns) { + const outputPr = run.output?.pr_url; + if (typeof outputPr === "string" && outputPr) { + addPr(outputPr, `output-pr:${outputPr}`); + } + for (const plan of readRunPlans(run)) { + const key = `plan:${plan.id ?? plan.storage_path ?? plan.name}`; + if (seenPlanKeys.has(key)) continue; + seenPlanKeys.add(key); + rows.push({ + kind: "plan", + key, + name: planTitle(plan.name), + storagePath: plan.storage_path ?? null, + runId: run.id, + }); + } } const slackUrl = task.latest_run?.state?.slack_thread_url; @@ -246,19 +256,17 @@ function CanvasRow({ name, url }: { name: string; url: string | null }) { ); } -// A file the agent uploaded during the run. Clicking presigns a fresh URL and -// opens it (download) — the presign endpoint needs the task + run id + path. -function FileRow({ +// A plan the agent produced during a run. Clicking presigns a fresh URL and +// opens it — the presign endpoint needs the task + run id + storage path. +function PlanRow({ taskId, runId, name, - typeLabel, storagePath, }: { taskId: string; runId: string | null; name: string; - typeLabel: string; storagePath: string | null; }) { const client = useOptionalAuthenticatedClient(); @@ -273,7 +281,7 @@ function FileRow({ ) .then((url) => openExternalUrl(url)) .catch((error: unknown) => { - toast.error("Couldn't open artifact", { + toast.error("Couldn't open plan", { description: error instanceof Error ? error.message : String(error), }); @@ -282,9 +290,9 @@ function FileRow({ : undefined; return ( } + icon={} title={name} - detail={typeLabel} + detail="Plan" external={canOpen} onOpen={onOpen} /> @@ -298,7 +306,11 @@ export function TaskArtifactsList({ task: Task; timeline: ThreadTimelineRow[]; }) { - const rows = useMemo(() => buildRows(task, timeline), [task, timeline]); + const { runs } = useTaskRuns(task.id); + const rows = useMemo( + () => buildRows(task, timeline, runs), + [task, timeline, runs], + ); if (rows.length === 0) { return ( @@ -324,13 +336,12 @@ export function TaskArtifactsList({ ) : row.kind === "canvas" ? ( - ) : row.kind === "file" ? ( - ) : ( diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.tsx index 9b74e6f143..18b7fc3f69 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.tsx @@ -127,7 +127,7 @@ export function ThreadMessageRow({ {userDisplayName(message.author)} - + {artifact.kind === "canvas" ? "Canvas" : "Pull request"} - + {artifact.kind === "canvas" ? ( @@ -333,43 +333,58 @@ export function ThreadArtifactRow({ } // A lifecycle event in the Activity timeline (task created / run finished) — -// the same ThreadItem shape as message and artifact rows (gutter avatar + -// header) so every row in the timeline reads alike. `action` is the muted -// suffix after the bold title ("created this task"). +// Timestamp pinned to the right edge of a row header. `ml-auto` pushes it past +// the flexible title; `self-baseline` keeps it on the author's baseline. +function RightTimestamp({ dateTime }: { dateTime: string }) { + return ( + + + + ); +} + +// The x-offset of the avatar column's center within a ThreadItem row: row +// padding-inline (0.5rem) + gutter width (2.5rem) − half the lg avatar +// (2.35rem). The timeline rail and event nodes sit on this line so a slim +// event row lines up with the avatars above and below it. +const RAIL_LEFT = "1.825rem"; + +// A slim lifecycle event on the timeline rail (task created / run finished): +// a small node sitting on the rail, a one-line label, and the time on the +// right. Deliberately lighter than a message row — GitHub/Linear-style — so +// events read as markers on the spine rather than empty chunky rows. function ActivityEventRow({ - avatar, + node, title, action, timestamp, }: { - avatar: ReactNode; + node: ReactNode; title: string; action?: string; timestamp: string; }) { return ( - - {avatar} - - - {title} - {action && ( - {action} - )} - - - - +
+
+
{node}
+
+ + {title} + {action && {action}} + + +
); } -// The gutter avatar for a system lifecycle event (no person): an icon on the -// same neutral avatar the artifact rows use, so its size matches every row. -function SystemAvatar({ icon }: { icon: ReactNode }) { +// A neutral chip node for a system event with no person (run finished). Opaque +// so it masks the rail behind it, reading as a node on the spine. +function EventNode({ icon }: { icon: ReactNode }) { return ( - - {icon} - + + {icon} + ); } @@ -394,7 +409,7 @@ function UserMessageRow({ {author ? userDisplayName(author) : "You"} - + @@ -805,11 +820,11 @@ function ThreadConversation({ ts: createdTs, node: ( } title={task.created_by ? userDisplayName(task.created_by) : "Someone"} @@ -890,18 +905,18 @@ function ThreadConversation({ ts: updatedTs + 1, node: ( ) : ( @@ -965,11 +980,23 @@ function ThreadConversation({ {!isReady ? ( ) : ( - - {activityNodes.map((entry) => ( - {entry.node} - ))} - +
+ {/* The timeline spine: a continuous rail down the avatar column + that every row's avatar/node sits on, so slim event rows and + chunky message rows read as one timeline. */} +
+
+ + {activityNodes.map((entry) => ( + {entry.node} + ))} + +
+
)}
)} diff --git a/packages/ui/src/features/canvas/hooks/useTaskRuns.ts b/packages/ui/src/features/canvas/hooks/useTaskRuns.ts new file mode 100644 index 0000000000..367c0b77a1 --- /dev/null +++ b/packages/ui/src/features/canvas/hooks/useTaskRuns.ts @@ -0,0 +1,24 @@ +import type { TaskRun } from "@posthog/shared/domain-types"; +import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; + +const TASK_RUNS_POLL_INTERVAL_MS = 30_000; + +/** + * Every run of a task, newest-or-oldest as the backend returns them. Used to + * gather artifacts (PRs, plans) produced across all of a task's runs, not just + * the latest one. + */ +export function useTaskRuns(taskId: string | undefined): { + runs: TaskRun[]; + isLoading: boolean; +} { + const query = useAuthenticatedQuery( + ["task-runs", taskId ?? "none"], + (client) => client.listTaskRuns(taskId as string), + { + enabled: !!taskId, + refetchInterval: TASK_RUNS_POLL_INTERVAL_MS, + }, + ); + return { runs: query.data ?? [], isLoading: query.isLoading }; +} From 642a132e22c0d7cc2d1f755b18be9c8dde06fb1e Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 25 Jul 2026 15:44:38 +0200 Subject: [PATCH 21/57] feat(canvas): move account menu to a compact avatar in the title bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The account/project/org menu no longer takes a full two-line card at the bottom of the sidebar in the spaces layout. ProjectSwitcher gains a "compact" variant — a single ~28px user avatar trigger — placed at the right end of the title bar next to the bell/inbox/PostHog Web. The dropdown body is unchanged (user header, project + org switchers, Discord/changelog/learn-more, settings, logout) but anchors to a fixed width and right-aligns under the avatar. The bottom card stays for the non-spaces Code layout. Frees the sidebar's bottom for the space's pinned/recent content. Generated-By: PostHog Code Task-Id: c401c7cb-e436-4da3-80bf-1d3008ee5d38 --- docs/plans/spaces-layout-prototype.md | 2 +- .../canvas/components/ChannelsSidebar.tsx | 13 ++-- .../sidebar/components/ProjectSwitcher.tsx | 68 +++++++++++++------ packages/ui/src/router/routes/__root.tsx | 4 ++ 4 files changed, 60 insertions(+), 27 deletions(-) diff --git a/docs/plans/spaces-layout-prototype.md b/docs/plans/spaces-layout-prototype.md index 4cd208093f..138d1a1ae3 100644 --- a/docs/plans/spaces-layout-prototype.md +++ b/docs/plans/spaces-layout-prototype.md @@ -54,7 +54,7 @@ The single biggest input to this plan: **most of the mockup's nouns already exis ### Shell & top bar - When `spacesLayout` is on (and inside `/website`), the title bar center swaps the browser-tab strip for a **search field** that opens the existing ⌘K `CommandMenu` on click/focus (no new search engine; the palette already finds tasks, channels, files, actions). -- Right side of the title bar gains two icon buttons: **Activity** (bell, unread-mention dot from `useUnreadChannels`/`activitySeenStore`) → navigates to `/website/activity`; **Inbox** → navigates to a new `/website/inbox` mirror route rendering the existing `InboxView` (same pattern as the existing `/website/activity|command-center|skills|mcp-servers` mirrors), so opening it doesn't eject you from the space. +- Right side of the title bar gains: **Activity** (bell, unread-mention dot from `useUnreadChannels`/`activitySeenStore`) → `/website/activity`; **Inbox** → a new `/website/inbox` mirror route rendering the existing `InboxView`; and the **account menu** as a compact avatar (`ProjectSwitcher variant="compact"`) — the same rich project/org/settings/logout menu, moved out of the sidebar's bottom card so that space is freed for the space's content (same pattern as the existing `/website/activity|command-center|skills|mcp-servers` mirrors), so opening it doesn't eject you from the space. - Open question tracked below: the mockup has no global tab strip (tabs move *inside* the task). The prototype hides `BrowserTabStrip` in spaces mode to match; if that feels bad we re-show it. ### Sidebar in space mode diff --git a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx index b781793d2e..af060635a2 100644 --- a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx @@ -248,11 +248,14 @@ export function ChannelsSidebar() { whenever channels are on so the landing shows the dot row too. */} {channelsEnabled && } - {/* Workspace switcher pinned to the bottom. Its dropdown carries the - Settings entry, so there's no separate Settings row. */} - - - + {/* Workspace switcher pinned to the bottom in the Code layout. In the + spaces layout it moves to the title bar (compact avatar) so the + sidebar's bottom is freed for the space's content. */} + {!channelsEnabled && ( + + + + )}
); diff --git a/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx b/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx index d129496db3..068639e74f 100644 --- a/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx +++ b/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx @@ -41,6 +41,7 @@ import { import { EXTERNAL_LINKS } from "@posthog/shared"; import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; import { useAuthStateValue } from "@posthog/ui/features/auth/store"; +import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar"; import { useLogoutMutation, useSelectProjectMutation, @@ -58,9 +59,20 @@ import { Avatar, Box } from "@radix-ui/themes"; import { ChevronRightIcon } from "lucide-react"; import { type ReactNode, useMemo, useState } from "react"; -// The two-line user/project card used at the bottom of the sidebar. -export function ProjectSwitcher() { +/** + * The account / project / org menu. Two triggers, same menu body: + * - "card" (default): the two-line user/project card at the bottom of the + * sidebar. + * - "compact": a single avatar button for the top bar, so the menu doesn't + * claim a whole sidebar row. + */ +export function ProjectSwitcher({ + variant = "card", +}: { + variant?: "card" | "compact"; +}) { const [popoverOpen, setPopoverOpen] = useState(false); + const compact = variant === "compact"; const holdPeek = useHoldSidebarPeek(); const handleOpenChange = (next: boolean): void => { @@ -178,30 +190,44 @@ export function ProjectSwitcher() { - - - {currentProject?.name ?? "No project selected"} - - - {currentUser?.email ?? "No email"} - - - - - - + compact ? ( + + ) : ( + + + + {currentProject?.name ?? "No project selected"} + + + {currentUser?.email ?? "No email"} + + + + + + + ) } /> diff --git a/packages/ui/src/router/routes/__root.tsx b/packages/ui/src/router/routes/__root.tsx index f45d5e5f92..d788c38744 100644 --- a/packages/ui/src/router/routes/__root.tsx +++ b/packages/ui/src/router/routes/__root.tsx @@ -46,6 +46,7 @@ import { useInboxDeepLink } from "@posthog/ui/features/inbox/hooks/useInboxDeepL import { useIntegrations } from "@posthog/ui/features/integrations/useIntegrations"; import { useScoutDeepLink } from "@posthog/ui/features/scouts/hooks/useScoutDeepLink"; import { useSetupDiscovery } from "@posthog/ui/features/setup/useSetupDiscovery"; +import { ProjectSwitcher } from "@posthog/ui/features/sidebar/components/ProjectSwitcher"; import { beginSidebarPeek, cancelSidebarPeek, @@ -436,6 +437,9 @@ function RootLayout() { PostHog Web )} + {/* Account / project / org menu, moved out of the sidebar into a + compact avatar here in the spaces layout. */} + {channelsEnabled && } )} From c62301689a8461fecbc625fc9c63e33485cd7db0 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 25 Jul 2026 15:44:40 +0200 Subject: [PATCH 22/57] fix(canvas): show current project name in the compact account trigger The compact title-bar account trigger now shows the current project name next to the avatar (a small pill, avatar + truncated name, max-width so it stays compact) instead of a bare avatar, so you can see which project you're in at a glance. Generated-By: PostHog Code Task-Id: c401c7cb-e436-4da3-80bf-1d3008ee5d38 --- .../ui/src/features/sidebar/components/ProjectSwitcher.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx b/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx index 068639e74f..1271287160 100644 --- a/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx +++ b/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx @@ -194,9 +194,12 @@ export function ProjectSwitcher({ ) : ( Date: Sat, 25 Jul 2026 15:44:42 +0200 Subject: [PATCH 23/57] fix(canvas): label Activity/Inbox, align account pill with sibling buttons Title-bar polish per feedback: the Activity (bell) and Inbox buttons now carry text labels (size sm, icon + label) instead of bare icons. The account trigger is now a real quill outline Button so its radius, height, and padding match the sibling buttons exactly; the avatar drops to xs (smaller, with the button's own padding so it isn't flush) and the current project name shows in full (no truncation). Generated-By: PostHog Code Task-Id: c401c7cb-e436-4da3-80bf-1d3008ee5d38 --- .../canvas/components/SpacesTitleBar.tsx | 8 +++++--- .../sidebar/components/ProjectSwitcher.tsx | 18 ++++++++++-------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/packages/ui/src/features/canvas/components/SpacesTitleBar.tsx b/packages/ui/src/features/canvas/components/SpacesTitleBar.tsx index 758613aec2..c70aa11fd4 100644 --- a/packages/ui/src/features/canvas/components/SpacesTitleBar.tsx +++ b/packages/ui/src/features/canvas/components/SpacesTitleBar.tsx @@ -53,23 +53,25 @@ export function SpacesTitleBarActions() { <> ); diff --git a/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx b/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx index 1271287160..8ec84d3f0b 100644 --- a/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx +++ b/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx @@ -21,6 +21,7 @@ import { AutocompleteItem, AutocompleteList, AutocompleteStatus, + Button, DropdownMenu, DropdownMenuContent, DropdownMenuGroup, @@ -191,16 +192,17 @@ export function ProjectSwitcher({ - - - {currentProject?.name ?? "No project"} - - + + {currentProject?.name ?? "No project"} + ) : ( Date: Sat, 25 Jul 2026 15:44:43 +0200 Subject: [PATCH 24/57] fix(canvas): left-align title-bar search, pad account avatar The title-bar search pill now left-aligns (justify-start) instead of centering. The account button's avatar shrinks to 16px so it sits with the same top/bottom breathing room as the sibling buttons' icons rather than filling the button height. Generated-By: PostHog Code Task-Id: c401c7cb-e436-4da3-80bf-1d3008ee5d38 --- packages/ui/src/features/canvas/components/SpacesTitleBar.tsx | 2 +- .../ui/src/features/sidebar/components/ProjectSwitcher.tsx | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/features/canvas/components/SpacesTitleBar.tsx b/packages/ui/src/features/canvas/components/SpacesTitleBar.tsx index c70aa11fd4..03225f6705 100644 --- a/packages/ui/src/features/canvas/components/SpacesTitleBar.tsx +++ b/packages/ui/src/features/canvas/components/SpacesTitleBar.tsx @@ -20,7 +20,7 @@ import { useMemo } from "react"; export function SpacesSearchField() { const toggleCommandMenu = useCommandMenuStore((s) => s.toggle); return ( - + ) : ( From 70bd9bf1f32bad7c637ac8c5adf420f1b73b9d31 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 25 Jul 2026 15:44:44 +0200 Subject: [PATCH 25/57] fix(canvas): align title-bar search left edge with the content pane Drop the search flex's left padding so the pill's left edge lines up exactly with the content pane / task column (the title-bar-left block is the sidebar width, so the search flex already begins at the pane's left edge). Keeps right padding off the empty draggable middle. Generated-By: PostHog Code Task-Id: c401c7cb-e436-4da3-80bf-1d3008ee5d38 --- .../ui/src/features/canvas/components/SpacesTitleBar.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/features/canvas/components/SpacesTitleBar.tsx b/packages/ui/src/features/canvas/components/SpacesTitleBar.tsx index 03225f6705..91ecde55ef 100644 --- a/packages/ui/src/features/canvas/components/SpacesTitleBar.tsx +++ b/packages/ui/src/features/canvas/components/SpacesTitleBar.tsx @@ -20,7 +20,11 @@ import { useMemo } from "react"; export function SpacesSearchField() { const toggleCommandMenu = useCommandMenuStore((s) => s.toggle); return ( - + // No left padding: the pill's left edge lines up with the content pane / + // task column (the title-bar-left block is exactly the sidebar width, so + // this flex starts at the pane's left edge). Right padding keeps it off + // the empty draggable middle. + + )} + {onToggleCollapsed && ( + + )} + {onClose && ( + + )} + + ); +} + +function ActivityTabsRow({ + tab, + onTabChange, +}: { + tab: ActivityTab; + onTabChange: (tab: ActivityTab) => void; +}) { + return ( +
+ {ACTIVITY_TABS.map((t) => ( + + ))} +
+ ); +} + +// A slim lifecycle event on the rail (task created / run finished): a node on +// the spine, a one-line label, and the time — lighter than a message row. +function ActivityEventRow({ + node, + title, + action, + timestamp, +}: { + node: ReactNode; + title: string; + action?: string; + timestamp: string; +}) { + return ( +
+
+
{node}
+
+ + {title} + {action && {action}} + + +
+ ); +} + +// A neutral chip node for a system event with no person (run finished). Opaque +// so it masks the rail behind it, reading as a node on the spine. +function EventNode({ icon }: { icon: ReactNode }) { + return ( + + {icon} + + ); +} + +// A user message sent to the agent, styled like the human comment rows. +function UserMessageRow({ + author, + content, + timestamp, +}: { + author?: UserBasic | null; + content: string; + timestamp: string; +}) { + return ( + + + + + + + + {author ? userDisplayName(author) : "You"} + + + + + + {content} + + + + + ); +} + +function ActivityConversation({ + task, + channelId, + onClose, + onToggleCollapsed, + onOpenFull, + showTaskSummary, +}: { + task: Task; + channelId: string; + onClose?: () => void; + onToggleCollapsed?: () => void; + onOpenFull?: () => void; + showTaskSummary: boolean; +}) { + const taskId = task.id; + const client = useOptionalAuthenticatedClient(); + const { data: currentUser } = useCurrentUser({ client }); + + const { messages, isLoading } = useTaskThread(taskId); + const { postMessage, isPosting } = usePostTaskThreadMessage(taskId); + const { postMessageToAgent, isPostingToAgent } = + usePostTaskThreadMessageToAgent(taskId); + const { deleteMessage } = useDeleteTaskThreadMessage(taskId); + const { sendToAgent, isSending } = useSendTaskThreadMessageToAgent(taskId); + const isSendingToAgent = isPostingToAgent || isSending; + const { members } = useOrgMembers(); + + const { + session, + repoPath, + isCloud, + events, + cloudStatus, + isPromptPending, + isInitializing, + hasError, + errorTitle, + } = useSessionViewState(taskId, task); + useSessionConnection({ + taskId, + task, + session, + repoPath, + isCloud, + isSuspended: shouldSuspendThreadSession({ + isCloud, + hasRun: Boolean(task.latest_run?.id), + hasSession: Boolean(session), + }), + }); + const pendingPermissions = usePendingPermissionsForTask(taskId); + + const agentStatus = useMemo( + () => + deriveThreadAgentStatus({ + hasActivity: events.length > 0 || !!task.latest_run, + hasError, + cloudStatus, + errorTitle, + pendingPermissionCount: pendingPermissions.size, + isPromptPending, + isInitializing, + }), + [ + events.length, + task.latest_run, + hasError, + cloudStatus, + errorTitle, + pendingPermissions.size, + isPromptPending, + isInitializing, + ], + ); + + const timeline = useMemo(() => buildThreadTimeline(messages), [messages]); + + const [tab, setTab] = useState("timeline"); + const handleTabChange = useCallback( + (next: ActivityTab) => { + setTab(next); + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "activity_tab_change", + surface: "activity_panel", + task_id: taskId, + tab: next, + }); + }, + [taskId], + ); + // Comments = the human conversation without the agent's artifact + // announcements (those live in the Artifacts tab). + const commentRows = useMemo( + () => timeline.filter((row) => row.kind === "human"), + [timeline], + ); + + const [draft, setDraft] = useState(""); + const scrollRef = useRef(null); + + const handleMentionInsert = useCallback( + (member: UserBasic) => { + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "mention_member", + surface: "activity_panel", + task_id: taskId, + mentioned_user_id: member.uuid, + }); + }, + [taskId], + ); + + // biome-ignore lint/correctness/useExhaustiveDependencies: scroll when rendered thread content changes + useEffect(() => { + scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }); + }, [timeline, events.length, agentStatus?.phase, tab]); + + const isTaskAuthor = + !!currentUser?.uuid && currentUser.uuid === task.created_by?.uuid; + const canForward = + !!task.latest_run && + !isTerminalStatus(task.latest_run.status) && + !isSendingToAgent; + + const submit = async () => { + const content = draft.trim(); + if (!content || isPosting || isSendingToAgent) return; + const sendToAgentRequested = hasAgentMention(content); + if (sendToAgentRequested && (!isTaskAuthor || !canForward)) { + toast.error("Couldn't send to agent", { + description: + "Only the task author can @agent while the task has an active run.", + }); + return; + } + setDraft(""); + try { + if (sendToAgentRequested) { + const { sendError } = await postMessageToAgent(content); + if (sendError) { + toast.error("Message posted, but couldn't send it to the agent", { + description: + sendError instanceof Error + ? sendError.message + : String(sendError), + }); + } + } else { + await postMessage(content); + } + } catch (error) { + setDraft(content); + toast.error("Couldn't post message", { + description: error instanceof Error ? error.message : String(error), + }); + } + }; + + const handleSendToAgent = useCallback( + (messageId: string) => { + sendToAgent(messageId).catch((error: unknown) => { + toast.error("Couldn't send message to agent", { + description: error instanceof Error ? error.message : String(error), + }); + }); + }, + [sendToAgent], + ); + + const handleDelete = useCallback( + (messageId: string) => { + deleteMessage(messageId).catch((error: unknown) => { + toast.error("Couldn't delete message", { + description: error instanceof Error ? error.message : String(error), + }); + }); + }, + [deleteMessage], + ); + + const isReady = !isInitializing && !isLoading; + + // The Timeline is the task's full history: creation, user messages to the + // agent, human comments, artifact announcements, the run-output PR, and the + // run's terminal status — merged and sorted by timestamp below. + const conversationItems = useMemo( + () => buildConversationItems(events, isPromptPending).items, + [events, isPromptPending], + ); + const activityNodes = useMemo(() => { + const nodes: { key: string; ts: number; node: ReactNode }[] = []; + const createdTs = Date.parse(task.created_at) || 0; + nodes.push({ + key: "task-created", + ts: createdTs, + node: ( + + } + title={task.created_by ? userDisplayName(task.created_by) : "Someone"} + action="created this task" + timestamp={task.created_at} + /> + ), + }); + + for (const item of conversationItems) { + if (item.type !== "user_message") continue; + nodes.push({ + key: `user-message-${item.id}`, + ts: item.timestamp, + node: ( + // The session carries no per-message author; the task owner drives + // it, so attribute their avatar rather than a generic glyph. + + ), + }); + } + + let hasPrArtifact = false; + for (const row of timeline) { + if (row.kind === "artifact" && row.artifact.kind === "pr") { + hasPrArtifact = true; + } + nodes.push({ + key: `thread-${row.message.id}`, + ts: row.timestamp, + node: + row.kind === "human" ? ( + handleSendToAgent(row.message.id)} + onDelete={() => handleDelete(row.message.id)} + /> + ) : ( + + ), + }); + } + + const updatedTs = Date.parse(task.updated_at) || createdTs; + const outputPr = task.latest_run?.output?.pr_url; + if (typeof outputPr === "string" && outputPr && !hasPrArtifact) { + nodes.push({ + key: "output-pr", + ts: updatedTs, + node: ( + + ), + }); + } + + const runStatus = task.latest_run?.status; + if (runStatus && isTerminalStatus(runStatus)) { + const succeeded = runStatus === "completed"; + nodes.push({ + key: "run-status", + ts: updatedTs + 1, + node: ( + + ) : ( + + ) + } + /> + } + title={`Task ${runStatus.replace(/_/g, " ")}`} + timestamp={task.updated_at} + /> + ), + }); + } + + return nodes.sort((a, b) => a.ts - b.ts); + }, [ + conversationItems, + timeline, + task, + isTaskAuthor, + canForward, + currentUser?.uuid, + currentUser?.email, + handleSendToAgent, + handleDelete, + ]); + + return ( +
+ + + + {showTaskSummary && ( +
+ +
+ )} + {tab === "artifacts" ? ( +
+ +
+ ) : tab === "comments" ? ( +
+ +
+ ) : ( +
+ {!isReady ? ( + + ) : ( +
+ {/* The timeline spine: a continuous rail down the avatar column + that every row's avatar/node sits on, so slim event rows and + chunky message rows read as one timeline. */} +
+
+ + {activityNodes.map((entry) => ( + {entry.node} + ))} + +
+
+ )} +
+ )} + + {tab !== "artifacts" && agentStatus && ( + + )} + + {tab !== "artifacts" && ( + + )} +
+ ); +} + +export function ActivityPanel({ + taskId, + channelId, + task: taskProp, + onClose, + collapsed, + onToggleCollapsed, + onOpenFull, + showTaskSummary = true, +}: { + taskId: string; + channelId: string; + task?: Task; + onClose?: () => void; + collapsed?: boolean; + onToggleCollapsed?: () => void; + onOpenFull?: () => void; + showTaskSummary?: boolean; +}) { + const { data: fetchedTask } = useQuery({ + ...taskDetailQuery(taskId), + enabled: !taskProp && !collapsed, + }); + const task = taskProp ?? fetchedTask; + + if (collapsed) { + return ( +
+ +
+ ); + } + + if (!task) { + return ; + } + + return ( + + ); +} diff --git a/packages/ui/src/features/canvas/components/ChannelHeader.tsx b/packages/ui/src/features/canvas/components/ChannelHeader.tsx index e936f8f5af..08cd3c2358 100644 --- a/packages/ui/src/features/canvas/components/ChannelHeader.tsx +++ b/packages/ui/src/features/canvas/components/ChannelHeader.tsx @@ -1,17 +1,21 @@ import { HashIcon } from "@phosphor-icons/react"; import { Button, cn } from "@posthog/quill"; +import { ChannelTabs } from "@posthog/ui/features/canvas/components/ChannelTabs"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useMarkChannelSeen } from "@posthog/ui/features/canvas/hooks/useMarkChannelSeen"; +import { useSpacesLayout } from "@posthog/ui/features/canvas/hooks/useSpacesLayout"; 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 old -// section tab strip (Loops / Artifacts / Recents / CONTEXT.md) is gone: the -// spaces sidebar carries those entries now. +// channel row) and highlights `bg-fill-selected` while you're there. In the +// spaces layout the section tab strip (Loops / Artifacts / Recents / +// CONTEXT.md) is dropped — the space sidebar carries those entries; flag off +// keeps the strip exactly as before. export function ChannelHeader({ channelId }: { channelId: string }) { const navigate = useNavigate(); + const spacesOn = useSpacesLayout(); const { channels } = useChannels(); const channelName = channels.find((c) => c.id === channelId)?.name; const pathname = useRouterState({ select: (s) => s.location.pathname }); @@ -36,6 +40,7 @@ export function ChannelHeader({ channelId }: { channelId: string }) { {channelName ?? "Channel"} + {!spacesOn && }
); } diff --git a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx index af060635a2..6d50b02de5 100644 --- a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx @@ -10,6 +10,7 @@ import { SpaceDots } from "@posthog/ui/features/canvas/components/SpaceDots"; import { SpaceSidebar } from "@posthog/ui/features/canvas/components/SpaceSidebar"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useSpaceSwipe } from "@posthog/ui/features/canvas/hooks/useSpaces"; +import { useSpacesLayout } from "@posthog/ui/features/canvas/hooks/useSpacesLayout"; import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; import { useSpaceStore } from "@posthog/ui/features/canvas/stores/spaceStore"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; @@ -86,39 +87,46 @@ export function ChannelsSidebar() { ); const channelsEnabled = useSidebarStore((s) => s.channelsEnabled) && bluebirdEnabled; + // The Spaces layout has exactly one gate: its feature flag. When on it + // subsumes the channels world (the "Enable channels" toggle is irrelevant + // and hidden); when off, the toggle drives the old Channels alpha exactly + // as before. + const spacesOn = useSpacesLayout(); + const channelsWorld = spacesOn || channelsEnabled; // The Switch (in SidebarNavSection) 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 bodyChannelsWorld = useDeferredValue(channelsWorld); const archivedTaskIds = useArchivedTaskIds(); - // Spaces layout (prototype): while a channel is active the sidebar scopes to - // it (Arc-style space). The route is the source of truth — visiting any - // /website/$channelId page adopts that channel as the current space, and it - // sticks across channel-less routes (inbox, activity). Two transient body - // overrides sit on top: browsing the full channel list ("#") and the draft - // new-space chooser ("+"); picking any space dismisses them. + // Spaces layout: while a channel is active the sidebar scopes to it. The + // route is the source of truth — visiting any /website/$channelId page + // adopts that channel as the current space, and it sticks across + // channel-less routes (inbox, activity). Two transient body overrides sit + // on top: browsing the full channel list ("#") and the draft new-space + // chooser ("+"); picking any space dismisses them. All of this machinery is + // inert while the flag is off. const params = useParams({ strict: false }); const routeChannelId = params.channelId; const currentChannelId = useSpaceStore((s) => s.currentChannelId); const setCurrentChannel = useSpaceStore((s) => s.setCurrentChannel); const browsing = useSpaceStore((s) => s.browsing); const draftSpace = useSpaceStore((s) => s.draftSpace); - const { channels } = useChannels({ enabled: channelsEnabled }); + const { channels } = useChannels({ enabled: spacesOn }); useEffect(() => { - if (!routeChannelId) return; + if (!spacesOn || !routeChannelId) return; // Browsing the all-channels list is a preview: clicking a channel shows its // activity in the main view but keeps the directory open in the sidebar and // does NOT scope to (or pin) it. Only non-browse navigation (a dot, a deep // link, in-space nav) adopts a channel as the current space. if (useSpaceStore.getState().browsing) return; setCurrentChannel(routeChannelId); - }, [routeChannelId, setCurrentChannel]); + }, [spacesOn, routeChannelId, setCurrentChannel]); const inSpace = - channelsEnabled && currentChannelId != null && !browsing && !draftSpace; + spacesOn && currentChannelId != null && !browsing && !draftSpace; // Leaving the channels world (navigating to a non-channel route) closes the // pickers. Previewing a channel from the browse list keeps the directory @@ -130,11 +138,11 @@ export function ChannelsSidebar() { useEffect(() => { if (prevHistoryIndexRef.current === historyIndex) return; prevHistoryIndexRef.current = historyIndex; - if (routeChannelId) return; + if (!spacesOn || routeChannelId) return; const state = useSpaceStore.getState(); if (state.browsing) state.setBrowsing(false); if (state.draftSpace) state.setDraftSpace(false); - }, [historyIndex, routeChannelId]); + }, [historyIndex, routeChannelId, spacesOn]); // The personal "#me" space is the default: on first load with nothing // scoped, land there. Once any space has been current (including via the @@ -144,7 +152,7 @@ export function ChannelsSidebar() { if (currentChannelId) autoScopedRef.current = true; }, [currentChannelId]); useEffect(() => { - if (!channelsEnabled || autoScopedRef.current || currentChannelId) return; + if (!spacesOn || autoScopedRef.current || currentChannelId) return; if (browsing || draftSpace) return; const me = channels.find((c) => c.name === PERSONAL_CHANNEL_NAME); if (me) { @@ -152,7 +160,7 @@ export function ChannelsSidebar() { setCurrentChannel(me.id); } }, [ - channelsEnabled, + spacesOn, channels, currentChannelId, browsing, @@ -162,7 +170,7 @@ export function ChannelsSidebar() { // Horizontal trackpad swipe anywhere on the sidebar cycles spaces — from a // space, the channel list, or the draft view alike. One space per gesture. - const handleSpaceSwipe = useSpaceSwipe(channelsEnabled); + const handleSpaceSwipe = useSpaceSwipe(spacesOn); return ( - {/* The nav owns the "Enable channels" toggle + Canvas rows (gated by - the same flag), so this section carries the whole merged nav. In - channels mode the spaces chrome owns the sidebar (search / inbox / - activity live in the title bar) — browse mode shows purely the - channel list — so the nav only renders on the pre-scope landing. */} - {(!channelsEnabled || + {/* The nav carries the whole merged nav (incl. the "Enable channels" + toggle while the spaces flag is off). In the spaces layout the + space chrome owns the sidebar (search / inbox / activity live in + the title bar), so the nav only renders on the pre-scope landing; + flag off keeps it always visible, exactly as before. */} + {(!spacesOn || (currentChannelId == null && !browsing && !draftSpace)) && ( )} - {/* Body precedence: the draft new-space chooser, then the active - space, then the channel list (browse mode / landing), else the - task list. Each owns its own scroll region. Gated on the deferred - value so the toggle paints before this heavy swap. */} - {bodyChannelsEnabled && draftSpace ? ( + {/* Body precedence: (spaces only) the draft new-space chooser, then + the active space; then the channel list (browse mode / landing / + old channels alpha), else the task list. Each owns its own scroll + region. Gated on the deferred value so the toggle paints before + this heavy swap. */} + {bodyChannelsWorld && spacesOn && draftSpace ? ( - ) : bodyChannelsEnabled && inSpace && currentChannelId ? ( + ) : bodyChannelsWorld && inSpace && currentChannelId ? ( - ) : bodyChannelsEnabled ? ( + ) : bodyChannelsWorld ? ( <> {/* The fab is a sibling of the scroll region, not a child, so it @@ -225,9 +234,9 @@ 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 && ( + {/* Archived is a task-list affordance — hidden while the body shows + the channel tree (old alpha) or a space, not tasks. */} + {!channelsWorld && archivedTaskIds.size > 0 && ( - ))} - - ); -} - function ThreadHeader({ onClose, onToggleCollapsed, @@ -482,7 +344,7 @@ function ThreadHeader({ return (
- Activity + Thread
{onOpenFull && ( + + + + + } + /> + + Created by + + onCreatedByChange(value as CreatedByFilter) + } + > + {CREATED_BY_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + Status + + onStatusChange(value === "any" ? null : value) + } + > + {STATUS_FILTER_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + +
+ {searchOpen && ( +
+ onQueryChange(event.target.value)} + placeholder="Search…" + aria-label="Search recent items" + className="h-6 text-[12px]" + /> +
+ )} + ); } -const RECENTS_CAP = 30; - /** - * The sidebar body while a space (channel) is active — the Arc-style layout: - * New task, the channel header, the channel's sections (Context / Loops / - * Artifacts) as nav rows, then pinned and recent tasks & canvases. Slides in - * on space switches; the sidebar-wide swipe handler lives in ChannelsSidebar. + * The sidebar body while a space (channel) is active: New task, the channel + * header, its sections (Context / Loops / Artifacts), then pinned and recent + * tasks & canvases. The sidebar-wide swipe handler lives in ChannelsSidebar. */ export function SpaceSidebar({ channelId }: { channelId: string }) { const navigate = useNavigate(); @@ -260,22 +176,8 @@ export function SpaceSidebar({ channelId }: { channelId: string }) { const channelName = channels.find((c) => c.id === channelId)?.name ?? "channel"; - const { dashboards } = useDashboards(channelId); - // The channel's full task feed (server-side `getTasks({ channel })`) — every - // filed task with its full object, regardless of author. Joining the id-only - // channelTasks list against the mine-only task query silently dropped every - // teammate's task; the feed is the same source the channel home renders. - const { channel: backendChannel } = useBackendChannel(channelName); - const { tasks: feedTasks } = useChannelFeed(backendChannel?.id); - const archivedTaskIds = useArchivedTaskIds(); - const { pinnedTaskIds, togglePin } = usePinnedTasks(); - const { archiveTask } = useArchiveTask({ navigateSpace: "website" }); - const { setPinned: setCanvasPinned } = useDashboardMutations(); - const client = useOptionalAuthenticatedClient(); - const { data: currentUser } = useCurrentUser({ client }); + const { items, meUuid, meName } = useSpaceItems(channelId, channelName); - // Recent-section controls: title search plus created-by / status filters, - // mirroring the old task list's header (minus "Add folder"). const [searchOpen, setSearchOpen] = useState(false); const [query, setQuery] = useState(""); const [createdByFilter, setCreatedByFilter] = @@ -284,113 +186,16 @@ export function SpaceSidebar({ channelId }: { channelId: string }) { const filtersActive = createdByFilter !== "anyone" || statusFilter !== null; const base = `/website/${channelId}`; - const isPersonal = channelName === PERSONAL_CHANNEL_NAME; - const meUuid = currentUser?.uuid ?? null; - const meName = currentUser ? userDisplayName(currentUser) : null; - - const items = useMemo(() => { - const canvasItems: SpaceItem[] = dashboards.map((d: DashboardSummary) => ({ - key: `canvas:${d.id}`, - kind: "canvas", - title: d.name, - ts: d.updatedAt, - pinned: d.pinnedAt != null, - icon: iconForTemplate(d.templateId, { - size: 15, - className: "text-violet-9", - }), - status: null, - rawStatus: null, - statusVariant: "default" as const, - authorUser: null, - authorName: d.createdBy ?? null, - isActive: pathname === `${base}/dashboards/${d.id}`, - onClick: () => - void navigate({ - to: "/website/$channelId/dashboards/$dashboardId", - params: { channelId, dashboardId: d.id }, - }), - onTogglePin: () => { - setCanvasPinned(d.id, d.pinnedAt == null).catch(() => { - toast.error("Couldn't update pin"); - }); - }, - })); - - const taskItems: SpaceItem[] = feedTasks.flatMap((task) => { - if (archivedTaskIds.has(task.id)) return []; - return [ - { - key: `task:${task.id}`, - kind: "task" as const, - title: task.title || "Untitled task", - ts: Date.parse(task.updated_at) || 0, - pinned: pinnedTaskIds.has(task.id), - icon: , - status: humanizeStatus(task.latest_run?.status), - rawStatus: task.latest_run?.status ?? null, - statusVariant: statusVariantFor(task.latest_run?.status), - authorUser: task.created_by ?? null, - authorName: task.created_by ? userDisplayName(task.created_by) : null, - isActive: pathname === `${base}/tasks/${task.id}`, - onClick: () => - void navigate({ - to: "/website/$channelId/tasks/$taskId", - params: { channelId, taskId: task.id }, - }), - onTogglePin: () => { - togglePin(task.id).catch(() => { - toast.error("Couldn't update pin"); - }); - }, - onArchive: () => { - void archiveTask({ taskId: task.id }); - }, - }, - ]; - }); - - const all = [...canvasItems, ...taskItems].sort((a, b) => b.ts - a.ts); - - // The personal space is *yours*: the shared "me" folder can hold other - // people's items, so drop anything whose author is known and isn't you. - // Unknown authors stay (better to over-show than hide your own work), and - // nothing is dropped until the current user has actually loaded. - if (!isPersonal || (!meUuid && !meName)) return all; - return all.filter((item) => { - if (item.authorUser) return item.authorUser.uuid === meUuid; - if (item.authorName && meName) return item.authorName === meName; - return true; - }); - }, [ - dashboards, - feedTasks, - archivedTaskIds, - pinnedTaskIds, - pathname, - base, - channelId, - navigate, - togglePin, - archiveTask, - setCanvasPinned, - isPersonal, - meUuid, - meName, - ]); - const pinnedItems = items.filter((i) => i.pinned); - // The Recent list honors the header's search + filters; Pinned stays as-is. const normalizedQuery = query.trim().toLowerCase(); const recentItems = items - .filter((i) => !i.pinned) .filter((i) => { + if (i.pinned) return false; if (normalizedQuery && !i.title.toLowerCase().includes(normalizedQuery)) { return false; } if (createdByFilter !== "anyone") { - // Tasks carry the author's uuid; canvases only a display name. const isMine = i.authorUser ? i.authorUser.uuid === meUuid : i.authorName != null && meName != null && i.authorName === meName; @@ -425,14 +230,10 @@ export function SpaceSidebar({ channelId }: { channelId: string }) { transition={{ duration: 0.18, ease: [0, 0, 0.2, 1] }} className="flex h-full min-h-0 flex-col" > - {/* Channel header: the space's identity, click = channel home. */} - - - - - } - /> - {/* Same construction as the task list's filter menu so the - two read identically. */} - - Created by - - setCreatedByFilter(value as CreatedByFilter) - } - > - {CREATED_BY_OPTIONS.map((option) => ( - - {option.label} - - ))} - - - Status - - setStatusFilter(value === "any" ? null : value) - } - > - {STATUS_FILTER_OPTIONS.map((option) => ( - - {option.label} - - ))} - - - - - {searchOpen && ( -
- setQuery(event.target.value)} - placeholder="Search…" - aria-label="Search recent items" - className="h-6 text-[12px]" - /> -
- )} + { + 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) => ( diff --git a/packages/ui/src/features/canvas/components/SpacesTitleBar.tsx b/packages/ui/src/features/canvas/components/SpacesTitleBar.tsx index 91ecde55ef..bc9123c436 100644 --- a/packages/ui/src/features/canvas/components/SpacesTitleBar.tsx +++ b/packages/ui/src/features/canvas/components/SpacesTitleBar.tsx @@ -12,18 +12,10 @@ import { isMac } from "@posthog/ui/utils/platform"; import { Flex } from "@radix-ui/themes"; import { useMemo } from "react"; -/** - * The spaces-layout title bar center: a persistent search pill that opens the - * command menu. Replaces the browser-tab strip while channels are on (the - * prototype moves tabs inside the task view instead). - */ +/** Title-bar search pill (opens the command menu); replaces the tab strip in spaces. */ export function SpacesSearchField() { const toggleCommandMenu = useCommandMenuStore((s) => s.toggle); return ( - // No left padding: the pill's left edge lines up with the content pane / - // task column (the title-bar-left block is exactly the sidebar width, so - // this flex starts at the pane's left edge). Right padding keeps it off - // the empty draggable middle. - {!spacesOn && } + {!channelsLayout && }
); } diff --git a/packages/ui/src/features/canvas/components/SpaceItemRow.tsx b/packages/ui/src/features/canvas/components/ChannelItemRow.tsx similarity index 98% rename from packages/ui/src/features/canvas/components/SpaceItemRow.tsx rename to packages/ui/src/features/canvas/components/ChannelItemRow.tsx index 526a69e2a5..63c3cc7be8 100644 --- a/packages/ui/src/features/canvas/components/SpaceItemRow.tsx +++ b/packages/ui/src/features/canvas/components/ChannelItemRow.tsx @@ -16,7 +16,7 @@ export type StatusVariant = | "success" | "warning"; -export interface SpaceItem { +export interface ChannelItem { key: string; kind: "task" | "canvas"; title: string; @@ -68,7 +68,7 @@ const HOVER_ACTION_CLASS = "flex h-5 w-5 cursor-pointer items-center justify-center rounded text-gray-10 transition-colors hover:bg-gray-4 hover:text-gray-12"; // A pinned/recent row with a hover card carrying the full title, status, author. -export function SpaceItemRow({ item }: { item: SpaceItem }) { +export function ChannelItemRow({ item }: { item: ChannelItem }) { return ( s.open); + const { counts } = useInboxAllReports({ + ignoreFilters: true, + refetchIntervalMs: INBOX_REFETCH_INTERVAL_MS, + }); + + const withTrack = (item: SidebarNavItem, action: () => void) => () => { + track(ANALYTICS_EVENTS.SIDEBAR_NAV_ITEM_CLICKED, { item, in_more: false }); + action(); + }; + + return ( + + + + + + ); +} diff --git a/packages/ui/src/features/canvas/components/SpaceSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelSidebar.tsx similarity index 86% rename from packages/ui/src/features/canvas/components/SpaceSidebar.tsx rename to packages/ui/src/features/canvas/components/ChannelSidebar.tsx index 7625b8e47d..359177d8f4 100644 --- a/packages/ui/src/features/canvas/components/SpaceSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ChannelSidebar.tsx @@ -1,7 +1,6 @@ import { BookOpenTextIcon, FunnelSimple as FunnelSimpleIcon, - HashIcon, MagnifyingGlass, PackageIcon, PlusIcon, @@ -19,15 +18,14 @@ import { MenuLabel, } from "@posthog/quill"; import { LOOPS_FLAG } from "@posthog/shared"; -import { SpaceItemRow } from "@posthog/ui/features/canvas/components/SpaceItemRow"; +import { ChannelItemRow } from "@posthog/ui/features/canvas/components/ChannelItemRow"; +import { ChannelSwitcher } from "@posthog/ui/features/canvas/components/ChannelSwitcher"; +import { useChannelItems } from "@posthog/ui/features/canvas/hooks/useChannelItems"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; -import { useSpaceItems } from "@posthog/ui/features/canvas/hooks/useSpaceItems"; -import { useSpaceStore } from "@posthog/ui/features/canvas/stores/spaceStore"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { SidebarItem } from "@posthog/ui/features/sidebar/components/SidebarItem"; import { navigateToChannelNewTask } from "@posthog/ui/router/navigationBridge"; import { useNavigate, useRouterState } from "@tanstack/react-router"; -import { motion } from "framer-motion"; import { type ReactNode, useState } from "react"; type CreatedByFilter = "anyone" | "me" | "others"; @@ -166,17 +164,16 @@ function RecentSectionHeader({ * header, its sections (Context / Loops / Artifacts), then pinned and recent * tasks & canvases. The sidebar-wide swipe handler lives in ChannelsSidebar. */ -export function SpaceSidebar({ channelId }: { channelId: string }) { +export function ChannelSidebar({ channelId }: { channelId: string }) { const navigate = useNavigate(); const pathname = useRouterState({ select: (s) => s.location.pathname }); - const direction = useSpaceStore((s) => s.direction); const loopsEnabled = useFeatureFlag(LOOPS_FLAG, import.meta.env.DEV); const { channels } = useChannels(); const channelName = channels.find((c) => c.id === channelId)?.name ?? "channel"; - const { items, meUuid, meName } = useSpaceItems(channelId, channelName); + const { items, meUuid, meName } = useChannelItems(channelId, channelName); const [searchOpen, setSearchOpen] = useState(false); const [query, setQuery] = useState(""); @@ -223,25 +220,8 @@ export function SpaceSidebar({ channelId }: { channelId: string }) { ); return ( - - +
+
{sectionRow( @@ -293,7 +273,7 @@ export function SpaceSidebar({ channelId }: { channelId: string }) { Pinned
{pinnedItems.map((item) => ( - + ))}
@@ -318,7 +298,7 @@ export function SpaceSidebar({ channelId }: { channelId: string }) { {recentItems.length > 0 ? (
{recentItems.map((item) => ( - + ))}
) : ( @@ -331,10 +311,10 @@ export function SpaceSidebar({ channelId }: { channelId: string }) { {items.length === 0 && (

- Tasks and canvases you create in this space show up here. + Tasks and canvases you create in this channel show up here.

)}
- +
); } diff --git a/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx b/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx new file mode 100644 index 0000000000..a26fa26ff4 --- /dev/null +++ b/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx @@ -0,0 +1,221 @@ +import { + CaretUpDownIcon, + HashIcon, + PlusIcon, + StarIcon, + UserIcon, +} from "@phosphor-icons/react"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, + cn, + Input, + Popover, + PopoverContent, + PopoverTrigger, + Separator, +} from "@posthog/quill"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { CreateChannelModal } from "@posthog/ui/features/canvas/components/CreateChannelModal"; +import { + useChannelStars, + 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 { useCurrentChannelStore } from "@posthog/ui/features/canvas/stores/currentChannelStore"; +import { navigateToChannel } from "@posthog/ui/router/navigationBridge"; +import { track } from "@posthog/ui/shell/analytics"; +import { useMemo, useState } from "react"; + +function ChannelGlyph({ channel }: { channel: Channel }) { + return channel.name === PERSONAL_CHANNEL_NAME ? ( + + ) : ( + + ); +} + +// One channel in the switcher; right-click stars/unstars it. +function SwitcherRow({ + channel, + active, + onPick, +}: { + channel: Channel; + active: boolean; + onPick: () => void; +}) { + const isMe = channel.name === PERSONAL_CHANNEL_NAME; + const { isStarred, toggleStar } = useChannelStarToggle(channel); + + const row = ( + + ); + + if (isMe) return row; + return ( + + + + + + {isStarred ? "Unstar channel" : "Star channel"} + + + + ); +} + +/** + * The channel header as a switcher: clicking the "# channel" title opens a + * searchable popover — "#me" first, then starred channels, then the rest. + */ +export function ChannelSwitcher({ channelId }: { channelId: string }) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [createOpen, setCreateOpen] = useState(false); + const { channels } = useChannels(); + const { starredRefToShortcutId } = useChannelStars(); + const setCurrentChannel = useCurrentChannelStore((s) => s.setCurrentChannel); + const current = channels.find((c) => c.id === channelId); + + const { top, rest } = useMemo(() => { + const me = channels.find((c) => c.name === PERSONAL_CHANNEL_NAME); + const byPath = new Map(channels.map((c) => [c.path, c])); + const starred: Channel[] = []; + for (const ref of starredRefToShortcutId.keys()) { + const channel = byPath.get(ref); + if (channel && channel.name !== PERSONAL_CHANNEL_NAME) { + starred.push(channel); + } + } + const top = me ? [me, ...starred] : starred; + const seen = new Set(top.map((c) => c.id)); + const rest = channels + .filter((c) => !seen.has(c.id)) + .sort((a, b) => a.name.localeCompare(b.name)); + return { top, rest }; + }, [channels, starredRefToShortcutId]); + + const normalizedQuery = query.trim().toLowerCase(); + const matches = (c: Channel) => + !normalizedQuery || c.name.toLowerCase().includes(normalizedQuery); + const topFiltered = top.filter(matches); + const restFiltered = rest.filter(matches); + + const pick = (channel: Channel) => { + setOpen(false); + setQuery(""); + setCurrentChannel(channel.id); + navigateToChannel(channel.id); + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "open_channel", + surface: "sidebar", + channel_id: channel.id, + }); + }; + + return ( + <> + + + + {current ? ( + + ) : ( + + )} + + + {current?.name ?? "channel"} + + + + } + /> + +
+ setQuery(event.target.value)} + placeholder="Search channels…" + aria-label="Search channels" + className="h-7 text-[13px]" + /> +
+
+ {topFiltered.map((channel) => ( + pick(channel)} + /> + ))} + {topFiltered.length > 0 && restFiltered.length > 0 && ( + + )} + {restFiltered.map((channel) => ( + pick(channel)} + /> + ))} + {topFiltered.length === 0 && restFiltered.length === 0 && ( +

+ No channels match "{query.trim()}". +

+ )} +
+ + +
+
+ + + ); +} diff --git a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx index 0557cd5008..6c3a3716df 100644 --- a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx @@ -2,17 +2,15 @@ import { ArchiveIcon } from "@phosphor-icons/react"; import { 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 { NewSpaceDraft } from "@posthog/ui/features/canvas/components/NewSpaceDraft"; -import { SpaceDots } from "@posthog/ui/features/canvas/components/SpaceDots"; -import { SpaceSidebar } from "@posthog/ui/features/canvas/components/SpaceSidebar"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; -import { useSpaceSwipe } from "@posthog/ui/features/canvas/hooks/useSpaces"; -import { useSpacesLayout } from "@posthog/ui/features/canvas/hooks/useSpacesLayout"; +import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; -import { useSpaceStore } from "@posthog/ui/features/canvas/stores/spaceStore"; +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"; @@ -83,74 +81,44 @@ export function ChannelsSidebar() { ); const channelsEnabled = useSidebarStore((s) => s.channelsEnabled) && bluebirdEnabled; - // Spaces subsumes the channels world when on (the toggle is then hidden); - // when off, the toggle drives the old Channels alpha. `channelsWorld` = the - // body shows channels either way. - const spacesOn = useSpacesLayout(); - const channelsWorld = spacesOn || channelsEnabled; + // The new layout subsumes the channels alpha when on (the toggle is then + // hidden); when off, the toggle drives the alpha. `channelsWorld` = the body + // shows channels either way. + const channelsLayout = useChannelsLayout(); + const channelsWorld = channelsLayout || channelsEnabled; // Deferred so the toggle paints before the heavy channel tree (ChannelsList // mounts a provider-laden row per channel) swaps in. const bodyChannelsWorld = useDeferredValue(channelsWorld); const archivedTaskIds = useArchivedTaskIds(); - // Spaces scoping (see spaceStore for the model): the route is the source of - // truth — a /website/$channelId page adopts that channel as the current - // space. Inert while the flag is off. + // Channel scoping: the route is the source of truth — a /website/$channelId + // page adopts that channel as current. Inert while the flag is off. const params = useParams({ strict: false }); const routeChannelId = params.channelId; - const currentChannelId = useSpaceStore((s) => s.currentChannelId); - const setCurrentChannel = useSpaceStore((s) => s.setCurrentChannel); - const browsing = useSpaceStore((s) => s.browsing); - const draftSpace = useSpaceStore((s) => s.draftSpace); - const { channels } = useChannels({ enabled: spacesOn }); + const currentChannelId = useCurrentChannelStore((s) => s.currentChannelId); + const setCurrentChannel = useCurrentChannelStore((s) => s.setCurrentChannel); + const { channels } = useChannels({ enabled: channelsLayout }); useEffect(() => { - if (!spacesOn || !routeChannelId) return; - // Browsing is a preview — it shows a channel's activity without scoping to - // it, so don't adopt the route while the directory is open. - if (useSpaceStore.getState().browsing) return; + if (!channelsLayout || !routeChannelId) return; setCurrentChannel(routeChannelId); - }, [spacesOn, routeChannelId, setCurrentChannel]); - const inSpace = - spacesOn && currentChannelId != null && !browsing && !draftSpace; + }, [channelsLayout, routeChannelId, setCurrentChannel]); + const inChannel = channelsLayout && currentChannelId != null; - const historyIndex = useRouterState({ - select: (s) => s.location.state.__TSR_index, - }); - const prevHistoryIndexRef = useRef(historyIndex); - useEffect(() => { - if (prevHistoryIndexRef.current === historyIndex) return; - prevHistoryIndexRef.current = historyIndex; - if (!spacesOn || routeChannelId) return; - const state = useSpaceStore.getState(); - if (state.browsing) state.setBrowsing(false); - if (state.draftSpace) state.setDraftSpace(false); - }, [historyIndex, routeChannelId, spacesOn]); - - // Default to the personal "#me" space on first load; once any space has been - // current, never auto-scope again so the pickers can't be hijacked. + // Default to the personal "#me" channel on first load; once any channel has + // been current, never auto-scope again. const autoScopedRef = useRef(false); useEffect(() => { if (currentChannelId) autoScopedRef.current = true; }, [currentChannelId]); useEffect(() => { - if (!spacesOn || autoScopedRef.current || currentChannelId) return; - if (browsing || draftSpace) return; + if (!channelsLayout || autoScopedRef.current || currentChannelId) return; const me = channels.find((c) => c.name === PERSONAL_CHANNEL_NAME); if (me) { autoScopedRef.current = true; setCurrentChannel(me.id); } - }, [ - spacesOn, - channels, - currentChannelId, - browsing, - draftSpace, - setCurrentChannel, - ]); - - const handleSpaceSwipe = useSpaceSwipe(spacesOn); + }, [channelsLayout, channels, currentChannelId, setCurrentChannel]); return ( endSidebarPeek()} onPeekDismiss={cancelSidebarPeek} > - - {(!spacesOn || - (currentChannelId == null && !browsing && !draftSpace)) && ( - - )} + + {!inChannel && } - {/* draft chooser → active space (spaces only) → channel list → tasks */} - {bodyChannelsWorld && spacesOn && draftSpace ? ( - - - - ) : bodyChannelsWorld && inSpace && currentChannelId ? ( - - - + {/* active channel → channel list → tasks */} + {inChannel && currentChannelId ? ( + <> + + + + + + ) : bodyChannelsWorld ? ( <> @@ -220,10 +181,8 @@ export function ChannelsSidebar() { - {spacesOn && } - - {/* In the spaces layout the account switcher moves to the title bar. */} - {!spacesOn && ( + {/* In the new layout the account switcher moves to the title bar. */} + {!channelsLayout && ( diff --git a/packages/ui/src/features/canvas/components/NewSpaceDraft.tsx b/packages/ui/src/features/canvas/components/NewSpaceDraft.tsx deleted file mode 100644 index f8bebb8d71..0000000000 --- a/packages/ui/src/features/canvas/components/NewSpaceDraft.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { HashIcon, PlusIcon, XIcon } from "@phosphor-icons/react"; -import { Button, Input } from "@posthog/quill"; -import { CreateChannelModal } from "@posthog/ui/features/canvas/components/CreateChannelModal"; -import { useChannelStarMutations } from "@posthog/ui/features/canvas/hooks/useChannelStars"; -import { - type Channel, - useChannels, -} from "@posthog/ui/features/canvas/hooks/useChannels"; -import { useSpaces } from "@posthog/ui/features/canvas/hooks/useSpaces"; -import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; -import { useSpaceStore } from "@posthog/ui/features/canvas/stores/spaceStore"; -import { SidebarItem } from "@posthog/ui/features/sidebar/components/SidebarItem"; -import { toast } from "@posthog/ui/primitives/toast"; -import { motion } from "framer-motion"; -import { useState } from "react"; - -/** - * The draft "new space" chooser ("+"): attach an existing channel (starring it) - * or create a new one. Cancelling returns to the previous space. - */ -export function NewSpaceDraft() { - const { spaces, switchTo } = useSpaces(); - const { channels } = useChannels(); - const { star } = useChannelStarMutations(); - const setDraftSpace = useSpaceStore((s) => s.setDraftSpace); - const [createOpen, setCreateOpen] = useState(false); - const [query, setQuery] = useState(""); - - // Channels that aren't a space yet — candidates to attach to this draft. - const addable = channels - .filter( - (c) => - c.name !== PERSONAL_CHANNEL_NAME && !spaces.some((s) => s.id === c.id), - ) - .sort((a, b) => a.name.localeCompare(b.name)); - const normalizedQuery = query.trim().toLowerCase(); - const filtered = normalizedQuery - ? addable.filter((c) => c.name.toLowerCase().includes(normalizedQuery)) - : addable; - - const attach = (channel: Channel) => { - star(channel).catch((error: unknown) => { - toast.error("Couldn't add space", { - description: error instanceof Error ? error.message : String(error), - }); - }); - switchTo(channel, "draft"); // also dismisses the draft override - }; - - return ( - -
- - New space - - -
-

- Pick a channel for this space, or create a new one. -

- -
- setQuery(event.target.value)} - placeholder="Search channels…" - aria-label="Search channels" - className="h-7 text-[13px]" - /> -
- -
- {filtered.length > 0 && ( -
- {filtered.map((channel) => ( - } - label={channel.name} - onClick={() => attach(channel)} - /> - ))} -
- )} - {filtered.length === 0 && addable.length > 0 && ( -

- No channels match "{query.trim()}". -

- )} - {addable.length === 0 && ( -

- Every channel is already a space — create a new one. -

- )} -
- } - label="New channel…" - onClick={() => setCreateOpen(true)} - /> -
-
- - -
- ); -} diff --git a/packages/ui/src/features/canvas/components/SpaceDots.tsx b/packages/ui/src/features/canvas/components/SpaceDots.tsx deleted file mode 100644 index bd2d4c5515..0000000000 --- a/packages/ui/src/features/canvas/components/SpaceDots.tsx +++ /dev/null @@ -1,341 +0,0 @@ -import { HashIcon, PlusIcon, UserIcon, XIcon } from "@phosphor-icons/react"; -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger, - cn, - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@posthog/quill"; -import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; -import { ensurePersonalChannel } from "@posthog/ui/features/canvas/ensurePersonalChannel"; -import { - useChannelStarMutations, - useChannelStars, -} from "@posthog/ui/features/canvas/hooks/useChannelStars"; -import { - type Channel, - useChannelMutations, - useChannels, -} from "@posthog/ui/features/canvas/hooks/useChannels"; -import { useSpaces } from "@posthog/ui/features/canvas/hooks/useSpaces"; -import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; -import { useIsChannelUnread } from "@posthog/ui/features/canvas/hooks/useUnreadChannels"; -import { useSpaceEmojiStore } from "@posthog/ui/features/canvas/stores/spaceEmojiStore"; -import { useSpaceStore } from "@posthog/ui/features/canvas/stores/spaceStore"; -import { toast } from "@posthog/ui/primitives/toast"; -import { track } from "@posthog/ui/shell/analytics"; -import { useNavigate } from "@tanstack/react-router"; -import { motion } from "framer-motion"; -import { useEffect } from "react"; - -const RAIL_BUTTON_CLASS = - "flex size-5 shrink-0 items-center justify-center rounded text-gray-10 transition-colors hover:bg-gray-3 hover:text-gray-12"; - -const SPACE_EMOJIS = [ - "🦔", - "🚀", - "🔥", - "⭐", - "🎨", - "📊", - "🧪", - "🔧", - "📦", - "🤖", - "💬", - "🧠", - "📈", - "🐛", - "🎯", - "⚡", - "🌊", - "📝", - "🔒", - "❤️", -] as const; - -// One space in the dot row; right-click sets an emoji or removes the space. -function SpaceDot({ - channel, - active, - unread, - onSelect, -}: { - channel: Channel; - active: boolean; - unread: boolean; - onSelect: () => void; -}) { - const isMe = channel.name === PERSONAL_CHANNEL_NAME; - const emoji = useSpaceEmojiStore((s) => s.emojiByChannelId[channel.id]); - const setEmoji = useSpaceEmojiStore((s) => s.setEmoji); - const { starredRefToShortcutId } = useChannelStars(); - const { unstar } = useChannelStarMutations(); - const shortcutId = starredRefToShortcutId.get(channel.path); - - const removeSpace = () => { - if (!shortcutId) return; - unstar(shortcutId).catch((error: unknown) => { - toast.error("Couldn't remove space", { - description: error instanceof Error ? error.message : String(error), - }); - }); - }; - - const glyph = emoji ? ( - - {emoji} - - ) : isMe ? ( - - ) : ( - - ); - - return ( - - - - {glyph} - - } - /> - } - /> - # {channel.name} - - -
- {SPACE_EMOJIS.map((option) => ( - - ))} -
- {(emoji || (!isMe && shortcutId)) && } - {emoji && ( - setEmoji(channel.id, null)}> - - Remove emoji - - )} - {!isMe && shortcutId && ( - - - Remove space - - )} -
-
- ); -} - -/** - * The space switcher pinned above the sidebar footer. "#me" is first; added - * spaces append to the right. "#" toggles the all-channels list; "+" opens a - * draft new space. Ctrl+Alt+←/→ and horizontal swipes cycle spaces. - */ -export function SpaceDots() { - const navigate = useNavigate(); - const { spaces, currentChannelId, switchTo, cycle } = useSpaces(); - const { channels } = useChannels(); - const { createChannel } = useChannelMutations(); - const browsing = useSpaceStore((s) => s.browsing); - const draftSpace = useSpaceStore((s) => s.draftSpace); - const setBrowsing = useSpaceStore((s) => s.setBrowsing); - const setDraftSpace = useSpaceStore((s) => s.setDraftSpace); - const isUnread = useIsChannelUnread(); - - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - if (!event.ctrlKey || !event.altKey) return; - if (event.key === "ArrowLeft") { - event.preventDefault(); - cycle(-1, "keyboard"); - } else if (event.key === "ArrowRight") { - event.preventDefault(); - cycle(1, "keyboard"); - } - }; - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, [cycle]); - - const hasMe = spaces.some((c) => c.name === PERSONAL_CHANNEL_NAME); - const overrideOpen = browsing || draftSpace; - - // Closing the "#" directory returns to the current space (or the landing), - // leaving no trace — the preview never scopes or pins. - const toggleBrowsing = () => { - track(ANALYTICS_EVENTS.CHANNEL_ACTION, { - action_type: "space_browse_toggle", - surface: "space_switcher", - open: !browsing, - }); - if (!browsing) { - setBrowsing(true); - return; - } - const current = channels.find((c) => c.id === currentChannelId); - if (current) { - switchTo(current, "browse"); - } else { - setBrowsing(false); - void navigate({ to: "/website" }); - } - }; - - const toggleDraftSpace = () => { - track(ANALYTICS_EVENTS.CHANNEL_ACTION, { - action_type: "space_draft_toggle", - surface: "space_switcher", - open: !draftSpace, - }); - setDraftSpace(!draftSpace); - }; - - // Only used before the "#me" channel exists; afterwards it's a regular dot. - const openPersonalSpace = () => { - ensurePersonalChannel(channels, createChannel) - .then((me) => switchTo(me, "me")) - .catch((error: unknown) => { - toast.error("Couldn't open your personal space", { - description: error instanceof Error ? error.message : String(error), - }); - }); - }; - - return ( - -
- - - - - } - /> - All channels - - -
- {/* "#me" not provisioned yet — show its slot anyway. */} - {!hasMe && ( - - - - - } - /> - # me - - )} - {spaces.map((channel) => ( - switchTo(channel)} - /> - ))} - {draftSpace && ( - - )} -
- - - - - - } - /> - New space - -
-
- ); -} diff --git a/packages/ui/src/features/canvas/components/SpacesTitleBar.tsx b/packages/ui/src/features/canvas/components/SpacesTitleBar.tsx deleted file mode 100644 index bc9123c436..0000000000 --- a/packages/ui/src/features/canvas/components/SpacesTitleBar.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { BellIcon, MagnifyingGlassIcon, TrayIcon } from "@phosphor-icons/react"; -import { countUnseenActivity } from "@posthog/core/canvas/mentionActivity"; -import { Button } from "@posthog/quill"; -import { useMentionActivity } from "@posthog/ui/features/canvas/hooks/useMentionActivity"; -import { useActivitySeenStore } from "@posthog/ui/features/canvas/stores/activitySeenStore"; -import { - navigateToActivity, - navigateToInbox, -} from "@posthog/ui/router/navigationBridge"; -import { useCommandMenuStore } from "@posthog/ui/shell/commandMenuStore"; -import { isMac } from "@posthog/ui/utils/platform"; -import { Flex } from "@radix-ui/themes"; -import { useMemo } from "react"; - -/** Title-bar search pill (opens the command menu); replaces the tab strip in spaces. */ -export function SpacesSearchField() { - const toggleCommandMenu = useCommandMenuStore((s) => s.toggle); - return ( - - - - ); -} - -/** Title-bar Activity (bell + unread dot) and Inbox buttons. */ -export function SpacesTitleBarActions() { - const { items } = useMentionActivity(); - const lastSeenAt = useActivitySeenStore((s) => s.lastSeenAt); - const unseen = useMemo( - () => countUnseenActivity(items, lastSeenAt), - [items, lastSeenAt], - ); - - return ( - <> - - - - ); -} diff --git a/packages/ui/src/features/canvas/components/ThreadSidebar.tsx b/packages/ui/src/features/canvas/components/ThreadSidebar.tsx index 229e6a7510..1dfa13cb29 100644 --- a/packages/ui/src/features/canvas/components/ThreadSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ThreadSidebar.tsx @@ -2,14 +2,14 @@ import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import type { Task } from "@posthog/shared/domain-types"; import { ActivityPanel } from "@posthog/ui/features/canvas/components/ActivityPanel"; import { ThreadPanel } from "@posthog/ui/features/canvas/components/ThreadPanel"; -import { useSpacesLayout } from "@posthog/ui/features/canvas/hooks/useSpacesLayout"; +import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { useThreadPanelStore } from "@posthog/ui/features/canvas/stores/threadPanelStore"; import { ResizableSidebar } from "@posthog/ui/primitives/ResizableSidebar"; import { track } from "@posthog/ui/shell/analytics"; import { useState } from "react"; -// The right-hand dock for a task's thread (collapsible, resizable). Spaces uses -// the tabbed ActivityPanel; flag off keeps the legacy ThreadPanel. +// The right-hand dock for a task's thread (collapsible, resizable). Flag on +// swaps the legacy ThreadPanel for the tabbed ActivityPanel. export function ThreadSidebar({ taskId, channelId, @@ -31,14 +31,14 @@ export function ThreadSidebar({ const setWidth = useThreadPanelStore((s) => s.setWidth); const setCollapsed = useThreadPanelStore((s) => s.setCollapsed); const [isResizing, setIsResizing] = useState(false); - const spacesOn = useSpacesLayout(); - const Panel = spacesOn ? ActivityPanel : ThreadPanel; + const channelsLayout = useChannelsLayout(); + const Panel = channelsLayout ? ActivityPanel : ThreadPanel; const toggleCollapsed = (next: boolean) => { setCollapsed(next); track(ANALYTICS_EVENTS.CHANNEL_ACTION, { action_type: next ? "collapse_thread" : "expand_thread", - surface: spacesOn ? "activity_panel" : "thread_panel", + surface: channelsLayout ? "activity_panel" : "thread_panel", task_id: taskId, }); }; diff --git a/packages/ui/src/features/canvas/hooks/useSpaceItems.tsx b/packages/ui/src/features/canvas/hooks/useChannelItems.tsx similarity index 73% rename from packages/ui/src/features/canvas/hooks/useSpaceItems.tsx rename to packages/ui/src/features/canvas/hooks/useChannelItems.tsx index ea36c37256..aea1b154cd 100644 --- a/packages/ui/src/features/canvas/hooks/useSpaceItems.tsx +++ b/packages/ui/src/features/canvas/hooks/useChannelItems.tsx @@ -4,12 +4,12 @@ import { useArchivedTaskIds } from "@posthog/ui/features/archive/useArchivedTask import { useArchiveTask } from "@posthog/ui/features/archive/useArchiveTask"; import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; -import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon"; import { + type ChannelItem, humanizeStatus, - type SpaceItem, statusVariantFor, -} from "@posthog/ui/features/canvas/components/SpaceItemRow"; +} from "@posthog/ui/features/canvas/components/ChannelItemRow"; +import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon"; import { useChannelFeed } from "@posthog/ui/features/canvas/hooks/useChannelFeed"; import { useDashboardMutations, @@ -26,14 +26,14 @@ import { useNavigate, useRouterState } from "@tanstack/react-router"; import { useMemo } from "react"; /** - * A space's canvases + task feed as merged, newest-first items. The personal - * "#me" space is filtered to the current user. Also returns the user's identity + * A channel's canvases + task feed as merged, newest-first items. The personal + * "#me" channel is filtered to the current user. Also returns the user's identity * for the recent-list filters. */ -export function useSpaceItems( +export function useChannelItems( channelId: string, channelName: string, -): { items: SpaceItem[]; meUuid: string | null; meName: string | null } { +): { items: ChannelItem[]; meUuid: string | null; meName: string | null } { const navigate = useNavigate(); const pathname = useRouterState({ select: (s) => s.location.pathname }); @@ -52,36 +52,38 @@ export function useSpaceItems( const meUuid = currentUser?.uuid ?? null; const meName = currentUser ? userDisplayName(currentUser) : null; - const items = useMemo(() => { - const canvasItems: SpaceItem[] = dashboards.map((d: DashboardSummary) => ({ - key: `canvas:${d.id}`, - kind: "canvas", - title: d.name, - ts: d.updatedAt, - pinned: d.pinnedAt != null, - icon: iconForTemplate(d.templateId, { - size: 15, - className: "text-violet-9", - }), - status: null, - rawStatus: null, - statusVariant: "default" as const, - authorUser: null, - authorName: d.createdBy ?? null, - isActive: pathname === `${base}/dashboards/${d.id}`, - onClick: () => - void navigate({ - to: "/website/$channelId/dashboards/$dashboardId", - params: { channelId, dashboardId: d.id }, + const items = useMemo(() => { + const canvasItems: ChannelItem[] = dashboards.map( + (d: DashboardSummary) => ({ + key: `canvas:${d.id}`, + kind: "canvas", + title: d.name, + ts: d.updatedAt, + pinned: d.pinnedAt != null, + icon: iconForTemplate(d.templateId, { + size: 15, + className: "text-violet-9", }), - onTogglePin: () => { - setCanvasPinned(d.id, d.pinnedAt == null).catch(() => { - toast.error("Couldn't update pin"); - }); - }, - })); + status: null, + rawStatus: null, + statusVariant: "default" as const, + authorUser: null, + authorName: d.createdBy ?? null, + isActive: pathname === `${base}/dashboards/${d.id}`, + onClick: () => + void navigate({ + to: "/website/$channelId/dashboards/$dashboardId", + params: { channelId, dashboardId: d.id }, + }), + onTogglePin: () => { + setCanvasPinned(d.id, d.pinnedAt == null).catch(() => { + toast.error("Couldn't update pin"); + }); + }, + }), + ); - const taskItems: SpaceItem[] = feedTasks.flatMap((task) => { + const taskItems: ChannelItem[] = feedTasks.flatMap((task) => { if (archivedTaskIds.has(task.id)) return []; return [ { @@ -116,7 +118,7 @@ export function useSpaceItems( const all = [...canvasItems, ...taskItems].sort((a, b) => b.ts - a.ts); - // The personal space is yours: drop items with a known author that isn't + // The personal channel is yours: drop items with a known author that isn't // you (unknown authors stay; wait until the current user has loaded). if (!isPersonal || (!meUuid && !meName)) return all; return all.filter((item) => { diff --git a/packages/ui/src/features/canvas/hooks/useChannelsLayout.ts b/packages/ui/src/features/canvas/hooks/useChannelsLayout.ts new file mode 100644 index 0000000000..8f65627ca8 --- /dev/null +++ b/packages/ui/src/features/canvas/hooks/useChannelsLayout.ts @@ -0,0 +1,16 @@ +import { CHANNELS_LAYOUT_FLAG, PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; +import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; + +/** + * The single gate for the new channels layout — every gate reads this hook, + * not the raw flag. No dev default (read from PostHog like normal, so dev + * matches prod); bluebird keeps its dev default as the backend guard. + */ +export function useChannelsLayout(): boolean { + const bluebirdEnabled = useFeatureFlag( + PROJECT_BLUEBIRD_FLAG, + import.meta.env.DEV, + ); + const layoutEnabled = useFeatureFlag(CHANNELS_LAYOUT_FLAG, false); + return layoutEnabled && bluebirdEnabled; +} diff --git a/packages/ui/src/features/canvas/hooks/useSpaces.ts b/packages/ui/src/features/canvas/hooks/useSpaces.ts deleted file mode 100644 index a2126fed0a..0000000000 --- a/packages/ui/src/features/canvas/hooks/useSpaces.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; -import { useChannelStars } 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 { useSpaceStore } from "@posthog/ui/features/canvas/stores/spaceStore"; -import { navigateToChannel } from "@posthog/ui/router/navigationBridge"; -import { track } from "@posthog/ui/shell/analytics"; -import { useCallback, useMemo, useRef } from "react"; - -/** What drove a space switch, for analytics. */ -export type SpaceSwitchMethod = - | "dot" - | "swipe" - | "keyboard" - | "browse" - | "draft" - | "me"; - -/** - * The spaces in the dot switcher: the personal "#me" channel first, then the - * user's starred channels, plus the current channel when it isn't one of those - * (a temporarily-visited space). - */ -export function useSpaces(): { - spaces: Channel[]; - currentChannelId: string | null; - currentIndex: number; - switchTo: (channel: Channel, method?: SpaceSwitchMethod) => void; - cycle: (delta: 1 | -1, method?: SpaceSwitchMethod) => void; -} { - const { channels } = useChannels(); - const { starredRefToShortcutId } = useChannelStars(); - const currentChannelId = useSpaceStore((s) => s.currentChannelId); - const setCurrentChannel = useSpaceStore((s) => s.setCurrentChannel); - - const spaces = useMemo(() => { - const me = channels.find((c) => c.name === PERSONAL_CHANNEL_NAME); - // Star order (creation order), so a new space lands at the right end. - const byPath = new Map(channels.map((c) => [c.path, c])); - const starred: Channel[] = []; - for (const ref of starredRefToShortcutId.keys()) { - const channel = byPath.get(ref); - if (channel && channel.name !== PERSONAL_CHANNEL_NAME) { - starred.push(channel); - } - } - const list = me ? [me, ...starred] : starred; - const current = channels.find((c) => c.id === currentChannelId); - if (current && !list.some((c) => c.id === current.id)) { - list.push(current); - } - return list; - }, [channels, starredRefToShortcutId, currentChannelId]); - - const currentIndex = spaces.findIndex((c) => c.id === currentChannelId); - - const switchTo = useCallback( - (channel: Channel, method: SpaceSwitchMethod = "dot") => { - const from = spaces.findIndex((c) => c.id === currentChannelId); - const to = spaces.findIndex((c) => c.id === channel.id); - // Unknown positions (from the landing) read as moving right. - const direction = from !== -1 && to !== -1 && to < from ? -1 : 1; - setCurrentChannel(channel.id, direction); - navigateToChannel(channel.id); - track(ANALYTICS_EVENTS.CHANNEL_ACTION, { - action_type: "space_switch", - surface: "space_switcher", - channel_id: channel.id, - method, - }); - }, - [spaces, currentChannelId, setCurrentChannel], - ); - - const cycle = useCallback( - (delta: 1 | -1, method: SpaceSwitchMethod = "keyboard") => { - if (spaces.length === 0) return; - // Clamp at the ends — no wrap-around. - const from = currentIndex === -1 ? (delta > 0 ? -1 : 1) : currentIndex; - const nextIndex = Math.max(0, Math.min(spaces.length - 1, from + delta)); - if (nextIndex === currentIndex) return; - const next = spaces[nextIndex]; - if (next) switchTo(next, method); - }, - [spaces, currentIndex, switchTo], - ); - - return { spaces, currentChannelId, currentIndex, switchTo, cycle }; -} - -// One swipe = one space, regardless of speed. A trackpad swipe is a burst of -// wheel events with a long inertia tail; two guards keep the whole burst to a -// single switch: after firing we stay locked until the wheel goes quiet -// (QUIET_GAP) *and* a hard floor since the last fire has passed -// (MIN_FIRE_INTERVAL) — an inertia tail satisfies neither. -const QUIET_GAP_MS = 400; -const MIN_FIRE_INTERVAL_MS = 500; -const FIRE_PX = 40; - -interface SwipeGate { - lastEventAt: number; - firedAt: number; - accumX: number; - locked: boolean; -} - -/** Horizontal trackpad swipe → cycle spaces (attach via `onWheel`). */ -export function useSpaceSwipe( - enabled: boolean, -): (event: React.WheelEvent) => void { - const { cycle } = useSpaces(); - const gate = useRef({ - lastEventAt: 0, - firedAt: 0, - accumX: 0, - locked: false, - }); - - return useCallback( - (event: React.WheelEvent) => { - if (!enabled) return; - const now = Date.now(); - const g = gate.current; - const gap = now - g.lastEventAt; - g.lastEventAt = now; - - if (g.locked) { - if (gap > QUIET_GAP_MS && now - g.firedAt > MIN_FIRE_INTERVAL_MS) { - g.locked = false; - g.accumX = 0; - } else { - return; - } - } else if (gap > QUIET_GAP_MS) { - g.accumX = 0; - } - - if (Math.abs(event.deltaX) <= Math.abs(event.deltaY)) return; - - g.accumX += event.deltaX; - if (Math.abs(g.accumX) >= FIRE_PX) { - const direction = g.accumX > 0 ? 1 : -1; - g.locked = true; - g.firedAt = now; - g.accumX = 0; - cycle(direction, "swipe"); - } - }, - [enabled, cycle], - ); -} diff --git a/packages/ui/src/features/canvas/hooks/useSpacesLayout.ts b/packages/ui/src/features/canvas/hooks/useSpacesLayout.ts deleted file mode 100644 index 5fccae96d3..0000000000 --- a/packages/ui/src/features/canvas/hooks/useSpacesLayout.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { PROJECT_BLUEBIRD_FLAG, SPACES_LAYOUT_FLAG } from "@posthog/shared"; -import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; - -/** - * The single gate for the Spaces layout — every gate reads this hook, not the - * raw flag. The spaces flag has no dev default (read from PostHog like normal, - * so dev matches prod); bluebird keeps its dev default as the backend guard. - */ -export function useSpacesLayout(): boolean { - const bluebirdEnabled = useFeatureFlag( - PROJECT_BLUEBIRD_FLAG, - import.meta.env.DEV, - ); - const spacesEnabled = useFeatureFlag(SPACES_LAYOUT_FLAG, false); - return spacesEnabled && bluebirdEnabled; -} diff --git a/packages/ui/src/features/canvas/stores/currentChannelStore.ts b/packages/ui/src/features/canvas/stores/currentChannelStore.ts new file mode 100644 index 0000000000..1ed1de7628 --- /dev/null +++ b/packages/ui/src/features/canvas/stores/currentChannelStore.ts @@ -0,0 +1,15 @@ +import { create } from "zustand"; + +/** + * The channel the sidebar is currently scoped to. Survives channel-less routes + * (inbox, activity, settings). + */ +interface CurrentChannelState { + currentChannelId: string | null; + setCurrentChannel: (channelId: string | null) => void; +} + +export const useCurrentChannelStore = create()((set) => ({ + currentChannelId: null, + setCurrentChannel: (currentChannelId) => set({ currentChannelId }), +})); diff --git a/packages/ui/src/features/canvas/stores/spaceEmojiStore.ts b/packages/ui/src/features/canvas/stores/spaceEmojiStore.ts deleted file mode 100644 index 8f86c89725..0000000000 --- a/packages/ui/src/features/canvas/stores/spaceEmojiStore.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { create } from "zustand"; -import { persist } from "zustand/middleware"; - -/** - * Per-space emoji, set from a dot's right-click menu. Pure view state persisted - * to localStorage, keyed by channel id. - */ -interface SpaceEmojiState { - emojiByChannelId: Record; - setEmoji: (channelId: string, emoji: string | null) => void; -} - -export const useSpaceEmojiStore = create()( - persist( - (set) => ({ - emojiByChannelId: {}, - setEmoji: (channelId, emoji) => - set((state) => { - const next = { ...state.emojiByChannelId }; - if (emoji) { - next[channelId] = emoji; - } else { - delete next[channelId]; - } - return { emojiByChannelId: next }; - }), - }), - { name: "space-emoji" }, - ), -); diff --git a/packages/ui/src/features/canvas/stores/spaceStore.ts b/packages/ui/src/features/canvas/stores/spaceStore.ts deleted file mode 100644 index 0b58652844..0000000000 --- a/packages/ui/src/features/canvas/stores/spaceStore.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { create } from "zustand"; - -/** - * View state for the spaces chrome: which channel the sidebar is scoped to, the - * last switch direction (drives the slide), and two mutually-exclusive - * sidebar-body overrides — `browsing` (the all-channels list) and `draftSpace` - * (the new-space chooser). The current space survives channel-less routes - * (inbox, activity, settings); selecting any space clears both overrides. - */ -interface SpaceState { - currentChannelId: string | null; - /** +1 = moved right in the dot order, -1 = left. */ - direction: 1 | -1; - browsing: boolean; - draftSpace: boolean; - setCurrentChannel: (channelId: string | null, direction?: 1 | -1) => void; - setBrowsing: (browsing: boolean) => void; - setDraftSpace: (draftSpace: boolean) => void; -} - -export const useSpaceStore = create()((set) => ({ - currentChannelId: null, - direction: 1, - browsing: false, - draftSpace: false, - setCurrentChannel: (currentChannelId, direction) => - set((state) => ({ - currentChannelId, - direction: direction ?? state.direction, - browsing: false, - draftSpace: false, - })), - setBrowsing: (browsing) => - set((state) => ({ - browsing, - draftSpace: browsing ? false : state.draftSpace, - })), - setDraftSpace: (draftSpace) => - set((state) => ({ - draftSpace, - browsing: draftSpace ? false : state.browsing, - })), -})); diff --git a/packages/ui/src/features/panels/components/TabbedPanel.test.tsx b/packages/ui/src/features/panels/components/TabbedPanel.test.tsx index 5b8e60ad0f..58f3634dc9 100644 --- a/packages/ui/src/features/panels/components/TabbedPanel.test.tsx +++ b/packages/ui/src/features/panels/components/TabbedPanel.test.tsx @@ -19,8 +19,8 @@ vi.mock("./PanelDropZones", () => ({ PanelDropZones: () => null, })); -vi.mock("@posthog/ui/features/canvas/hooks/useSpacesLayout", () => ({ - useSpacesLayout: () => false, +vi.mock("@posthog/ui/features/canvas/hooks/useChannelsLayout", () => ({ + useChannelsLayout: () => false, })); vi.mock("./PanelTab", () => ({ diff --git a/packages/ui/src/features/panels/components/TabbedPanel.tsx b/packages/ui/src/features/panels/components/TabbedPanel.tsx index 36986ea378..c37cf16d22 100644 --- a/packages/ui/src/features/panels/components/TabbedPanel.tsx +++ b/packages/ui/src/features/panels/components/TabbedPanel.tsx @@ -12,7 +12,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@posthog/quill"; -import { useSpacesLayout } from "@posthog/ui/features/canvas/hooks/useSpacesLayout"; +import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { PanelDropZones } from "@posthog/ui/features/panels/components/PanelDropZones"; import type { SplitDirection } from "@posthog/ui/features/panels/panelLayoutStore"; import type { PanelContent } from "@posthog/ui/features/panels/panelTypes"; @@ -100,7 +100,7 @@ export const TabbedPanel: React.FC = ({ emptyState, }) => { const hostClient = useHostTRPCClient(); - const spacesOn = useSpacesLayout(); + const channelsLayout = useChannelsLayout(); const [mountedTabs, setMountedTabs] = useState<{ scopeKey: string; tabIds: Set; @@ -231,11 +231,11 @@ export const TabbedPanel: React.FC = ({ badge={tab.badge} /> ))} - {/* Spaces: a menu (New chat is coming, shown disabled). Flag off: + {/* Flag on: a menu (New chat is coming, shown disabled). Flag off: the plain add-terminal button. */} {content.droppable && onAddTerminal && - (spacesOn ? ( + (channelsLayout ? ( ({ useAppView })); vi.mock("@posthog/ui/features/feature-flags/useFeatureFlag", () => ({ useFeatureFlag: () => true, })); -// These tests pin the legacy layout (spaces flag off), where the "Enable -// channels" toggle row is present. -vi.mock("@posthog/ui/features/canvas/hooks/useSpacesLayout", () => ({ - useSpacesLayout: () => false, +// These tests pin the legacy layout (flag off), where the "Enable channels" +// toggle row is present. +vi.mock("@posthog/ui/features/canvas/hooks/useChannelsLayout", () => ({ + useChannelsLayout: () => false, })); vi.mock("@posthog/ui/router/navigationBridge", () => ({ navigateToActivity, diff --git a/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx b/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx index 3d32781aeb..c3232486ca 100644 --- a/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx +++ b/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx @@ -3,7 +3,7 @@ import { ANALYTICS_EVENTS, type SidebarNavItem, } from "@posthog/shared/analytics-events"; -import { useSpacesLayout } from "@posthog/ui/features/canvas/hooks/useSpacesLayout"; +import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { useCommandCenterStore } from "@posthog/ui/features/command-center/commandCenterStore"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { useInboxAllReports } from "@posthog/ui/features/inbox/hooks/useInboxAllReports"; @@ -75,9 +75,9 @@ export function SidebarNavSection({ const channelsEnabled = useSidebarStore((s) => s.channelsEnabled) && bluebirdEnabled; const setChannelsEnabled = useSidebarStore((s) => s.setChannelsEnabled); - // The spaces layout subsumes the channels world: while its flag is on, the + // The new channels layout subsumes the alpha: while its flag is on, the // "Enable channels" toggle is meaningless, so its row is hidden. - const spacesOn = useSpacesLayout(); + const channelsLayout = useChannelsLayout(); // When this section renders inside the Channels space, the destinations that // have a /website mirror stay in that space; everything else (and the whole @@ -156,9 +156,8 @@ export function SidebarNavSection({ const navItemAvailable: Record = { inbox: true, "command-center": true, - // Hidden while the spaces flag is on — spaces subsume channels, so the - // opt-in toggle would be a no-op. - contexts: bluebirdEnabled && !spacesOn, + // Hidden while the new layout is on — the opt-in toggle would be a no-op. + contexts: bluebirdEnabled && !channelsLayout, // Activity (the mentions feed) is a channels surface, so it only appears // once channels are enabled. activity: channelsEnabled, diff --git a/packages/ui/src/router/routes/__root.tsx b/packages/ui/src/router/routes/__root.tsx index 374eaaf681..c829e00823 100644 --- a/packages/ui/src/router/routes/__root.tsx +++ b/packages/ui/src/router/routes/__root.tsx @@ -28,13 +28,9 @@ import { FeedbackModal, type FeedbackModalMode, } from "@posthog/ui/features/canvas/components/FeedbackModal"; -import { - SpacesSearchField, - SpacesTitleBarActions, -} from "@posthog/ui/features/canvas/components/SpacesTitleBar"; import { useCanvasDeepLink } from "@posthog/ui/features/canvas/hooks/useCanvasDeepLink"; import { useChannelDeepLink } from "@posthog/ui/features/canvas/hooks/useChannelDeepLink"; -import { useSpacesLayout } from "@posthog/ui/features/canvas/hooks/useSpacesLayout"; +import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { CommandMenu } from "@posthog/ui/features/command/CommandMenu"; import { GlobalFilePicker } from "@posthog/ui/features/command/GlobalFilePicker"; import { KeyboardShortcutsSheet } from "@posthog/ui/features/command/KeyboardShortcutsSheet"; @@ -197,9 +193,9 @@ function RootLayout() { ); const channelsToggleOn = useSidebarStore((s) => s.channelsEnabled); const channelsEnabled = channelsToggleOn && bluebirdEnabled; - // The Spaces layout has exactly one gate: its feature flag (no sidebar - // toggle). When on it subsumes the channels world entirely. - const spacesOn = useSpacesLayout(); + // The new channels layout has exactly one gate: its feature flag (no + // sidebar toggle). When on it subsumes the channels alpha entirely. + const channelsLayout = useChannelsLayout(); // When the sidebar is collapsed (Cmd+B) the title bar's left block shrinks to // fit its own controls so the tab strip flushes left with the content pane. const sidebarOpen = useSidebarStore((s) => s.open); @@ -418,19 +414,16 @@ function RootLayout() {
)}
- {/* Spaces layout: the title-bar center is a persistent search pill - (tabs live inside the task view instead of a global strip). - Flag off keeps the browser-tab strip exactly as before: channel - tabs under /website, plain task tabs in Code. */} - {spacesOn ? : } + {/* The new layout has no global tab strip (tabs live inside the + task view); search/inbox/activity live in the sidebar nav. */} + {!channelsLayout && } {/* Gated so an empty right-side group can't claim a no-drag rect in the title bar for nothing — every pixel without controls should drag the window. */} - {(billingEnabled || channelsEnabled || spacesOn) && ( + {(billingEnabled || channelsEnabled || channelsLayout) && ( - {spacesOn && } - {(channelsEnabled || spacesOn) && ( + {(channelsEnabled || channelsLayout) && ( @@ -141,19 +132,18 @@ export function ChannelSwitcher({ channelId }: { channelId: string }) { } /> @@ -183,7 +173,7 @@ export function ChannelSwitcher({ channelId }: { channelId: string }) { /> ))} {topFiltered.length > 0 && restFiltered.length > 0 && ( - + )} {restFiltered.map((channel) => ( )} - + ); @@ -88,7 +105,7 @@ export function ChannelSwitcher({ channelId }: { channelId: string }) { const setCurrentChannel = useCurrentChannelStore((s) => s.setCurrentChannel); const current = channels.find((c) => c.id === channelId); - const { top, rest } = useMemo(() => { + const { top, rest, starred } = useMemo(() => { const me = channels.find((c) => c.name === PERSONAL_CHANNEL_NAME); const byPath = new Map(channels.map((c) => [c.path, c])); const starred: Channel[] = []; @@ -103,7 +120,7 @@ export function ChannelSwitcher({ channelId }: { channelId: string }) { const rest = channels .filter((c) => !seen.has(c.id)) .sort((a, b) => a.name.localeCompare(b.name)); - return { top, rest }; + return { top, rest, starred }; }, [channels, starredRefToShortcutId]); const normalizedQuery = query.trim().toLowerCase(); @@ -124,6 +141,29 @@ export function ChannelSwitcher({ channelId }: { channelId: string }) { }); }; + // mod+1..4 jump to the Nth starred channel. Same ctrl guard as SWITCH_TASK: + // plain ctrl+N belongs to tab switching. + useHotkeys( + SHORTCUTS.SWITCH_STARRED_CHANNEL, + (event, handler) => { + if (event.ctrlKey && !event.metaKey) return; + const slot = Number.parseInt(handler.keys?.[0] ?? "", 10); + const channel = starred[slot - 1]; + if (channel && slot <= STARRED_HOTKEY_SLOTS) pick(channel); + }, + { + enableOnFormTags: true, + enableOnContentEditable: true, + preventDefault: true, + }, + [starred, pick], + ); + + const hotkeySlotFor = (channel: Channel) => { + const index = starred.indexOf(channel); + return index >= 0 && index < STARRED_HOTKEY_SLOTS ? index + 1 : undefined; + }; + return ( <> @@ -151,7 +191,10 @@ export function ChannelSwitcher({ channelId }: { channelId: string }) { align="start" side="bottom" sideOffset={4} - className="w-64 p-1" + // quill's popover ships `gap-4` plus unlayered `padding`/`width` CSS + // that beats plain utilities, hence the `!` overrides — this is a + // menu, not a card, so it gets menu-tight spacing. + className="w-64! gap-0 p-1!" >
-
+
{topFiltered.map((channel) => ( pick(channel)} + hotkeySlot={hotkeySlotFor(channel)} /> ))} {topFiltered.length > 0 && restFiltered.length > 0 && ( diff --git a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx index 6c3a3716df..7f6a2a1be0 100644 --- a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx @@ -30,7 +30,7 @@ import { useSidebarEdgeHoverPeek } from "@posthog/ui/primitives/hooks/useSidebar import { ResizableSidebar } from "@posthog/ui/primitives/ResizableSidebar"; import { navigateToArchived } from "@posthog/ui/router/navigationBridge"; import { Box, Flex } from "@radix-ui/themes"; -import { useParams, useRouterState } from "@tanstack/react-router"; +import { useParams } from "@tanstack/react-router"; import { useDeferredValue, useEffect, useRef } from "react"; export function ChannelsSidebar() { @@ -140,7 +140,7 @@ export function ChannelsSidebar() { {/* active channel → channel list → tasks */} {inChannel && currentChannelId ? ( <> - + @@ -181,12 +181,9 @@ export function ChannelsSidebar() { - {/* In the new layout the account switcher moves to the title bar. */} - {!channelsLayout && ( - - - - )} + + + ); diff --git a/packages/ui/src/features/canvas/hooks/useChannelItems.tsx b/packages/ui/src/features/canvas/hooks/useChannelItems.tsx index aea1b154cd..6fd5351462 100644 --- a/packages/ui/src/features/canvas/hooks/useChannelItems.tsx +++ b/packages/ui/src/features/canvas/hooks/useChannelItems.tsx @@ -33,13 +33,21 @@ import { useMemo } from "react"; export function useChannelItems( channelId: string, channelName: string, -): { items: ChannelItem[]; meUuid: string | null; meName: string | null } { +): { + items: ChannelItem[]; + meUuid: string | null; + meName: string | null; + isLoading: boolean; +} { const navigate = useNavigate(); const pathname = useRouterState({ select: (s) => s.location.pathname }); - const { dashboards } = useDashboards(channelId); - const { channel: backendChannel } = useBackendChannel(channelName); - const { tasks: feedTasks } = useChannelFeed(backendChannel?.id); + const { dashboards, isLoading: dashboardsLoading } = useDashboards(channelId); + const { channel: backendChannel, isLoading: channelLoading } = + useBackendChannel(channelName); + const { tasks: feedTasks, isLoading: feedLoading } = useChannelFeed( + backendChannel?.id, + ); const archivedTaskIds = useArchivedTaskIds(); const { pinnedTaskIds, togglePin } = usePinnedTasks(); const { archiveTask } = useArchiveTask({ navigateSpace: "website" }); @@ -143,5 +151,10 @@ export function useChannelItems( meName, ]); - return { items, meUuid, meName }; + return { + items, + meUuid, + meName, + isLoading: dashboardsLoading || channelLoading || feedLoading, + }; } diff --git a/packages/ui/src/features/command/keyboard-shortcuts.ts b/packages/ui/src/features/command/keyboard-shortcuts.ts index 70dc6870a3..68420a6d99 100644 --- a/packages/ui/src/features/command/keyboard-shortcuts.ts +++ b/packages/ui/src/features/command/keyboard-shortcuts.ts @@ -19,6 +19,7 @@ export const SHORTCUTS = { CLOSE_TAB: "mod+w", SWITCH_TAB: "ctrl+1,ctrl+2,ctrl+3,ctrl+4,ctrl+5,ctrl+6,ctrl+7,ctrl+8,ctrl+9", SWITCH_TASK: "mod+1,mod+2,mod+3,mod+4,mod+5,mod+6,mod+7,mod+8,mod+9", + SWITCH_STARRED_CHANNEL: "mod+1,mod+2,mod+3,mod+4", OPEN_IN_EDITOR: "mod+o", COPY_PATH: "mod+shift+c", TOGGLE_FOCUS: "mod+r", @@ -119,6 +120,13 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ description: "Switch to task 1-9", category: "navigation", }, + { + id: "switch-starred-channel", + keys: "mod+1-4", + description: "Switch to starred channel 1-4", + category: "navigation", + context: "Channels", + }, { id: "prev-task", keys: "mod+shift+[", diff --git a/packages/ui/src/router/routes/__root.tsx b/packages/ui/src/router/routes/__root.tsx index c829e00823..b4b291b7b2 100644 --- a/packages/ui/src/router/routes/__root.tsx +++ b/packages/ui/src/router/routes/__root.tsx @@ -43,7 +43,6 @@ import { useInboxDeepLink } from "@posthog/ui/features/inbox/hooks/useInboxDeepL import { useIntegrations } from "@posthog/ui/features/integrations/useIntegrations"; import { useScoutDeepLink } from "@posthog/ui/features/scouts/hooks/useScoutDeepLink"; import { useSetupDiscovery } from "@posthog/ui/features/setup/useSetupDiscovery"; -import { ProjectSwitcher } from "@posthog/ui/features/sidebar/components/ProjectSwitcher"; import { beginSidebarPeek, cancelSidebarPeek, @@ -434,9 +433,6 @@ function RootLayout() { PostHog Web )} - {/* Account / project / org menu — moves out of the sidebar into - a compact avatar in the new layout. */} - {channelsLayout && } )} diff --git a/packages/ui/src/shell/GlobalEventHandlers.tsx b/packages/ui/src/shell/GlobalEventHandlers.tsx index 4ba6fa0000..667963d783 100644 --- a/packages/ui/src/shell/GlobalEventHandlers.tsx +++ b/packages/ui/src/shell/GlobalEventHandlers.tsx @@ -6,6 +6,7 @@ import { useService } from "@posthog/di/react"; import { useHostTRPC } from "@posthog/host-router/react"; import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; import type { Task } from "@posthog/shared/domain-types"; +import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { useReviewNavigationStore } from "@posthog/ui/features/code-review/reviewNavigationStore"; import { SHORTCUTS } from "@posthog/ui/features/command/keyboard-shortcuts"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; @@ -73,14 +74,16 @@ export function GlobalEventHandlers({ const visualTaskOrder = useVisualTaskOrder(sidebarData); // With channels on, mod+1-9 belongs to the browser tab strip (it switches to - // the Nth tab). Yield those keys so task-switching only owns them in the Code - // nav; the strip's own handler is gated on the same flag. + // the Nth tab); in the new layout mod+1-4 belongs to starred channels + // (ChannelSwitcher). Yield those keys so task-switching only owns them in + // the Code nav. const bluebirdEnabled = useFeatureFlag( PROJECT_BLUEBIRD_FLAG, import.meta.env.DEV, ); const channelsEnabled = useSidebarStore((s) => s.channelsEnabled) && bluebirdEnabled; + const channelsLayout = useChannelsLayout(); const taskById = useMemo(() => { const map = new Map(); @@ -211,7 +214,7 @@ export function GlobalEventHandlers({ ); // Task switching with mod+1-9 — off when channels are on (the browser tab - // strip claims those keys to switch tabs by index). + // strip / starred-channel shortcuts claim those keys). useHotkeys( SHORTCUTS.SWITCH_TASK, (event, handler) => { @@ -222,7 +225,7 @@ export function GlobalEventHandlers({ const index = parseInt(keyPressed, 10); handleSwitchTask(index); }, - { ...globalOptions, enabled: !channelsEnabled }, + { ...globalOptions, enabled: !channelsEnabled && !channelsLayout }, [handleSwitchTask], ); From ace3d2e7c63b60f78bf08db2a14185c5fd508355 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 25 Jul 2026 15:45:00 +0200 Subject: [PATCH 35/57] =?UTF-8?q?feat(canvas):=20=E2=8C=980=20opens=20#me,?= =?UTF-8?q?=20starred=20channels=20extend=20to=20=E2=8C=989?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #me now has its own ⌘0 shortcut, and starred-channel shortcuts run the full ⌘1-9 range instead of stopping at 4. Generated-By: PostHog Code Task-Id: c401c7cb-e436-4da3-80bf-1d3008ee5d38 --- .../canvas/components/ChannelSwitcher.tsx | 25 +++++++++++-------- .../features/command/keyboard-shortcuts.ts | 7 +++--- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx b/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx index a52d4d7bcf..fe04ab9ec3 100644 --- a/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx +++ b/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx @@ -38,11 +38,12 @@ import { track } from "@posthog/ui/shell/analytics"; import { useMemo, useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; -// mod+1..4 jump to the first four starred channels (see SWITCH_STARRED_CHANNEL). -const STARRED_HOTKEY_SLOTS = 4; +// mod+0 jumps to #me; mod+1..9 jump to the first nine starred channels +// (see SWITCH_STARRED_CHANNEL). +const STARRED_HOTKEY_SLOTS = 9; -// One channel in the switcher; right-click stars/unstars it. Starred channels -// in the first four slots show their mod+N hotkey. +// One channel in the switcher; right-click stars/unstars it. #me and the first +// nine starred channels show their mod+N hotkey. function SwitcherRow({ channel, active, @@ -105,8 +106,8 @@ export function ChannelSwitcher({ channelId }: { channelId: string }) { const setCurrentChannel = useCurrentChannelStore((s) => s.setCurrentChannel); const current = channels.find((c) => c.id === channelId); - const { top, rest, starred } = useMemo(() => { - const me = channels.find((c) => c.name === PERSONAL_CHANNEL_NAME); + const { top, rest, me, starred } = useMemo(() => { + const me = channels.find((c) => c.name === PERSONAL_CHANNEL_NAME) ?? null; const byPath = new Map(channels.map((c) => [c.path, c])); const starred: Channel[] = []; for (const ref of starredRefToShortcutId.keys()) { @@ -120,7 +121,7 @@ export function ChannelSwitcher({ channelId }: { channelId: string }) { const rest = channels .filter((c) => !seen.has(c.id)) .sort((a, b) => a.name.localeCompare(b.name)); - return { top, rest, starred }; + return { top, rest, me, starred }; }, [channels, starredRefToShortcutId]); const normalizedQuery = query.trim().toLowerCase(); @@ -141,14 +142,15 @@ export function ChannelSwitcher({ channelId }: { channelId: string }) { }); }; - // mod+1..4 jump to the Nth starred channel. Same ctrl guard as SWITCH_TASK: - // plain ctrl+N belongs to tab switching. + // mod+0 → #me, mod+1..9 → the Nth starred channel. Same ctrl guard as + // SWITCH_TASK: plain ctrl+N belongs to tab switching. useHotkeys( SHORTCUTS.SWITCH_STARRED_CHANNEL, (event, handler) => { if (event.ctrlKey && !event.metaKey) return; const slot = Number.parseInt(handler.keys?.[0] ?? "", 10); - const channel = starred[slot - 1]; + if (Number.isNaN(slot)) return; + const channel = slot === 0 ? me : starred[slot - 1]; if (channel && slot <= STARRED_HOTKEY_SLOTS) pick(channel); }, { @@ -156,10 +158,11 @@ export function ChannelSwitcher({ channelId }: { channelId: string }) { enableOnContentEditable: true, preventDefault: true, }, - [starred, pick], + [me, starred, pick], ); const hotkeySlotFor = (channel: Channel) => { + if (me && channel.id === me.id) return 0; const index = starred.indexOf(channel); return index >= 0 && index < STARRED_HOTKEY_SLOTS ? index + 1 : undefined; }; diff --git a/packages/ui/src/features/command/keyboard-shortcuts.ts b/packages/ui/src/features/command/keyboard-shortcuts.ts index 68420a6d99..3ee102b0e3 100644 --- a/packages/ui/src/features/command/keyboard-shortcuts.ts +++ b/packages/ui/src/features/command/keyboard-shortcuts.ts @@ -19,7 +19,8 @@ export const SHORTCUTS = { CLOSE_TAB: "mod+w", SWITCH_TAB: "ctrl+1,ctrl+2,ctrl+3,ctrl+4,ctrl+5,ctrl+6,ctrl+7,ctrl+8,ctrl+9", SWITCH_TASK: "mod+1,mod+2,mod+3,mod+4,mod+5,mod+6,mod+7,mod+8,mod+9", - SWITCH_STARRED_CHANNEL: "mod+1,mod+2,mod+3,mod+4", + SWITCH_STARRED_CHANNEL: + "mod+0,mod+1,mod+2,mod+3,mod+4,mod+5,mod+6,mod+7,mod+8,mod+9", OPEN_IN_EDITOR: "mod+o", COPY_PATH: "mod+shift+c", TOGGLE_FOCUS: "mod+r", @@ -122,8 +123,8 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ }, { id: "switch-starred-channel", - keys: "mod+1-4", - description: "Switch to starred channel 1-4", + keys: "mod+0-9", + description: "Switch to channel (⌘0 = #me, ⌘1-9 = starred)", category: "navigation", context: "Channels", }, From 57e83cb933f3d468b93f1199f9021320e22a7e86 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 25 Jul 2026 15:45:02 +0200 Subject: [PATCH 36/57] feat(canvas): star/unstar in switcher rows + channel header, Feed nav item - Switcher rows get a hover-revealed star toggle (always visible when starred); right-click keeps working - Channel header (feed) gets a star button next to the channel name - Channel sidebar gains a Feed row above Context that routes to the channel home - Star/unstar tracked as channel actions on both surfaces Generated-By: PostHog Code Task-Id: c401c7cb-e436-4da3-80bf-1d3008ee5d38 --- .../canvas/components/ChannelHeader.tsx | 48 +++++++++++++-- .../canvas/components/ChannelSidebar.tsx | 8 +++ .../canvas/components/ChannelSwitcher.tsx | 59 ++++++++++++++----- 3 files changed, 94 insertions(+), 21 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ChannelHeader.tsx b/packages/ui/src/features/canvas/components/ChannelHeader.tsx index 340f4ba0cf..5c1ec9d441 100644 --- a/packages/ui/src/features/canvas/components/ChannelHeader.tsx +++ b/packages/ui/src/features/canvas/components/ChannelHeader.tsx @@ -1,20 +1,55 @@ -import { HashIcon } from "@phosphor-icons/react"; +import { HashIcon, 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 { 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"; +// Star/unstar the channel from its header — the feed-side counterpart to the +// switcher's hover star. +function ChannelStarButton({ channel }: { channel: Channel }) { + const { isStarred, toggleStar } = useChannelStarToggle(channel); + return ( + + ); +} + // The shared channel header: a clickable "# channel" routing to the channel -// home. The new layout drops the section tab strip (the channel sidebar -// carries those entries); flag off keeps the strip as before. +// home, plus a star toggle. The new layout drops the section tab strip (the +// channel sidebar carries those entries); flag off keeps the strip as before. 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 mark the channel read here. @@ -36,6 +71,9 @@ export function ChannelHeader({ channelId }: { channelId: string }) { {channelName ?? "Channel"} + {channel && channel.name !== PERSONAL_CHANNEL_NAME && ( + + )} {!channelsLayout && }
); diff --git a/packages/ui/src/features/canvas/components/ChannelSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelSidebar.tsx index aa481c223b..d6ef4ec234 100644 --- a/packages/ui/src/features/canvas/components/ChannelSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ChannelSidebar.tsx @@ -1,5 +1,6 @@ import { BookOpenTextIcon, + ChatsCircleIcon, FunnelSimple as FunnelSimpleIcon, MagnifyingGlass, PackageIcon, @@ -249,6 +250,13 @@ export function ChannelSidebar({ channelId }: { channelId: string }) {
+ {sectionRow( + "Feed", + , + base, + () => + void navigate({ to: "/website/$channelId", params: { channelId } }), + )} {sectionRow( "Context", , diff --git a/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx b/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx index fe04ab9ec3..9f933b21ba 100644 --- a/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx +++ b/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx @@ -42,8 +42,9 @@ import { useHotkeys } from "react-hotkeys-hook"; // (see SWITCH_STARRED_CHANNEL). const STARRED_HOTKEY_SLOTS = 9; -// One channel in the switcher; right-click stars/unstars it. #me and the first -// nine starred channels show their mod+N hotkey. +// One channel in the switcher: click switches, the star toggles (hover-revealed +// unless starred), right-click offers the same. #me and the first nine starred +// channels show their mod+N hotkey. function SwitcherRow({ channel, active, @@ -58,25 +59,51 @@ function SwitcherRow({ const isMe = channel.name === PERSONAL_CHANNEL_NAME; const { isStarred, toggleStar } = useChannelStarToggle(channel); + const trackedToggleStar = () => { + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: isStarred ? "unstar" : "star", + surface: "sidebar", + channel_id: channel.id, + }); + toggleStar(); + }; + const row = ( - + {!isMe && ( + )} - +
); if (isMe) return row; @@ -84,7 +111,7 @@ function SwitcherRow({ - + {isStarred ? "Unstar channel" : "Star channel"} From 66cf620da5d9d8b11ee7f730ea68506bc96ff756 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 25 Jul 2026 15:45:04 +0200 Subject: [PATCH 37/57] feat(canvas): Arc-style nav tiles with Command Center, switcher star + side popover - Bring Command Center back as a global nav destination inside a channel (it was only reachable from the pre-channel landing nav) - Search / Inbox / Activity / Command Center become a 4-up grid of square icon tiles, Arc-style: label in the tooltip, count badge in the corner - Add a star toggle beside the channel switcher trigger so the header matches the switcher rows and the feed header - Channel switcher popover opens to the right of the sidebar instead of below, so it no longer covers the channel list Generated-By: PostHog Code Task-Id: c401c7cb-e436-4da3-80bf-1d3008ee5d38 --- .../features/canvas/components/ChannelNav.tsx | 150 ++++++++++++++++-- .../canvas/components/ChannelSwitcher.tsx | 36 ++++- 2 files changed, 165 insertions(+), 21 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ChannelNav.tsx b/packages/ui/src/features/canvas/components/ChannelNav.tsx index fd908afd75..865dc2efcc 100644 --- a/packages/ui/src/features/canvas/components/ChannelNav.tsx +++ b/packages/ui/src/features/canvas/components/ChannelNav.tsx @@ -1,34 +1,121 @@ +import { + BellIcon, + EnvelopeSimple, + Lightning, + MagnifyingGlass, +} from "@phosphor-icons/react"; +import { countUnseenActivity } from "@posthog/core/canvas/mentionActivity"; +import { cn } from "@posthog/quill"; import { ANALYTICS_EVENTS, type SidebarNavItem, } from "@posthog/shared/analytics-events"; +import { useMentionActivity } from "@posthog/ui/features/canvas/hooks/useMentionActivity"; +import { useActivitySeenStore } from "@posthog/ui/features/canvas/stores/activitySeenStore"; +import { SHORTCUTS } from "@posthog/ui/features/command/keyboard-shortcuts"; +import { useCommandCenterStore } from "@posthog/ui/features/command-center/commandCenterStore"; import { useInboxAllReports } from "@posthog/ui/features/inbox/hooks/useInboxAllReports"; -import { ActivityItem } from "@posthog/ui/features/sidebar/components/items/ActivityItem"; -import { InboxItem } from "@posthog/ui/features/sidebar/components/items/InboxItem"; import { NewTaskItem } from "@posthog/ui/features/sidebar/components/items/NewTaskItem"; -import { SearchItem } from "@posthog/ui/features/sidebar/components/items/SearchItem"; +import { Tooltip } from "@posthog/ui/primitives/Tooltip"; import { navigateToActivity, navigateToChannelNewTask, navigateToInbox, + navigateToWebsiteCommandCenter, } from "@posthog/ui/router/navigationBridge"; import { useAppView } from "@posthog/ui/router/useAppView"; import { track } from "@posthog/ui/shell/analytics"; import { useCommandMenuStore } from "@posthog/ui/shell/commandMenuStore"; import { Box, Flex } from "@radix-ui/themes"; import { useRouterState } from "@tanstack/react-router"; +import { type ReactNode, useMemo } from "react"; const INBOX_REFETCH_INTERVAL_MS = 60_000; -/** New task / Search / Inbox / Activity rows shown above the channel switcher. */ +// A corner count on a nav tile — red for unread-style counts, neutral for +// ambient counts like the command center's filled cells. +function TileBadge({ + count, + tone = "notification", +}: { + count: number; + tone?: "notification" | "neutral"; +}) { + if (count <= 0) return null; + return ( + + {count > 99 ? "99+" : count} + + ); +} + +// An Arc-style square icon tile: label lives in the tooltip, count in the +// corner. Used for the channel-scoped global nav (search / inbox / activity / +// command center). +function NavTile({ + icon, + label, + shortcut, + isActive, + onClick, + badge, +}: { + icon: ReactNode; + label: string; + shortcut?: string; + isActive: boolean; + onClick: () => void; + badge?: ReactNode; +}) { + return ( + + + + ); +} + +/** + * The channel-scoped global nav: New task on top, then an Arc-style tile grid + * for Search / Inbox / Activity / Command Center. Shown above the channel + * switcher. + */ export function ChannelNav({ channelId }: { channelId: string }) { const view = useAppView(); const pathname = useRouterState({ select: (s) => s.location.pathname }); const openCommandMenu = useCommandMenuStore((s) => s.open); + const { counts } = useInboxAllReports({ ignoreFilters: true, refetchIntervalMs: INBOX_REFETCH_INTERVAL_MS, }); + const { items: activityItems } = useMentionActivity(); + const lastSeenAt = useActivitySeenStore((s) => s.lastSeenAt); + const unseenActivity = useMemo( + () => countUnseenActivity(activityItems, lastSeenAt), + [activityItems, lastSeenAt], + ); + // Command Center lives in the /website space here; count the filled grid + // cells as its active indicator. + const commandCenterCells = useCommandCenterStore((s) => s.cells); + const commandCenterCount = commandCenterCells.filter((c) => c != null).length; const withTrack = (item: SidebarNavItem, action: () => void) => () => { track(ANALYTICS_EVENTS.SIDEBAR_NAV_ITEM_CLICKED, { item, in_more: false }); @@ -36,8 +123,8 @@ export function ChannelNav({ channelId }: { channelId: string }) { }; return ( - - + + @@ -45,16 +132,47 @@ export function ChannelNav({ channelId }: { channelId: string }) { )} /> - - - +
+ } + label="Search" + shortcut={SHORTCUTS.COMMAND_MENU} + isActive={false} + onClick={withTrack("search", openCommandMenu)} + /> + } + label="Inbox" + shortcut={SHORTCUTS.INBOX} + isActive={view.type === "inbox"} + onClick={withTrack("inbox", navigateToInbox)} + badge={} + /> + + } + label="Activity" + isActive={view.type === "activity"} + onClick={withTrack("activity", navigateToActivity)} + badge={} + /> + + } + label="Command Center" + isActive={view.type === "command-center"} + onClick={withTrack("command_center", navigateToWebsiteCommandCenter)} + badge={} + /> +
); } diff --git a/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx b/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx index 9f933b21ba..8b731a2ec3 100644 --- a/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx +++ b/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx @@ -120,6 +120,29 @@ function SwitcherRow({ ); } +// Star toggle for the current channel, sitting beside the switcher trigger so +// the header stays consistent with the switcher rows and the feed header. +function TriggerStar({ channel }: { channel: Channel }) { + const { isStarred, toggleStar } = useChannelStarToggle(channel); + return ( + + ); +} + /** * The channel header as a switcher: clicking the "# channel" title opens a * searchable popover — "#me" first, then starred channels, then the rest. @@ -195,14 +218,14 @@ export function ChannelSwitcher({ channelId }: { channelId: string }) { }; return ( - <> +
@@ -219,8 +242,8 @@ export function ChannelSwitcher({ channelId }: { channelId: string }) { /> + {current && current.name !== PERSONAL_CHANNEL_NAME && ( + + )} - +
); } From c6ce91b52c6d286026f73c5e1ce20e432977883a Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 25 Jul 2026 15:45:05 +0200 Subject: [PATCH 38/57] fix(canvas): fixed-size captioned nav tiles, star inside switcher, popover clears sidebar - Nav tiles are a fixed 48px instead of aspect-square in a fluid grid, so they no longer balloon as the sidebar widens. Four fit inside SIDEBAR_MIN_WIDTH, so the row never wraps. - Each tile gets a caption under its icon, so the destination is legible without hovering; the tooltip keeps the full name plus a properly formatted shortcut. - New task moves below the launcher row. - The switcher star becomes an overlay parked in a reserved well inside the trigger rather than a sibling button, so the trigger keeps a predictable full-row width. - The switcher popover offset now clears the row's 8px inset plus an 8px gap, landing it just past the content pane border instead of hovering over the sidebar. Generated-By: PostHog Code Task-Id: c401c7cb-e436-4da3-80bf-1d3008ee5d38 --- .../features/canvas/components/ChannelNav.tsx | 87 +++++++++++-------- .../canvas/components/ChannelSwitcher.tsx | 34 +++++--- 2 files changed, 73 insertions(+), 48 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ChannelNav.tsx b/packages/ui/src/features/canvas/components/ChannelNav.tsx index 865dc2efcc..0b635e27b8 100644 --- a/packages/ui/src/features/canvas/components/ChannelNav.tsx +++ b/packages/ui/src/features/canvas/components/ChannelNav.tsx @@ -12,7 +12,10 @@ import { } from "@posthog/shared/analytics-events"; import { useMentionActivity } from "@posthog/ui/features/canvas/hooks/useMentionActivity"; import { useActivitySeenStore } from "@posthog/ui/features/canvas/stores/activitySeenStore"; -import { SHORTCUTS } from "@posthog/ui/features/command/keyboard-shortcuts"; +import { + formatHotkey, + SHORTCUTS, +} from "@posthog/ui/features/command/keyboard-shortcuts"; import { useCommandCenterStore } from "@posthog/ui/features/command-center/commandCenterStore"; import { useInboxAllReports } from "@posthog/ui/features/inbox/hooks/useInboxAllReports"; import { NewTaskItem } from "@posthog/ui/features/sidebar/components/items/NewTaskItem"; @@ -26,14 +29,20 @@ import { import { useAppView } from "@posthog/ui/router/useAppView"; import { track } from "@posthog/ui/shell/analytics"; import { useCommandMenuStore } from "@posthog/ui/shell/commandMenuStore"; -import { Box, Flex } from "@radix-ui/themes"; +import { Flex } from "@radix-ui/themes"; import { useRouterState } from "@tanstack/react-router"; import { type ReactNode, useMemo } from "react"; const INBOX_REFETCH_INTERVAL_MS = 60_000; +// Tiles are a fixed 48px so they read the same at any sidebar width. Four of +// them plus gaps fit inside SIDEBAR_MIN_WIDTH (240 - 16 padding = 224 ≥ 210), +// so the row never wraps; a wider sidebar just leaves trailing space. +const TILE_CLASS = + "relative flex size-12 shrink-0 flex-col items-center justify-center gap-0.5 rounded-lg border border-border bg-gray-2 text-gray-11 transition-colors hover:bg-gray-3 hover:text-gray-12"; + // A corner count on a nav tile — red for unread-style counts, neutral for -// ambient counts like the command center's filled cells. +// ambient ones like the command center's filled cells. function TileBadge({ count, tone = "notification", @@ -45,7 +54,7 @@ function TileBadge({ return ( void; @@ -81,11 +94,14 @@ function NavTile({ aria-label={label} onClick={onClick} className={cn( - "relative flex aspect-square items-center justify-center rounded-lg border border-border bg-gray-2 text-gray-11 transition-colors hover:bg-gray-3 hover:text-gray-12", + TILE_CLASS, isActive && "border-transparent bg-fill-selected text-gray-12", )} > {icon} + + {caption} + {badge} @@ -93,9 +109,8 @@ function NavTile({ } /** - * The channel-scoped global nav: New task on top, then an Arc-style tile grid - * for Search / Inbox / Activity / Command Center. Shown above the channel - * switcher. + * The channel-scoped global nav: a launcher row for Search / Inbox / Activity + * / Command Center, then New task. Sits above the channel switcher. */ export function ChannelNav({ channelId }: { channelId: string }) { const view = useAppView(); @@ -112,8 +127,8 @@ export function ChannelNav({ channelId }: { channelId: string }) { () => countUnseenActivity(activityItems, lastSeenAt), [activityItems, lastSeenAt], ); - // Command Center lives in the /website space here; count the filled grid - // cells as its active indicator. + // Command Center lives in the /website space here; its filled grid cells are + // the ambient "how much is parked in there" count. const commandCenterCells = useCommandCenterStore((s) => s.cells); const commandCenterCount = commandCenterCells.filter((c) => c != null).length; @@ -122,57 +137,57 @@ export function ChannelNav({ channelId }: { channelId: string }) { action(); }; + const isActivity = view.type === "activity"; + const isCommandCenter = view.type === "command-center"; + return ( - - - navigateToChannelNewTask(channelId), - )} - /> - -
+
} + icon={} label="Search" - shortcut={SHORTCUTS.COMMAND_MENU} + caption="Search" + shortcut={formatHotkey(SHORTCUTS.COMMAND_MENU)} isActive={false} onClick={withTrack("search", openCommandMenu)} /> } + icon={} label="Inbox" - shortcut={SHORTCUTS.INBOX} + caption="Inbox" + shortcut={formatHotkey(SHORTCUTS.INBOX)} isActive={view.type === "inbox"} onClick={withTrack("inbox", navigateToInbox)} badge={} /> - } + icon={} label="Activity" - isActive={view.type === "activity"} + caption="Activity" + isActive={isActivity} onClick={withTrack("activity", navigateToActivity)} badge={} /> } label="Command Center" - isActive={view.type === "command-center"} + caption="Command" + isActive={isCommandCenter} onClick={withTrack("command_center", navigateToWebsiteCommandCenter)} badge={} />
+ + navigateToChannelNewTask(channelId), + )} + /> ); } diff --git a/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx b/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx index 8b731a2ec3..a9509123f5 100644 --- a/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx +++ b/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx @@ -42,6 +42,12 @@ import { useHotkeys } from "react-hotkeys-hook"; // (see SWITCH_STARRED_CHANNEL). const STARRED_HOTKEY_SLOTS = 9; +// The popover opens to the right of the sidebar, anchored to the trigger — +// which is inset from the sidebar edge by the row's `mx-2`. Clearing that 8px +// plus an 8px gap lands the panel just past the content pane's left border +// instead of hovering over the sidebar. +const POPOVER_SIDE_OFFSET = 16; + // One channel in the switcher: click switches, the star toggles (hover-revealed // unless starred), right-click offers the same. #me and the first nine starred // channels show their mod+N hotkey. @@ -120,8 +126,11 @@ function SwitcherRow({ ); } -// Star toggle for the current channel, sitting beside the switcher trigger so +// Star toggle for the current channel, overlaid inside the switcher trigger so // the header stays consistent with the switcher rows and the feed header. +// Deliberately an overlay rather than a sibling: a sibling would shrink the +// trigger, and the popover anchors to the trigger's right edge (see +// POPOVER_SIDE_OFFSET). function TriggerStar({ channel }: { channel: Channel }) { const { isStarred, toggleStar } = useChannelStarToggle(channel); return ( @@ -136,7 +145,9 @@ function TriggerStar({ channel }: { channel: Channel }) { }); toggleStar(); }} - className="flex size-7 shrink-0 items-center justify-center rounded-md border border-border text-gray-10 transition-colors hover:bg-gray-3 hover:text-gray-12" + // Parks in the `size-6` well the trigger reserves: 8px trigger padding + + // 12px caret + 6px gap = 26px in from the row's right edge. + className="-translate-y-1/2 absolute top-1/2 right-[26px] flex size-6 items-center justify-center rounded text-gray-10 transition-colors hover:bg-gray-4 hover:text-gray-12" > @@ -217,15 +228,17 @@ export function ChannelSwitcher({ channelId }: { channelId: string }) { return index >= 0 && index < STARRED_HOTKEY_SLOTS ? index + 1 : undefined; }; + const showStar = current != null && current.name !== PERSONAL_CHANNEL_NAME; + return ( -
+
@@ -233,17 +246,16 @@ export function ChannelSwitcher({ channelId }: { channelId: string }) { {current?.name ?? "channel"} - + {/* Well the overlaid star parks in, so it never covers the name. */} + {showStar && } + } /> - {current && current.name !== PERSONAL_CHANNEL_NAME && ( - - )} + {showStar && current && }
); From 5dc6b0e9cc0e4420c3e1714397116a77181ec017 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 25 Jul 2026 15:45:06 +0200 Subject: [PATCH 39/57] design(canvas): 2x2 labelled launcher pills, New task split out below a rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The row of square tiles couldn't carry its own labels: four squares across SIDEBAR_MIN_WIDTH leave ~44px of caption, so "Command Center" truncated to "Comm…". Segmented-control practice is to size every cell to the longest label and keep labels to one or two words, so the cells become landscape pills in a 2x2 — icon beside an 11px label instead of a 10px caption crammed under it, with "Command" abbreviating the longest name and the tooltip carrying it in full. Also drops the per-tile borders (four competing outlines) for a filled surface with a three-step rest/hover/selected progression, moves counts inline to the trailing edge, and separates New task from the launcher with a rule — it acts on the current channel rather than being a global destination. Widths stay fixed rather than fluid, so the pills don't balloon as the sidebar widens. Generated-By: PostHog Code Task-Id: c401c7cb-e436-4da3-80bf-1d3008ee5d38 --- .../features/canvas/components/ChannelNav.tsx | 123 ++++++++++-------- 1 file changed, 71 insertions(+), 52 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ChannelNav.tsx b/packages/ui/src/features/canvas/components/ChannelNav.tsx index 0b635e27b8..2e6590b928 100644 --- a/packages/ui/src/features/canvas/components/ChannelNav.tsx +++ b/packages/ui/src/features/canvas/components/ChannelNav.tsx @@ -5,7 +5,7 @@ import { MagnifyingGlass, } from "@phosphor-icons/react"; import { countUnseenActivity } from "@posthog/core/canvas/mentionActivity"; -import { cn } from "@posthog/quill"; +import { cn, Separator } from "@posthog/quill"; import { ANALYTICS_EVENTS, type SidebarNavItem, @@ -29,21 +29,28 @@ import { import { useAppView } from "@posthog/ui/router/useAppView"; import { track } from "@posthog/ui/shell/analytics"; import { useCommandMenuStore } from "@posthog/ui/shell/commandMenuStore"; -import { Flex } from "@radix-ui/themes"; import { useRouterState } from "@tanstack/react-router"; import { type ReactNode, useMemo } from "react"; const INBOX_REFETCH_INTERVAL_MS = 60_000; -// Tiles are a fixed 48px so they read the same at any sidebar width. Four of -// them plus gaps fit inside SIDEBAR_MIN_WIDTH (240 - 16 padding = 224 ≥ 210), -// so the row never wraps; a wider sidebar just leaves trailing space. -const TILE_CLASS = - "relative flex size-12 shrink-0 flex-col items-center justify-center gap-0.5 rounded-lg border border-border bg-gray-2 text-gray-11 transition-colors hover:bg-gray-3 hover:text-gray-12"; +/* + * Landscape pills in a 2x2 rather than a row of squares: a square small enough + * to fit four across SIDEBAR_MIN_WIDTH leaves ~44px of caption, which truncates + * the longest label ("Command Center" → "Comm…"). Segmented-control practice is + * to size every cell to the longest label and keep labels to one or two words, + * so the cells run landscape at a legible 11px and the label is abbreviated to + * "Command" — the tooltip carries the full name. + * + * Width is fixed, not fluid: two 104px pills plus the gap come to 214px, inside + * the 224px that SIDEBAR_MIN_WIDTH leaves after padding, and they stay that size + * as the sidebar widens instead of ballooning. + */ +const PILL_WIDTH_CLASS = "w-[104px]"; -// A corner count on a nav tile — red for unread-style counts, neutral for -// ambient ones like the command center's filled cells. -function TileBadge({ +// Inbox/activity counts read as unread (red); the command center's filled cells +// are ambient, so they stay neutral. +function PillBadge({ count, tone = "notification", }: { @@ -54,10 +61,10 @@ function TileBadge({ return ( {count > 99 ? "99+" : count} @@ -66,14 +73,14 @@ function TileBadge({ } /** - * A launcher tile: icon over a short caption, so the destination is legible - * without hovering. The tooltip carries the full name and shortcut, which is - * what `caption` is abbreviated from. + * One global destination. The selected state layers a fill with brighter text + * rather than relying on colour alone, and the tooltip carries the full name + * plus shortcut — which is what `label` is abbreviated from. */ -function NavTile({ +function NavPill({ icon, label, - caption, + fullLabel, shortcut, isActive, onClick, @@ -81,26 +88,37 @@ function NavTile({ }: { icon: ReactNode; label: string; - caption: string; + fullLabel?: string; shortcut?: string; isActive: boolean; onClick: () => void; badge?: ReactNode; }) { + const name = fullLabel ?? label; return ( - + @@ -109,8 +127,9 @@ function NavTile({ } /** - * The channel-scoped global nav: a launcher row for Search / Inbox / Activity - * / Command Center, then New task. Sits above the channel switcher. + * The channel-scoped global nav: a 2x2 launcher for Search / Inbox / Activity / + * Command Center, then New task below a rule — New task is an action on the + * current channel, not one of the global destinations. */ export function ChannelNav({ channelId }: { channelId: string }) { const view = useAppView(); @@ -141,53 +160,53 @@ export function ChannelNav({ channelId }: { channelId: string }) { const isCommandCenter = view.type === "command-center"; return ( - -
- } +
+
+ } label="Search" - caption="Search" shortcut={formatHotkey(SHORTCUTS.COMMAND_MENU)} isActive={false} onClick={withTrack("search", openCommandMenu)} /> - } + } label="Inbox" - caption="Inbox" shortcut={formatHotkey(SHORTCUTS.INBOX)} isActive={view.type === "inbox"} onClick={withTrack("inbox", navigateToInbox)} - badge={} + badge={} /> - } + } label="Activity" - caption="Activity" isActive={isActivity} onClick={withTrack("activity", navigateToActivity)} - badge={} + badge={} /> - } - label="Command Center" - caption="Command" + label="Command" + fullLabel="Command Center" isActive={isCommandCenter} onClick={withTrack("command_center", navigateToWebsiteCommandCenter)} - badge={} + badge={} />
- - navigateToChannelNewTask(channelId), - )} - /> - + +
+ + navigateToChannelNewTask(channelId), + )} + /> +
+
); } From ce3559b0621f62339d5fe9ba8e252aa1aed716fe Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 25 Jul 2026 15:45:08 +0200 Subject: [PATCH 40/57] design(canvas): search moves to the title bar, nav collapses to one icon row, New task under the switcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Search leaves the sidebar nav for the title bar, beside the history controls — it is chrome rather than a destination, and it keeps company with the back/forward group. - The remaining destinations (Inbox, Activity, Command Center) collapse to a single row of icon-only buttons; names and shortcuts move into tooltips, and counts become corner notification badges. - New task moves under the channel switcher, where it reads as an action on the current channel rather than a global destination. Its own separator is gone, since ChannelsSidebar already rules off the nav. ChannelNav no longer needs a channelId now that New task has moved out. Generated-By: PostHog Code Task-Id: c401c7cb-e436-4da3-80bf-1d3008ee5d38 --- .../features/canvas/components/ChannelNav.tsx | 150 +++++------------- .../canvas/components/ChannelSidebar.tsx | 17 ++ .../canvas/components/ChannelsSidebar.tsx | 2 +- packages/ui/src/router/routes/__root.tsx | 67 +++++--- 4 files changed, 109 insertions(+), 127 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ChannelNav.tsx b/packages/ui/src/features/canvas/components/ChannelNav.tsx index 2e6590b928..31f41888df 100644 --- a/packages/ui/src/features/canvas/components/ChannelNav.tsx +++ b/packages/ui/src/features/canvas/components/ChannelNav.tsx @@ -1,11 +1,6 @@ -import { - BellIcon, - EnvelopeSimple, - Lightning, - MagnifyingGlass, -} from "@phosphor-icons/react"; +import { BellIcon, EnvelopeSimple, Lightning } from "@phosphor-icons/react"; import { countUnseenActivity } from "@posthog/core/canvas/mentionActivity"; -import { cn, Separator } from "@posthog/quill"; +import { cn } from "@posthog/quill"; import { ANALYTICS_EVENTS, type SidebarNavItem, @@ -18,39 +13,23 @@ import { } from "@posthog/ui/features/command/keyboard-shortcuts"; import { useCommandCenterStore } from "@posthog/ui/features/command-center/commandCenterStore"; import { useInboxAllReports } from "@posthog/ui/features/inbox/hooks/useInboxAllReports"; -import { NewTaskItem } from "@posthog/ui/features/sidebar/components/items/NewTaskItem"; import { Tooltip } from "@posthog/ui/primitives/Tooltip"; import { navigateToActivity, - navigateToChannelNewTask, navigateToInbox, navigateToWebsiteCommandCenter, } from "@posthog/ui/router/navigationBridge"; import { useAppView } from "@posthog/ui/router/useAppView"; import { track } from "@posthog/ui/shell/analytics"; -import { useCommandMenuStore } from "@posthog/ui/shell/commandMenuStore"; -import { useRouterState } from "@tanstack/react-router"; -import { type ReactNode, useMemo } from "react"; +import type { ReactNode } from "react"; +import { useMemo } from "react"; const INBOX_REFETCH_INTERVAL_MS = 60_000; -/* - * Landscape pills in a 2x2 rather than a row of squares: a square small enough - * to fit four across SIDEBAR_MIN_WIDTH leaves ~44px of caption, which truncates - * the longest label ("Command Center" → "Comm…"). Segmented-control practice is - * to size every cell to the longest label and keep labels to one or two words, - * so the cells run landscape at a legible 11px and the label is abbreviated to - * "Command" — the tooltip carries the full name. - * - * Width is fixed, not fluid: two 104px pills plus the gap come to 214px, inside - * the 224px that SIDEBAR_MIN_WIDTH leaves after padding, and they stay that size - * as the sidebar widens instead of ballooning. - */ -const PILL_WIDTH_CLASS = "w-[104px]"; - // Inbox/activity counts read as unread (red); the command center's filled cells -// are ambient, so they stay neutral. -function PillBadge({ +// are ambient, so they stay neutral. Sits proud of the button corner the way a +// notification badge does, which the row's gap leaves room for. +function IconBadge({ count, tone = "notification", }: { @@ -61,7 +40,7 @@ function PillBadge({ return ( void; badge?: ReactNode; }) { - const name = fullLabel ?? label; return ( - + @@ -127,14 +88,13 @@ function NavPill({ } /** - * The channel-scoped global nav: a 2x2 launcher for Search / Inbox / Activity / - * Command Center, then New task below a rule — New task is an action on the - * current channel, not one of the global destinations. + * The channel-scoped global destinations — Inbox, Activity, Command Center — as + * a single row of icon buttons above the channel switcher. Search lives in the + * title bar next to the history controls, and New task sits under the switcher + * since it acts on the current channel. */ -export function ChannelNav({ channelId }: { channelId: string }) { +export function ChannelNav() { const view = useAppView(); - const pathname = useRouterState({ select: (s) => s.location.pathname }); - const openCommandMenu = useCommandMenuStore((s) => s.open); const { counts } = useInboxAllReports({ ignoreFilters: true, @@ -160,53 +120,31 @@ export function ChannelNav({ channelId }: { channelId: string }) { const isCommandCenter = view.type === "command-center"; return ( -
-
- } - label="Search" - shortcut={formatHotkey(SHORTCUTS.COMMAND_MENU)} - isActive={false} - onClick={withTrack("search", openCommandMenu)} - /> - } - 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" - fullLabel="Command Center" - isActive={isCommandCenter} - onClick={withTrack("command_center", navigateToWebsiteCommandCenter)} - badge={} - /> -
- -
- - navigateToChannelNewTask(channelId), - )} - /> -
+
+ } + 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 index d6ef4ec234..26aead4ca4 100644 --- a/packages/ui/src/features/canvas/components/ChannelSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ChannelSidebar.tsx @@ -19,12 +19,16 @@ import { Skeleton, } from "@posthog/quill"; import { LOOPS_FLAG } from "@posthog/shared"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { ChannelItemRow } from "@posthog/ui/features/canvas/components/ChannelItemRow"; import { ChannelSwitcher } from "@posthog/ui/features/canvas/components/ChannelSwitcher"; import { useChannelItems } from "@posthog/ui/features/canvas/hooks/useChannelItems"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; +import { NewTaskItem } from "@posthog/ui/features/sidebar/components/items/NewTaskItem"; import { SidebarItem } from "@posthog/ui/features/sidebar/components/SidebarItem"; +import { navigateToChannelNewTask } from "@posthog/ui/router/navigationBridge"; +import { track } from "@posthog/ui/shell/analytics"; import { useNavigate, useRouterState } from "@tanstack/react-router"; import { type ReactNode, useState } from "react"; @@ -249,6 +253,19 @@ export function ChannelSidebar({ channelId }: { channelId: string }) {
+
+ { + track(ANALYTICS_EVENTS.SIDEBAR_NAV_ITEM_CLICKED, { + item: "new_task", + in_more: false, + }); + navigateToChannelNewTask(channelId); + }} + /> +
+
{sectionRow( "Feed", diff --git a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx index 7f6a2a1be0..ffba2a2e80 100644 --- a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx @@ -140,7 +140,7 @@ export function ChannelsSidebar() { {/* active channel → channel list → tasks */} {inChannel && currentChannelId ? ( <> - + diff --git a/packages/ui/src/router/routes/__root.tsx b/packages/ui/src/router/routes/__root.tsx index b4b291b7b2..2687475381 100644 --- a/packages/ui/src/router/routes/__root.tsx +++ b/packages/ui/src/router/routes/__root.tsx @@ -2,6 +2,7 @@ import { ArrowSquareOut, CaretLeftIcon, CaretRightIcon, + MagnifyingGlass, } from "@phosphor-icons/react"; import { useHostTRPC, useHostTRPCClient } from "@posthog/host-router/react"; import { Button, ButtonGroup } from "@posthog/quill"; @@ -34,6 +35,10 @@ import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannels import { CommandMenu } from "@posthog/ui/features/command/CommandMenu"; import { GlobalFilePicker } from "@posthog/ui/features/command/GlobalFilePicker"; import { KeyboardShortcutsSheet } from "@posthog/ui/features/command/KeyboardShortcutsSheet"; +import { + formatHotkey, + SHORTCUTS, +} from "@posthog/ui/features/command/keyboard-shortcuts"; import { ConnectivityBanner } from "@posthog/ui/features/connectivity/ConnectivityBanner"; import { useNewTaskDeepLink } from "@posthog/ui/features/deep-links/useNewTaskDeepLink"; import { useOpenTargetDeepLink } from "@posthog/ui/features/deep-links/useOpenTargetDeepLink"; @@ -59,6 +64,7 @@ import { UpdateAvailableModal } from "@posthog/ui/features/updates/UpdateAvailab import { WhatsNewModal } from "@posthog/ui/features/updates/WhatsNewModal"; import { useWorkspaces } from "@posthog/ui/features/workspace/useWorkspace"; import LogosLandscape from "@posthog/ui/primitives/Logo"; +import { Tooltip } from "@posthog/ui/primitives/Tooltip"; import { useAppView } from "@posthog/ui/router/useAppView"; import { openTask, openTaskInput } from "@posthog/ui/router/useOpenTask"; import { track } from "@posthog/ui/shell/analytics"; @@ -388,28 +394,49 @@ function RootLayout() { )} - {localWorkspaces && ( + {(localWorkspaces || channelsLayout) && ( - - - + + + )} + {/* Search rides the title bar beside the history controls + rather than the sidebar nav — it's chrome, not a + destination. */} + {channelsLayout && ( + - - - + + + )} )} From 4b6b4db8afc1ec035a4123a533944c859447de58 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 25 Jul 2026 15:45:10 +0200 Subject: [PATCH 41/57] fix(canvas): stable switcher height, star on #me, elevated New task, drop nav rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The switcher trigger sized itself off its contents, and the 24px star well only existed on starrable channels — so #code rendered 4px taller than #me and everything below shifted on every switch. The trigger now has a fixed height and always reserves the well. - #me shows a dimmed, non-interactive star in both the trigger and the dropdown row. It is permanently pinned to slot 0 so there is nothing to toggle, but the slot keeps every row's trailing edge aligned and the trigger identical across channels. - New task gains an opt-in elevated variant (quill outline button: distinct fill plus a hairline border) so it reads as the primary action rather than another nav row. The legacy sidebar keeps the plain row. - Drops the rule between the nav icons and the switcher — the bordered switcher already separates them. Generated-By: PostHog Code Task-Id: c401c7cb-e436-4da3-80bf-1d3008ee5d38 --- .../canvas/components/ChannelSidebar.tsx | 1 + .../canvas/components/ChannelSwitcher.tsx | 31 ++++++++++-- .../canvas/components/ChannelsSidebar.tsx | 3 +- .../sidebar/components/items/NewTaskItem.tsx | 47 ++++++++++++++++--- 4 files changed, 70 insertions(+), 12 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ChannelSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelSidebar.tsx index 26aead4ca4..2266de1c49 100644 --- a/packages/ui/src/features/canvas/components/ChannelSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ChannelSidebar.tsx @@ -255,6 +255,7 @@ export function ChannelSidebar({ channelId }: { channelId: string }) {
{ track(ANALYTICS_EVENTS.SIDEBAR_NAV_ITEM_CLICKED, { diff --git a/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx b/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx index a9509123f5..1da6ffb451 100644 --- a/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx +++ b/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx @@ -96,7 +96,17 @@ function SwitcherRow({ {formatHotkey(`mod+${hotkeySlot}`)} )} - {!isMe && ( + {isMe ? ( + // #me is permanently pinned to slot 0, so it shows a star but doesn't + // offer to toggle one. Occupying the slot also keeps every row's + // trailing edge aligned. + + + + ) : ( } @@ -315,6 +328,16 @@ export function ChannelSwitcher({ channelId }: { channelId: string }) { {showStar && current && } + {/* #me can't be starred, but it still shows one so the trigger reads the + same on every channel. */} + {current && !showStar && ( + + + + )}
); diff --git a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx index ffba2a2e80..3fafb72513 100644 --- a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx @@ -140,8 +140,9 @@ export function ChannelsSidebar() { {/* active channel → channel list → tasks */} {inChannel && currentChannelId ? ( <> + {/* No rule under the nav — the bordered channel switcher directly + below already reads as the divider. */} - diff --git a/packages/ui/src/features/sidebar/components/items/NewTaskItem.tsx b/packages/ui/src/features/sidebar/components/items/NewTaskItem.tsx index a4fd352279..32fdcca0e4 100644 --- a/packages/ui/src/features/sidebar/components/items/NewTaskItem.tsx +++ b/packages/ui/src/features/sidebar/components/items/NewTaskItem.tsx @@ -1,5 +1,5 @@ import { Plus } from "@phosphor-icons/react"; -import { Badge } from "@posthog/quill"; +import { Badge, Button, cn } from "@posthog/quill"; import { 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"; @@ -9,12 +9,49 @@ import { SidebarKbdHint } from "./SidebarKbdHint"; interface NewTaskItemProps { isActive: boolean; onClick: () => void; + /** + * Renders as a raised button rather than a plain nav row — for surfaces where + * New task is the primary action sitting next to a list of destinations, + * rather than one row among equals. + */ + elevated?: boolean; } -export function NewTaskItem({ isActive, onClick }: NewTaskItemProps) { +export function NewTaskItem({ + isActive, + onClick, + elevated = false, +}: NewTaskItemProps) { const hasDraft = useDraftStore( (s) => !isContentEmpty(s.drafts["task-input"]), ); + const draftBadge = hasDraft ? ( + + Draft + + ) : null; + + if (elevated) { + return ( + + ); + } + return ( - {hasDraft ? ( - - Draft - - ) : null} + {draftBadge} } From 569d3374634091f596403d0dba822f099459e465 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 25 Jul 2026 15:45:12 +0200 Subject: [PATCH 42/57] design(canvas): New task becomes a floating action button over the channel list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit As a bordered row directly beneath the channel switcher, New task was a second box of near-identical weight — two equal-weight controls competing at the top of the sidebar, with plain rows underneath. Neither won, and the stack read top-heavy. Moving it to a FAB separates action from navigation spatially: the top is now one chrome control plus flat destination rows, and the primary action gets primary treatment. It reuses the accent-filled round button the channel list already floats over its own list (ChannelsFab), so the two views stay coherent. The "Draft" badge becomes a pip on the button, drawn in the button's foreground so it stays legible on the accent fill, with the draft state also called out in the tooltip. Reverts the elevated NewTaskItem variant added for the previous attempt, now that nothing uses it. Generated-By: PostHog Code Task-Id: c401c7cb-e436-4da3-80bf-1d3008ee5d38 --- .../canvas/components/ChannelSidebar.tsx | 112 ++++++++---------- .../features/canvas/components/NewTaskFab.tsx | 58 +++++++++ .../sidebar/components/items/NewTaskItem.tsx | 47 ++------ 3 files changed, 115 insertions(+), 102 deletions(-) create mode 100644 packages/ui/src/features/canvas/components/NewTaskFab.tsx diff --git a/packages/ui/src/features/canvas/components/ChannelSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelSidebar.tsx index 2266de1c49..f62441e6fa 100644 --- a/packages/ui/src/features/canvas/components/ChannelSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ChannelSidebar.tsx @@ -19,16 +19,13 @@ import { Skeleton, } from "@posthog/quill"; import { LOOPS_FLAG } from "@posthog/shared"; -import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { ChannelItemRow } from "@posthog/ui/features/canvas/components/ChannelItemRow"; import { ChannelSwitcher } from "@posthog/ui/features/canvas/components/ChannelSwitcher"; +import { NewTaskFab } from "@posthog/ui/features/canvas/components/NewTaskFab"; import { useChannelItems } from "@posthog/ui/features/canvas/hooks/useChannelItems"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; -import { NewTaskItem } from "@posthog/ui/features/sidebar/components/items/NewTaskItem"; import { SidebarItem } from "@posthog/ui/features/sidebar/components/SidebarItem"; -import { navigateToChannelNewTask } from "@posthog/ui/router/navigationBridge"; -import { track } from "@posthog/ui/shell/analytics"; import { useNavigate, useRouterState } from "@tanstack/react-router"; import { type ReactNode, useState } from "react"; @@ -253,21 +250,7 @@ export function ChannelSidebar({ channelId }: { channelId: string }) {
-
- { - track(ANALYTICS_EVENTS.SIDEBAR_NAV_ITEM_CLICKED, { - item: "new_task", - in_more: false, - }); - navigateToChannelNewTask(channelId); - }} - /> -
- -
+
{sectionRow( "Feed", , @@ -308,55 +291,60 @@ export function ChannelSidebar({ channelId }: { channelId: string }) { )}
-
- {isLoading && items.length === 0 && } - - {pinnedItems.length > 0 && ( - <> - Pinned -
- {pinnedItems.map((item) => ( - - ))} -
- - )} + {/* Relative so the New task FAB can float over the list, the way + ChannelsFab does over the channel list. */} +
+
+ {isLoading && items.length === 0 && } - {(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 ? ( + {pinnedItems.length > 0 && ( + <> + Pinned
- {recentItems.map((item) => ( + {pinnedItems.map((item) => ( ))}
- ) : ( -

- Nothing matches the current search or filters. -

- )} - - )} + + )} - {!isLoading && items.length === 0 && ( -

- Tasks and canvases you create in this channel show up here. -

- )} + {(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) => ( + + ))} +
+ ) : ( +

+ Nothing matches the current search or filters. +

+ )} + + )} + + {!isLoading && items.length === 0 && ( +

+ Tasks and canvases you create in this channel show up here. +

+ )} +
+
); diff --git a/packages/ui/src/features/canvas/components/NewTaskFab.tsx b/packages/ui/src/features/canvas/components/NewTaskFab.tsx new file mode 100644 index 0000000000..b55702e58d --- /dev/null +++ b/packages/ui/src/features/canvas/components/NewTaskFab.tsx @@ -0,0 +1,58 @@ +import { PlusIcon } from "@phosphor-icons/react"; +import { Button } from "@posthog/quill"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +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 } from "@posthog/ui/primitives/Tooltip"; +import { navigateToChannelNewTask } from "@posthog/ui/router/navigationBridge"; +import { track } from "@posthog/ui/shell/analytics"; + +/** + * New task for the active channel, floated over the bottom-right of its item + * list — the same create affordance the channel list uses (see ChannelsFab). + * + * It sits here rather than in the nav stack because it is an action, not a + * destination: as a bordered row directly under the channel switcher it read as + * a second switcher, two equal-weight boxes competing at the top of the sidebar. + */ +export function NewTaskFab({ channelId }: { channelId: string }) { + const hasDraft = useDraftStore( + (s) => !isContentEmpty(s.drafts["task-input"]), + ); + + return ( + + + + ); +} diff --git a/packages/ui/src/features/sidebar/components/items/NewTaskItem.tsx b/packages/ui/src/features/sidebar/components/items/NewTaskItem.tsx index 32fdcca0e4..a4fd352279 100644 --- a/packages/ui/src/features/sidebar/components/items/NewTaskItem.tsx +++ b/packages/ui/src/features/sidebar/components/items/NewTaskItem.tsx @@ -1,5 +1,5 @@ import { Plus } from "@phosphor-icons/react"; -import { Badge, Button, cn } from "@posthog/quill"; +import { Badge } from "@posthog/quill"; import { 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"; @@ -9,49 +9,12 @@ import { SidebarKbdHint } from "./SidebarKbdHint"; interface NewTaskItemProps { isActive: boolean; onClick: () => void; - /** - * Renders as a raised button rather than a plain nav row — for surfaces where - * New task is the primary action sitting next to a list of destinations, - * rather than one row among equals. - */ - elevated?: boolean; } -export function NewTaskItem({ - isActive, - onClick, - elevated = false, -}: NewTaskItemProps) { +export function NewTaskItem({ isActive, onClick }: NewTaskItemProps) { const hasDraft = useDraftStore( (s) => !isContentEmpty(s.drafts["task-input"]), ); - const draftBadge = hasDraft ? ( - - Draft - - ) : null; - - if (elevated) { - return ( - - ); - } - return ( - {draftBadge} + {hasDraft ? ( + + Draft + + ) : null} } From 5e491dec50055a3d8bbab2aa6a25b961384c039a Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 25 Jul 2026 15:45:14 +0200 Subject: [PATCH 43/57] feat(canvas): FAB opens a New task / New canvas menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The channel FAB now opens a menu instead of going straight to the task composer, offering New task and New canvas — matching the "+" dropdown already on the channel rows. New canvas reuses the existing create path (useCreateAndOpenDashboard behind trackAndCreateCanvas) rather than a second one, so it creates on the default template, enters edit mode, navigates, and reports creation the same way every other canvas entry point does. The menu opens upward, since the button sits at the bottom. Generated-By: PostHog Code Task-Id: c401c7cb-e436-4da3-80bf-1d3008ee5d38 --- .../features/canvas/components/NewTaskFab.tsx | 103 ++++++++++++------ 1 file changed, 71 insertions(+), 32 deletions(-) diff --git a/packages/ui/src/features/canvas/components/NewTaskFab.tsx b/packages/ui/src/features/canvas/components/NewTaskFab.tsx index b55702e58d..ec63f690f4 100644 --- a/packages/ui/src/features/canvas/components/NewTaskFab.tsx +++ b/packages/ui/src/features/canvas/components/NewTaskFab.tsx @@ -1,6 +1,14 @@ -import { PlusIcon } from "@phosphor-icons/react"; -import { Button } from "@posthog/quill"; +import { ChartBarIcon, FileTextIcon, PlusIcon } from "@phosphor-icons/react"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@posthog/quill"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { trackAndCreateCanvas } from "@posthog/ui/features/canvas/createCanvasAnalytics"; +import { useCreateAndOpenDashboard } from "@posthog/ui/features/canvas/hooks/useDashboards"; import { formatHotkey, SHORTCUTS, @@ -12,8 +20,9 @@ import { navigateToChannelNewTask } from "@posthog/ui/router/navigationBridge"; import { track } from "@posthog/ui/shell/analytics"; /** - * New task for the active channel, floated over the bottom-right of its item - * list — the same create affordance the channel list uses (see ChannelsFab). + * The create affordance for the active channel, floated over the bottom-right + * of its item list — the same treatment the channel list uses (see + * ChannelsFab), and it opens its menu upward for the same reason. * * It sits here rather than in the nav stack because it is an action, not a * destination: as a bordered row directly under the channel switcher it read as @@ -23,36 +32,66 @@ export function NewTaskFab({ channelId }: { channelId: string }) { const hasDraft = useDraftStore( (s) => !isContentEmpty(s.drafts["task-input"]), ); + const createAndOpenCanvas = useCreateAndOpenDashboard(channelId); return ( - - - + + + {/* Replaces the "Draft" badge the nav row carried: a pip in the + button's own foreground, so it stays legible on the accent + fill. */} + {hasDraft && ( + + )} + + } + /> + + + { + track(ANALYTICS_EVENTS.SIDEBAR_NAV_ITEM_CLICKED, { + item: "new_task", + in_more: false, + }); + navigateToChannelNewTask(channelId); + }} + > + + New task + + { + // Create + open a canvas on the default template directly; the + // canvas's own composer drives what gets built. + trackAndCreateCanvas( + channelId, + undefined, + "sidebar", + () => void createAndOpenCanvas(), + ); + }} + > + + New canvas + + + ); } From a0c7aae127e651ce8a8e384660ddb749872d9e2f Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 25 Jul 2026 15:45:16 +0200 Subject: [PATCH 44/57] fix(sidebar): raise minimum width so the title bar strip breathes, search leads the history controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The title bar's left strip is pinned to the sidebar width and none of its contents shrink. Its intrinsic width is 228px — 78px traffic-light inset, 62px logo + toggle, 80px search + history buttons, 8px trailing padding — so at a 240px minimum the two groups sat exactly one 12px gap apart and search was pressed against the sidebar edge. The minimum is now 272, leaving them ~44px apart. Search also moves ahead of the history buttons, so the history group rather than search sits against the edge. createSidebarStore gains an optional minWidth that clamps live sets and rehydrated values alike; without it a width persisted under the old 240 minimum (or the legacy Code width adopted by the channels-sidebar migration) would survive below the new floor, since nothing re-clamped on load. The skills sidebar passes no minWidth and is unaffected. Generated-By: PostHog Code Task-Id: c401c7cb-e436-4da3-80bf-1d3008ee5d38 --- .../canvas/components/channelsSidebarStore.ts | 6 ++- packages/ui/src/features/sidebar/constants.ts | 9 ++++- packages/ui/src/router/routes/__root.tsx | 38 +++++++++---------- packages/ui/src/shell/createSidebarStore.ts | 24 ++++++++++-- 4 files changed, 53 insertions(+), 24 deletions(-) diff --git a/packages/ui/src/features/canvas/components/channelsSidebarStore.ts b/packages/ui/src/features/canvas/components/channelsSidebarStore.ts index c376b91bb1..91a42f7691 100644 --- a/packages/ui/src/features/canvas/components/channelsSidebarStore.ts +++ b/packages/ui/src/features/canvas/components/channelsSidebarStore.ts @@ -1,8 +1,12 @@ +import { SIDEBAR_MIN_WIDTH } from "@posthog/ui/features/sidebar/constants"; import { createSidebarStore } from "@posthog/ui/shell/createSidebarStore"; export const useChannelsSidebarStore = createSidebarStore({ name: "channels-sidebar", - defaultWidth: 240, + defaultWidth: SIDEBAR_MIN_WIDTH, + // Also re-clamps widths persisted before the minimum rose, and the legacy + // Code width adopted by the migration below. + minWidth: SIDEBAR_MIN_WIDTH, }); // One-time migration: the unified layout replaced the Code sidebar (whose diff --git a/packages/ui/src/features/sidebar/constants.ts b/packages/ui/src/features/sidebar/constants.ts index ede8445df3..fca2ce970b 100644 --- a/packages/ui/src/features/sidebar/constants.ts +++ b/packages/ui/src/features/sidebar/constants.ts @@ -1,6 +1,13 @@ import type { SidebarNavItem } from "@posthog/shared/analytics-events"; -export const SIDEBAR_MIN_WIDTH = 240; +/** + * The title bar's left strip is pinned to the sidebar width and its contents + * don't shrink: 78px of traffic-light inset, 62px of logo + sidebar toggle, + * 80px of search + history buttons, and 8px of trailing padding — 228px. At + * 240 that left the two groups 12px apart, with search jammed against the + * sidebar edge; this leaves them ~44px apart. + */ +export const SIDEBAR_MIN_WIDTH = 272; export const CUSTOMIZABLE_NAV_ITEMS = [ { id: "inbox", label: "Inbox", analyticsId: "inbox", defaultVisible: true }, diff --git a/packages/ui/src/router/routes/__root.tsx b/packages/ui/src/router/routes/__root.tsx index 2687475381..566c1f3f6a 100644 --- a/packages/ui/src/router/routes/__root.tsx +++ b/packages/ui/src/router/routes/__root.tsx @@ -396,6 +396,25 @@ function RootLayout() { {(localWorkspaces || channelsLayout) && ( + {/* Search rides the title bar rather than the sidebar nav — + it's chrome, not a destination — and leads the history + controls so those stay against the sidebar edge. */} + {channelsLayout && ( + + + + )} {localWorkspaces && ( - - )} )} diff --git a/packages/ui/src/shell/createSidebarStore.ts b/packages/ui/src/shell/createSidebarStore.ts index 1d72c081d5..5e78bc5467 100644 --- a/packages/ui/src/shell/createSidebarStore.ts +++ b/packages/ui/src/shell/createSidebarStore.ts @@ -20,20 +20,28 @@ interface CreateSidebarStoreOptions { name: string; defaultWidth: number; defaultOpen?: boolean; + /** + * Floor applied to live sets and to rehydrated values alike. Without it a + * width persisted while the minimum was lower survives below it forever, + * since nothing re-clamps on load. + */ + minWidth?: number; } export function createSidebarStore(options: CreateSidebarStoreOptions) { - const { name, defaultWidth, defaultOpen = true } = options; + const { name, defaultWidth, defaultOpen = true, minWidth } = options; + const clampWidth = (width: number) => + minWidth === undefined ? width : Math.max(minWidth, width); return create()( persist( (set) => ({ open: defaultOpen, - width: defaultWidth, + width: clampWidth(defaultWidth), isResizing: false, setOpen: (open) => set({ open }), toggle: () => set((state) => ({ open: !state.open })), - setWidth: (width) => set({ width }), + setWidth: (width) => set({ width: clampWidth(width) }), setIsResizing: (isResizing) => set({ isResizing }), }), { @@ -42,6 +50,16 @@ export function createSidebarStore(options: CreateSidebarStoreOptions) { open: state.open, width: state.width, }), + merge: (persisted, current) => { + const stored = (persisted ?? {}) as Partial< + Pick + >; + return { + ...current, + open: stored.open ?? current.open, + width: clampWidth(stored.width ?? current.width), + }; + }, }, ), ); From b395fceb14dd83bdda576573f4c13b77fc8b0433 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 25 Jul 2026 15:45:18 +0200 Subject: [PATCH 45/57] fix(canvas): creating a task inside a channel keeps it in that channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⌘N (and every other generic entry point — the command menu, the sidebar "+") called openTaskInput with no space, which routed to the unscoped /code new-task screen even while a channel was open, so the task was created outside the channel. openTaskInput now routes to the current channel's new-task screen when one is set. A current channel only exists while the channels layout is on, so this is gated on that flag without threading it through every caller — and ChannelsSidebar now clears the scope when the layout goes away, since flags resolve after first paint and can flip mid-session, which keeps that stand-in honest. WebsiteNewTask also has to honour prefill for this to be safe: it renders on a route that already merges prefill into the view, but never read it, so redirecting a prefill-carrying call (a deep link's prompt, a report association) would have silently dropped it. It now passes the same prefill props /website/new does. Generated-By: PostHog Code Task-Id: c401c7cb-e436-4da3-80bf-1d3008ee5d38 --- .../canvas/components/ChannelsSidebar.tsx | 6 ++++++ .../canvas/components/WebsiteNewTask.test.tsx | 17 ++++++++++++++++- .../canvas/components/WebsiteNewTask.tsx | 10 ++++++++++ packages/ui/src/router/useOpenTask.ts | 12 +++++++++++- 4 files changed, 43 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx index 3fafb72513..1687ee5a00 100644 --- a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx @@ -103,6 +103,12 @@ export function ChannelsSidebar() { if (!channelsLayout || !routeChannelId) return; setCurrentChannel(routeChannelId); }, [channelsLayout, routeChannelId, setCurrentChannel]); + // Flags resolve after first paint and can flip mid-session, so clear the + // scope when the layout goes away. Keeps "a channel is current" a reliable + // stand-in for the flag elsewhere — openTaskInput routes creates on it. + useEffect(() => { + if (!channelsLayout) setCurrentChannel(null); + }, [channelsLayout, setCurrentChannel]); const inChannel = channelsLayout && currentChannelId != null; // Default to the personal "#me" channel on first load; once any channel has diff --git a/packages/ui/src/features/canvas/components/WebsiteNewTask.test.tsx b/packages/ui/src/features/canvas/components/WebsiteNewTask.test.tsx index 0ee1e45e2e..5a3c5bb8bb 100644 --- a/packages/ui/src/features/canvas/components/WebsiteNewTask.test.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteNewTask.test.tsx @@ -46,7 +46,22 @@ vi.mock("@posthog/ui/shell/analytics", () => ({ track })); vi.mock("@tanstack/react-query", () => ({ useQueryClient: () => ({ setQueryData: vi.fn() }), })); -vi.mock("@tanstack/react-router", () => ({ useNavigate: () => vi.fn() })); +vi.mock("@tanstack/react-router", () => ({ + useNavigate: () => vi.fn(), + // The view reads the matched route so it can pass new-task prefill through. + useRouterState: ({ + select, + }: { + select: (s: { + matches: { routeId: string; params: Record }[]; + }) => unknown; + }) => + select({ + matches: [ + { routeId: "/website/$channelId/new", params: { channelId: "chan-1" } }, + ], + }), +})); import { WebsiteNewTask } from "./WebsiteNewTask"; diff --git a/packages/ui/src/features/canvas/components/WebsiteNewTask.tsx b/packages/ui/src/features/canvas/components/WebsiteNewTask.tsx index 37863bbae8..d3dca1d1a0 100644 --- a/packages/ui/src/features/canvas/components/WebsiteNewTask.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteNewTask.tsx @@ -11,6 +11,7 @@ import { taskDetailQuery } from "@posthog/ui/features/tasks/queries"; import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; import { ResizableSidebar } from "@posthog/ui/primitives/ResizableSidebar"; import { toast } from "@posthog/ui/primitives/toast"; +import { useAppView } from "@posthog/ui/router/useAppView"; import { track } from "@posthog/ui/shell/analytics"; import { Flex } from "@radix-ui/themes"; import { useQueryClient } from "@tanstack/react-query"; @@ -23,6 +24,7 @@ import { useCallback, useMemo, useState } from "react"; // channel folder on the project's desktop_file_system surface. export function WebsiteNewTask({ channelId }: { channelId: string }) { const navigate = useNavigate(); + const view = useAppView(); const queryClient = useQueryClient(); const { fileTask } = useChannelTaskMutations(); const { channels } = useChannels(); @@ -112,6 +114,14 @@ export function WebsiteNewTask({ channelId }: { channelId: string }) { channelName={channelName} channelContextId={channelId} allowNoRepo + // The same prefill /code and /website/new read, so a prompt handed to + // openTaskInput survives being routed into a channel. + initialPrompt={view.initialPrompt} + initialPromptKey={view.taskInputRequestId} + initialCloudRepository={view.initialCloudRepository} + initialModel={view.initialModel} + initialMode={view.initialMode} + reportAssociation={view.reportAssociation} suggestions={CHANNEL_TASK_SUGGESTIONS} onSuggestionSelect={(label) => track(ANALYTICS_EVENTS.CHANNEL_ACTION, { diff --git a/packages/ui/src/router/useOpenTask.ts b/packages/ui/src/router/useOpenTask.ts index f5f01fb607..073518014d 100644 --- a/packages/ui/src/router/useOpenTask.ts +++ b/packages/ui/src/router/useOpenTask.ts @@ -1,6 +1,7 @@ import { resolveService, resolveServiceOptional } from "@posthog/di/container"; import { ANALYTICS_EVENTS } from "@posthog/shared"; import type { Task } from "@posthog/shared/domain-types"; +import { useCurrentChannelStore } from "@posthog/ui/features/canvas/stores/currentChannelStore"; import { NAVIGATION_TASK_BINDER, type NavigationTaskBinder, @@ -115,7 +116,16 @@ export function openTaskInput( : undefined, }, }); - if (options.space === "website") { + // In the channels layout everything you create belongs to the channel you're + // in, so every entry point (⌘N, the command menu, the sidebar "+") lands on + // that channel's new-task screen rather than an unscoped one. A current + // channel only exists while that layout is active — ChannelsSidebar sets it + // under the flag and clears it when the flag goes off — so this is gated on + // the flag without having to thread it through every caller. + const currentChannelId = useCurrentChannelStore.getState().currentChannelId; + if (currentChannelId) { + nav.navigateToChannelNewTask(currentChannelId); + } else if (options.space === "website") { nav.navigateToWebsiteNew(); } else { nav.navigateToCode(); From 975ecc8cccea8b754526f66b248943280618f5cd Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 25 Jul 2026 15:45:20 +0200 Subject: [PATCH 46/57] design(canvas): dim the app behind the open channel switcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Open over the feed, the channel list competed with the text around it — quill's default popover shadow is faint on a dark surface, so nothing separated the menu from its backdrop. Adds a scrim that fades in with the popover, reusing the tokens and easing of the sidebar-peek scrim in __root so the two dims read as one effect. It is portaled to the body to sit above the app but below the popover's z-50, and stays pointer-transparent so close-on-outside-press still works; the docked sidebar carries no z-index, so it dims along with everything else. The popover also picks up a deeper shadow to lift it off the dimmed backdrop — with a `!` override, since quill sets box-shadow from unlayered CSS that beats plain utilities. Generated-By: PostHog Code Task-Id: c401c7cb-e436-4da3-80bf-1d3008ee5d38 --- .../canvas/components/ChannelSwitcher.tsx | 34 ++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx b/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx index 1da6ffb451..a49f3f414d 100644 --- a/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx +++ b/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx @@ -36,6 +36,7 @@ import { import { navigateToChannel } from "@posthog/ui/router/navigationBridge"; import { track } from "@posthog/ui/shell/analytics"; import { useMemo, useState } from "react"; +import { createPortal } from "react-dom"; import { useHotkeys } from "react-hotkeys-hook"; // mod+0 jumps to #me; mod+1..9 jump to the first nine starred channels @@ -136,6 +137,29 @@ function SwitcherRow({ ); } +/** + * Dims the app while the switcher is open, so the channel list reads against + * the feed instead of competing with it. + * + * Portaled to the body and pointer-transparent: it has to sit above the app but + * below the popover's own z-50, and letting presses through keeps Base UI's + * close-on-outside-press working. Tokens and timing match the sidebar-peek + * scrim in __root, so the two dims look like the same effect. + */ +function SwitcherScrim({ open }: { open: boolean }) { + if (typeof document === "undefined") return null; + return createPortal( +
, + document.body, + ); +} + // Star toggle for the current channel, overlaid inside the switcher trigger so // the header stays consistent with the switcher rows and the feed header. // Deliberately an overlay rather than a sibling: a sibling would shrink the @@ -242,6 +266,7 @@ export function ChannelSwitcher({ channelId }: { channelId: string }) { return (
+
Date: Sat, 25 Jul 2026 15:45:21 +0200 Subject: [PATCH 47/57] refactor(canvas): use Base UI's popover backdrop for the switcher dim, and deepen it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the hand-rolled scrim with Popover.Backdrop. Base UI is a peer dependency of quill imported by specifier, so quill's Popover and the parts from @base-ui/react share one instance and one context — the backdrop reads the open state itself, mounts and unmounts with the popup, and exposes data-starting-style / data-ending-style, which replaces the manual open prop, the always-mounted createPortal, and the opacity toggle. It is also interactive, so clicking the dim now closes the menu. It still needs Popover.Portal: the backdrop renders in place, and ResizableSidebar's transform makes the sidebar a containing block that would clip a fixed overlay. Deepens the dim from blackA-2/5 to blackA-4/7, since the feed stayed legible enough to compete at the old strength. Generated-By: PostHog Code Task-Id: c401c7cb-e436-4da3-80bf-1d3008ee5d38 --- .../canvas/components/ChannelSwitcher.tsx | 35 ++++++------------- 1 file changed, 10 insertions(+), 25 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx b/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx index a49f3f414d..ad885e975a 100644 --- a/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx +++ b/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx @@ -1,3 +1,4 @@ +import { Popover as BasePopover } from "@base-ui/react/popover"; import { CaretUpDownIcon, HashIcon, @@ -36,7 +37,6 @@ import { import { navigateToChannel } from "@posthog/ui/router/navigationBridge"; import { track } from "@posthog/ui/shell/analytics"; import { useMemo, useState } from "react"; -import { createPortal } from "react-dom"; import { useHotkeys } from "react-hotkeys-hook"; // mod+0 jumps to #me; mod+1..9 jump to the first nine starred channels @@ -137,29 +137,6 @@ function SwitcherRow({ ); } -/** - * Dims the app while the switcher is open, so the channel list reads against - * the feed instead of competing with it. - * - * Portaled to the body and pointer-transparent: it has to sit above the app but - * below the popover's own z-50, and letting presses through keeps Base UI's - * close-on-outside-press working. Tokens and timing match the sidebar-peek - * scrim in __root, so the two dims look like the same effect. - */ -function SwitcherScrim({ open }: { open: boolean }) { - if (typeof document === "undefined") return null; - return createPortal( -
, - document.body, - ); -} - // Star toggle for the current channel, overlaid inside the switcher trigger so // the header stays consistent with the switcher rows and the feed header. // Deliberately an overlay rather than a sibling: a sibling would shrink the @@ -266,8 +243,16 @@ export function ChannelSwitcher({ channelId }: { channelId: string }) { return (
- + {/* Base UI's own backdrop rather than a hand-rolled scrim: it reads the + open state from the popover's context, mounts and unmounts itself, + and exposes the starting/ending-style hooks the fade below uses. + Portaled because ResizableSidebar's transform makes the sidebar a + containing block, which would otherwise clip a fixed overlay; z-40 + keeps it under the popup's z-50. Clicking it closes the menu. */} + + + Date: Sat, 25 Jul 2026 15:45:23 +0200 Subject: [PATCH 48/57] chore(canvas): trim comments, drop dead compact switcher, align empty states Cuts the PR's added comments by a quarter, keeping only non-obvious why and cross-file contracts and dropping narration of what the code plainly does. Also fixes a stale one: the shortcut note still said mod+1-4 after the range widened to mod+0-9. Removes ProjectSwitcher's "compact" variant. It was added for the title-bar avatar, which later moved back into the sidebar, leaving the branch, its props and two imports unreachable. Two convention fixes: ChannelSidebar's empty states now use quill's , matching how ChannelsList renders its own in the same sidebar; and ActivityTimeline's static rail offset moves from an inline style to a Tailwind utility. StatusVariant is no longer exported, since it is only used in the file that declares it. Generated-By: PostHog Code Task-Id: c401c7cb-e436-4da3-80bf-1d3008ee5d38 --- packages/shared/src/flags.ts | 7 +- .../canvas/components/ActivityPanel.tsx | 3 +- .../canvas/components/ActivityTimeline.tsx | 9 +-- .../canvas/components/ChannelHeader.tsx | 8 +- .../canvas/components/ChannelItemRow.tsx | 8 +- .../features/canvas/components/ChannelNav.tsx | 15 ++-- .../canvas/components/ChannelSidebar.tsx | 27 ++++--- .../canvas/components/ChannelSwitcher.tsx | 56 +++++--------- .../canvas/components/ChannelsSidebar.tsx | 13 ++-- .../features/canvas/components/NewTaskFab.tsx | 17 ++--- .../canvas/components/TaskArtifactsList.tsx | 1 - .../canvas/components/WebsiteNewTask.tsx | 3 +- .../canvas/components/channelsSidebarStore.ts | 3 +- .../canvas/hooks/useChannelsLayout.ts | 5 +- .../sidebar/components/ProjectSwitcher.tsx | 75 ++++++------------- packages/ui/src/features/sidebar/constants.ts | 8 +- packages/ui/src/router/useOpenTask.ts | 9 +-- packages/ui/src/shell/GlobalEventHandlers.tsx | 7 +- packages/ui/src/shell/createSidebarStore.ts | 5 +- 19 files changed, 97 insertions(+), 182 deletions(-) diff --git a/packages/shared/src/flags.ts b/packages/shared/src/flags.ts index e9f435fc94..81c9442141 100644 --- a/packages/shared/src/flags.ts +++ b/packages/shared/src/flags.ts @@ -15,10 +15,9 @@ export const DISCOVERY_RUN_FLAG = "posthog-code-discovery-run"; // routes, channels and dashboards. export const PROJECT_BLUEBIRD_FLAG = "project-bluebird"; /** - * Gates the new channels layout (channel-scoped sidebar + the task Activity - * panel). Off = the previous experience, including the "Enable channels" alpha - * toggle; on = the full new experience, no toggle. Requires project-bluebird. - * The key predates the rename and stays in sync with the live PostHog flag. + * Gates the new channels layout (channel-scoped sidebar + task Activity panel). + * Off keeps the previous experience and its "Enable channels" toggle. Requires + * project-bluebird. The key predates the rename, matching the live flag. */ export const CHANNELS_LAYOUT_FLAG = "code-spaces-layout"; // Gates the Loops feature: the sidebar Loops space and the per-channel Loops tab. diff --git a/packages/ui/src/features/canvas/components/ActivityPanel.tsx b/packages/ui/src/features/canvas/components/ActivityPanel.tsx index 7eb3246da3..aada9f0a0d 100644 --- a/packages/ui/src/features/canvas/components/ActivityPanel.tsx +++ b/packages/ui/src/features/canvas/components/ActivityPanel.tsx @@ -223,8 +223,7 @@ function ActivityConversation({ }, [taskId], ); - // Comments = the human thread without artifact announcements (those are the - // Artifacts tab). + // The human thread, without the announcements the Artifacts tab owns. const commentRows = useMemo( () => timeline.filter((row) => row.kind === "human"), [timeline], diff --git a/packages/ui/src/features/canvas/components/ActivityTimeline.tsx b/packages/ui/src/features/canvas/components/ActivityTimeline.tsx index cdd81bb3bc..0b6232ceb1 100644 --- a/packages/ui/src/features/canvas/components/ActivityTimeline.tsx +++ b/packages/ui/src/features/canvas/components/ActivityTimeline.tsx @@ -25,10 +25,6 @@ import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; import type { buildConversationItems } from "@posthog/ui/features/sessions/components/buildConversationItems"; import { Fragment, type ReactNode, useMemo } from "react"; -// Avatar-column center: row padding (0.5rem) + gutter (2.5rem) − half the lg -// avatar (2.35rem). The rail sits here so event rows align with message avatars. -const RAIL_LEFT = "1.825rem"; - type ConversationItem = ReturnType< typeof buildConversationItems >["items"][number]; @@ -258,8 +254,9 @@ export function ActivityTimeline({
diff --git a/packages/ui/src/features/canvas/components/ChannelHeader.tsx b/packages/ui/src/features/canvas/components/ChannelHeader.tsx index 5c1ec9d441..69a8502bb0 100644 --- a/packages/ui/src/features/canvas/components/ChannelHeader.tsx +++ b/packages/ui/src/features/canvas/components/ChannelHeader.tsx @@ -14,8 +14,7 @@ import { track } from "@posthog/ui/shell/analytics"; import { Text } from "@radix-ui/themes"; import { useNavigate, useRouterState } from "@tanstack/react-router"; -// Star/unstar the channel from its header — the feed-side counterpart to the -// switcher's hover star. +// The feed-side counterpart to the switcher's hover star. function ChannelStarButton({ channel }: { channel: Channel }) { const { isStarred, toggleStar } = useChannelStarToggle(channel); return ( @@ -41,9 +40,8 @@ function ChannelStarButton({ channel }: { channel: Channel }) { ); } -// The shared channel header: a clickable "# channel" routing to the channel -// home, plus a star toggle. The new layout drops the section tab strip (the -// channel sidebar carries those entries); flag off keeps the strip as before. +// 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(); diff --git a/packages/ui/src/features/canvas/components/ChannelItemRow.tsx b/packages/ui/src/features/canvas/components/ChannelItemRow.tsx index 63c3cc7be8..fcdaf2a409 100644 --- a/packages/ui/src/features/canvas/components/ChannelItemRow.tsx +++ b/packages/ui/src/features/canvas/components/ChannelItemRow.tsx @@ -9,12 +9,7 @@ import { NestedButton } from "@posthog/ui/primitives/NestedButton"; import { Tooltip } from "@posthog/ui/primitives/Tooltip"; import type { ReactNode } from "react"; -export type StatusVariant = - | "default" - | "destructive" - | "info" - | "success" - | "warning"; +type StatusVariant = "default" | "destructive" | "info" | "success" | "warning"; export interface ChannelItem { key: string; @@ -67,7 +62,6 @@ export function statusVariantFor( const HOVER_ACTION_CLASS = "flex h-5 w-5 cursor-pointer items-center justify-center rounded text-gray-10 transition-colors hover:bg-gray-4 hover:text-gray-12"; -// A pinned/recent row with a hover card carrying the full title, status, author. export function ChannelItemRow({ item }: { item: ChannelItem }) { return ( diff --git a/packages/ui/src/features/canvas/components/ChannelNav.tsx b/packages/ui/src/features/canvas/components/ChannelNav.tsx index 31f41888df..7dfb8b8ee6 100644 --- a/packages/ui/src/features/canvas/components/ChannelNav.tsx +++ b/packages/ui/src/features/canvas/components/ChannelNav.tsx @@ -26,9 +26,8 @@ import { useMemo } from "react"; const INBOX_REFETCH_INTERVAL_MS = 60_000; -// Inbox/activity counts read as unread (red); the command center's filled cells -// are ambient, so they stay neutral. Sits proud of the button corner the way a -// notification badge does, which the row's gap leaves room for. +// Unread counts read red; the command center's filled cells are ambient, so +// they stay neutral. function IconBadge({ count, tone = "notification", @@ -51,7 +50,6 @@ function IconBadge({ ); } -/** One global destination as a bare icon button; the tooltip names it. */ function NavIcon({ icon, label, @@ -88,10 +86,8 @@ function NavIcon({ } /** - * The channel-scoped global destinations — Inbox, Activity, Command Center — as - * a single row of icon buttons above the channel switcher. Search lives in the - * title bar next to the history controls, and New task sits under the switcher - * since it acts on the current channel. + * Global destinations above the channel switcher. Search lives in the title + * bar, and New task under the switcher, since it acts on the current channel. */ export function ChannelNav() { const view = useAppView(); @@ -106,8 +102,7 @@ export function ChannelNav() { () => countUnseenActivity(activityItems, lastSeenAt), [activityItems, lastSeenAt], ); - // Command Center lives in the /website space here; its filled grid cells are - // the ambient "how much is parked in there" count. + // Its filled grid cells are the ambient "how much is parked there" count. const commandCenterCells = useCommandCenterStore((s) => s.cells); const commandCenterCount = commandCenterCells.filter((c) => c != null).length; diff --git a/packages/ui/src/features/canvas/components/ChannelSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelSidebar.tsx index f62441e6fa..90e27aec8c 100644 --- a/packages/ui/src/features/canvas/components/ChannelSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ChannelSidebar.tsx @@ -14,6 +14,8 @@ import { DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger, + Empty, + EmptyHeader, Input, MenuLabel, Skeleton, @@ -160,7 +162,7 @@ function RecentSectionHeader({ ); } -// Varied-width ghost rows so the loading state reads as the list it becomes. +// Varied widths so the loading state reads as the list it becomes. const SKELETON_ROW_WIDTHS = [ "w-3/5", "w-4/5", @@ -185,8 +187,8 @@ function ChannelItemsSkeleton() { } /** - * The sidebar body while a channel is active: the switcher, its sections - * (Context / Loops / Artifacts), then pinned and recent tasks & canvases. + * The sidebar body while a channel is active: the switcher, the channel's + * sections, then its pinned and recent tasks & canvases. */ export function ChannelSidebar({ channelId }: { channelId: string }) { const navigate = useNavigate(); @@ -291,8 +293,7 @@ export function ChannelSidebar({ channelId }: { channelId: string }) { )}
- {/* Relative so the New task FAB can float over the list, the way - ChannelsFab does over the channel list. */} + {/* Relative so the FAB can float over the list. */}
{isLoading && items.length === 0 && } @@ -331,17 +332,21 @@ export function ChannelSidebar({ channelId }: { channelId: string }) { ))}
) : ( -

- Nothing matches the current search or filters. -

+ + + Nothing matches the current search or filters. + + )} )} {!isLoading && items.length === 0 && ( -

- Tasks and canvases you create in this channel show up here. -

+ + + Tasks and canvases you create in this channel show up here. + + )}
diff --git a/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx b/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx index ad885e975a..b5b3021161 100644 --- a/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx +++ b/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx @@ -39,19 +39,13 @@ import { track } from "@posthog/ui/shell/analytics"; import { useMemo, useState } from "react"; import { useHotkeys } from "react-hotkeys-hook"; -// mod+0 jumps to #me; mod+1..9 jump to the first nine starred channels -// (see SWITCH_STARRED_CHANNEL). +// mod+0 is #me, mod+1..9 the starred channels (see SWITCH_STARRED_CHANNEL). const STARRED_HOTKEY_SLOTS = 9; -// The popover opens to the right of the sidebar, anchored to the trigger — -// which is inset from the sidebar edge by the row's `mx-2`. Clearing that 8px -// plus an 8px gap lands the panel just past the content pane's left border -// instead of hovering over the sidebar. +// Clears the row's 8px `mx-2` inset plus an 8px gap, so the panel lands past +// the sidebar edge rather than over it. const POPOVER_SIDE_OFFSET = 16; -// One channel in the switcher: click switches, the star toggles (hover-revealed -// unless starred), right-click offers the same. #me and the first nine starred -// channels show their mod+N hotkey. function SwitcherRow({ channel, active, @@ -98,9 +92,8 @@ function SwitcherRow({ )} {isMe ? ( - // #me is permanently pinned to slot 0, so it shows a star but doesn't - // offer to toggle one. Occupying the slot also keeps every row's - // trailing edge aligned. + // #me is permanently pinned, so its star is inert — but the slot + // still has to be filled to keep the rows aligned. @@ -214,8 +204,7 @@ export function ChannelSwitcher({ channelId }: { channelId: string }) { }); }; - // mod+0 → #me, mod+1..9 → the Nth starred channel. Same ctrl guard as - // SWITCH_TASK: plain ctrl+N belongs to tab switching. + // Same ctrl guard as SWITCH_TASK: plain ctrl+N belongs to tab switching. useHotkeys( SHORTCUTS.SWITCH_STARRED_CHANNEL, (event, handler) => { @@ -244,12 +233,9 @@ export function ChannelSwitcher({ channelId }: { channelId: string }) { return (
- {/* Base UI's own backdrop rather than a hand-rolled scrim: it reads the - open state from the popover's context, mounts and unmounts itself, - and exposes the starting/ending-style hooks the fade below uses. - Portaled because ResizableSidebar's transform makes the sidebar a - containing block, which would otherwise clip a fixed overlay; z-40 - keeps it under the popup's z-50. Clicking it closes the menu. */} + {/* Portaled because ResizableSidebar's transform makes the sidebar a + containing block, which would clip a fixed overlay; z-40 keeps it + under the popup's z-50. */} @@ -258,9 +244,9 @@ export function ChannelSwitcher({ channelId }: { channelId: string }) { @@ -279,10 +264,8 @@ export function ChannelSwitcher({ channelId }: { channelId: string }) { align="start" side="right" sideOffset={POPOVER_SIDE_OFFSET} - // quill's popover ships `gap-4` plus unlayered `padding`/`width`/ - // `box-shadow` CSS that beats plain utilities, hence the `!` - // overrides — this is a menu, not a card, so it gets menu-tight - // spacing, and a deeper shadow to lift it off the dimmed backdrop. + // quill sets gap/padding/width/shadow from unlayered CSS that beats + // plain utilities, hence the `!` overrides. className="w-64! gap-0 p-1! shadow-2xl!" >
@@ -339,8 +322,7 @@ export function ChannelSwitcher({ channelId }: { channelId: string }) { {showStar && current && } - {/* #me can't be starred, but it still shows one so the trigger reads the - same on every channel. */} + {/* Inert star for #me, so the trigger reads the same on every channel. */} {current && !showStar && ( s.currentChannelId); @@ -103,9 +103,9 @@ export function ChannelsSidebar() { if (!channelsLayout || !routeChannelId) return; setCurrentChannel(routeChannelId); }, [channelsLayout, routeChannelId, setCurrentChannel]); - // Flags resolve after first paint and can flip mid-session, so clear the - // scope when the layout goes away. Keeps "a channel is current" a reliable - // stand-in for the flag elsewhere — openTaskInput routes creates on it. + // Flags resolve after first paint and can flip mid-session. Clearing keeps + // "a channel is current" a reliable stand-in for the flag — openTaskInput + // routes creates on it. useEffect(() => { if (!channelsLayout) setCurrentChannel(null); }, [channelsLayout, setCurrentChannel]); @@ -143,11 +143,8 @@ export function ChannelsSidebar() { {!inChannel && } - {/* active channel → channel list → tasks */} {inChannel && currentChannelId ? ( <> - {/* No rule under the nav — the bordered channel switcher directly - below already reads as the divider. */} diff --git a/packages/ui/src/features/canvas/components/NewTaskFab.tsx b/packages/ui/src/features/canvas/components/NewTaskFab.tsx index ec63f690f4..2428baf398 100644 --- a/packages/ui/src/features/canvas/components/NewTaskFab.tsx +++ b/packages/ui/src/features/canvas/components/NewTaskFab.tsx @@ -20,13 +20,9 @@ import { navigateToChannelNewTask } from "@posthog/ui/router/navigationBridge"; import { track } from "@posthog/ui/shell/analytics"; /** - * The create affordance for the active channel, floated over the bottom-right - * of its item list — the same treatment the channel list uses (see - * ChannelsFab), and it opens its menu upward for the same reason. - * - * It sits here rather than in the nav stack because it is an action, not a - * destination: as a bordered row directly under the channel switcher it read as - * a second switcher, two equal-weight boxes competing at the top of the sidebar. + * The active channel's create affordance, floated over its item list the way + * ChannelsFab is over the channel list — hence the upward menu. It lives here + * rather than the nav stack because it is an action, not a destination. */ export function NewTaskFab({ channelId }: { channelId: string }) { const hasDraft = useDraftStore( @@ -50,9 +46,7 @@ export function NewTaskFab({ channelId }: { channelId: string }) { className="absolute right-3 bottom-3 z-10 rounded-full shadow-lg" > - {/* Replaces the "Draft" badge the nav row carried: a pip in the - button's own foreground, so it stays legible on the accent - fill. */} + {/* In the button's own foreground, to stay legible on accent. */} {hasDraft && ( { - // Create + open a canvas on the default template directly; the - // canvas's own composer drives what gets built. + // Default template; the canvas's composer drives what gets built. trackAndCreateCanvas( channelId, undefined, diff --git a/packages/ui/src/features/canvas/components/TaskArtifactsList.tsx b/packages/ui/src/features/canvas/components/TaskArtifactsList.tsx index 8de6e33a04..7a9ce4400e 100644 --- a/packages/ui/src/features/canvas/components/TaskArtifactsList.tsx +++ b/packages/ui/src/features/canvas/components/TaskArtifactsList.tsx @@ -178,7 +178,6 @@ function ArtifactListRow({ ); } -// PR row: live state + comment count from GitHub; opens the diff in the review pane. function PrRow({ url, taskId }: { url: string; taskId: string }) { const parsed = parseHttpsUrl(url); const safeUrl = parsed?.origin === "https://github.com" ? parsed.href : null; diff --git a/packages/ui/src/features/canvas/components/WebsiteNewTask.tsx b/packages/ui/src/features/canvas/components/WebsiteNewTask.tsx index d3dca1d1a0..5f1009927f 100644 --- a/packages/ui/src/features/canvas/components/WebsiteNewTask.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteNewTask.tsx @@ -114,8 +114,7 @@ export function WebsiteNewTask({ channelId }: { channelId: string }) { channelName={channelName} channelContextId={channelId} allowNoRepo - // The same prefill /code and /website/new read, so a prompt handed to - // openTaskInput survives being routed into a channel. + // So a prompt handed to openTaskInput survives routing into a channel. initialPrompt={view.initialPrompt} initialPromptKey={view.taskInputRequestId} initialCloudRepository={view.initialCloudRepository} diff --git a/packages/ui/src/features/canvas/components/channelsSidebarStore.ts b/packages/ui/src/features/canvas/components/channelsSidebarStore.ts index 91a42f7691..5b2bc6a35b 100644 --- a/packages/ui/src/features/canvas/components/channelsSidebarStore.ts +++ b/packages/ui/src/features/canvas/components/channelsSidebarStore.ts @@ -4,8 +4,7 @@ import { createSidebarStore } from "@posthog/ui/shell/createSidebarStore"; export const useChannelsSidebarStore = createSidebarStore({ name: "channels-sidebar", defaultWidth: SIDEBAR_MIN_WIDTH, - // Also re-clamps widths persisted before the minimum rose, and the legacy - // Code width adopted by the migration below. + // Also re-clamps the legacy Code width adopted by the migration below. minWidth: SIDEBAR_MIN_WIDTH, }); diff --git a/packages/ui/src/features/canvas/hooks/useChannelsLayout.ts b/packages/ui/src/features/canvas/hooks/useChannelsLayout.ts index 8f65627ca8..2d891f42be 100644 --- a/packages/ui/src/features/canvas/hooks/useChannelsLayout.ts +++ b/packages/ui/src/features/canvas/hooks/useChannelsLayout.ts @@ -2,9 +2,8 @@ import { CHANNELS_LAYOUT_FLAG, PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; /** - * The single gate for the new channels layout — every gate reads this hook, - * not the raw flag. No dev default (read from PostHog like normal, so dev - * matches prod); bluebird keeps its dev default as the backend guard. + * The single gate for the new channels layout — read this, not the raw flag. + * No dev default, so dev matches prod; bluebird keeps its own backend guard. */ export function useChannelsLayout(): boolean { const bluebirdEnabled = useFeatureFlag( diff --git a/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx b/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx index 70df141f61..6d408b58a9 100644 --- a/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx +++ b/packages/ui/src/features/sidebar/components/ProjectSwitcher.tsx @@ -21,7 +21,6 @@ import { AutocompleteItem, AutocompleteList, AutocompleteStatus, - Button, DropdownMenu, DropdownMenuContent, DropdownMenuGroup, @@ -42,7 +41,6 @@ import { import { EXTERNAL_LINKS } from "@posthog/shared"; import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; import { useAuthStateValue } from "@posthog/ui/features/auth/store"; -import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar"; import { useLogoutMutation, useSelectProjectMutation, @@ -60,20 +58,9 @@ import { Avatar, Box } from "@radix-ui/themes"; import { ChevronRightIcon } from "lucide-react"; import { type ReactNode, useMemo, useState } from "react"; -/** - * The account / project / org menu. Two triggers, same menu body: - * - "card" (default): the two-line user/project card at the bottom of the - * sidebar. - * - "compact": a single avatar button for the top bar, so the menu doesn't - * claim a whole sidebar row. - */ -export function ProjectSwitcher({ - variant = "card", -}: { - variant?: "card" | "compact"; -}) { +/** The account / project / org menu at the bottom of the sidebar. */ +export function ProjectSwitcher() { const [popoverOpen, setPopoverOpen] = useState(false); - const compact = variant === "compact"; const holdPeek = useHoldSidebarPeek(); const handleOpenChange = (next: boolean): void => { @@ -191,50 +178,30 @@ export function ProjectSwitcher({ - {/* Shrink the avatar below the button height so it sits with the - same breathing room as the sibling buttons' 14px icons. */} - - {currentProject?.name ?? "No project"} - - ) : ( - - - - {currentProject?.name ?? "No project selected"} - - - {currentUser?.email ?? "No email"} - - - - - - - ) + + + + {currentProject?.name ?? "No project selected"} + + + {currentUser?.email ?? "No email"} + + + + + + } /> diff --git a/packages/ui/src/features/sidebar/constants.ts b/packages/ui/src/features/sidebar/constants.ts index fca2ce970b..5c396d2673 100644 --- a/packages/ui/src/features/sidebar/constants.ts +++ b/packages/ui/src/features/sidebar/constants.ts @@ -1,11 +1,9 @@ import type { SidebarNavItem } from "@posthog/shared/analytics-events"; /** - * The title bar's left strip is pinned to the sidebar width and its contents - * don't shrink: 78px of traffic-light inset, 62px of logo + sidebar toggle, - * 80px of search + history buttons, and 8px of trailing padding — 228px. At - * 240 that left the two groups 12px apart, with search jammed against the - * sidebar edge; this leaves them ~44px apart. + * The title bar's left strip is pinned to this width and none of its contents + * shrink: inset, logo, toggle, search and history buttons come to 228px, so + * anything near 240 leaves the two groups touching. */ export const SIDEBAR_MIN_WIDTH = 272; diff --git a/packages/ui/src/router/useOpenTask.ts b/packages/ui/src/router/useOpenTask.ts index 073518014d..2e5c371dd1 100644 --- a/packages/ui/src/router/useOpenTask.ts +++ b/packages/ui/src/router/useOpenTask.ts @@ -116,12 +116,9 @@ export function openTaskInput( : undefined, }, }); - // In the channels layout everything you create belongs to the channel you're - // in, so every entry point (⌘N, the command menu, the sidebar "+") lands on - // that channel's new-task screen rather than an unscoped one. A current - // channel only exists while that layout is active — ChannelsSidebar sets it - // under the flag and clears it when the flag goes off — so this is gated on - // the flag without having to thread it through every caller. + // In the channels layout every entry point (⌘N, the command menu, the "+") + // creates inside the channel you're in. A current channel only exists while + // that layout is on (ChannelsSidebar), so this needs no flag of its own. const currentChannelId = useCurrentChannelStore.getState().currentChannelId; if (currentChannelId) { nav.navigateToChannelNewTask(currentChannelId); diff --git a/packages/ui/src/shell/GlobalEventHandlers.tsx b/packages/ui/src/shell/GlobalEventHandlers.tsx index 667963d783..83eaa152a9 100644 --- a/packages/ui/src/shell/GlobalEventHandlers.tsx +++ b/packages/ui/src/shell/GlobalEventHandlers.tsx @@ -73,10 +73,9 @@ export function GlobalEventHandlers({ const sidebarData = useSidebarData({ activeView: view }); const visualTaskOrder = useVisualTaskOrder(sidebarData); - // With channels on, mod+1-9 belongs to the browser tab strip (it switches to - // the Nth tab); in the new layout mod+1-4 belongs to starred channels - // (ChannelSwitcher). Yield those keys so task-switching only owns them in - // the Code nav. + // mod+N belongs to the browser tab strip with channels on, and to the + // starred channels in the new layout (ChannelSwitcher), so task-switching + // only owns those keys in the Code nav. const bluebirdEnabled = useFeatureFlag( PROJECT_BLUEBIRD_FLAG, import.meta.env.DEV, diff --git a/packages/ui/src/shell/createSidebarStore.ts b/packages/ui/src/shell/createSidebarStore.ts index 5e78bc5467..bbfe139a2b 100644 --- a/packages/ui/src/shell/createSidebarStore.ts +++ b/packages/ui/src/shell/createSidebarStore.ts @@ -21,9 +21,8 @@ interface CreateSidebarStoreOptions { defaultWidth: number; defaultOpen?: boolean; /** - * Floor applied to live sets and to rehydrated values alike. Without it a - * width persisted while the minimum was lower survives below it forever, - * since nothing re-clamps on load. + * Floor for live sets and rehydrated values alike — without it a width + * persisted under an older, lower minimum survives below the new one. */ minWidth?: number; } From dd7aaea331c32235540495f642c1f351a45711d9 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 25 Jul 2026 15:45:24 +0200 Subject: [PATCH 49/57] fix(sidebar): keep the flag-off layout unchanged, and cover it with a test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes in this PR reached users who don't have the channels layout. The sidebar minimum went from 240 to 272 for everyone, but the extra room exists for the title bar's search button, which only the new layout renders — without it the strip needs 208px and fits 240 comfortably. Anyone on the old layout with a narrow sidebar would have been widened for nothing. SIDEBAR_MIN_WIDTH returns to 240 and the new layout raises its own floor: ResizableSidebar takes an optional minWidth, and ChannelsSidebar supplies it and lifts an already-narrower width once, only while the flag is on. The channel header's star also rendered unconditionally, so it appeared in the pre-existing channels alpha, which reaches that header too. It is now behind the flag like the rest of the new layout. Adds a test for openTaskInput, the riskiest shared change, pinning that creates go to Code (or an explicitly requested website space) until a channel is scoped, which only the new layout does. Generated-By: PostHog Code Task-Id: c401c7cb-e436-4da3-80bf-1d3008ee5d38 --- .../canvas/components/ChannelHeader.tsx | 2 +- .../canvas/components/ChannelsSidebar.tsx | 12 ++++ .../canvas/components/channelsSidebarStore.ts | 1 + packages/ui/src/features/sidebar/constants.ts | 11 ++-- .../ui/src/primitives/ResizableSidebar.tsx | 7 ++- packages/ui/src/router/useOpenTask.test.ts | 60 +++++++++++++++++++ 6 files changed, 87 insertions(+), 6 deletions(-) create mode 100644 packages/ui/src/router/useOpenTask.test.ts diff --git a/packages/ui/src/features/canvas/components/ChannelHeader.tsx b/packages/ui/src/features/canvas/components/ChannelHeader.tsx index 69a8502bb0..b8f09db4a7 100644 --- a/packages/ui/src/features/canvas/components/ChannelHeader.tsx +++ b/packages/ui/src/features/canvas/components/ChannelHeader.tsx @@ -69,7 +69,7 @@ export function ChannelHeader({ channelId }: { channelId: string }) { {channelName ?? "Channel"} - {channel && channel.name !== PERSONAL_CHANNEL_NAME && ( + {channelsLayout && channel && channel.name !== PERSONAL_CHANNEL_NAME && ( )} {!channelsLayout && } diff --git a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx index 5bbbecb06f..4f771f0242 100644 --- a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx @@ -18,6 +18,7 @@ import { ProjectSwitcher } from "@posthog/ui/features/sidebar/components/Project import { SidebarMenu } from "@posthog/ui/features/sidebar/components/SidebarMenu"; import { SidebarNavSection } from "@posthog/ui/features/sidebar/components/SidebarNavSection"; import { UpdateBanner } from "@posthog/ui/features/sidebar/components/UpdateBanner"; +import { CHANNELS_SIDEBAR_MIN_WIDTH } from "@posthog/ui/features/sidebar/constants"; import { beginSidebarPeek, cancelSidebarPeek, @@ -90,6 +91,16 @@ export function ChannelsSidebar() { // mounts a provider-laden row per channel) swaps in. const bodyChannelsWorld = useDeferredValue(channelsWorld); + // The channels layout's title bar needs more room than the shared floor, so + // it raises the minimum here rather than in the constant — leaving the width + // of everyone on the old layout untouched. + 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(); // The route is the source of truth: a /website/$channelId page adopts that @@ -134,6 +145,7 @@ export function ChannelsSidebar() { isResizing={isResizing} setIsResizing={setIsResizing} side="left" + minWidth={minWidth} setOpen={setOpen} peek={peek} onPeekEnter={beginSidebarPeek} diff --git a/packages/ui/src/features/canvas/components/channelsSidebarStore.ts b/packages/ui/src/features/canvas/components/channelsSidebarStore.ts index 5b2bc6a35b..eaed603c31 100644 --- a/packages/ui/src/features/canvas/components/channelsSidebarStore.ts +++ b/packages/ui/src/features/canvas/components/channelsSidebarStore.ts @@ -5,6 +5,7 @@ export const useChannelsSidebarStore = createSidebarStore({ name: "channels-sidebar", defaultWidth: SIDEBAR_MIN_WIDTH, // Also re-clamps the legacy Code width adopted by the migration below. + // The channels layout raises the floor further, at runtime (ChannelsSidebar). minWidth: SIDEBAR_MIN_WIDTH, }); diff --git a/packages/ui/src/features/sidebar/constants.ts b/packages/ui/src/features/sidebar/constants.ts index 5c396d2673..31e98a32f5 100644 --- a/packages/ui/src/features/sidebar/constants.ts +++ b/packages/ui/src/features/sidebar/constants.ts @@ -1,11 +1,14 @@ import type { SidebarNavItem } from "@posthog/shared/analytics-events"; +export const SIDEBAR_MIN_WIDTH = 240; + /** - * The title bar's left strip is pinned to this width and none of its contents - * shrink: inset, logo, toggle, search and history buttons come to 228px, so - * anything near 240 leaves the two groups touching. + * Wider floor for the channels layout only. The title bar's left strip is + * pinned to the sidebar width and none of its contents shrink; that layout + * adds a search button, taking the strip to 228px, so 240 would leave the two + * groups touching. Off, the strip needs 208px and keeps the usual floor. */ -export const SIDEBAR_MIN_WIDTH = 272; +export const CHANNELS_SIDEBAR_MIN_WIDTH = 272; export const CUSTOMIZABLE_NAV_ITEMS = [ { id: "inbox", label: "Inbox", analyticsId: "inbox", defaultVisible: true }, diff --git a/packages/ui/src/primitives/ResizableSidebar.tsx b/packages/ui/src/primitives/ResizableSidebar.tsx index 6cc504db5d..88d257a92f 100644 --- a/packages/ui/src/primitives/ResizableSidebar.tsx +++ b/packages/ui/src/primitives/ResizableSidebar.tsx @@ -25,6 +25,9 @@ interface ResizableSidebarProps { isResizing: boolean; setIsResizing: (isResizing: boolean) => void; side: "left" | "right"; + // Floor for drag-resize. Defaults to SIDEBAR_MIN_WIDTH; callers whose chrome + // needs more room can raise it. + minWidth?: number; // Enables drag-to-close/reopen. Without it, dragging just clamps at min. setOpen?: (open: boolean) => void; // While closed, the panel can "peek" — slide out over the content as a @@ -45,6 +48,7 @@ export const ResizableSidebar: React.FC = ({ isResizing, setIsResizing, side, + minWidth = SIDEBAR_MIN_WIDTH, setOpen, peek = false, onPeekEnter, @@ -101,7 +105,7 @@ export const ResizableSidebar: React.FC = ({ const pointer = side === "left" ? e.clientX : window.innerWidth - e.clientX; const maxWidth = window.innerWidth * 0.5; - const clamped = Math.max(SIDEBAR_MIN_WIDTH, Math.min(maxWidth, pointer)); + const clamped = Math.max(minWidth, Math.min(maxWidth, pointer)); if (open) { if (pointer < DRAG_COLLAPSE_AT && setOpen) { @@ -166,6 +170,7 @@ export const ResizableSidebar: React.FC = ({ isResizing, setIsResizing, side, + minWidth, open, peek, width, diff --git a/packages/ui/src/router/useOpenTask.test.ts b/packages/ui/src/router/useOpenTask.test.ts new file mode 100644 index 0000000000..26125f1cb4 --- /dev/null +++ b/packages/ui/src/router/useOpenTask.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const navigateToChannelNewTask = vi.fn(); +const navigateToWebsiteNew = vi.fn(); +const navigateToCode = vi.fn(); + +vi.mock("./navigationBridge", () => ({ + navigateToChannelNewTask: (...args: unknown[]) => + navigateToChannelNewTask(...args), + navigateToWebsiteNew: () => navigateToWebsiteNew(), + navigateToCode: () => navigateToCode(), + navigateToChannelTask: vi.fn(), + navigateToTaskDetail: vi.fn(), + navigateToFolderSettings: vi.fn(), +})); +vi.mock("@posthog/di/container", () => ({ + resolveService: vi.fn(), + resolveServiceOptional: vi.fn(), +})); +vi.mock("@posthog/ui/shell/analytics", () => ({ + track: vi.fn(), + setActiveTaskContext: vi.fn(), +})); + +import { useCurrentChannelStore } from "@posthog/ui/features/canvas/stores/currentChannelStore"; +import { openTaskInput } from "./useOpenTask"; + +describe("openTaskInput channel scoping", () => { + beforeEach(() => { + vi.clearAllMocks(); + useCurrentChannelStore.setState({ currentChannelId: null }); + }); + + // Without the channels layout nothing sets a current channel, so creates must + // land where they always did rather than being pulled into a channel. + it("routes to Code when no channel is current", () => { + openTaskInput(); + expect(navigateToCode).toHaveBeenCalledTimes(1); + expect(navigateToChannelNewTask).not.toHaveBeenCalled(); + }); + + it("still honours an explicit website space when no channel is current", () => { + openTaskInput({ space: "website" }); + expect(navigateToWebsiteNew).toHaveBeenCalledTimes(1); + expect(navigateToChannelNewTask).not.toHaveBeenCalled(); + }); + + it("routes into the current channel once one is scoped", () => { + useCurrentChannelStore.setState({ currentChannelId: "chan-1" }); + openTaskInput(); + expect(navigateToChannelNewTask).toHaveBeenCalledWith("chan-1"); + expect(navigateToCode).not.toHaveBeenCalled(); + }); + + it("carries prefill into the channel route", () => { + useCurrentChannelStore.setState({ currentChannelId: "chan-1" }); + openTaskInput({ initialPrompt: "ship it" }); + expect(navigateToChannelNewTask).toHaveBeenCalledWith("chan-1"); + }); +}); From 6673b94a086da0376280367e47deb7a6d11fca6f Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 25 Jul 2026 15:45:27 +0200 Subject: [PATCH 50/57] feat(canvas): lock glyph for private channels, and theme-token colours in the sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A private channel now shows a lock where a public one shows a hash, the Slack convention, so privacy reads at a glance. One helper (channelGlyph) decides it for every surface that renders channel identity — sidebar list, switcher trigger and rows, channel header, breadcrumb, command palette — which also retires the awkward trailing lock the #me row carried, since the leading glyph now says it. The personal #me channel is the only channel that is private today: neither the folder Channel nor the backend TaskChannel carries a privacy flag, so isPrivateChannel is the single place that has to learn about one when it lands. Also moves the channel sidebar off the absolute gray scale and onto the theme's fill/foreground tokens. The fill tokens are color-mix(foreground N%, transparent) — relative to the surface and tuned per mode (4% light, 7% dark) — whereas gray-3 is absolute, and its relationship to the chrome it is painted on inverts between modes: 3 L* below a near-white chrome in light, where darker reads heavier, so the nav's filled chips came out as muddy beige slabs. They now follow the same transparent-rest, fill-on-hover idiom as every other sidebar control. Generated-By: PostHog Code Task-Id: c401c7cb-e436-4da3-80bf-1d3008ee5d38 --- .../canvas/components/ChannelBreadcrumb.tsx | 7 ++- .../canvas/components/ChannelHeader.tsx | 8 +++- .../canvas/components/ChannelItemRow.tsx | 16 ++++--- .../features/canvas/components/ChannelNav.tsx | 6 +-- .../canvas/components/ChannelSidebar.tsx | 4 +- .../canvas/components/ChannelSwitcher.tsx | 43 +++++++++++-------- .../canvas/components/ChannelsList.tsx | 41 +++++++----------- .../canvas/components/channelGlyph.test.tsx | 19 ++++++++ .../canvas/components/channelGlyph.tsx | 41 ++++++++++++++++++ .../ui/src/features/command/CommandMenu.tsx | 7 ++- 10 files changed, 129 insertions(+), 63 deletions(-) create mode 100644 packages/ui/src/features/canvas/components/channelGlyph.test.tsx create mode 100644 packages/ui/src/features/canvas/components/channelGlyph.tsx 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", + })} - + {channelGlyph(channelName, { + size: 20, + className: "shrink-0 text-muted-foreground/80", + })} {channelName ?? "Channel"} diff --git a/packages/ui/src/features/canvas/components/ChannelItemRow.tsx b/packages/ui/src/features/canvas/components/ChannelItemRow.tsx index fcdaf2a409..7b5cc0957b 100644 --- a/packages/ui/src/features/canvas/components/ChannelItemRow.tsx +++ b/packages/ui/src/features/canvas/components/ChannelItemRow.tsx @@ -60,7 +60,7 @@ export function statusVariantFor( } const HOVER_ACTION_CLASS = - "flex h-5 w-5 cursor-pointer items-center justify-center rounded text-gray-10 transition-colors hover:bg-gray-4 hover:text-gray-12"; + "flex h-5 w-5 cursor-pointer items-center justify-center rounded text-muted-foreground transition-colors hover:bg-fill-hover hover:text-foreground"; export function ChannelItemRow({ item }: { item: ChannelItem }) { return ( @@ -79,7 +79,7 @@ export function ChannelItemRow({ item }: { item: ChannelItem }) { onClick={item.onClick} endContent={ <> - + {formatRelativeTimeShort(item.ts)} @@ -122,14 +122,14 @@ export function ChannelItemRow({ item }: { item: ChannelItem }) { >
- + {item.icon}
-

+

{item.title}

-

+

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

@@ -152,10 +152,12 @@ export function ChannelItemRow({ item }: { item: ChannelItem }) { )}
-

+

{item.authorName ?? "Unknown"}

-

Created by

+

+ Created by +

)} diff --git a/packages/ui/src/features/canvas/components/ChannelNav.tsx b/packages/ui/src/features/canvas/components/ChannelNav.tsx index 7dfb8b8ee6..fee67462da 100644 --- a/packages/ui/src/features/canvas/components/ChannelNav.tsx +++ b/packages/ui/src/features/canvas/components/ChannelNav.tsx @@ -42,7 +42,7 @@ function IconBadge({ "-top-1 -right-1 absolute inline-flex h-3.5 min-w-3.5 items-center justify-center rounded-full px-1 font-semibold text-[9px] tabular-nums leading-none ring-2 ring-chrome", tone === "notification" ? "bg-(--red-9) text-white" - : "bg-gray-5 text-gray-11", + : "bg-muted text-muted-foreground", )} > {count > 99 ? "99+" : count} @@ -74,8 +74,8 @@ function NavIcon({ className={cn( "relative flex size-8 shrink-0 items-center justify-center rounded-lg transition-colors duration-100", isActive - ? "bg-fill-selected text-gray-12" - : "bg-gray-3 text-gray-11 hover:bg-gray-4 hover:text-gray-12", + ? "bg-fill-selected text-foreground" + : "text-muted-foreground hover:bg-fill-hover hover:text-foreground", )} > {icon} diff --git a/packages/ui/src/features/canvas/components/ChannelSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelSidebar.tsx index 90e27aec8c..7411e2b050 100644 --- a/packages/ui/src/features/canvas/components/ChannelSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ChannelSidebar.tsx @@ -52,10 +52,10 @@ const CREATED_BY_OPTIONS: readonly { value: CreatedByFilter; label: string }[] = ] as const; const HEADER_ICON_BUTTON_CLASS = - "flex size-5 shrink-0 items-center justify-center rounded text-gray-10 transition-colors hover:bg-gray-3 hover:text-gray-12"; + "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-gray-3 text-gray-12"); + cn(HEADER_ICON_BUTTON_CLASS, active && "bg-fill-selected text-foreground"); const RECENTS_CAP = 30; diff --git a/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx b/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx index b5b3021161..feb012f271 100644 --- a/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx +++ b/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx @@ -1,10 +1,5 @@ import { Popover as BasePopover } from "@base-ui/react/popover"; -import { - CaretUpDownIcon, - HashIcon, - PlusIcon, - StarIcon, -} from "@phosphor-icons/react"; +import { CaretUpDownIcon, PlusIcon, StarIcon } from "@phosphor-icons/react"; import { ContextMenu, ContextMenuContent, @@ -20,6 +15,7 @@ import { } from "@posthog/quill"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { CreateChannelModal } from "@posthog/ui/features/canvas/components/CreateChannelModal"; +import { channelGlyph } from "@posthog/ui/features/canvas/components/channelGlyph"; import { useChannelStars, useChannelStarToggle, @@ -72,7 +68,7 @@ function SwitcherRow({ const row = (
@@ -82,9 +78,12 @@ function SwitcherRow({ className="flex min-w-0 flex-1 items-center gap-2 px-2 py-1 text-left text-[13px]" > - + {channelGlyph(channel.name, { + size: 14, + className: "text-muted-foreground", + })} - + {channel.name} {hotkeySlot != null && ( @@ -96,7 +95,7 @@ function SwitcherRow({ // still has to be filled to keep the rows aligned. @@ -106,7 +105,7 @@ function SwitcherRow({ aria-label={isStarred ? "Unstar channel" : "Star channel"} onClick={trackedToggleStar} className={cn( - "mr-1 flex size-5 shrink-0 items-center justify-center rounded text-gray-10 transition-colors hover:text-gray-12", + "mr-1 flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:text-foreground", !isStarred && "opacity-0 group-hover:opacity-100", )} > @@ -148,7 +147,7 @@ function TriggerStar({ channel }: { channel: Channel }) { }} // Parks in the trigger's reserved well: 8px padding + 12px caret + 6px // gap = 26px from the right edge. - className="-translate-y-1/2 absolute top-1/2 right-[26px] flex size-6 items-center justify-center rounded text-gray-10 transition-colors hover:bg-gray-4 hover:text-gray-12" + className="-translate-y-1/2 absolute top-1/2 right-[26px] flex size-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-fill-hover hover:text-foreground" > @@ -247,16 +246,22 @@ export function ChannelSwitcher({ channelId }: { channelId: string }) { // Fixed height with an unconditional star well: sized off its // contents, a starrable channel ran 4px taller than #me and // everything below shifted on switch. - className="flex h-8 w-full items-center gap-1.5 rounded-md border border-border px-2 text-left transition-colors hover:bg-gray-3 aria-expanded:bg-gray-3" + className="flex h-8 w-full items-center gap-1.5 rounded-md border border-border px-2 text-left transition-colors hover:bg-fill-hover aria-expanded:bg-fill-selected" > - + {channelGlyph(current?.name, { + size: 14, + className: "text-muted-foreground", + })} - + {current?.name ?? "channel"} - + } /> @@ -300,7 +305,7 @@ export function ChannelSwitcher({ channelId }: { channelId: string }) { /> ))} {topFiltered.length === 0 && restFiltered.length === 0 && ( -

+

No channels match "{query.trim()}".

)} @@ -312,7 +317,7 @@ export function ChannelSwitcher({ channelId }: { channelId: string }) { setOpen(false); setCreateOpen(true); }} - className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-[13px] text-gray-11 transition-colors hover:bg-gray-3" + className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-[13px] text-muted-foreground transition-colors hover:bg-fill-hover" > @@ -326,7 +331,7 @@ export function ChannelSwitcher({ channelId }: { channelId: string }) { {current && !showStar && ( diff --git a/packages/ui/src/features/canvas/components/ChannelsList.tsx b/packages/ui/src/features/canvas/components/ChannelsList.tsx index 9279e0e8e2..49cd1531ac 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, @@ -43,6 +42,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"; @@ -346,18 +346,18 @@ function ChannelSection({ params: { channelId: channel.id }, }); }} - 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" > - + ), + })} 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. */} -
diff --git a/packages/ui/src/features/canvas/components/channelGlyph.test.tsx b/packages/ui/src/features/canvas/components/channelGlyph.test.tsx new file mode 100644 index 0000000000..c5c9721736 --- /dev/null +++ b/packages/ui/src/features/canvas/components/channelGlyph.test.tsx @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { isPrivateChannel } from "./channelGlyph"; + +describe("isPrivateChannel", () => { + it.each([ + ["me", true], + [" Me ", true], + ["ME", true], + ["code", false], + ["posthog-feedback", false], + // Not a prefix match: only the personal channel itself is private. + ["meeting-notes", false], + ["team-me", false], + [undefined, false], + ["", false], + ])("%s -> %s", (name, expected) => { + expect(isPrivateChannel(name)).toBe(expected); + }); +}); diff --git a/packages/ui/src/features/canvas/components/channelGlyph.tsx b/packages/ui/src/features/canvas/components/channelGlyph.tsx new file mode 100644 index 0000000000..f312c0d000 --- /dev/null +++ b/packages/ui/src/features/canvas/components/channelGlyph.tsx @@ -0,0 +1,41 @@ +import { + HashIcon, + type IconWeight, + LockSimpleIcon, +} from "@phosphor-icons/react"; +import { + normalizeChannelName, + PERSONAL_CHANNEL_NAME, +} from "@posthog/ui/features/canvas/hooks/useTaskChannels"; +import type { ReactNode } from "react"; + +/** + * Whether only its own members can see a channel. + * + * The personal "#me" channel is the only one today — it is per-user and can't + * be shared. Neither the folder `Channel` nor the backend `TaskChannel` carries + * a general privacy flag, so this is the one place that has to learn about it + * when one lands. + */ +export function isPrivateChannel(channelName: string | undefined): boolean { + if (!channelName) return false; + return normalizeChannelName(channelName) === PERSONAL_CHANNEL_NAME; +} + +/** + * A channel's leading glyph: a lock when it's private, otherwise a hash — the + * Slack convention, so privacy reads at a glance instead of having to be known. + */ +export function channelGlyph( + channelName: string | undefined, + opts?: { size?: number; className?: string; weight?: IconWeight }, +): ReactNode { + const Icon = isPrivateChannel(channelName) ? LockSimpleIcon : HashIcon; + return ( + + ); +} diff --git a/packages/ui/src/features/command/CommandMenu.tsx b/packages/ui/src/features/command/CommandMenu.tsx index bed635d6a8..532ea11c64 100644 --- a/packages/ui/src/features/command/CommandMenu.tsx +++ b/packages/ui/src/features/command/CommandMenu.tsx @@ -3,7 +3,6 @@ import { CaretRightIcon, ChartLine, EnvelopeSimple, - HashIcon, RepeatIcon, } from "@phosphor-icons/react"; import { workspaceIdSet } from "@posthog/core/command-center/eligibility"; @@ -32,6 +31,7 @@ import { } from "@posthog/shared/analytics-events"; import type { Task } from "@posthog/shared/domain-types"; import { useArchivedTaskIds } from "@posthog/ui/features/archive/useArchivedTaskIds"; +import { channelGlyph } from "@posthog/ui/features/canvas/components/channelGlyph"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useTaskChannelMap } from "@posthog/ui/features/canvas/hooks/useTaskChannelMap"; import { useReviewNavigationStore } from "@posthog/ui/features/code-review/reviewNavigationStore"; @@ -529,7 +529,10 @@ export function CommandMenu({ open, onOpenChange }: CommandMenuProps) { id: `channel-${channel.id}`, label: channel.name, keywords: "channel", - icon: , + icon: channelGlyph(channel.name, { + size: 12, + className: "text-muted-foreground", + }), action: "open-channel" as CommandMenuAction, channelId: channel.id, onRun: () => { From 89657e3c5e2902050afde3e422a8b02808268e40 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Sat, 25 Jul 2026 15:45:29 +0200 Subject: [PATCH 51/57] fix(channels): make the spaces layout production-ready MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A review of the prototype turned up ten verified defects, three of which bite every dogfooder the moment the flag is on. Fixed here, along with the forks the prototype introduced against the code it was copied from. The three that mattered most: - The channel sidebar fabricated the name `"channel"` while the folder list was still loading and handed it to `useBackendChannel`, whose resolve-or-create effect then provisioned a real backend channel called "channel" on every cold load. `useChannelItems` now resolves the name itself and reports loading until it knows it, so no caller can invent one. That also fixes the personal channel briefly showing other people's canvases. - Gating `BrowserTabStrip` behind the flag unmounted the only global owner of Cmd+W, whose handler exists specifically to stop Electron's Window > Close from taking the window. A headless `TabShortcutFallback` holds the key wherever the strip isn't mounted — including the settings shell, which never mounted it either. - Nothing cleared `currentChannelId` on a project switch or logout, so `openTaskInput` filed the next task into the previous project's channel. `useCurrentChannel` now resolves the id against the live list and self-heals; the auth side effects and channel deletion clear it explicitly. Also: `mod+0` leaves the starred-channel chord (Electron's main process owns CmdOrCtrl+0 for "Actual Size", which a renderer preventDefault can't beat), so than one that only mounts once a channel is scoped; the Archived row comes back, since the layout offers an Archive action on every row; the command-center badge counts live tasks instead of persisted cell ids; and the auto-scope latch resets when the flag reads false so a flag flicker can't strand the sidebar unscoped. Consolidations, so the prototype's copies can't drift from the originals: `useThreadConversation` is now shared by ThreadPanel and ActivityPanel (the @agent authorization rule and the draft-restore-on-failure existed twice); `usePrArtifact` and a shared `parseHttpsUrl` replace three PR-row copies, and give WebsiteChannelArtifacts the github-origin check the other two had; run status has one label map instead of four disagreeing ones; `CountBadge` absorbs three copies of the 99+ cap. Conventions: run artifacts are decoded with a Zod schema, and the sidebar's empty states use the prescribed `` composition. Also fixed while here: the shortcuts sheet no longer advertises keys nothing owns in the current layout; nav clicks carry a `layout` property so the two sidebars are distinguishable in analytics; `CHANNELS_SPACE_VIEWED` moves to the shell so space adoption doesn't read as zero once the flag lands; the Activity tabs use quill's Tabs primitive for real tab semantics; and the channel sidebar sits behind an error boundary so one bad row can't take out the project switcher. The Customize-sidebar settings page is hidden under the flag rather than left silently inert — the layout's nav is fixed. Sidebar customization and the global Loops list are deliberately not carried over; see the PR description. Generated-By: PostHog Code Task-Id: 4020e833-acf4-46ee-bdca-88e4a60e225d --- .../platform-adapters/auth-side-effects.ts | 5 + apps/web/src/web-auth-side-effects.ts | 5 + packages/core/src/canvas/channelItems.test.ts | 237 ++++++++++++ packages/core/src/canvas/channelItems.ts | 136 +++++++ .../src/canvas/runArtifactSchemas.test.ts | 36 ++ .../core/src/canvas/runArtifactSchemas.ts | 26 ++ packages/core/src/canvas/runStatus.test.ts | 75 ++++ packages/core/src/canvas/runStatus.ts | 66 ++++ packages/core/src/command-center/grid.test.ts | 32 ++ packages/core/src/command-center/grid.ts | 15 + packages/shared/src/analytics-events.ts | 11 + .../browser-tabs/TabShortcutFallback.test.tsx | 30 ++ .../browser-tabs/TabShortcutFallback.tsx | 26 ++ .../canvas/components/ActivityPanel.tsx | 365 +++++------------- .../canvas/components/ChannelFeedView.tsx | 27 +- .../canvas/components/ChannelHotkeys.test.tsx | 96 +++++ .../canvas/components/ChannelHotkeys.tsx | 53 +++ .../canvas/components/ChannelItemRow.tsx | 131 ++++--- .../features/canvas/components/ChannelNav.tsx | 60 ++- .../canvas/components/ChannelSidebar.tsx | 140 ++++--- .../canvas/components/ChannelSwitcher.tsx | 80 +--- .../canvas/components/ChannelsList.tsx | 33 +- .../components/ChannelsSidebar.test.tsx | 226 +++++++++++ .../canvas/components/ChannelsSidebar.tsx | 64 ++- .../features/canvas/components/NewTaskFab.tsx | 6 +- .../canvas/components/TaskArtifactsList.tsx | 73 ++-- .../canvas/components/ThreadPanel.tsx | 244 +++--------- .../components/WebsiteChannelArtifacts.tsx | 52 +-- .../canvas/freeform/useHomeCanvasView.ts | 5 +- .../canvas/hooks/useChannelItems.test.tsx | 181 +++++++++ .../features/canvas/hooks/useChannelItems.tsx | 212 +++++----- .../canvas/hooks/useCurrentChannel.test.tsx | 80 ++++ .../canvas/hooks/useCurrentChannel.ts | 48 +++ .../canvas/hooks/useStarredChannelSlots.ts | 55 +++ .../canvas/hooks/useThreadConversation.ts | 227 +++++++++++ .../hooks/useTrackChannelsSpaceViewed.ts | 53 +++ .../canvas/stores/currentChannelStore.ts | 10 + .../useCommandCenterActiveCount.ts | 31 ++ .../command/KeyboardShortcutsSheet.tsx | 9 +- .../command/keyboard-shortcuts.test.ts | 60 +++ .../features/command/keyboard-shortcuts.ts | 36 +- .../features/git-interaction/usePrArtifact.ts | 51 +++ .../settings/components/SettingsPanel.tsx | 4 + .../settings/settingsVisibility.test.ts | 12 + .../features/settings/settingsVisibility.ts | 12 + .../components/SidebarNavSection.test.tsx | 3 +- .../sidebar/components/SidebarNavSection.tsx | 20 +- .../components/items/CommandCenterItem.tsx | 24 +- .../task-detail/components/TaskInput.tsx | 11 +- .../stores/taskInputPrefillStore.ts | 21 +- packages/ui/src/primitives/CountBadge.tsx | 30 +- packages/ui/src/router/routes/__root.tsx | 19 +- packages/ui/src/router/useOpenTask.test.ts | 77 +++- packages/ui/src/router/useOpenTask.ts | 18 +- packages/ui/src/shell/GlobalEventHandlers.tsx | 5 +- packages/ui/src/utils/posthogLinks.ts | 14 + 56 files changed, 2696 insertions(+), 982 deletions(-) create mode 100644 packages/core/src/canvas/channelItems.test.ts create mode 100644 packages/core/src/canvas/channelItems.ts create mode 100644 packages/core/src/canvas/runArtifactSchemas.test.ts create mode 100644 packages/core/src/canvas/runArtifactSchemas.ts create mode 100644 packages/core/src/canvas/runStatus.test.ts create mode 100644 packages/core/src/canvas/runStatus.ts create mode 100644 packages/ui/src/features/browser-tabs/TabShortcutFallback.test.tsx create mode 100644 packages/ui/src/features/browser-tabs/TabShortcutFallback.tsx create mode 100644 packages/ui/src/features/canvas/components/ChannelHotkeys.test.tsx create mode 100644 packages/ui/src/features/canvas/components/ChannelHotkeys.tsx create mode 100644 packages/ui/src/features/canvas/components/ChannelsSidebar.test.tsx create mode 100644 packages/ui/src/features/canvas/hooks/useChannelItems.test.tsx create mode 100644 packages/ui/src/features/canvas/hooks/useCurrentChannel.test.tsx create mode 100644 packages/ui/src/features/canvas/hooks/useCurrentChannel.ts create mode 100644 packages/ui/src/features/canvas/hooks/useStarredChannelSlots.ts create mode 100644 packages/ui/src/features/canvas/hooks/useThreadConversation.ts create mode 100644 packages/ui/src/features/canvas/hooks/useTrackChannelsSpaceViewed.ts create mode 100644 packages/ui/src/features/command-center/useCommandCenterActiveCount.ts create mode 100644 packages/ui/src/features/command/keyboard-shortcuts.test.ts create mode 100644 packages/ui/src/features/git-interaction/usePrArtifact.ts diff --git a/apps/code/src/renderer/platform-adapters/auth-side-effects.ts b/apps/code/src/renderer/platform-adapters/auth-side-effects.ts index 2a567d93c5..0e87804a26 100644 --- a/apps/code/src/renderer/platform-adapters/auth-side-effects.ts +++ b/apps/code/src/renderer/platform-adapters/auth-side-effects.ts @@ -6,6 +6,7 @@ import { } from "@posthog/ui/features/auth/authQueries"; import { useAuthUiStateStore } from "@posthog/ui/features/auth/authUiStateStore"; import type { IAuthSideEffects } from "@posthog/ui/features/auth/identifiers"; +import { resetCurrentChannel } from "@posthog/ui/features/canvas/stores/currentChannelStore"; import { useOnboardingStore } from "@posthog/ui/features/onboarding/onboardingStore"; import { resetSessionService } from "@posthog/ui/features/sessions/sessionServiceHost"; import { openTaskInput } from "@posthog/ui/router/useOpenTask"; @@ -30,6 +31,9 @@ export class RendererAuthSideEffects implements IAuthSideEffects { onProjectSelected(): void { clearAuthScopedQueries(); void refreshAuthStateQuery(); + // Before openTaskInput, which files a new task into the scoped channel — + // a channel id from the project we just left. + resetCurrentChannel(); openTaskInput(); } @@ -40,6 +44,7 @@ export class RendererAuthSideEffects implements IAuthSideEffects { if (previousRegion) { useAuthUiStateStore.getState().setStaleRegion(previousRegion); } + resetCurrentChannel(); openTaskInput(); useOnboardingStore.getState().resetSelections(); } diff --git a/apps/web/src/web-auth-side-effects.ts b/apps/web/src/web-auth-side-effects.ts index a22b6081ce..03fed679b4 100644 --- a/apps/web/src/web-auth-side-effects.ts +++ b/apps/web/src/web-auth-side-effects.ts @@ -5,6 +5,7 @@ import { } from "@posthog/ui/features/auth/authQueries"; import { useAuthUiStateStore } from "@posthog/ui/features/auth/authUiStateStore"; import type { IAuthSideEffects } from "@posthog/ui/features/auth/identifiers"; +import { resetCurrentChannel } from "@posthog/ui/features/canvas/stores/currentChannelStore"; import { useOnboardingStore } from "@posthog/ui/features/onboarding/onboardingStore"; import { openTaskInput } from "@posthog/ui/router/useOpenTask"; import { injectable } from "inversify"; @@ -24,6 +25,9 @@ export class WebAuthSideEffects implements IAuthSideEffects { onProjectSelected(): void { clearAuthScopedQueries(); void refreshAuthStateQuery(); + // Before openTaskInput, which files a new task into the scoped channel — + // a channel id from the project we just left. + resetCurrentChannel(); openTaskInput(); } @@ -32,6 +36,7 @@ export class WebAuthSideEffects implements IAuthSideEffects { if (previousRegion) { useAuthUiStateStore.getState().setStaleRegion(previousRegion); } + resetCurrentChannel(); openTaskInput(); useOnboardingStore.getState().resetSelections(); } diff --git a/packages/core/src/canvas/channelItems.test.ts b/packages/core/src/canvas/channelItems.test.ts new file mode 100644 index 0000000000..abe6a15200 --- /dev/null +++ b/packages/core/src/canvas/channelItems.test.ts @@ -0,0 +1,237 @@ +import type { Task, UserBasic } from "@posthog/shared/domain-types"; +import { describe, expect, it } from "vitest"; +import { + buildChannelItems, + type ChannelItemModel, + filterChannelItems, + isOwnedBy, +} from "./channelItems"; +import type { DashboardSummary } from "./dashboardSchemas"; + +const ME: UserBasic = { + id: 1, + uuid: "me-uuid", + distinct_id: "me", + first_name: "Ada", + last_name: "Lovelace", + email: "ada@posthog.com", +}; + +const OTHER: UserBasic = { + id: 2, + uuid: "other-uuid", + distinct_id: "other", + first_name: "Grace", + last_name: "Hopper", + email: "grace@posthog.com", +}; + +function canvas(over: Partial = {}): DashboardSummary { + return { + id: "d1", + channelId: "c1", + name: "Canvas", + templateId: "freeform", + createdAt: 0, + updatedAt: 1_000, + ...over, + } as DashboardSummary; +} + +function task(over: Partial = {}): Task { + return { + id: "t1", + title: "Task", + updated_at: new Date(2_000).toISOString(), + created_by: ME, + ...over, + } as Task; +} + +const NONE: ReadonlySet = new Set(); + +describe("buildChannelItems", () => { + it("merges canvases and tasks newest-first", () => { + const items = buildChannelItems({ + dashboards: [canvas({ id: "old", updatedAt: 1_000 })], + feedTasks: [ + task({ id: "new", updated_at: new Date(5_000).toISOString() }), + ], + archivedTaskIds: NONE, + pinnedTaskIds: NONE, + ownedBy: null, + }); + expect(items.map((i) => i.key)).toEqual(["task:new", "canvas:old"]); + }); + + it("drops archived tasks but keeps canvases", () => { + const items = buildChannelItems({ + dashboards: [canvas()], + feedTasks: [task({ id: "gone" })], + archivedTaskIds: new Set(["gone"]), + pinnedTaskIds: NONE, + ownedBy: null, + }); + expect(items.map((i) => i.kind)).toEqual(["canvas"]); + }); + + it("marks pinned state from each source's own signal", () => { + const items = buildChannelItems({ + dashboards: [canvas({ id: "pinned-canvas", pinnedAt: 42 })], + feedTasks: [task({ id: "pinned-task" })], + archivedTaskIds: NONE, + pinnedTaskIds: new Set(["pinned-task"]), + ownedBy: null, + }); + expect(items.every((i) => i.pinned)).toBe(true); + }); + + it("falls back to a placeholder title for untitled tasks", () => { + const [item] = buildChannelItems({ + dashboards: [], + feedTasks: [task({ title: "" })], + archivedTaskIds: NONE, + pinnedTaskIds: NONE, + ownedBy: null, + }); + expect(item.title).toBe("Untitled task"); + }); + + it("treats an unparseable updated_at as epoch rather than NaN", () => { + const [item] = buildChannelItems({ + dashboards: [], + feedTasks: [task({ updated_at: "not a date" })], + archivedTaskIds: NONE, + pinnedTaskIds: NONE, + ownedBy: null, + }); + expect(item.ts).toBe(0); + }); + + it("returns everything when the owner is unknown", () => { + const items = buildChannelItems({ + dashboards: [canvas({ createdBy: "Grace Hopper" })], + feedTasks: [task({ created_by: OTHER })], + archivedTaskIds: NONE, + pinnedTaskIds: NONE, + ownedBy: null, + }); + expect(items).toHaveLength(2); + }); + + it("filters to the owner for the personal channel", () => { + const items = buildChannelItems({ + dashboards: [ + canvas({ id: "mine", createdBy: "Ada Lovelace" }), + canvas({ id: "theirs", createdBy: "Grace Hopper" }), + ], + feedTasks: [ + task({ id: "mine-task", created_by: ME }), + task({ id: "their-task", created_by: OTHER }), + ], + archivedTaskIds: NONE, + pinnedTaskIds: NONE, + ownedBy: { uuid: ME.uuid, name: "Ada Lovelace" }, + }); + expect(items.map((i) => i.id).sort()).toEqual(["mine", "mine-task"]); + }); + + it("keeps items whose author is unknown", () => { + const items = buildChannelItems({ + dashboards: [canvas({ id: "orphan", createdBy: undefined })], + feedTasks: [task({ id: "orphan-task", created_by: null })], + archivedTaskIds: NONE, + pinnedTaskIds: NONE, + ownedBy: { uuid: ME.uuid, name: "Ada Lovelace" }, + }); + expect(items).toHaveLength(2); + }); +}); + +describe("isOwnedBy", () => { + it("prefers uuid over display name when a full user is present", () => { + const item = { authorUser: OTHER, authorName: "Ada Lovelace" }; + expect(isOwnedBy(item, { uuid: ME.uuid, name: "Ada Lovelace" })).toBe( + false, + ); + }); + + it("cannot match a name-only author when our own name is unknown", () => { + const item = { authorUser: null, authorName: "Grace Hopper" }; + expect(isOwnedBy(item, { uuid: ME.uuid, name: null })).toBe(true); + }); +}); + +function model(over: Partial = {}): ChannelItemModel { + return { + key: "task:t1", + kind: "task", + id: "t1", + title: "Ship the thing", + ts: 0, + pinned: false, + rawStatus: null, + authorUser: ME, + authorName: null, + templateId: null, + ...over, + }; +} + +describe("filterChannelItems", () => { + const me = { uuid: ME.uuid, name: "Ada Lovelace" }; + + it("matches titles case-insensitively", () => { + const items = [model({ title: "Ship IT" }), model({ title: "Other" })]; + const result = filterChannelItems(items, { + query: " ship ", + createdBy: "anyone", + status: null, + me, + }); + expect(result.map((i) => i.title)).toEqual(["Ship IT"]); + }); + + it.each([ + ["me", ["mine"]], + ["others", ["theirs"]], + ["anyone", ["mine", "theirs"]], + ] as const)("filters createdBy=%s", (createdBy, expected) => { + const items = [ + model({ id: "mine", authorUser: ME }), + model({ id: "theirs", authorUser: OTHER }), + ]; + const result = filterChannelItems(items, { + query: "", + createdBy, + status: null, + me, + }); + expect(result.map((i) => i.id)).toEqual(expected); + }); + + it("filters by run status, including not_started", () => { + const items = [ + model({ id: "fresh", rawStatus: "not_started" }), + model({ id: "done", rawStatus: "completed" }), + ]; + const result = filterChannelItems(items, { + query: "", + createdBy: "anyone", + status: "not_started", + me, + }); + expect(result.map((i) => i.id)).toEqual(["fresh"]); + }); + + it("excludes canvases when a run status is selected", () => { + const items = [model({ kind: "canvas", rawStatus: null })]; + const result = filterChannelItems(items, { + query: "", + createdBy: "anyone", + status: "completed", + me, + }); + expect(result).toEqual([]); + }); +}); diff --git a/packages/core/src/canvas/channelItems.ts b/packages/core/src/canvas/channelItems.ts new file mode 100644 index 0000000000..f3edebc0f4 --- /dev/null +++ b/packages/core/src/canvas/channelItems.ts @@ -0,0 +1,136 @@ +import type { + Task, + TaskRunStatus, + UserBasic, +} from "@posthog/shared/domain-types"; +import type { DashboardSummary } from "./dashboardSchemas"; + +/** + * A channel's canvases and tasks as one sortable list. Deliberately data-only — + * no icons, no click handlers — so the memo that builds it depends on data + * alone and doesn't churn on every navigation. + */ +export interface ChannelItemModel { + /** Stable identity across rebuilds; also the route-activeness key. */ + key: string; + kind: "task" | "canvas"; + id: string; + title: string; + /** Epoch ms the item was last updated. */ + ts: number; + pinned: boolean; + /** Tasks only: the latest run's status. */ + rawStatus: TaskRunStatus | null; + /** The author as a full user when we have one (tasks). */ + authorUser: UserBasic | null; + /** The author as a bare display name when that's all we have (canvases). */ + authorName: string | null; + /** Canvases only: which template drew it, so the caller can pick an icon. */ + templateId: string | null; +} + +/** Who "me" is, for ownership checks. Either field may be unknown. */ +export interface ChannelItemOwner { + uuid: string | null; + name: string | null; +} + +/** + * Whether an item belongs to `owner`. Items whose author we don't know count as + * owned — a channel shouldn't hide your own items just because the author field + * came back empty. Canvases only carry a display name, hence the name fallback. + */ +export function isOwnedBy( + item: Pick, + owner: ChannelItemOwner, +): boolean { + if (item.authorUser) return item.authorUser.uuid === owner.uuid; + if (item.authorName && owner.name) return item.authorName === owner.name; + return true; +} + +export function buildChannelItems({ + dashboards, + feedTasks, + archivedTaskIds, + pinnedTaskIds, + ownedBy, +}: { + dashboards: readonly DashboardSummary[]; + feedTasks: readonly Task[]; + archivedTaskIds: ReadonlySet; + pinnedTaskIds: ReadonlySet; + /** + * Set only for the personal channel, and only once the current user is known + * — otherwise the list is returned unfiltered rather than guessing. + */ + ownedBy: ChannelItemOwner | null; +}): ChannelItemModel[] { + const canvasItems: ChannelItemModel[] = dashboards.map((d) => ({ + key: `canvas:${d.id}`, + kind: "canvas", + id: d.id, + title: d.name, + ts: d.updatedAt, + pinned: d.pinnedAt != null, + rawStatus: null, + authorUser: null, + authorName: d.createdBy ?? null, + templateId: d.templateId, + })); + + const taskItems: ChannelItemModel[] = feedTasks.flatMap((task) => + archivedTaskIds.has(task.id) + ? [] + : [ + { + key: `task:${task.id}`, + kind: "task" as const, + id: task.id, + title: task.title || "Untitled task", + ts: Date.parse(task.updated_at) || 0, + pinned: pinnedTaskIds.has(task.id), + rawStatus: task.latest_run?.status ?? null, + authorUser: task.created_by ?? null, + authorName: null, + templateId: null, + }, + ], + ); + + const all = [...canvasItems, ...taskItems].sort((a, b) => b.ts - a.ts); + return ownedBy ? all.filter((item) => isOwnedBy(item, ownedBy)) : all; +} + +export type CreatedByFilter = "anyone" | "me" | "others"; + +export function filterChannelItems( + items: readonly ChannelItemModel[], + { + query, + createdBy, + status, + me, + }: { + query: string; + createdBy: CreatedByFilter; + status: TaskRunStatus | null; + me: ChannelItemOwner; + }, +): ChannelItemModel[] { + const normalizedQuery = query.trim().toLowerCase(); + return items.filter((item) => { + if ( + normalizedQuery && + !item.title.toLowerCase().includes(normalizedQuery) + ) { + return false; + } + if (createdBy !== "anyone") { + const mine = isOwnedBy(item, me); + if (createdBy === "me" ? !mine : mine) return false; + } + if (status && item.rawStatus !== status) return false; + return true; + }); +} diff --git a/packages/core/src/canvas/runArtifactSchemas.test.ts b/packages/core/src/canvas/runArtifactSchemas.test.ts new file mode 100644 index 0000000000..e2db2fe283 --- /dev/null +++ b/packages/core/src/canvas/runArtifactSchemas.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { parseRunPlans } from "./runArtifactSchemas"; + +describe("parseRunPlans", () => { + it.each([ + { name: "undefined", raw: undefined }, + { name: "null", raw: null }, + { name: "an object", raw: { artifacts: [] } }, + { name: "a string", raw: "plan" }, + ])("reads $name as no artifacts", ({ raw }) => { + expect(parseRunPlans(raw)).toEqual([]); + }); + + it("keeps only plan artifacts", () => { + const plans = parseRunPlans([ + { id: "a", type: "plan", name: "Plan A" }, + { id: "b", type: "upload", name: "internal blob" }, + ]); + expect(plans).toEqual([{ id: "a", type: "plan", name: "Plan A" }]); + }); + + // One bad entry shouldn't take the Artifacts tab down with it. + it("drops entries that don't match the shape", () => { + const plans = parseRunPlans([ + { id: 42, type: "plan" }, + null, + "not an object", + { type: "plan", storage_path: "runs/1/plan.md" }, + ]); + expect(plans).toEqual([{ type: "plan", storage_path: "runs/1/plan.md" }]); + }); + + it("ignores an artifact with no type", () => { + expect(parseRunPlans([{ id: "a", name: "mystery" }])).toEqual([]); + }); +}); diff --git a/packages/core/src/canvas/runArtifactSchemas.ts b/packages/core/src/canvas/runArtifactSchemas.ts new file mode 100644 index 0000000000..cf23b88bde --- /dev/null +++ b/packages/core/src/canvas/runArtifactSchemas.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; + +/** + * An entry in a run's untyped `artifacts` blob. Every field is optional because + * the payload is whatever the backend attached; only `type` is load-bearing. + */ +export const runArtifactSchema = z.object({ + id: z.string().optional(), + name: z.string().optional(), + type: z.string().optional(), + storage_path: z.string().optional(), +}); +export type RunArtifact = z.infer; + +/** + * The plan artifacts on a run. Entries that don't parse are dropped rather than + * throwing — one malformed artifact shouldn't take the whole panel down — and a + * non-array blob reads as "no artifacts". + */ +export function parseRunPlans(raw: unknown): RunArtifact[] { + if (!Array.isArray(raw)) return []; + return raw.flatMap((entry) => { + const parsed = runArtifactSchema.safeParse(entry); + return parsed.success && parsed.data.type === "plan" ? [parsed.data] : []; + }); +} diff --git a/packages/core/src/canvas/runStatus.test.ts b/packages/core/src/canvas/runStatus.test.ts new file mode 100644 index 0000000000..7c19dd9715 --- /dev/null +++ b/packages/core/src/canvas/runStatus.test.ts @@ -0,0 +1,75 @@ +import type { TaskRunStatus } from "@posthog/shared/domain-types"; +import { describe, expect, it } from "vitest"; +import { + RUN_STATUS_FILTER_OPTIONS, + RUN_STATUS_LABELS, + runStatusLabel, + runStatusVariant, +} from "./runStatus"; + +const ALL_STATUSES: TaskRunStatus[] = [ + "not_started", + "queued", + "in_progress", + "completed", + "failed", + "cancelled", +]; + +describe("runStatusLabel", () => { + it.each([ + ["completed", "Ready"], + ["in_progress", "In progress"], + ["not_started", "Not started"], + ["queued", "Queued"], + ["failed", "Failed"], + ["cancelled", "Cancelled"], + ] as const)("labels %s as %s", (status, expected) => { + expect(runStatusLabel(status)).toBe(expected); + }); + + it.each([null, undefined])("returns null for %s", (status) => { + expect(runStatusLabel(status)).toBeNull(); + }); +}); + +describe("runStatusVariant", () => { + it.each([ + ["completed", "success"], + ["failed", "destructive"], + ["in_progress", "info"], + ["queued", "default"], + ["not_started", "default"], + ["cancelled", "default"], + ] as const)("maps %s to %s", (status, expected) => { + expect(runStatusVariant(status)).toBe(expected); + }); + + it("falls back to default when there is no run", () => { + expect(runStatusVariant(null)).toBe("default"); + }); +}); + +describe("RUN_STATUS_FILTER_OPTIONS", () => { + it("leads with the any-status option", () => { + expect(RUN_STATUS_FILTER_OPTIONS[0]).toEqual({ + value: null, + label: "Any status", + }); + }); + + it("offers every run status, so none is silently unfilterable", () => { + const offered = RUN_STATUS_FILTER_OPTIONS.map((o) => o.value).filter( + (v) => v !== null, + ); + expect(new Set(offered)).toEqual(new Set(ALL_STATUSES)); + }); + + it("reuses the shared labels rather than restating them", () => { + for (const option of RUN_STATUS_FILTER_OPTIONS) { + if (option.value) { + expect(option.label).toBe(RUN_STATUS_LABELS[option.value]); + } + } + }); +}); diff --git a/packages/core/src/canvas/runStatus.ts b/packages/core/src/canvas/runStatus.ts new file mode 100644 index 0000000000..8e36c9b52d --- /dev/null +++ b/packages/core/src/canvas/runStatus.ts @@ -0,0 +1,66 @@ +import type { TaskRunStatus } from "@posthog/shared/domain-types"; + +/** Badge tone for a run status. Mirrors quill's `Badge` variants. */ +export type RunStatusVariant = + | "default" + | "destructive" + | "info" + | "success" + | "warning"; + +/** + * The one place a run status becomes words. Keyed on `TaskRunStatus` so adding + * a status is a compile error here rather than an unlabelled badge downstream. + */ +export const RUN_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", +}; + +const RUN_STATUS_VARIANTS: Record = { + not_started: "default", + queued: "default", + in_progress: "info", + completed: "success", + failed: "destructive", + cancelled: "default", +}; + +export function runStatusLabel( + status: TaskRunStatus | null | undefined, +): string | null { + return status ? RUN_STATUS_LABELS[status] : null; +} + +export function runStatusVariant( + status: TaskRunStatus | null | undefined, +): RunStatusVariant { + return status ? RUN_STATUS_VARIANTS[status] : "default"; +} + +/** + * Statuses worth filtering a channel's items by, in the order they're offered. + * Derived from the label map so a new status can't be silently unfilterable. + */ +export const RUN_STATUS_FILTER_OPTIONS: readonly { + value: TaskRunStatus | null; + label: string; +}[] = [ + { value: null, label: "Any status" }, + ...( + [ + "not_started", + "queued", + "in_progress", + "completed", + "failed", + "cancelled", + ] as const + ).map((value) => ({ value, label: RUN_STATUS_LABELS[value] })), +]; 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 6355548533..d7a40fd29e 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -254,10 +254,19 @@ export type SidebarNavItem = | "more" | "customize_sidebar"; +/** Which sidebar shell the click came from, so the two can be compared. */ +export type SidebarLayout = "code" | "channels"; + export interface SidebarNavItemClickedProperties { item: SidebarNavItem; /** True when the row was clicked inside the expanded More section. */ in_more: boolean; + /** + * Which shell rendered the row. Both shells fire this event with the same + * item names, so without it the layouts are indistinguishable — and comparing + * them is the whole point of running one behind a flag. + */ + layout?: SidebarLayout; } export interface SidebarCustomizedProperties { @@ -996,6 +1005,8 @@ export interface ChannelsSpaceViewedProperties { /** Total channels visible when the space mounts. */ channel_count: number; starred_count: number; + /** Which shell the space was entered through. */ + layout?: SidebarLayout; } // Subscription / billing events 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/components/ActivityPanel.tsx b/packages/ui/src/features/canvas/components/ActivityPanel.tsx index aada9f0a0d..6d12555778 100644 --- a/packages/ui/src/features/canvas/components/ActivityPanel.tsx +++ b/packages/ui/src/features/canvas/components/ActivityPanel.tsx @@ -1,50 +1,28 @@ -import { - ArrowSquareOutIcon, - CaretRightIcon, - XIcon, -} from "@phosphor-icons/react"; -import { - buildThreadTimeline, - deriveThreadAgentStatus, - hasAgentMention, - shouldSuspendThreadSession, -} from "@posthog/core/canvas/threadTimeline"; -import { Button, cn } from "@posthog/quill"; +import { CaretRightIcon } from "@phosphor-icons/react"; +import { Button, cn, Tabs, TabsList, TabsTrigger } from "@posthog/quill"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; -import type { Task, UserBasic } from "@posthog/shared/domain-types"; -import { isTerminalStatus } from "@posthog/shared/domain-types"; -import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; -import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser"; +import type { Task } from "@posthog/shared/domain-types"; import { ActivityTimeline } from "@posthog/ui/features/canvas/components/ActivityTimeline"; import { TaskCard } from "@posthog/ui/features/canvas/components/ChannelFeedView"; import { TaskArtifactsList } from "@posthog/ui/features/canvas/components/TaskArtifactsList"; import { AgentStatusLine, ThreadLoadingState, + ThreadPanelHeader, ThreadReplyComposer, ThreadTimeline, } from "@posthog/ui/features/canvas/components/ThreadPanel"; -import { useOrgMembers } from "@posthog/ui/features/canvas/hooks/useOrgMembers"; -import { - useDeleteTaskThreadMessage, - usePostTaskThreadMessage, - usePostTaskThreadMessageToAgent, - useSendTaskThreadMessageToAgent, - useTaskThread, -} from "@posthog/ui/features/canvas/hooks/useTaskThread"; +import { useThreadConversation } from "@posthog/ui/features/canvas/hooks/useThreadConversation"; import { buildConversationItems } from "@posthog/ui/features/sessions/components/buildConversationItems"; -import { useSessionConnection } from "@posthog/ui/features/sessions/hooks/useSessionConnection"; -import { useSessionViewState } from "@posthog/ui/features/sessions/hooks/useSessionViewState"; -import { usePendingPermissionsForTask } from "@posthog/ui/features/sessions/sessionStore"; import { taskDetailQuery } from "@posthog/ui/features/tasks/queries"; -import { toast } from "@posthog/ui/primitives/toast"; import { track } from "@posthog/ui/shell/analytics"; import { useQuery } from "@tanstack/react-query"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; /** * The flag-gated task panel — Timeline / Artifacts / Comments. Legacy - * ThreadPanel stays for flag-off; its row/composer primitives are reused here. + * ThreadPanel stays for flag-off; both drive the same conversation through + * useThreadConversation, so the posting rules can't diverge between them. */ type ActivityTab = "timeline" | "artifacts" | "comments"; @@ -55,58 +33,16 @@ const ACTIVITY_TABS: readonly { key: ActivityTab; label: string }[] = [ { key: "comments", label: "Comments" }, ] as const; +// Artifacts is a read-only list: nothing to post into, and no agent to report on. +const TABS_WITH_COMPOSER: ReadonlySet = new Set([ + "timeline", + "comments", +]); + // Right-align row timestamps via container CSS, leaving the shared rows untouched. const TIMESTAMP_END_CLASS = "[&_[data-slot=thread-item-timestamp]]:ml-auto [&_[data-slot=thread-item-timestamp]]:shrink-0 [&_[data-slot=thread-item-timestamp]]:pl-2"; -function ActivityHeader({ - onClose, - onToggleCollapsed, - onOpenFull, -}: { - onClose?: () => void; - onToggleCollapsed?: () => void; - onOpenFull?: () => void; -}) { - return ( -
-
- Activity -
- {onOpenFull && ( - - )} - {onToggleCollapsed && ( - - )} - {onClose && ( - - )} -
- ); -} - function ActivityTabsRow({ tab, onTabChange, @@ -115,19 +51,19 @@ function ActivityTabsRow({ onTabChange: (tab: ActivityTab) => void; }) { return ( -
- {ACTIVITY_TABS.map((t) => ( - - ))} +
+ onTabChange(value as ActivityTab)} + > + + {ACTIVITY_TABS.map((t) => ( + + {t.label} + + ))} + +
); } @@ -148,67 +84,24 @@ function ActivityConversation({ showTaskSummary: boolean; }) { const taskId = task.id; - const client = useOptionalAuthenticatedClient(); - const { data: currentUser } = useCurrentUser({ client }); - - const { messages, isLoading } = useTaskThread(taskId); - const { postMessage, isPosting } = usePostTaskThreadMessage(taskId); - const { postMessageToAgent, isPostingToAgent } = - usePostTaskThreadMessageToAgent(taskId); - const { deleteMessage } = useDeleteTaskThreadMessage(taskId); - const { sendToAgent, isSending } = useSendTaskThreadMessageToAgent(taskId); - const isSendingToAgent = isPostingToAgent || isSending; - const { members } = useOrgMembers(); - const { - session, - repoPath, - isCloud, + timeline, + agentStatus, events, - cloudStatus, isPromptPending, - isInitializing, - hasError, - errorTitle, - } = useSessionViewState(taskId, task); - useSessionConnection({ - taskId, - task, - session, - repoPath, - isCloud, - isSuspended: shouldSuspendThreadSession({ - isCloud, - hasRun: Boolean(task.latest_run?.id), - hasSession: Boolean(session), - }), - }); - const pendingPermissions = usePendingPermissionsForTask(taskId); - - const agentStatus = useMemo( - () => - deriveThreadAgentStatus({ - hasActivity: events.length > 0 || !!task.latest_run, - hasError, - cloudStatus, - errorTitle, - pendingPermissionCount: pendingPermissions.size, - isPromptPending, - isInitializing, - }), - [ - events.length, - task.latest_run, - hasError, - cloudStatus, - errorTitle, - pendingPermissions.size, - isPromptPending, - isInitializing, - ], - ); - - const timeline = useMemo(() => buildThreadTimeline(messages), [messages]); + isReady, + members, + currentUser, + isTaskAuthor, + canForward, + draft, + setDraft, + isSubmitDisabled, + submit, + sendMessageToAgent, + deleteMessage, + onMentionInsert, + } = useThreadConversation(task, { surface: "activity_panel" }); const [tab, setTab] = useState("timeline"); const handleTabChange = useCallback( @@ -223,102 +116,64 @@ function ActivityConversation({ }, [taskId], ); + // The human thread, without the announcements the Artifacts tab owns. const commentRows = useMemo( () => timeline.filter((row) => row.kind === "human"), [timeline], ); - - const [draft, setDraft] = useState(""); - const scrollRef = useRef(null); - - const handleMentionInsert = useCallback( - (member: UserBasic) => { - track(ANALYTICS_EVENTS.CHANNEL_ACTION, { - action_type: "mention_member", - surface: "activity_panel", - task_id: taskId, - mentioned_user_id: member.uuid, - }); - }, - [taskId], + // Only the Timeline renders these, and `events` churns on every streamed + // agent event — so don't process the whole event log for the other tabs. + const conversationItems = useMemo( + () => + tab === "timeline" + ? buildConversationItems(events, isPromptPending).items + : [], + [tab, events, isPromptPending], ); + const scrollRef = useRef(null); // biome-ignore lint/correctness/useExhaustiveDependencies: scroll when rendered thread content changes useEffect(() => { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }); }, [timeline, events.length, agentStatus?.phase, tab]); - const isTaskAuthor = - !!currentUser?.uuid && currentUser.uuid === task.created_by?.uuid; - const canForward = - !!task.latest_run && - !isTerminalStatus(task.latest_run.status) && - !isSendingToAgent; + const showComposer = TABS_WITH_COMPOSER.has(tab); - const submit = async () => { - const content = draft.trim(); - if (!content || isPosting || isSendingToAgent) return; - const sendToAgentRequested = hasAgentMention(content); - if (sendToAgentRequested && (!isTaskAuthor || !canForward)) { - toast.error("Couldn't send to agent", { - description: - "Only the task author can @agent while the task has an active run.", - }); - return; + const body = () => { + if (tab === "artifacts") { + return ; } - setDraft(""); - try { - if (sendToAgentRequested) { - const { sendError } = await postMessageToAgent(content); - if (sendError) { - toast.error("Message posted, but couldn't send it to the agent", { - description: - sendError instanceof Error - ? sendError.message - : String(sendError), - }); - } - } else { - await postMessage(content); - } - } catch (error) { - setDraft(content); - toast.error("Couldn't post message", { - description: error instanceof Error ? error.message : String(error), - }); + if (tab === "comments") { + return ( + + ); } + if (!isReady) return ; + return ( + + ); }; - const handleSendToAgent = useCallback( - (messageId: string) => { - sendToAgent(messageId).catch((error: unknown) => { - toast.error("Couldn't send message to agent", { - description: error instanceof Error ? error.message : String(error), - }); - }); - }, - [sendToAgent], - ); - - const handleDelete = useCallback( - (messageId: string) => { - deleteMessage(messageId).catch((error: unknown) => { - toast.error("Couldn't delete message", { - description: error instanceof Error ? error.message : String(error), - }); - }); - }, - [deleteMessage], - ); - - const isReady = !isInitializing && !isLoading; - - const conversationItems = useMemo( - () => buildConversationItems(events, isPromptPending).items, - [events, isPromptPending], - ); - return (
-
)} - {tab === "artifacts" ? ( -
- -
- ) : tab === "comments" ? ( -
- -
- ) : ( -
- {!isReady ? ( - - ) : ( - - )} -
- )} +
+ {body()} +
- {tab !== "artifacts" && agentStatus && ( - - )} + {showComposer && agentStatus && } - {tab !== "artifacts" && ( + {showComposer && ( )}
diff --git a/packages/ui/src/features/canvas/components/ChannelFeedView.tsx b/packages/ui/src/features/canvas/components/ChannelFeedView.tsx index 2b1878b44f..83f402e777 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, @@ -75,17 +79,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 @@ -101,18 +94,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/ChannelHotkeys.test.tsx b/packages/ui/src/features/canvas/components/ChannelHotkeys.test.tsx new file mode 100644 index 0000000000..e4e7cfe547 --- /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, no switcher. + // Binding the keys inside the switcher 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..16bfb4a0fa --- /dev/null +++ b/packages/ui/src/features/canvas/components/ChannelHotkeys.tsx @@ -0,0 +1,53 @@ +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 switcher: the switcher only renders + * 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/ChannelItemRow.tsx b/packages/ui/src/features/canvas/components/ChannelItemRow.tsx index 7b5cc0957b..b19beca7da 100644 --- a/packages/ui/src/features/canvas/components/ChannelItemRow.tsx +++ b/packages/ui/src/features/canvas/components/ChannelItemRow.tsx @@ -1,68 +1,70 @@ import { PreviewCard } from "@base-ui/react/preview-card"; -import { Archive, PushPin } from "@phosphor-icons/react"; +import { Archive, FileTextIcon, PushPin } from "@phosphor-icons/react"; +import type { ChannelItemModel } from "@posthog/core/canvas/channelItems"; +import { + runStatusLabel, + runStatusVariant, +} from "@posthog/core/canvas/runStatus"; import { Avatar, AvatarFallback, Badge } from "@posthog/quill"; import { formatRelativeTimeShort } from "@posthog/shared"; -import type { UserBasic } from "@posthog/shared/domain-types"; import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar"; +import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon"; +import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; import { SidebarItem } from "@posthog/ui/features/sidebar/components/SidebarItem"; import { NestedButton } from "@posthog/ui/primitives/NestedButton"; import { Tooltip } from "@posthog/ui/primitives/Tooltip"; import type { ReactNode } from "react"; -type StatusVariant = "default" | "destructive" | "info" | "success" | "warning"; - -export interface ChannelItem { - key: string; - kind: "task" | "canvas"; - title: string; - ts: number; - pinned: boolean; - icon: ReactNode; - isActive: boolean; - /** Run status ("In progress", …) for the hover card; tasks only. */ - status: string | null; - /** Raw run status value, for the status filter. */ - rawStatus: string | null; - statusVariant: StatusVariant; - /** Who created it — the full user when known (tasks), else a name. */ - authorUser: UserBasic | null; - authorName: string | null; - onClick: () => void; - onTogglePin: () => void; - /** Tasks only — canvases can't be archived. */ - onArchive?: () => void; +/** + * What a row can do. One object per channel rather than closures per item, so + * the item list stays plain data and doesn't rebuild on every navigation. + */ +export interface ChannelItemActions { + open: (item: ChannelItemModel) => void; + togglePin: (item: ChannelItemModel) => void; + archive: (item: ChannelItemModel) => void; } -export function humanizeStatus( - status: string | null | undefined, -): string | null { - if (!status) return null; - const text = status.replace(/_/g, " "); - return text.charAt(0).toUpperCase() + text.slice(1); +// The channel sidebar's own chrome. Deliberately not shared with the Code +// sidebar's TaskItem: that one is still on the absolute gray scale, while these +// rows use the theme's fill/foreground tokens. +const HOVER_ACTION_CLASS = + "flex h-5 w-5 cursor-pointer items-center justify-center rounded text-muted-foreground transition-colors hover:bg-fill-hover hover:text-foreground"; +const HOVER_TOOLBAR_CLASS = + "hidden shrink-0 items-center gap-0.5 group-hover:flex"; +const TIMESTAMP_CLASS = + "shrink-0 text-[11px] text-muted-foreground group-hover:hidden"; + +function itemIcon(item: ChannelItemModel): ReactNode { + return item.kind === "canvas" ? ( + // Matches the schema's own default for boards saved before templating. + iconForTemplate(item.templateId ?? "freeform", { + size: 15, + className: "text-violet-9", + }) + ) : ( + + ); } -export function statusVariantFor( - status: string | null | undefined, -): StatusVariant { - if (!status) return "default"; - const value = status.toLowerCase(); - if (value.includes("complete")) return "success"; - if (value.includes("fail") || value.includes("error")) return "destructive"; - if ( - value.includes("progress") || - value.includes("running") || - value.includes("pending") || - value.includes("start") - ) { - return "info"; - } - return "default"; +function authorLabel(item: ChannelItemModel): string | null { + if (item.authorUser) return userDisplayName(item.authorUser); + return item.authorName; } -const HOVER_ACTION_CLASS = - "flex h-5 w-5 cursor-pointer items-center justify-center rounded text-muted-foreground transition-colors hover:bg-fill-hover hover:text-foreground"; +export function ChannelItemRow({ + item, + isActive, + actions, +}: { + item: ChannelItemModel; + isActive: boolean; + actions: ChannelItemActions; +}) { + const icon = itemIcon(item); + const statusLabel = runStatusLabel(item.rawStatus); + const author = authorLabel(item); -export function ChannelItemRow({ item }: { item: ChannelItem }) { return ( {item.title}} - isActive={item.isActive} - onClick={item.onClick} + isActive={isActive} + onClick={() => actions.open(item)} endContent={ <> - + {formatRelativeTimeShort(item.ts)} - + actions.togglePin(item)} > - {item.onArchive && ( + {/* Canvases can't be archived. */} + {item.kind === "task" && ( actions.archive(item)} > @@ -123,7 +126,7 @@ export function ChannelItemRow({ item }: { item: ChannelItem }) {
- {item.icon} + {icon}

@@ -135,25 +138,27 @@ export function ChannelItemRow({ item }: { item: ChannelItem }) {

- {item.status && ( + {statusLabel && (
- {item.status} + + {statusLabel} +
)} - {(item.authorUser || item.authorName) && ( + {author && (
{item.authorUser ? ( ) : ( - {(item.authorName ?? "?").charAt(0).toUpperCase()} + {author.charAt(0).toUpperCase()} )}

- {item.authorName ?? "Unknown"} + {author}

Created by diff --git a/packages/ui/src/features/canvas/components/ChannelNav.tsx b/packages/ui/src/features/canvas/components/ChannelNav.tsx index fee67462da..541f5043e9 100644 --- a/packages/ui/src/features/canvas/components/ChannelNav.tsx +++ b/packages/ui/src/features/canvas/components/ChannelNav.tsx @@ -11,8 +11,9 @@ import { formatHotkey, SHORTCUTS, } from "@posthog/ui/features/command/keyboard-shortcuts"; -import { useCommandCenterStore } from "@posthog/ui/features/command-center/commandCenterStore"; +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, @@ -26,29 +27,10 @@ import { useMemo } from "react"; const INBOX_REFETCH_INTERVAL_MS = 60_000; -// Unread counts read red; the command center's filled cells are ambient, so -// they stay neutral. -function IconBadge({ - count, - tone = "notification", -}: { - count: number; - tone?: "notification" | "neutral"; -}) { - if (count <= 0) return null; - return ( - - {count > 99 ? "99+" : count} - - ); -} +// Overlaid on a 32px icon button, so smaller than the badge's default sizing and +// ringed to read against the button beneath it. +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, @@ -102,12 +84,20 @@ export function ChannelNav() { () => countUnseenActivity(activityItems, lastSeenAt), [activityItems, lastSeenAt], ); - // Its filled grid cells are the ambient "how much is parked there" count. - const commandCenterCells = useCommandCenterStore((s) => s.cells); - const commandCenterCount = commandCenterCells.filter((c) => c != null).length; + // Cells holding a task that still exists — the ambient "how much is parked + // there" count. Counting non-empty cells instead would include the brainrot + // and terminal sentinels, and would never drop when a parked task is deleted + // (the persisted cell keeps its id). + const commandCenterCount = useCommandCenterActiveCount(); const withTrack = (item: SidebarNavItem, action: () => void) => () => { - track(ANALYTICS_EVENTS.SIDEBAR_NAV_ITEM_CLICKED, { item, in_more: false }); + // `layout` is what makes this distinguishable from the Code sidebar's + // identically-named clicks — the comparison the flag exists to enable. + track(ANALYTICS_EVENTS.SIDEBAR_NAV_ITEM_CLICKED, { + item, + in_more: false, + layout: "channels", + }); action(); }; @@ -122,14 +112,16 @@ export function ChannelNav() { shortcut={formatHotkey(SHORTCUTS.INBOX)} isActive={view.type === "inbox"} onClick={withTrack("inbox", navigateToInbox)} - badge={} + badge={} /> } label="Activity" isActive={isActivity} onClick={withTrack("activity", navigateToActivity)} - badge={} + badge={ + + } /> } + badge={ + + } />

); diff --git a/packages/ui/src/features/canvas/components/ChannelSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelSidebar.tsx index 7411e2b050..bf8a323c9d 100644 --- a/packages/ui/src/features/canvas/components/ChannelSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ChannelSidebar.tsx @@ -6,6 +6,9 @@ import { 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, @@ -15,34 +18,24 @@ import { 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 { ChannelItemRow } from "@posthog/ui/features/canvas/components/ChannelItemRow"; import { ChannelSwitcher } from "@posthog/ui/features/canvas/components/ChannelSwitcher"; import { NewTaskFab } from "@posthog/ui/features/canvas/components/NewTaskFab"; import { useChannelItems } from "@posthog/ui/features/canvas/hooks/useChannelItems"; -import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; 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, useState } from "react"; - -type CreatedByFilter = "anyone" | "me" | "others"; - -const STATUS_FILTER_OPTIONS: readonly { - value: string | null; - label: string; -}[] = [ - { value: null, label: "Any status" }, - { value: "in_progress", label: "In progress" }, - { value: "completed", label: "Completed" }, - { value: "failed", label: "Failed" }, - { value: "cancelled", label: "Cancelled" }, -] as const; +import { type ReactNode, useMemo, useState } from "react"; const CREATED_BY_OPTIONS: readonly { value: CreatedByFilter; label: string }[] = [ @@ -76,8 +69,8 @@ function RecentSectionHeader({ onQueryChange: (value: string) => void; createdByFilter: CreatedByFilter; onCreatedByChange: (value: CreatedByFilter) => void; - statusFilter: string | null; - onStatusChange: (value: string | null) => void; + statusFilter: TaskRunStatus | null; + onStatusChange: (value: TaskRunStatus | null) => void; filtersActive: boolean; }) { return ( @@ -131,10 +124,12 @@ function RecentSectionHeader({ - onStatusChange(value === "any" ? null : value) + onStatusChange( + value === "any" ? null : (value as TaskRunStatus), + ) } > - {STATUS_FILTER_OPTIONS.map((option) => ( + {RUN_STATUS_FILTER_OPTIONS.map((option) => ( s.location.pathname }); const loopsEnabled = useFeatureFlag(LOOPS_FLAG, import.meta.env.DEV); - const { channels } = useChannels(); - const channelName = - channels.find((c) => c.id === channelId)?.name ?? "channel"; - - const { items, meUuid, meName, isLoading } = useChannelItems( - channelId, - channelName, - ); + 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 [statusFilter, setStatusFilter] = useState(null); const filtersActive = createdByFilter !== "anyone" || statusFilter !== null; const base = `/website/${channelId}`; - const pinnedItems = items.filter((i) => i.pinned); + // 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 normalizedQuery = query.trim().toLowerCase(); - const recentItems = items - .filter((i) => { - if (i.pinned) return false; - if (normalizedQuery && !i.title.toLowerCase().includes(normalizedQuery)) { - return false; - } - if (createdByFilter !== "anyone") { - const isMine = i.authorUser - ? i.authorUser.uuid === meUuid - : i.authorName != null && meName != null && i.authorName === meName; - if (createdByFilter === "me" && !isMine) return false; - if (createdByFilter === "others" && isMine) return false; - } - if (statusFilter && i.rawStatus !== statusFilter) return false; - return true; - }) - .slice(0, RECENTS_CAP); + 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, @@ -295,15 +282,37 @@ export function ChannelSidebar({ channelId }: { channelId: string }) { {/* Relative so the FAB can float over the list. */}
-
+
{isLoading && items.length === 0 && } + {channelMissing && ( + + + + + + Channel unavailable + + It may have been deleted, or belong to another project. + + + + )} + {pinnedItems.length > 0 && ( <> Pinned
{pinnedItems.map((item) => ( - + ))}
@@ -328,23 +337,40 @@ export function ChannelSidebar({ channelId }: { channelId: string }) { {recentItems.length > 0 ? (
{recentItems.map((item) => ( - + ))}
) : ( - - - Nothing matches the current search or filters. + + + + + + No matches + + Try a different search or clear the filters. + )} )} - {!isLoading && items.length === 0 && ( - - - Tasks and canvases you create in this channel show up here. + {!isLoading && !channelMissing && items.length === 0 && ( + + + + + + Nothing here yet + + Tasks and canvases you create in this channel show up here. + )} diff --git a/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx b/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx index feb012f271..8ee851a4be 100644 --- a/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx +++ b/packages/ui/src/features/canvas/components/ChannelSwitcher.tsx @@ -12,31 +12,23 @@ import { PopoverContent, PopoverTrigger, Separator, + Skeleton, } from "@posthog/quill"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { CreateChannelModal } from "@posthog/ui/features/canvas/components/CreateChannelModal"; import { channelGlyph } from "@posthog/ui/features/canvas/components/channelGlyph"; -import { - useChannelStars, - useChannelStarToggle, -} from "@posthog/ui/features/canvas/hooks/useChannelStars"; +import { useChannelStarToggle } from "@posthog/ui/features/canvas/hooks/useChannelStars"; import { type Channel, useChannels, } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { useStarredChannelSlots } from "@posthog/ui/features/canvas/hooks/useStarredChannelSlots"; import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; import { useCurrentChannelStore } from "@posthog/ui/features/canvas/stores/currentChannelStore"; -import { - formatHotkey, - SHORTCUTS, -} from "@posthog/ui/features/command/keyboard-shortcuts"; +import { formatHotkey } from "@posthog/ui/features/command/keyboard-shortcuts"; import { navigateToChannel } from "@posthog/ui/router/navigationBridge"; import { track } from "@posthog/ui/shell/analytics"; -import { useMemo, useState } from "react"; -import { useHotkeys } from "react-hotkeys-hook"; - -// mod+0 is #me, mod+1..9 the starred channels (see SWITCH_STARRED_CHANNEL). -const STARRED_HOTKEY_SLOTS = 9; +import { useState } from "react"; // Clears the row's 8px `mx-2` inset plus an 8px gap, so the panel lands past // the sidebar edge rather than over it. @@ -162,33 +154,17 @@ export function ChannelSwitcher({ channelId }: { channelId: string }) { const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const [createOpen, setCreateOpen] = useState(false); - const { channels } = useChannels(); - const { starredRefToShortcutId } = useChannelStars(); + const { channels, isLoading: isLoadingChannels } = useChannels(); const setCurrentChannel = useCurrentChannelStore((s) => s.setCurrentChannel); const current = channels.find((c) => c.id === channelId); - - const { top, rest, me, starred } = useMemo(() => { - const me = channels.find((c) => c.name === PERSONAL_CHANNEL_NAME) ?? null; - const byPath = new Map(channels.map((c) => [c.path, c])); - const starred: Channel[] = []; - for (const ref of starredRefToShortcutId.keys()) { - const channel = byPath.get(ref); - if (channel && channel.name !== PERSONAL_CHANNEL_NAME) { - starred.push(channel); - } - } - const top = me ? [me, ...starred] : starred; - const seen = new Set(top.map((c) => c.id)); - const rest = channels - .filter((c) => !seen.has(c.id)) - .sort((a, b) => a.name.localeCompare(b.name)); - return { top, rest, me, starred }; - }, [channels, starredRefToShortcutId]); + // ChannelHotkeys owns the keys these slots describe; sharing the derivation + // keeps the advertised key and the key that fires in agreement. + const { slots, rest, slotFor } = useStarredChannelSlots(); const normalizedQuery = query.trim().toLowerCase(); const matches = (c: Channel) => !normalizedQuery || c.name.toLowerCase().includes(normalizedQuery); - const topFiltered = top.filter(matches); + const topFiltered = slots.filter(matches); const restFiltered = rest.filter(matches); const pick = (channel: Channel) => { @@ -203,30 +179,6 @@ export function ChannelSwitcher({ channelId }: { channelId: string }) { }); }; - // Same ctrl guard as SWITCH_TASK: plain ctrl+N belongs to tab switching. - useHotkeys( - SHORTCUTS.SWITCH_STARRED_CHANNEL, - (event, handler) => { - if (event.ctrlKey && !event.metaKey) return; - const slot = Number.parseInt(handler.keys?.[0] ?? "", 10); - if (Number.isNaN(slot)) return; - const channel = slot === 0 ? me : starred[slot - 1]; - if (channel && slot <= STARRED_HOTKEY_SLOTS) pick(channel); - }, - { - enableOnFormTags: true, - enableOnContentEditable: true, - preventDefault: true, - }, - [me, starred, pick], - ); - - const hotkeySlotFor = (channel: Channel) => { - if (me && channel.id === me.id) return 0; - const index = starred.indexOf(channel); - return index >= 0 && index < STARRED_HOTKEY_SLOTS ? index + 1 : undefined; - }; - const showStar = current != null && current.name !== PERSONAL_CHANNEL_NAME; return ( @@ -255,7 +207,15 @@ export function ChannelSwitcher({ channelId }: { channelId: string }) { })} - {current?.name ?? "channel"} + {current ? ( + current.name + ) : isLoadingChannels ? ( + // A placeholder word here would read as a real channel named + // "channel"; a skeleton says "still loading" honestly. + + ) : ( + "Unavailable" + )} pick(channel)} - hotkeySlot={hotkeySlotFor(channel)} + hotkeySlot={slotFor(channel)} /> ))} {topFiltered.length > 0 && restFiltered.length > 0 && ( diff --git a/packages/ui/src/features/canvas/components/ChannelsList.tsx b/packages/ui/src/features/canvas/components/ChannelsList.tsx index 49cd1531ac..e3ff859bea 100644 --- a/packages/ui/src/features/canvas/components/ChannelsList.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsList.tsx @@ -61,13 +61,18 @@ import { useTaskChannels, } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; import { useIsChannelUnread } from "@posthog/ui/features/canvas/hooks/useUnreadChannels"; +import { + resetCurrentChannel, + useCurrentChannelStore, +} from "@posthog/ui/features/canvas/stores/currentChannelStore"; import { copyChannelLink } from "@posthog/ui/features/canvas/utils/copyChannelLink"; import { useSidebarStore } from "@posthog/ui/features/sidebar/sidebarStore"; 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 @@ -127,6 +132,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", @@ -422,10 +433,7 @@ function ChannelSection({ surface: "sidebar", channel_id: channel.id, }); - navigate({ - to: "/website/$channelId/new", - params: { channelId: channel.id }, - }); + openTaskInput({ channelId: channel.id }); }} > @@ -560,7 +568,7 @@ function PersonalChannelRow() { surface: "sidebar", channel_id: channelId, }); - void navigate({ to: "/website/$channelId/new", params: { channelId } }); + openTaskInput({ channelId }); }; const newCanvas = async () => { @@ -746,19 +754,6 @@ export function ChannelsList() { 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]); - 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). 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..6efdf7eced --- /dev/null +++ b/packages/ui/src/features/canvas/components/ChannelsSidebar.test.tsx @@ -0,0 +1,226 @@ +import { Theme } from "@radix-ui/themes"; +import { render, screen } from "@testing-library/react"; +import { 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(), +})); + +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/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: () => ({}) })); + +import { PROJECT_BLUEBIRD_FLAG } from "@posthog/shared"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +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(); + useCurrentChannelStore.setState({ currentChannelId: null }); + useSidebarStore.setState({ channelsEnabled: false, open: true }); + }); + + describe("the Archived row", () => { + beforeEach(() => { + mocks.archivedTaskIds = new Set(["archived-1"]); + }); + + // The layout puts an Archive action on every item row, so hiding the + // destination leaves archived tasks with nowhere to be seen — + // navigateToArchived has no other caller in the app. + it("is present under the channels layout", () => { + mocks.channelsLayout = true; + mocks.channels = [ME]; + renderSidebar(); + expect(screen.getByText("Archived")).toBeTruthy(); + }); + + 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("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(), + ); + }); + }); +}); diff --git a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx index 4f771f0242..a2931a4e34 100644 --- a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx @@ -7,9 +7,10 @@ import { ChannelSidebar } from "@posthog/ui/features/canvas/components/ChannelSi 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 { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; 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 { 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"; @@ -27,6 +28,7 @@ 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"; @@ -90,6 +92,16 @@ export function ChannelsSidebar() { // Deferred so the toggle paints before the heavy channel tree (ChannelsList // mounts a provider-laden row per channel) swaps in. const bodyChannelsWorld = useDeferredValue(channelsWorld); + // The channels *alpha* replaced the task list with the channel tree, so the + // Archived row went with it. The new layout keeps its own item list and puts + // an Archive action on every row, so it needs the destination back — this is + // the only entry point to /code/archived in the app. Reads the deferred value + // so the row and the body it belongs to switch on the same paint. + const showArchivedRow = channelsLayout || !bodyChannelsWorld; + useTrackChannelsSpaceViewed({ + enabled: channelsWorld, + layout: channelsLayout ? "channels" : "code", + }); // The channels layout's title bar needs more room than the shared floor, so // it raises the minimum here rather than in the constant — leaving the width @@ -107,34 +119,34 @@ export function ChannelsSidebar() { // channel as current. Inert while the flag is off. const params = useParams({ strict: false }); const routeChannelId = params.channelId; - const currentChannelId = useCurrentChannelStore((s) => s.currentChannelId); const setCurrentChannel = useCurrentChannelStore((s) => s.setCurrentChannel); - const { channels } = useChannels({ enabled: channelsLayout }); + // Resolves the scoped id against the live list, and clears it when the flag + // is off or the channel no longer exists — so `currentChannelId` here is + // always a channel we can actually render. + const { currentChannelId, channels } = useCurrentChannel({ + enabled: channelsLayout, + }); useEffect(() => { if (!channelsLayout || !routeChannelId) return; setCurrentChannel(routeChannelId); }, [channelsLayout, routeChannelId, setCurrentChannel]); - // Flags resolve after first paint and can flip mid-session. Clearing keeps - // "a channel is current" a reliable stand-in for the flag — openTaskInput - // routes creates on it. - useEffect(() => { - if (!channelsLayout) setCurrentChannel(null); - }, [channelsLayout, setCurrentChannel]); const inChannel = channelsLayout && currentChannelId != null; - // Default to the personal "#me" channel on first load; once any channel has - // been current, never auto-scope again. + // Default to the personal "#me" channel on first load, then leave the choice + // alone. The latch resets when the layout turns off so a flag that resolves + // late (useFeatureFlag re-syncs on every flags payload) can't strand the + // sidebar unscoped for the rest of the session. const autoScopedRef = useRef(false); useEffect(() => { - if (currentChannelId) autoScopedRef.current = true; - }, [currentChannelId]); - useEffect(() => { - if (!channelsLayout || autoScopedRef.current || currentChannelId) return; - const me = channels.find((c) => c.name === PERSONAL_CHANNEL_NAME); - if (me) { - autoScopedRef.current = true; - setCurrentChannel(me.id); + if (!channelsLayout) { + autoScopedRef.current = false; + return; } + if (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, setCurrentChannel]); return ( @@ -159,7 +171,17 @@ export function ChannelsSidebar() { <> - + {/* The channel sidebar is the whole sidebar under this layout, so + a throw in one row would otherwise take ProjectSwitcher — the + only route to settings and project switching — down with it. + Degrade to the flat channel list instead. */} + } + resetKey={currentChannelId} + > + + ) : bodyChannelsWorld ? ( @@ -180,7 +202,7 @@ export function ChannelsSidebar() { - {!channelsWorld && archivedTaskIds.size > 0 && ( + {showArchivedRow && archivedTaskIds.size > 0 && (
); diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx index d91d91d5b4..b55529a42a 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelArtifacts.tsx @@ -1,10 +1,6 @@ import { CaretRightIcon } from "@phosphor-icons/react"; import type { ChannelTaskRecord } from "@posthog/core/canvas/channelTaskSchemas"; import type { DashboardSummary } from "@posthog/core/canvas/dashboardSchemas"; -import { - getPrVisualConfig, - parsePrNumber, -} from "@posthog/core/git-interaction/prStatus"; import { formatRelativeTimeShort } from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { useArchivedTaskIds } from "@posthog/ui/features/archive/useArchivedTaskIds"; @@ -12,8 +8,7 @@ import { ChannelHeader } from "@posthog/ui/features/canvas/components/ChannelHea import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon"; import { useChannelTasks } from "@posthog/ui/features/canvas/hooks/useChannelTasks"; import { useDashboards } from "@posthog/ui/features/canvas/hooks/useDashboards"; -import { getPrVisualIcon } from "@posthog/ui/features/git-interaction/prIcon"; -import { usePrDetails } from "@posthog/ui/features/git-interaction/usePrDetails"; +import { usePrArtifact } from "@posthog/ui/features/git-interaction/usePrArtifact"; import { useTasks } from "@posthog/ui/features/tasks/useTasks"; import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; import { track } from "@posthog/ui/shell/analytics"; @@ -159,7 +154,7 @@ export function WebsiteChannelArtifacts({ channelId }: { channelId: string }) { title={item.title} prUrl={item.prUrl} ts={item.ts} - onClick={() => openPr(item.prUrl)} + onClick={openPr} /> ), )} @@ -171,8 +166,8 @@ export function WebsiteChannelArtifacts({ channelId }: { channelId: string }) { } // A PR artifact row. The PR's lifecycle state (open / draft / merged / closed) -// is fetched per-URL (deduped + cached by usePrDetails) so the icon and label -// reflect the live state. +// comes from usePrArtifact, which also gates the URL — PR links come from run +// output, so a row must not fetch from whatever host that names. function PrArtifactRow({ title, prUrl, @@ -182,37 +177,28 @@ function PrArtifactRow({ title: string; prUrl: string; ts: number; - onClick: () => void; + onClick: (safeUrl: string) => void; }) { const { - meta: { state, merged, draft }, - } = usePrDetails(prUrl); - const config = getPrVisualConfig(state ?? "open", merged, draft); - const PrIcon = getPrVisualIcon(config.icon); - const prNumber = parsePrNumber(prUrl); + safeUrl, + title: prTitle, + stateLabel, + Icon, + iconColor, + accentColor, + } = usePrArtifact(prUrl); - const subtitle = [ - prNumber ? `Pull request #${prNumber}` : "Pull request", - // Only show the resolved state once we have it, to avoid a flash of "Open". - state ? config.label : null, - formatRelativeTimeShort(ts), - ] + const subtitle = [prTitle, stateLabel, formatRelativeTimeShort(ts)] .filter(Boolean) .join(" · "); return ( - } + accent={accentColor} + icon={} title={title} subtitle={subtitle} - onClick={onClick} + onClick={safeUrl ? () => onClick(safeUrl) : undefined} /> ); } @@ -228,13 +214,15 @@ function ArtifactRow({ accent: string; title: string; subtitle: string; - onClick: () => void; + /** Absent for a row with nowhere safe to go — a non-github PR link. */ + onClick?: () => void; }) { return (