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 (
+
+
+ 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.
+
+
+
+
+ );
+}
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.
+ {(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}
/>
+ 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 && (
+
+
+
+
+
+ )}
+
+ >
+ }
/>
)}
{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"
>
-
-
{/* Channel header: the space's identity, click = channel home. */}
)}
+ {/* 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({
+
+ {currentProject?.name ?? "No project"}
+
) : (
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() {
<>
0 ? `Activity (${unseen} new)` : "Activity"}
className="relative"
onClick={navigateToActivity}
>
+ Activity
{unseen > 0 && (
-
+
)}
+ Inbox
>
);
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 (
-
+
-
+ {/* 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"}
) : (
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.
+
Date: Sat, 25 Jul 2026 15:44:46 +0200
Subject: [PATCH 26/57] fix(task-detail): never return undefined from
useRefreshedTask
useRefreshedTask is typed to return Task but returned the query's `data`, which can be undefined when the refetchOnMount refetch resolves without data (e.g. a stale/cross-project task id). useTaskData then called getTaskRepository(task) unguarded, crashing the whole TaskDetail screen with "Cannot read properties of undefined (reading 'repository')". Fall back to the provided initialTask so a Task is always returned. Pre-existing latent bug surfaced by the spaces layout routing into the task view more often.
Generated-By: PostHog Code
Task-Id: c401c7cb-e436-4da3-80bf-1d3008ee5d38
---
.../ui/src/features/task-detail/hooks/useRefreshedTask.ts | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/packages/ui/src/features/task-detail/hooks/useRefreshedTask.ts b/packages/ui/src/features/task-detail/hooks/useRefreshedTask.ts
index e2f6be477d..df35a8f382 100644
--- a/packages/ui/src/features/task-detail/hooks/useRefreshedTask.ts
+++ b/packages/ui/src/features/task-detail/hooks/useRefreshedTask.ts
@@ -9,5 +9,9 @@ export function useRefreshedTask(taskId: string, initialTask: Task): Task {
refetchOnMount: "always",
});
- return data;
+ // The refetch can resolve without data (e.g. a stale / cross-project id
+ // where getTask returns nothing), leaving `data` undefined despite the
+ // Task return type. Fall back to the last-known task so consumers
+ // (useTaskData → getTaskRepository) never read off undefined and crash.
+ return data ?? initialTask;
}
From de1cbf54121767bb57a5889274b3189d0dfd2806 Mon Sep 17 00:00:00 2001
From: Shy Alter
Date: Sat, 25 Jul 2026 15:44:47 +0200
Subject: [PATCH 27/57] fix(canvas): drop New canvas from + menu, stop Soon
badge clipping
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The task tab strip's "+" menu now offers just New chat (disabled, "Soon") and New terminal — the New canvas item is removed. The menu gets a fixed width and the Soon badge is shrink-0 so it no longer gets clipped at the menu's edge.
Generated-By: PostHog Code
Task-Id: c401c7cb-e436-4da3-80bf-1d3008ee5d38
---
.../panels/components/TabbedPanel.tsx | 25 +++++++++----------
1 file changed, 12 insertions(+), 13 deletions(-)
diff --git a/packages/ui/src/features/panels/components/TabbedPanel.tsx b/packages/ui/src/features/panels/components/TabbedPanel.tsx
index e7cdd86c47..70d80c9540 100644
--- a/packages/ui/src/features/panels/components/TabbedPanel.tsx
+++ b/packages/ui/src/features/panels/components/TabbedPanel.tsx
@@ -1,6 +1,5 @@
import { useDroppable } from "@dnd-kit/react";
import {
- ChartBarIcon,
ChatCircleIcon,
Plus,
SquareSplitHorizontalIcon,
@@ -230,9 +229,9 @@ 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. */}
+ {/* "+" adds a tab. Only terminals work today; a chat tab is the
+ layout prototype's declared direction, shown disabled so the
+ menu communicates where this is going. */}
{content.droppable && onAddTerminal && (
= ({
}
/>
-
+ {/* Fixed width with room for the "Soon" badge so it isn't
+ clipped at the menu's edge. */}
+
New chat
-
+
Soon
@@ -254,13 +260,6 @@ export const TabbedPanel: React.FC = ({
New terminal
-
-
- New canvas
-
- Soon
-
-
)}
From ab69ec724293d1f827af4c99262172f7e3be6711 Mon Sep 17 00:00:00 2001
From: Shy Alter
Date: Sat, 25 Jul 2026 15:44:49 +0200
Subject: [PATCH 28/57] feat: gate the Spaces layout behind code-spaces-layout,
with analytics
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Put the whole Spaces experience behind a single feature flag (`code-spaces-layout`, via the `useSpacesLayout` hook) so that flag-off reproduces the previous experience exactly — both the default Code layout and the old Channels alpha — and flag-on gives the full new experience with no separate toggle. Bluebird stays underneath as the backend-availability guard.
- Re-gate every touched surface (title bar, sidebar body/dots/account card, channel header tabs, task tab-strip menu) on the hook rather than the channels toggle, restoring the old chrome when off.
- Extract the tabbed ActivityPanel as the spaces-only task panel; restore ThreadPanel to its original form for flag-off and share its row/composer primitives.
- Add space analytics (switch/browse/draft toggles, activity tab change) with a method dimension on space_switch.
- Fix a latent SpaceDots bug (unused toggleDraftSpace + missing analytics import), drop the redundant space_add event, and trim verbose comments.
Generated-By: PostHog Code
Task-Id: c401c7cb-e436-4da3-80bf-1d3008ee5d38
---
packages/shared/src/analytics-events.ts | 18 +-
packages/shared/src/flags.ts | 8 +
.../canvas/components/ActivityPanel.tsx | 696 ++++++++++++++++++
.../canvas/components/ChannelHeader.tsx | 11 +-
.../canvas/components/ChannelsSidebar.tsx | 87 ++-
.../canvas/components/NewSpaceDraft.tsx | 2 +-
.../features/canvas/components/SpaceDots.tsx | 26 +-
.../canvas/components/ThreadPanel.tsx | 412 ++---------
.../canvas/components/ThreadSidebar.tsx | 19 +-
.../ui/src/features/canvas/hooks/useSpaces.ts | 29 +-
.../features/canvas/hooks/useSpacesLayout.ts | 18 +
.../panels/components/TabbedPanel.test.tsx | 4 +
.../panels/components/TabbedPanel.tsx | 81 +-
.../components/SidebarNavSection.test.tsx | 5 +
.../sidebar/components/SidebarNavSection.tsx | 8 +-
packages/ui/src/router/routes/__root.tsx | 24 +-
16 files changed, 971 insertions(+), 477 deletions(-)
create mode 100644 packages/ui/src/features/canvas/components/ActivityPanel.tsx
create mode 100644 packages/ui/src/features/canvas/hooks/useSpacesLayout.ts
diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts
index c6b6c42644..1fb87cc027 100644
--- a/packages/shared/src/analytics-events.ts
+++ b/packages/shared/src/analytics-events.ts
@@ -872,6 +872,11 @@ export type ChannelsSurface =
| "canvas"
| "context"
| "thread_panel"
+ // Spaces layout: the task's tabbed Activity panel and the space chrome
+ // (dot switcher / space sidebar).
+ | "activity_panel"
+ | "space_switcher"
+ | "space_sidebar"
| "activity";
export type ChannelActionType =
@@ -905,7 +910,12 @@ export type ChannelActionType =
| "mention_member"
| "view_activity"
| "open_mention"
- | "canvas_mode_toggle";
+ | "canvas_mode_toggle"
+ // Spaces layout.
+ | "space_switch"
+ | "space_browse_toggle"
+ | "space_draft_toggle"
+ | "activity_tab_change";
export interface ChannelActionProperties {
action_type: ChannelActionType;
@@ -924,6 +934,12 @@ export interface ChannelActionProperties {
suggestion_label?: string;
/** For canvas_mode_toggle: whether canvas mode is being armed. */
armed?: boolean;
+ /** For space_switch: what drove it ("dot" | "swipe" | "keyboard" | "browse" | "draft" | "me"). */
+ method?: string;
+ /** For space_browse_toggle / space_draft_toggle: the resulting open state. */
+ open?: boolean;
+ /** For activity_tab_change: the tab landed on. */
+ tab?: string;
/** Whether the underlying mutation resolved successfully. */
success?: boolean;
}
diff --git a/packages/shared/src/flags.ts b/packages/shared/src/flags.ts
index cab52efaec..c376295b9d 100644
--- a/packages/shared/src/flags.ts
+++ b/packages/shared/src/flags.ts
@@ -14,6 +14,14 @@ export const DISCOVERY_RUN_FLAG = "posthog-code-discovery-run";
// Gates the entire canvas feature: the app rail's Channels space, the /website
// routes, channels and dashboards.
export const PROJECT_BLUEBIRD_FLAG = "project-bluebird";
+/**
+ * Gates the Spaces layout: channels as scoped spaces (per-space sidebar, dot
+ * switcher, title-bar search/inbox/account) and the task Activity panel
+ * (Timeline / Artifacts / Comments). Off = the previous experience exactly,
+ * including the "Enable channels" alpha toggle; on = the full new experience
+ * with no separate toggle. Requires project-bluebird underneath.
+ */
+export const SPACES_LAYOUT_FLAG = "code-spaces-layout";
// Gates the Loops feature: the sidebar Loops space and the per-channel Loops tab.
export const LOOPS_FLAG = "loops";
export const TASKS_PREWARM_SANDBOX_FLAG = "tasks-prewarm-sandbox";
diff --git a/packages/ui/src/features/canvas/components/ActivityPanel.tsx b/packages/ui/src/features/canvas/components/ActivityPanel.tsx
new file mode 100644
index 0000000000..faf76d5740
--- /dev/null
+++ b/packages/ui/src/features/canvas/components/ActivityPanel.tsx
@@ -0,0 +1,696 @@
+import {
+ ArrowSquareOutIcon,
+ CaretRightIcon,
+ CheckCircleIcon,
+ XCircleIcon,
+ XIcon,
+} from "@phosphor-icons/react";
+import {
+ buildThreadTimeline,
+ deriveThreadAgentStatus,
+ hasAgentMention,
+ shouldSuspendThreadSession,
+} from "@posthog/core/canvas/threadTimeline";
+import {
+ Button,
+ cn,
+ ThreadItem,
+ ThreadItemAuthor,
+ ThreadItemBody,
+ ThreadItemContent,
+ ThreadItemGroup,
+ ThreadItemGutter,
+ ThreadItemHeader,
+} 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 { UserAvatar } from "@posthog/ui/features/auth/UserAvatar";
+import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser";
+import { TaskCard } from "@posthog/ui/features/canvas/components/ChannelFeedView";
+import { TaskArtifactsList } from "@posthog/ui/features/canvas/components/TaskArtifactsList";
+import {
+ AgentStatusLine,
+ ThreadArtifactRow,
+ ThreadLoadingState,
+ ThreadMessageRow,
+ ThreadReplyComposer,
+ ThreadTimeline,
+} from "@posthog/ui/features/canvas/components/ThreadPanel";
+import { ThreadTimestamp } from "@posthog/ui/features/canvas/components/ThreadTimestamp";
+import { useOrgMembers } from "@posthog/ui/features/canvas/hooks/useOrgMembers";
+import {
+ useDeleteTaskThreadMessage,
+ usePostTaskThreadMessage,
+ usePostTaskThreadMessageToAgent,
+ useSendTaskThreadMessageToAgent,
+ useTaskThread,
+} from "@posthog/ui/features/canvas/hooks/useTaskThread";
+import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay";
+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 {
+ Fragment,
+ type ReactNode,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+
+/**
+ * The spaces-layout replacement for the task ThreadPanel: an "Activity" panel
+ * with three sub-views — Timeline (the task's full history on a rail),
+ * Artifacts (curated outputs across all runs), Comments (the human thread).
+ * The legacy ThreadPanel stays intact for the flag-off experience; the shared
+ * row/composer primitives are imported from it rather than forked.
+ */
+
+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;
+
+// Right-align every ThreadItem timestamp inside the panel (the mockup pins
+// times to the right edge). Container CSS rather than forked row components,
+// so the legacy ThreadPanel rows stay 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";
+
+// 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";
+
+function ActivityHeader({
+ onClose,
+ onToggleCollapsed,
+ onOpenFull,
+}: {
+ onClose?: () => void;
+ onToggleCollapsed?: () => void;
+ onOpenFull?: () => void;
+}) {
+ return (
+
+ );
+}
+
+// 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 (
+
+ {/* 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. */}
+
+
+ );
+ }
+
+ 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 && (
- {/* Arc-style space switcher: one dot per starred channel. Rendered
- whenever channels are on so the landing shows the dot row too. */}
- {channelsEnabled && }
+ {/* Arc-style space switcher: one dot per starred channel. Spaces
+ layout only. */}
+ {spacesOn && }
- {/* 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 && (
+ {/* Workspace switcher pinned to the bottom. In the spaces layout it
+ moves to the title bar (compact avatar) so the sidebar's bottom is
+ freed for the space's content. */}
+ {!spacesOn && (
diff --git a/packages/ui/src/features/canvas/components/NewSpaceDraft.tsx b/packages/ui/src/features/canvas/components/NewSpaceDraft.tsx
index b15ed466f3..15861045ef 100644
--- a/packages/ui/src/features/canvas/components/NewSpaceDraft.tsx
+++ b/packages/ui/src/features/canvas/components/NewSpaceDraft.tsx
@@ -47,7 +47,7 @@ export function NewSpaceDraft() {
});
});
// Clears the draft too (setCurrentChannel dismisses the overrides).
- switchTo(channel);
+ switchTo(channel, "draft");
};
return (
diff --git a/packages/ui/src/features/canvas/components/SpaceDots.tsx b/packages/ui/src/features/canvas/components/SpaceDots.tsx
index 4c0793f210..5c9888c476 100644
--- a/packages/ui/src/features/canvas/components/SpaceDots.tsx
+++ b/packages/ui/src/features/canvas/components/SpaceDots.tsx
@@ -11,6 +11,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@posthog/quill";
+import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events";
import { ensurePersonalChannel } from "@posthog/ui/features/canvas/ensurePersonalChannel";
import {
useChannelStarMutations,
@@ -27,6 +28,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 { track } from "@posthog/ui/shell/analytics";
import { useNavigate } from "@tanstack/react-router";
import { motion } from "framer-motion";
import { useEffect } from "react";
@@ -204,10 +206,10 @@ export function SpaceDots() {
if (!event.ctrlKey || !event.altKey) return;
if (event.key === "ArrowLeft") {
event.preventDefault();
- cycle(-1);
+ cycle(-1, "keyboard");
} else if (event.key === "ArrowRight") {
event.preventDefault();
- cycle(1);
+ cycle(1, "keyboard");
}
};
window.addEventListener("keydown", onKeyDown);
@@ -222,24 +224,38 @@ export function SpaceDots() {
// it returns to the current space (or the landing) so the preview leaves no
// trace — no space scoped, nothing pinned.
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);
+ 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);
+ };
+
// 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))
+ .then((me) => switchTo(me, "me"))
.catch((error: unknown) => {
toast.error("Couldn't open your personal space", {
description: error instanceof Error ? error.message : String(error),
@@ -323,7 +339,7 @@ export function SpaceDots() {
type="button"
aria-label="New space"
aria-pressed={draftSpace}
- onClick={() => setDraftSpace(!draftSpace)}
+ onClick={toggleDraftSpace}
className={cn(
RAIL_BUTTON_CLASS,
draftSpace && "bg-gray-3 text-gray-12",
diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.tsx
index 18b7fc3f69..88dbf3dcc7 100644
--- a/packages/ui/src/features/canvas/components/ThreadPanel.tsx
+++ b/packages/ui/src/features/canvas/components/ThreadPanel.tsx
@@ -1,12 +1,10 @@
import {
ArrowSquareOutIcon,
CaretRightIcon,
- CheckCircleIcon,
DotsThreeIcon,
PaperPlaneRightIcon,
RobotIcon,
TrashIcon,
- XCircleIcon,
XIcon,
} from "@phosphor-icons/react";
import {
@@ -27,7 +25,6 @@ import {
AvatarFallback,
Badge,
Button,
- cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
@@ -64,7 +61,6 @@ 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 {
@@ -77,7 +73,6 @@ 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";
@@ -89,15 +84,7 @@ 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 {
- Fragment,
- type ReactNode,
- useCallback,
- useEffect,
- useMemo,
- useRef,
- useState,
-} from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
export function ThreadMessageRow({
message,
@@ -127,7 +114,7 @@ export function ThreadMessageRow({
{userDisplayName(message.author)}
-
+
{artifact.kind === "canvas" ? "Canvas" : "Pull request"}
-
+
{artifact.kind === "canvas" ? (
@@ -332,96 +319,7 @@ export function ThreadArtifactRow({
);
}
-// A lifecycle event in the Activity timeline (task created / run finished) —
-// 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({
- 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 — 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 (
-
-
-
-
-
-
-
- {author ? userDisplayName(author) : "You"}
-
-
-
-
-
- {content}
-
-
-
-
- );
-}
-
-function ThreadLoadingState() {
+export function ThreadLoadingState() {
return (
@@ -434,42 +332,6 @@ 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 (
-
- {/* 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. */}
-
-
);
}
diff --git a/packages/ui/src/features/canvas/components/ThreadSidebar.tsx b/packages/ui/src/features/canvas/components/ThreadSidebar.tsx
index 4a81cf8700..7b04cc408d 100644
--- a/packages/ui/src/features/canvas/components/ThreadSidebar.tsx
+++ b/packages/ui/src/features/canvas/components/ThreadSidebar.tsx
@@ -1,15 +1,18 @@
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 { 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 hosting a task's ThreadPanel: a thin rail when
-// collapsed, a resizable sidebar otherwise. Shared by the channel feed and the
-// task detail route; owns the panel-store size/collapse reads so parents don't
-// re-render on every resize tick.
+// The right-hand dock hosting a task's thread: a thin rail when collapsed, a
+// resizable sidebar otherwise. Shared by the channel feed and the task detail
+// route; owns the panel-store size/collapse reads so parents don't re-render
+// on every resize tick. The spaces layout swaps the legacy ThreadPanel for the
+// tabbed ActivityPanel; flag off keeps the thread exactly as before.
export function ThreadSidebar({
taskId,
channelId,
@@ -31,19 +34,21 @@ 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 toggleCollapsed = (next: boolean) => {
setCollapsed(next);
track(ANALYTICS_EVENTS.CHANNEL_ACTION, {
action_type: next ? "collapse_thread" : "expand_thread",
- surface: "thread_panel",
+ surface: spacesOn ? "activity_panel" : "thread_panel",
task_id: taskId,
});
};
if (collapsed) {
return (
-
- void;
- cycle: (delta: 1 | -1) => void;
+ switchTo: (channel: Channel, method?: SpaceSwitchMethod) => void;
+ cycle: (delta: 1 | -1, method?: SpaceSwitchMethod) => void;
} {
const { channels } = useChannels();
const { starredRefToShortcutId } = useChannelStars();
@@ -49,7 +60,7 @@ export function useSpaces(): {
const currentIndex = spaces.findIndex((c) => c.id === currentChannelId);
const switchTo = useCallback(
- (channel: Channel) => {
+ (channel: Channel, method: SpaceSwitchMethod = "dot") => {
// 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);
@@ -58,12 +69,18 @@ export function useSpaces(): {
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) => {
+ (delta: 1 | -1, method: SpaceSwitchMethod = "keyboard") => {
if (spaces.length === 0) return;
// Clamp at the ends — wrapping around reads as the switcher jumping
// randomly when you swipe past the last space.
@@ -71,7 +88,7 @@ export function useSpaces(): {
const nextIndex = Math.max(0, Math.min(spaces.length - 1, from + delta));
if (nextIndex === currentIndex) return;
const next = spaces[nextIndex];
- if (next) switchTo(next);
+ if (next) switchTo(next, method);
},
[spaces, currentIndex, switchTo],
);
@@ -152,7 +169,7 @@ export function useSpaceSwipe(
g.locked = true;
g.firedAt = now;
g.accumX = 0;
- cycle(direction);
+ 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
new file mode 100644
index 0000000000..4659713d9b
--- /dev/null
+++ b/packages/ui/src/features/canvas/hooks/useSpacesLayout.ts
@@ -0,0 +1,18 @@
+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. Off → the previous experience exactly
+ * (the Code layout, and the old Channels alpha for users who had it toggled
+ * on); on → the full Spaces experience. Bluebird stays underneath as the
+ * backend-availability guard. Every gate reads this hook, never the raw flag,
+ * so the definition lives in one place.
+ */
+export function useSpacesLayout(): boolean {
+ const bluebirdEnabled = useFeatureFlag(
+ PROJECT_BLUEBIRD_FLAG,
+ import.meta.env.DEV,
+ );
+ const spacesEnabled = useFeatureFlag(SPACES_LAYOUT_FLAG, import.meta.env.DEV);
+ return spacesEnabled && bluebirdEnabled;
+}
diff --git a/packages/ui/src/features/panels/components/TabbedPanel.test.tsx b/packages/ui/src/features/panels/components/TabbedPanel.test.tsx
index 9ba602d936..5b8e60ad0f 100644
--- a/packages/ui/src/features/panels/components/TabbedPanel.test.tsx
+++ b/packages/ui/src/features/panels/components/TabbedPanel.test.tsx
@@ -19,6 +19,10 @@ vi.mock("./PanelDropZones", () => ({
PanelDropZones: () => null,
}));
+vi.mock("@posthog/ui/features/canvas/hooks/useSpacesLayout", () => ({
+ useSpacesLayout: () => false,
+}));
+
vi.mock("./PanelTab", () => ({
PanelTab: ({ label, onSelect }: { label: string; onSelect: () => void }) => (
diff --git a/packages/ui/src/features/panels/components/TabbedPanel.tsx b/packages/ui/src/features/panels/components/TabbedPanel.tsx
index 70d80c9540..ce895872e8 100644
--- a/packages/ui/src/features/panels/components/TabbedPanel.tsx
+++ b/packages/ui/src/features/panels/components/TabbedPanel.tsx
@@ -12,6 +12,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@posthog/quill";
+import { useSpacesLayout } from "@posthog/ui/features/canvas/hooks/useSpacesLayout";
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";
@@ -99,6 +100,7 @@ export const TabbedPanel: React.FC = ({
emptyState,
}) => {
const hostClient = useHostTRPCClient();
+ const spacesOn = useSpacesLayout();
const [mountedTabs, setMountedTabs] = useState<{
scopeKey: string;
tabIds: Set;
@@ -229,40 +231,51 @@ export const TabbedPanel: React.FC = ({
badge={tab.badge}
/>
))}
- {/* "+" adds a tab. Only terminals work today; a chat tab is the
- layout prototype's declared direction, shown disabled so the
- menu communicates where this is going. */}
- {content.droppable && onAddTerminal && (
-
- {}}>
-
-
- }
- />
- {/* Fixed width with room for the "Soon" badge so it isn't
- clipped at the menu's edge. */}
-
-
-
- New chat
-
- Soon
-
-
-
-
- New terminal
-
-
-
- )}
+ {/* "+" adds a tab. Spaces layout: a menu (New chat is the declared
+ direction, shown disabled; terminals work today). Flag off: the
+ plain add-terminal button, exactly as before. */}
+ {content.droppable &&
+ onAddTerminal &&
+ (spacesOn ? (
+
+ {}}>
+
+
+ }
+ />
+ {/* Fixed width with room for the "Soon" badge so it isn't
+ clipped at the menu's edge. */}
+
+
+
+ New chat
+
+ Soon
+
+
+
+
+ New terminal
+
+
+
+ ) : (
+
+
+
+
+
+ ))}
{/* Spacer to increase DND area */}
{content.droppable && (
diff --git a/packages/ui/src/features/sidebar/components/SidebarNavSection.test.tsx b/packages/ui/src/features/sidebar/components/SidebarNavSection.test.tsx
index 0525255c57..f81b7d257d 100644
--- a/packages/ui/src/features/sidebar/components/SidebarNavSection.test.tsx
+++ b/packages/ui/src/features/sidebar/components/SidebarNavSection.test.tsx
@@ -39,6 +39,11 @@ vi.mock("@posthog/ui/router/useAppView", () => ({ 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,
+}));
vi.mock("@posthog/ui/router/navigationBridge", () => ({
navigateToActivity,
navigateToAgents,
diff --git a/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx b/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx
index a9d3dd9200..3d32781aeb 100644
--- a/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx
+++ b/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx
@@ -3,6 +3,7 @@ import {
ANALYTICS_EVENTS,
type SidebarNavItem,
} from "@posthog/shared/analytics-events";
+import { useSpacesLayout } from "@posthog/ui/features/canvas/hooks/useSpacesLayout";
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";
@@ -74,6 +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
+ // "Enable channels" toggle is meaningless, so its row is hidden.
+ const spacesOn = useSpacesLayout();
// When this section renders inside the Channels space, the destinations that
// have a /website mirror stay in that space; everything else (and the whole
@@ -152,7 +156,9 @@ export function SidebarNavSection({
const navItemAvailable: Record = {
inbox: true,
"command-center": true,
- contexts: bluebirdEnabled,
+ // Hidden while the spaces flag is on — spaces subsume channels, so the
+ // opt-in toggle would be a no-op.
+ contexts: bluebirdEnabled && !spacesOn,
// 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 d788c38744..374eaaf681 100644
--- a/packages/ui/src/router/routes/__root.tsx
+++ b/packages/ui/src/router/routes/__root.tsx
@@ -34,6 +34,7 @@ import {
} 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 { CommandMenu } from "@posthog/ui/features/command/CommandMenu";
import { GlobalFilePicker } from "@posthog/ui/features/command/GlobalFilePicker";
import { KeyboardShortcutsSheet } from "@posthog/ui/features/command/KeyboardShortcutsSheet";
@@ -196,6 +197,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();
// 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);
@@ -414,19 +418,19 @@ function RootLayout() {
)}
- {/* 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 ? : }
+ {/* 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 ? : }
{/* 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) && (
+ {(billingEnabled || channelsEnabled || spacesOn) && (
- {channelsEnabled && }
+ {spacesOn && }
- {channelsEnabled && (
+ {(channelsEnabled || spacesOn) && (
)}
{/* Account / project / org menu, moved out of the sidebar into a
- compact avatar here in the spaces layout. */}
- {channelsEnabled && }
+ compact avatar in the spaces layout. */}
+ {spacesOn && }
)}
From 8a84de1951b7ed69aededbe399048d489e0beae8 Mon Sep 17 00:00:00 2001
From: Shy Alter
Date: Sat, 25 Jul 2026 15:44:50 +0200
Subject: [PATCH 29/57] fix(canvas): read the spaces-layout flag from PostHog
in dev too
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The spaces flag previously defaulted to import.meta.env.DEV, forcing the layout on in every dev build. Default it to false so it's read from PostHog like a normal flag — dev and production behave identically. Bluebird keeps its dev default as the backend-availability guard so local channels work is unaffected.
Generated-By: PostHog Code
Task-Id: c401c7cb-e436-4da3-80bf-1d3008ee5d38
---
.../ui/src/features/canvas/hooks/useSpacesLayout.ts | 12 ++++++++----
1 file changed, 8 insertions(+), 4 deletions(-)
diff --git a/packages/ui/src/features/canvas/hooks/useSpacesLayout.ts b/packages/ui/src/features/canvas/hooks/useSpacesLayout.ts
index 4659713d9b..c0737edbd8 100644
--- a/packages/ui/src/features/canvas/hooks/useSpacesLayout.ts
+++ b/packages/ui/src/features/canvas/hooks/useSpacesLayout.ts
@@ -4,15 +4,19 @@ import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFla
/**
* The single gate for the Spaces layout. Off → the previous experience exactly
* (the Code layout, and the old Channels alpha for users who had it toggled
- * on); on → the full Spaces experience. Bluebird stays underneath as the
- * backend-availability guard. Every gate reads this hook, never the raw flag,
- * so the definition lives in one place.
+ * on); on → the full Spaces experience. Every gate reads this hook, never the
+ * raw flag, so the definition lives in one place.
+ *
+ * The spaces flag has no dev default — it's read from PostHog like a normal
+ * flag so dev and production behave the same. Bluebird stays underneath as the
+ * backend-availability guard and keeps its dev default so local channels work
+ * is unaffected.
*/
export function useSpacesLayout(): boolean {
const bluebirdEnabled = useFeatureFlag(
PROJECT_BLUEBIRD_FLAG,
import.meta.env.DEV,
);
- const spacesEnabled = useFeatureFlag(SPACES_LAYOUT_FLAG, import.meta.env.DEV);
+ const spacesEnabled = useFeatureFlag(SPACES_LAYOUT_FLAG, false);
return spacesEnabled && bluebirdEnabled;
}
From 975ef6cddca2772fb902f76131c1d33333d03bc0 Mon Sep 17 00:00:00 2001
From: Shy Alter
Date: Sat, 25 Jul 2026 15:44:52 +0200
Subject: [PATCH 30/57] chore(canvas): tighten comments, split
ActivityPanel/SpaceSidebar, drop plan doc
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Cleanup pass over the spaces PR:
- Remove docs/plans/spaces-layout-prototype.md.
- Purge "Arc/Zen" wording and cut low-value comments (CSS/JSX narration, restatements, cross-file duplication); keep only non-obvious "why" and cross-file contracts.
- Split the two oversized components: ActivityPanel (696 → 457) extracts the timeline rows + node-building into ActivityTimeline.tsx; SpaceSidebar (610 → ~330) extracts the item hover-row into SpaceItemRow.tsx and the item-building into a useSpaceItems hook, and collapses the chained recent-list filters into one pass.
No behavior change. Typecheck + biome clean, 2089 UI tests pass.
Generated-By: PostHog Code
Task-Id: c401c7cb-e436-4da3-80bf-1d3008ee5d38
---
docs/plans/spaces-layout-prototype.md | 122 ----
packages/shared/src/flags.ts | 8 +-
.../canvas/components/ActivityPanel.tsx | 282 +---------
.../canvas/components/ActivityTimeline.tsx | 273 +++++++++
.../canvas/components/ChannelHeader.tsx | 12 +-
.../canvas/components/ChannelsSidebar.tsx | 73 +--
.../canvas/components/NewSpaceDraft.tsx | 9 +-
.../features/canvas/components/SpaceDots.tsx | 32 +-
.../canvas/components/SpaceItemRow.tsx | 173 ++++++
.../canvas/components/SpaceSidebar.tsx | 528 +++++-------------
.../canvas/components/SpacesTitleBar.tsx | 16 +-
.../canvas/components/TaskArtifactsList.tsx | 23 +-
.../canvas/components/ThreadSidebar.tsx | 7 +-
.../features/canvas/hooks/useSpaceItems.tsx | 145 +++++
.../ui/src/features/canvas/hooks/useSpaces.ts | 27 +-
.../features/canvas/hooks/useSpacesLayout.ts | 12 +-
.../src/features/canvas/hooks/useTaskRuns.ts | 6 +-
.../features/canvas/stores/spaceEmojiStore.ts | 4 +-
.../src/features/canvas/stores/spaceStore.ts | 17 +-
.../panels/components/TabbedPanel.tsx | 7 +-
20 files changed, 815 insertions(+), 961 deletions(-)
delete mode 100644 docs/plans/spaces-layout-prototype.md
create mode 100644 packages/ui/src/features/canvas/components/ActivityTimeline.tsx
create mode 100644 packages/ui/src/features/canvas/components/SpaceItemRow.tsx
create mode 100644 packages/ui/src/features/canvas/hooks/useSpaceItems.tsx
diff --git a/docs/plans/spaces-layout-prototype.md b/docs/plans/spaces-layout-prototype.md
deleted file mode 100644
index 138d1a1ae3..0000000000
--- a/docs/plans/spaces-layout-prototype.md
+++ /dev/null
@@ -1,122 +0,0 @@
-# Plan: "Spaces" Layout Prototype
-
-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)
-
-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.** 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.
-
-## 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: **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
-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.
-
-## What was built (single pass)
-
-**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 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.
-
-## 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). 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 — 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/shared/src/flags.ts b/packages/shared/src/flags.ts
index c376295b9d..0af3a0443e 100644
--- a/packages/shared/src/flags.ts
+++ b/packages/shared/src/flags.ts
@@ -15,11 +15,9 @@ export const DISCOVERY_RUN_FLAG = "posthog-code-discovery-run";
// routes, channels and dashboards.
export const PROJECT_BLUEBIRD_FLAG = "project-bluebird";
/**
- * Gates the Spaces layout: channels as scoped spaces (per-space sidebar, dot
- * switcher, title-bar search/inbox/account) and the task Activity panel
- * (Timeline / Artifacts / Comments). Off = the previous experience exactly,
- * including the "Enable channels" alpha toggle; on = the full new experience
- * with no separate toggle. Requires project-bluebird underneath.
+ * Gates the Spaces layout (channels as scoped spaces + the task Activity
+ * panel). Off = the previous experience, including the "Enable channels" alpha
+ * toggle; on = the full new experience, no toggle. Requires project-bluebird.
*/
export const SPACES_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 faf76d5740..3da36bcaad 100644
--- a/packages/ui/src/features/canvas/components/ActivityPanel.tsx
+++ b/packages/ui/src/features/canvas/components/ActivityPanel.tsx
@@ -1,8 +1,6 @@
import {
ArrowSquareOutIcon,
CaretRightIcon,
- CheckCircleIcon,
- XCircleIcon,
XIcon,
} from "@phosphor-icons/react";
import {
@@ -11,34 +9,21 @@ import {
hasAgentMention,
shouldSuspendThreadSession,
} from "@posthog/core/canvas/threadTimeline";
-import {
- Button,
- cn,
- ThreadItem,
- ThreadItemAuthor,
- ThreadItemBody,
- ThreadItemContent,
- ThreadItemGroup,
- ThreadItemGutter,
- ThreadItemHeader,
-} from "@posthog/quill";
+import { Button, cn } 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 { UserAvatar } from "@posthog/ui/features/auth/UserAvatar";
import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser";
+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,
- ThreadArtifactRow,
ThreadLoadingState,
- ThreadMessageRow,
ThreadReplyComposer,
ThreadTimeline,
} from "@posthog/ui/features/canvas/components/ThreadPanel";
-import { ThreadTimestamp } from "@posthog/ui/features/canvas/components/ThreadTimestamp";
import { useOrgMembers } from "@posthog/ui/features/canvas/hooks/useOrgMembers";
import {
useDeleteTaskThreadMessage,
@@ -47,7 +32,6 @@ import {
useSendTaskThreadMessageToAgent,
useTaskThread,
} from "@posthog/ui/features/canvas/hooks/useTaskThread";
-import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay";
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";
@@ -56,22 +40,11 @@ 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 {
- Fragment,
- type ReactNode,
- useCallback,
- useEffect,
- useMemo,
- useRef,
- useState,
-} from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
/**
- * The spaces-layout replacement for the task ThreadPanel: an "Activity" panel
- * with three sub-views — Timeline (the task's full history on a rail),
- * Artifacts (curated outputs across all runs), Comments (the human thread).
- * The legacy ThreadPanel stays intact for the flag-off experience; the shared
- * row/composer primitives are imported from it rather than forked.
+ * The spaces-layout task panel — Timeline / Artifacts / Comments. Legacy
+ * ThreadPanel stays for flag-off; its row/composer primitives are reused here.
*/
type ActivityTab = "timeline" | "artifacts" | "comments";
@@ -82,18 +55,10 @@ const ACTIVITY_TABS: readonly { key: ActivityTab; label: string }[] = [
{ key: "comments", label: "Comments" },
] as const;
-// Right-align every ThreadItem timestamp inside the panel (the mockup pins
-// times to the right edge). Container CSS rather than forked row components,
-// so the legacy ThreadPanel rows stay untouched.
+// 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";
-// 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";
-
function ActivityHeader({
onClose,
onToggleCollapsed,
@@ -167,75 +132,6 @@ function ActivityTabsRow({
);
}
-// 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,
@@ -327,8 +223,8 @@ function ActivityConversation({
},
[taskId],
);
- // Comments = the human conversation without the agent's artifact
- // announcements (those live in the Artifacts tab).
+ // Comments = the human thread without artifact announcements (those are the
+ // Artifacts tab).
const commentRows = useMemo(
() => timeline.filter((row) => row.kind === "human"),
[timeline],
@@ -419,144 +315,10 @@ function ActivityConversation({
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 (
) : (
-
- {/* 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. */}
-
-
+ );
+}
diff --git a/packages/ui/src/features/canvas/components/ChannelHeader.tsx b/packages/ui/src/features/canvas/components/ChannelHeader.tsx
index 08cd3c2358..47bc83e24e 100644
--- a/packages/ui/src/features/canvas/components/ChannelHeader.tsx
+++ b/packages/ui/src/features/canvas/components/ChannelHeader.tsx
@@ -7,12 +7,9 @@ import { useSpacesLayout } from "@posthog/ui/features/canvas/hooks/useSpacesLayo
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. 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.
+// The shared channel header: a clickable "# channel" routing to the channel
+// home. The spaces layout drops the section tab strip (the space sidebar
+// carries those entries); flag off keeps the strip as before.
export function ChannelHeader({ channelId }: { channelId: string }) {
const navigate = useNavigate();
const spacesOn = useSpacesLayout();
@@ -20,8 +17,7 @@ export function ChannelHeader({ channelId }: { channelId: string }) {
const channelName = channels.find((c) => c.id === channelId)?.name;
const pathname = useRouterState({ select: (s) => s.location.pathname });
const isHome = pathname === `/website/${channelId}`;
- // Every channel surface renders this header, so it is where "the viewer is
- // in this channel" is known — and therefore where the channel is marked read.
+ // Every channel surface renders this header, so mark the channel read here.
useMarkChannelSeen(channelName);
return (
diff --git a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx
index 6d50b02de5..0a3f07d63a 100644
--- a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx
+++ b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx
@@ -35,10 +35,9 @@ import { Box, Flex } from "@radix-ui/themes";
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
-// bottom: workspace switcher, the merged global nav, the "Enable channels"
-// opt-in, then the body — the task list by default, swapped for the channel
-// tree once channels are enabled — and Settings pinned to the bottom.
+// The unified app sidebar. Top to bottom: global nav, then the body — the task
+// list, the channel tree, or (spaces layout) the active space — and the
+// account switcher pinned to the bottom.
export function ChannelsSidebar() {
const width = useChannelsSidebarStore((state) => state.width);
const setWidth = useChannelsSidebarStore((state) => state.setWidth);
@@ -87,28 +86,20 @@ 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.
+ // 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 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.
+ // 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 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.
+ // 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.
const params = useParams({ strict: false });
const routeChannelId = params.channelId;
const currentChannelId = useSpaceStore((s) => s.currentChannelId);
@@ -118,19 +109,15 @@ export function ChannelsSidebar() {
const { channels } = useChannels({ enabled: spacesOn });
useEffect(() => {
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.
+ // 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;
setCurrentChannel(routeChannelId);
}, [spacesOn, routeChannelId, setCurrentChannel]);
const inSpace =
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
- // open, so a navigation that lands on a channel route is left alone.
+ // Navigating to a non-channel route closes the browse/draft pickers.
const historyIndex = useRouterState({
select: (s) => s.location.state.__TSR_index,
});
@@ -144,9 +131,8 @@ export function ChannelsSidebar() {
if (state.draftSpace) state.setDraftSpace(false);
}, [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
- // route), never auto-scope again, so the pickers can't be hijacked.
+ // 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.
const autoScopedRef = useRef(false);
useEffect(() => {
if (currentChannelId) autoScopedRef.current = true;
@@ -191,21 +177,15 @@ export function ChannelsSidebar() {
className="h-full bg-chrome"
onWheel={handleSpaceSwipe}
>
- {/* 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. */}
+ {/* In spaces, the title bar owns search/inbox/activity, so the nav
+ only shows on the pre-scope landing; flag off keeps it always on. */}
{(!spacesOn ||
(currentChannelId == null && !browsing && !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. */}
+ {/* Body precedence: draft chooser → active space (spaces only) →
+ channel list (browse / landing / old alpha) → task list. */}
{bodyChannelsWorld && spacesOn && draftSpace ? (
@@ -217,8 +197,7 @@ export function ChannelsSidebar() {
) : bodyChannelsWorld ? (
<>
- {/* The fab is a sibling of the scroll region, not a child, so it
- stays pinned to the bottom-right instead of scrolling away. */}
+ {/* Fab is a sibling of the scroll region so it stays pinned. */}
@@ -234,8 +213,7 @@ export function ChannelsSidebar() {
- {/* Archived is a task-list affordance — hidden while the body shows
- the channel tree (old alpha) or a space, not tasks. */}
+ {/* Archived is a task-list affordance — hidden when the body isn't tasks. */}
{!channelsWorld && archivedTaskIds.size > 0 && (
- {/* Arc-style space switcher: one dot per starred channel. Spaces
- layout only. */}
+ {/* Space switcher: one dot per starred channel. Spaces layout only. */}
{spacesOn && }
- {/* Workspace switcher pinned to the bottom. In the spaces layout it
- moves to the title bar (compact avatar) so the sidebar's bottom is
- freed for the space's content. */}
+ {/* Account switcher — moves to the title bar in the spaces layout. */}
{!spacesOn && (
diff --git a/packages/ui/src/features/canvas/components/NewSpaceDraft.tsx b/packages/ui/src/features/canvas/components/NewSpaceDraft.tsx
index 15861045ef..f8bebb8d71 100644
--- a/packages/ui/src/features/canvas/components/NewSpaceDraft.tsx
+++ b/packages/ui/src/features/canvas/components/NewSpaceDraft.tsx
@@ -15,10 +15,8 @@ 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.
+ * 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();
@@ -46,8 +44,7 @@ export function NewSpaceDraft() {
description: error instanceof Error ? error.message : String(error),
});
});
- // Clears the draft too (setCurrentChannel dismisses the overrides).
- switchTo(channel, "draft");
+ switchTo(channel, "draft"); // also dismisses the draft override
};
return (
diff --git a/packages/ui/src/features/canvas/components/SpaceDots.tsx b/packages/ui/src/features/canvas/components/SpaceDots.tsx
index 5c9888c476..bd2d4c5515 100644
--- a/packages/ui/src/features/canvas/components/SpaceDots.tsx
+++ b/packages/ui/src/features/canvas/components/SpaceDots.tsx
@@ -36,7 +36,6 @@ 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 = [
"🦔",
"🚀",
@@ -60,9 +59,7 @@ 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.
+// One space in the dot row; right-click sets an emoji or removes the space.
function SpaceDot({
channel,
active,
@@ -147,8 +144,6 @@ function SpaceDot({
# {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) => (
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.
+ // 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",
@@ -251,8 +242,7 @@ export function SpaceDots() {
setDraftSpace(!draftSpace);
};
- // Open (creating on first use) the personal space — only needed while the
- // "#me" channel doesn't exist yet; afterwards it's a regular dot.
+ // Only used before the "#me" channel exists; afterwards it's a regular dot.
const openPersonalSpace = () => {
ensurePersonalChannel(channels, createChannel)
.then((me) => switchTo(me, "me"))
@@ -266,8 +256,6 @@ export function SpaceDots() {
return (
- {/* Toggle the sidebar body into the all-channels list (in place — no
- popover, no navigation). */}
- {/* "#me" not provisioned yet: show its slot anyway, first. */}
+ {/* "#me" not provisioned yet — show its slot anyway. */}
{!hasMe && (
switchTo(channel)}
/>
))}
- {/* The draft space's temporary dot — a hollow placeholder until a
- channel is chosen or created. */}
{draftSpace && (
- {/* Open a draft new space (Arc-style): its dot appears immediately;
- the sidebar body becomes the choose-or-create view. */}
void;
+ onTogglePin: () => void;
+ /** Tasks only — canvases can't be archived. */
+ onArchive?: () => 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);
+}
+
+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";
+}
+
+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 }) {
+ return (
+
+
+ {item.title}}
+ isActive={item.isActive}
+ onClick={item.onClick}
+ endContent={
+ <>
+
+ {formatRelativeTimeShort(item.ts)}
+
+
+
+
+
+
+
+ {item.onArchive && (
+
+
+
+
+
+ )}
+
+ >
+ }
+ />
+
{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.
s.lastSeenAt);
diff --git a/packages/ui/src/features/canvas/components/TaskArtifactsList.tsx b/packages/ui/src/features/canvas/components/TaskArtifactsList.tsx
index aab524f679..8de6e33a04 100644
--- a/packages/ui/src/features/canvas/components/TaskArtifactsList.tsx
+++ b/packages/ui/src/features/canvas/components/TaskArtifactsList.tsx
@@ -55,10 +55,8 @@ interface RunArtifact {
storage_path?: string;
}
-// 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.
+// Run artifacts live on the run JSON but aren't in the typed TaskRun, so read
+// them past the type. Only plans are surfaced; other types are internal blobs.
function readRunPlans(run: TaskRun): RunArtifact[] {
const raw = (run as { artifacts?: unknown }).artifacts;
if (!Array.isArray(raw)) return [];
@@ -85,10 +83,9 @@ function parseHttpsUrl(url: string): URL | null {
}
/**
- * 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.
+ * A task's artifacts across all runs: thread-announced PRs and canvases, every
+ * run's output PR, plans, and the originating Slack thread. Internal upload
+ * blobs are excluded.
*/
function buildRows(
task: Task,
@@ -105,7 +102,6 @@ function buildRows(
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") {
@@ -120,8 +116,7 @@ function buildRows(
}
}
- // PRs and plans from every run of the task (fall back to latest_run while
- // the runs list is still loading).
+ // 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) {
@@ -183,8 +178,7 @@ function ArtifactListRow({
);
}
-// 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").
+// 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;
@@ -256,8 +250,7 @@ function CanvasRow({ name, url }: { name: string; url: string | null }) {
);
}
-// 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.
+// Clicking presigns a fresh URL (needs task + run id + storage path) and opens it.
function PlanRow({
taskId,
runId,
diff --git a/packages/ui/src/features/canvas/components/ThreadSidebar.tsx b/packages/ui/src/features/canvas/components/ThreadSidebar.tsx
index 7b04cc408d..229e6a7510 100644
--- a/packages/ui/src/features/canvas/components/ThreadSidebar.tsx
+++ b/packages/ui/src/features/canvas/components/ThreadSidebar.tsx
@@ -8,11 +8,8 @@ import { ResizableSidebar } from "@posthog/ui/primitives/ResizableSidebar";
import { track } from "@posthog/ui/shell/analytics";
import { useState } from "react";
-// The right-hand dock hosting a task's thread: a thin rail when collapsed, a
-// resizable sidebar otherwise. Shared by the channel feed and the task detail
-// route; owns the panel-store size/collapse reads so parents don't re-render
-// on every resize tick. The spaces layout swaps the legacy ThreadPanel for the
-// tabbed ActivityPanel; flag off keeps the thread exactly as before.
+// The right-hand dock for a task's thread (collapsible, resizable). Spaces uses
+// the tabbed ActivityPanel; flag off keeps the legacy ThreadPanel.
export function ThreadSidebar({
taskId,
channelId,
diff --git a/packages/ui/src/features/canvas/hooks/useSpaceItems.tsx b/packages/ui/src/features/canvas/hooks/useSpaceItems.tsx
new file mode 100644
index 0000000000..ea36c37256
--- /dev/null
+++ b/packages/ui/src/features/canvas/hooks/useSpaceItems.tsx
@@ -0,0 +1,145 @@
+import { FileTextIcon } from "@phosphor-icons/react";
+import type { DashboardSummary } from "@posthog/core/canvas/dashboardSchemas";
+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 { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser";
+import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon";
+import {
+ humanizeStatus,
+ type SpaceItem,
+ statusVariantFor,
+} from "@posthog/ui/features/canvas/components/SpaceItemRow";
+import { useChannelFeed } from "@posthog/ui/features/canvas/hooks/useChannelFeed";
+import {
+ useDashboardMutations,
+ useDashboards,
+} from "@posthog/ui/features/canvas/hooks/useDashboards";
+import {
+ PERSONAL_CHANNEL_NAME,
+ useBackendChannel,
+} from "@posthog/ui/features/canvas/hooks/useTaskChannels";
+import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay";
+import { usePinnedTasks } from "@posthog/ui/features/sidebar/usePinnedTasks";
+import { toast } from "@posthog/ui/primitives/toast";
+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
+ * for the recent-list filters.
+ */
+export function useSpaceItems(
+ channelId: string,
+ channelName: string,
+): { items: SpaceItem[]; meUuid: string | null; meName: string | null } {
+ 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 archivedTaskIds = useArchivedTaskIds();
+ const { pinnedTaskIds, togglePin } = usePinnedTasks();
+ const { archiveTask } = useArchiveTask({ navigateSpace: "website" });
+ const { setPinned: setCanvasPinned } = useDashboardMutations();
+ const client = useOptionalAuthenticatedClient();
+ const { data: currentUser } = useCurrentUser({ client });
+
+ 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: 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) => {
+ 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,
+ ]);
+
+ return { items, meUuid, meName };
+}
diff --git a/packages/ui/src/features/canvas/hooks/useSpaces.ts b/packages/ui/src/features/canvas/hooks/useSpaces.ts
index 1e70078401..4eec6a5906 100644
--- a/packages/ui/src/features/canvas/hooks/useSpaces.ts
+++ b/packages/ui/src/features/canvas/hooks/useSpaces.ts
@@ -20,10 +20,9 @@ export type SpaceSwitchMethod =
| "me";
/**
- * 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.
+ * 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[];
@@ -96,21 +95,11 @@ export function useSpaces(): {
return { spaces, currentChannelId, currentIndex, switchTo, cycle };
}
-// Swipe tuning. macOS "switch desktop" semantics: one swipe = one space,
-// regardless of speed. A trackpad swipe is a burst of wheel events (fingers +
-// a long, decaying inertia tail); the whole burst must move exactly one space.
-//
-// 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.
+// 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;
// Horizontal travel within one gesture that triggers the switch.
diff --git a/packages/ui/src/features/canvas/hooks/useSpacesLayout.ts b/packages/ui/src/features/canvas/hooks/useSpacesLayout.ts
index c0737edbd8..5fccae96d3 100644
--- a/packages/ui/src/features/canvas/hooks/useSpacesLayout.ts
+++ b/packages/ui/src/features/canvas/hooks/useSpacesLayout.ts
@@ -2,15 +2,9 @@ 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. Off → the previous experience exactly
- * (the Code layout, and the old Channels alpha for users who had it toggled
- * on); on → the full Spaces experience. Every gate reads this hook, never the
- * raw flag, so the definition lives in one place.
- *
- * The spaces flag has no dev default — it's read from PostHog like a normal
- * flag so dev and production behave the same. Bluebird stays underneath as the
- * backend-availability guard and keeps its dev default so local channels work
- * is unaffected.
+ * 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(
diff --git a/packages/ui/src/features/canvas/hooks/useTaskRuns.ts b/packages/ui/src/features/canvas/hooks/useTaskRuns.ts
index 367c0b77a1..400bb9fc5b 100644
--- a/packages/ui/src/features/canvas/hooks/useTaskRuns.ts
+++ b/packages/ui/src/features/canvas/hooks/useTaskRuns.ts
@@ -3,11 +3,7 @@ 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.
- */
+/** Every run of a task — for gathering artifacts across all runs, not just the latest. */
export function useTaskRuns(taskId: string | undefined): {
runs: TaskRun[];
isLoading: boolean;
diff --git a/packages/ui/src/features/canvas/stores/spaceEmojiStore.ts b/packages/ui/src/features/canvas/stores/spaceEmojiStore.ts
index 8f5d1a8989..8f86c89725 100644
--- a/packages/ui/src/features/canvas/stores/spaceEmojiStore.ts
+++ b/packages/ui/src/features/canvas/stores/spaceEmojiStore.ts
@@ -2,8 +2,8 @@ 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.
+ * 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;
diff --git a/packages/ui/src/features/canvas/stores/spaceStore.ts b/packages/ui/src/features/canvas/stores/spaceStore.ts
index fd892d0738..0b58652844 100644
--- a/packages/ui/src/features/canvas/stores/spaceStore.ts
+++ b/packages/ui/src/features/canvas/stores/spaceStore.ts
@@ -1,20 +1,17 @@
import { create } from "zustand";
/**
- * View state for the Arc-style "spaces" chrome (layout prototype): which
- * 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.
+ * 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. Drives the slide-in. */
+ /** +1 = moved right in the dot order, -1 = left. */
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;
@@ -26,8 +23,6 @@ export const useSpaceStore = create()((set) => ({
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,
diff --git a/packages/ui/src/features/panels/components/TabbedPanel.tsx b/packages/ui/src/features/panels/components/TabbedPanel.tsx
index ce895872e8..36986ea378 100644
--- a/packages/ui/src/features/panels/components/TabbedPanel.tsx
+++ b/packages/ui/src/features/panels/components/TabbedPanel.tsx
@@ -231,9 +231,8 @@ export const TabbedPanel: React.FC = ({
badge={tab.badge}
/>
))}
- {/* "+" adds a tab. Spaces layout: a menu (New chat is the declared
- direction, shown disabled; terminals work today). Flag off: the
- plain add-terminal button, exactly as before. */}
+ {/* Spaces: a menu (New chat is coming, shown disabled). Flag off:
+ the plain add-terminal button. */}
{content.droppable &&
onAddTerminal &&
(spacesOn ? (
@@ -245,8 +244,6 @@ export const TabbedPanel: React.FC = ({
}
/>
- {/* Fixed width with room for the "Soon" badge so it isn't
- clipped at the menu's edge. */}
Date: Sat, 25 Jul 2026 15:44:53 +0200
Subject: [PATCH 31/57] chore(canvas): cut more low-value comments in
sidebar/useSpaces
Second trimming pass on the two comment-heaviest files: drop JSX narration and inline restatements a reader infers from the code, keeping only real gotchas (deferred paint, peek singleton, bluebird gating, swipe double-fire guard) and the spaceStore cross-reference. Comment-only.
Generated-By: PostHog Code
Task-Id: c401c7cb-e436-4da3-80bf-1d3008ee5d38
---
.../canvas/components/ChannelsSidebar.tsx | 16 ++-------------
.../ui/src/features/canvas/hooks/useSpaces.ts | 20 ++++---------------
2 files changed, 6 insertions(+), 30 deletions(-)
diff --git a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx
index 0a3f07d63a..0557cd5008 100644
--- a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx
+++ b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx
@@ -35,9 +35,6 @@ import { Box, Flex } from "@radix-ui/themes";
import { useParams, useRouterState } from "@tanstack/react-router";
import { useDeferredValue, useEffect, useRef } from "react";
-// The unified app sidebar. Top to bottom: global nav, then the body — the task
-// list, the channel tree, or (spaces layout) the active space — and the
-// account switcher pinned to the bottom.
export function ChannelsSidebar() {
const width = useChannelsSidebarStore((state) => state.width);
const setWidth = useChannelsSidebarStore((state) => state.setWidth);
@@ -117,7 +114,6 @@ export function ChannelsSidebar() {
const inSpace =
spacesOn && currentChannelId != null && !browsing && !draftSpace;
- // Navigating to a non-channel route closes the browse/draft pickers.
const historyIndex = useRouterState({
select: (s) => s.location.state.__TSR_index,
});
@@ -154,8 +150,6 @@ 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(spacesOn);
return (
@@ -177,15 +171,12 @@ export function ChannelsSidebar() {
className="h-full bg-chrome"
onWheel={handleSpaceSwipe}
>
- {/* In spaces, the title bar owns search/inbox/activity, so the nav
- only shows on the pre-scope landing; flag off keeps it always on. */}
{(!spacesOn ||
(currentChannelId == null && !browsing && !draftSpace)) && (
)}
- {/* Body precedence: draft chooser → active space (spaces only) →
- channel list (browse / landing / old alpha) → task list. */}
+ {/* draft chooser → active space (spaces only) → channel list → tasks */}
{bodyChannelsWorld && spacesOn && draftSpace ? (
@@ -197,7 +188,6 @@ export function ChannelsSidebar() {
) : bodyChannelsWorld ? (
<>
- {/* Fab is a sibling of the scroll region so it stays pinned. */}
@@ -213,7 +203,6 @@ export function ChannelsSidebar() {
- {/* Archived is a task-list affordance — hidden when the body isn't tasks. */}
{!channelsWorld && archivedTaskIds.size > 0 && (
- {/* Space switcher: one dot per starred channel. Spaces layout only. */}
{spacesOn && }
- {/* Account switcher — moves to the title bar in the spaces layout. */}
+ {/* In the spaces layout the account switcher moves to the title bar. */}
{!spacesOn && (
diff --git a/packages/ui/src/features/canvas/hooks/useSpaces.ts b/packages/ui/src/features/canvas/hooks/useSpaces.ts
index 4eec6a5906..a2126fed0a 100644
--- a/packages/ui/src/features/canvas/hooks/useSpaces.ts
+++ b/packages/ui/src/features/canvas/hooks/useSpaces.ts
@@ -38,8 +38,7 @@ export function useSpaces(): {
const spaces = useMemo(() => {
const me = channels.find((c) => c.name === PERSONAL_CHANNEL_NAME);
- // 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.
+ // 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()) {
@@ -60,11 +59,9 @@ export function useSpaces(): {
const switchTo = useCallback(
(channel: Channel, method: SpaceSwitchMethod = "dot") => {
- // 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.
+ // 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);
@@ -81,8 +78,7 @@ export function useSpaces(): {
const cycle = useCallback(
(delta: 1 | -1, method: SpaceSwitchMethod = "keyboard") => {
if (spaces.length === 0) return;
- // Clamp at the ends — wrapping around reads as the switcher jumping
- // randomly when you swipe past the last space.
+ // 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;
@@ -102,7 +98,6 @@ export function useSpaces(): {
// (MIN_FIRE_INTERVAL) — an inertia tail satisfies neither.
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 {
@@ -112,10 +107,7 @@ interface SwipeGate {
locked: boolean;
}
-/**
- * Horizontal trackpad swipe → cycle spaces, exactly one space per swipe.
- * Attach the returned handler via `onWheel`.
- */
+/** Horizontal trackpad swipe → cycle spaces (attach via `onWheel`). */
export function useSpaceSwipe(
enabled: boolean,
): (event: React.WheelEvent) => void {
@@ -136,8 +128,6 @@ export function useSpaceSwipe(
g.lastEventAt = now;
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;
@@ -145,11 +135,9 @@ export function useSpaceSwipe(
return;
}
} else if (gap > QUIET_GAP_MS) {
- // Fresh gesture after a real pause.
g.accumX = 0;
}
- // Only horizontal-dominant events count; vertical scrolling is ignored.
if (Math.abs(event.deltaX) <= Math.abs(event.deltaY)) return;
g.accumX += event.deltaX;
From bef32f2389031c6ca6b567bb564a50db02114ff4 Mon Sep 17 00:00:00 2001
From: Shy Alter
Date: Sat, 25 Jul 2026 15:44:55 +0200
Subject: [PATCH 32/57] =?UTF-8?q?refactor(canvas):=20channels,=20not=20spa?=
=?UTF-8?q?ces=20=E2=80=94=20switcher=20popover,=20sidebar=20nav,=20drop?=
=?UTF-8?q?=20dots/emoji/swipe?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Design + naming overhaul of the flag-gated layout:
- The channel header is now a switcher: clicking "# channel" opens a searchable popover — #me first, then starred channels, then the rest — with right-click star/unstar and a "New channel…" entry. Replaces the dots rail entirely (dots, "#" browse, "+" draft are removed, along with swipe/keyboard cycling, the slide animation, and the per-channel emoji picker).
- Search / Inbox / Activity move out of the title bar back into the sidebar, above the channel switcher, with a separator between them. The title-bar search pill and Activity/Inbox buttons are gone.
- The "spaces" concept is renamed to channels everywhere: SpaceSidebar→ChannelSidebar, SpaceNav→ChannelNav, SpaceItemRow→ChannelItemRow, useSpaceItems→useChannelItems, spaceStore→currentChannelStore (now just currentChannelId), useSpacesLayout→useChannelsLayout, SPACES_LAYOUT_FLAG→CHANNELS_LAYOUT_FLAG. The flag key stays "code-spaces-layout" to match the live PostHog flag.
- Analytics: switcher picks track as open_channel/sidebar; the space_switch/browse/draft events and method/open props are removed.
Typecheck + biome clean, 2089 UI tests pass.
Generated-By: PostHog Code
Task-Id: c401c7cb-e436-4da3-80bf-1d3008ee5d38
---
packages/shared/src/analytics-events.ts | 13 +-
packages/shared/src/flags.ts | 5 +-
.../canvas/components/ActivityPanel.tsx | 2 +-
.../canvas/components/ChannelHeader.tsx | 8 +-
.../{SpaceItemRow.tsx => ChannelItemRow.tsx} | 4 +-
.../features/canvas/components/ChannelNav.tsx | 48 +++
.../{SpaceSidebar.tsx => ChannelSidebar.tsx} | 42 +--
.../canvas/components/ChannelSwitcher.tsx | 221 ++++++++++++
.../canvas/components/ChannelsSidebar.tsx | 109 ++----
.../canvas/components/NewSpaceDraft.tsx | 121 -------
.../features/canvas/components/SpaceDots.tsx | 341 ------------------
.../canvas/components/SpacesTitleBar.tsx | 70 ----
.../canvas/components/ThreadSidebar.tsx | 12 +-
...{useSpaceItems.tsx => useChannelItems.tsx} | 74 ++--
.../canvas/hooks/useChannelsLayout.ts | 16 +
.../ui/src/features/canvas/hooks/useSpaces.ts | 154 --------
.../features/canvas/hooks/useSpacesLayout.ts | 16 -
.../canvas/stores/currentChannelStore.ts | 15 +
.../features/canvas/stores/spaceEmojiStore.ts | 30 --
.../src/features/canvas/stores/spaceStore.ts | 43 ---
.../panels/components/TabbedPanel.test.tsx | 4 +-
.../panels/components/TabbedPanel.tsx | 8 +-
.../components/SidebarNavSection.test.tsx | 8 +-
.../sidebar/components/SidebarNavSection.tsx | 11 +-
packages/ui/src/router/routes/__root.tsx | 31 +-
25 files changed, 427 insertions(+), 979 deletions(-)
rename packages/ui/src/features/canvas/components/{SpaceItemRow.tsx => ChannelItemRow.tsx} (98%)
create mode 100644 packages/ui/src/features/canvas/components/ChannelNav.tsx
rename packages/ui/src/features/canvas/components/{SpaceSidebar.tsx => ChannelSidebar.tsx} (86%)
create mode 100644 packages/ui/src/features/canvas/components/ChannelSwitcher.tsx
delete mode 100644 packages/ui/src/features/canvas/components/NewSpaceDraft.tsx
delete mode 100644 packages/ui/src/features/canvas/components/SpaceDots.tsx
delete mode 100644 packages/ui/src/features/canvas/components/SpacesTitleBar.tsx
rename packages/ui/src/features/canvas/hooks/{useSpaceItems.tsx => useChannelItems.tsx} (73%)
create mode 100644 packages/ui/src/features/canvas/hooks/useChannelsLayout.ts
delete mode 100644 packages/ui/src/features/canvas/hooks/useSpaces.ts
delete mode 100644 packages/ui/src/features/canvas/hooks/useSpacesLayout.ts
create mode 100644 packages/ui/src/features/canvas/stores/currentChannelStore.ts
delete mode 100644 packages/ui/src/features/canvas/stores/spaceEmojiStore.ts
delete mode 100644 packages/ui/src/features/canvas/stores/spaceStore.ts
diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts
index 1fb87cc027..6355548533 100644
--- a/packages/shared/src/analytics-events.ts
+++ b/packages/shared/src/analytics-events.ts
@@ -872,11 +872,8 @@ export type ChannelsSurface =
| "canvas"
| "context"
| "thread_panel"
- // Spaces layout: the task's tabbed Activity panel and the space chrome
- // (dot switcher / space sidebar).
+ // The task's tabbed Activity panel (new channels layout).
| "activity_panel"
- | "space_switcher"
- | "space_sidebar"
| "activity";
export type ChannelActionType =
@@ -911,10 +908,6 @@ export type ChannelActionType =
| "view_activity"
| "open_mention"
| "canvas_mode_toggle"
- // Spaces layout.
- | "space_switch"
- | "space_browse_toggle"
- | "space_draft_toggle"
| "activity_tab_change";
export interface ChannelActionProperties {
@@ -934,10 +927,6 @@ export interface ChannelActionProperties {
suggestion_label?: string;
/** For canvas_mode_toggle: whether canvas mode is being armed. */
armed?: boolean;
- /** For space_switch: what drove it ("dot" | "swipe" | "keyboard" | "browse" | "draft" | "me"). */
- method?: string;
- /** For space_browse_toggle / space_draft_toggle: the resulting open state. */
- open?: boolean;
/** For activity_tab_change: the tab landed on. */
tab?: string;
/** Whether the underlying mutation resolved successfully. */
diff --git a/packages/shared/src/flags.ts b/packages/shared/src/flags.ts
index 0af3a0443e..e9f435fc94 100644
--- a/packages/shared/src/flags.ts
+++ b/packages/shared/src/flags.ts
@@ -15,11 +15,12 @@ export const DISCOVERY_RUN_FLAG = "posthog-code-discovery-run";
// routes, channels and dashboards.
export const PROJECT_BLUEBIRD_FLAG = "project-bluebird";
/**
- * Gates the Spaces layout (channels as scoped spaces + the task Activity
+ * 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.
*/
-export const SPACES_LAYOUT_FLAG = "code-spaces-layout";
+export const CHANNELS_LAYOUT_FLAG = "code-spaces-layout";
// Gates the Loops feature: the sidebar Loops space and the per-channel Loops tab.
export const LOOPS_FLAG = "loops";
export const TASKS_PREWARM_SANDBOX_FLAG = "tasks-prewarm-sandbox";
diff --git a/packages/ui/src/features/canvas/components/ActivityPanel.tsx b/packages/ui/src/features/canvas/components/ActivityPanel.tsx
index 3da36bcaad..7eb3246da3 100644
--- a/packages/ui/src/features/canvas/components/ActivityPanel.tsx
+++ b/packages/ui/src/features/canvas/components/ActivityPanel.tsx
@@ -43,7 +43,7 @@ import { useQuery } from "@tanstack/react-query";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
/**
- * The spaces-layout task panel — Timeline / Artifacts / Comments. Legacy
+ * The flag-gated task panel — Timeline / Artifacts / Comments. Legacy
* ThreadPanel stays for flag-off; its row/composer primitives are reused here.
*/
diff --git a/packages/ui/src/features/canvas/components/ChannelHeader.tsx b/packages/ui/src/features/canvas/components/ChannelHeader.tsx
index 47bc83e24e..340f4ba0cf 100644
--- a/packages/ui/src/features/canvas/components/ChannelHeader.tsx
+++ b/packages/ui/src/features/canvas/components/ChannelHeader.tsx
@@ -2,17 +2,17 @@ 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 { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout";
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" routing to the channel
-// home. The spaces layout drops the section tab strip (the space sidebar
+// home. 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 spacesOn = useSpacesLayout();
+ const channelsLayout = useChannelsLayout();
const { channels } = useChannels();
const channelName = channels.find((c) => c.id === channelId)?.name;
const pathname = useRouterState({ select: (s) => s.location.pathname });
@@ -36,7 +36,7 @@ export function ChannelHeader({ channelId }: { channelId: string }) {
{channelName ?? "Channel"}
- {!spacesOn && }
+ {!channelsLayout && }
);
}
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),
- )}
- />
-
-
+
+ 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 (
-
+
- {icon}
-
- {caption}
+
+ {icon}
+
+
+ {label}
{badge}
@@ -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 (
-
-
);
}
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 (
-
+
-
- {icon}
-
-
- {label}
-
+ {icon}
{badge}
@@ -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 (
-
{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) && (
-
- router.history.back()}
- >
-
-
- router.history.forward()}
+ {localWorkspaces && (
+
+ router.history.back()}
+ >
+
+
+ router.history.forward()}
+ >
+
+
+
+ )}
+ {/* 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.
+
+
+
+ ) : (
@@ -246,8 +259,8 @@ export function ChannelSwitcher({ channelId }: { channelId: string }) {
{current?.name ?? "channel"}
- {/* Well the overlaid star parks in, so it never covers the name. */}
- {showStar && }
+ {/* Well the star parks in, so it never covers the name. */}
+
}
@@ -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 (
+
+
+ New task
+ {draftBadge}
+
+
+ );
+ }
+
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 }) {
+ 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 (
+
+ {
+ track(ANALYTICS_EVENTS.SIDEBAR_NAV_ITEM_CLICKED, {
+ item: "new_task",
+ in_more: false,
+ });
+ navigateToChannelNewTask(channelId);
+ }}
+ >
+
+ {/* 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 && (
+
+ )}
+
+
+ );
+}
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 (
-
-
- New task
- {draftBadge}
-
-
- );
- }
-
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 (
-
- {
- track(ANALYTICS_EVENTS.SIDEBAR_NAV_ITEM_CLICKED, {
- item: "new_task",
- in_more: false,
- });
- navigateToChannelNewTask(channelId);
- }}
+
+
-
- {/* 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 && (
-
- )}
-
-
+
+
+ {/* 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 && (
)}
- {/* Search rides the title bar beside the history controls
- rather than the sidebar nav — it's chrome, not a
- destination. */}
- {channelsLayout && (
-
-
-
-
-
- )}
)}
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. */}
- 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 }) {
@@ -269,7 +255,6 @@ export function ChannelSwitcher({ channelId }: { channelId: string }) {
{current?.name ?? "channel"}
- {/* Well the star parks in, so it never covers the name. */}
@@ -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.