Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit 542eee4

Browse files
authored
feat(channels): add the flag-gated spaces layout (3/4) (#3814)
Co-authored-by: puemos <13174025+puemos@users.noreply.github.com>
1 parent a56d162 commit 542eee4

71 files changed

Lines changed: 2983 additions & 347 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/code/snapshots.yml‎

Lines changed: 24 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -72,30 +72,6 @@ snapshots:
7272
hash: v1.k4693efd2.1d199b2c4bba8034cb18fb5b49866b64a6eb3311591add5f58ec288710dce2e9.FCc9egSaO1Onih417yciSYKNROe8zqDZyyS6xuXCZGw
7373
billing-usagemeter--zero-spend-limit--light:
7474
hash: v1.k4693efd2.b50c18736bbde45fc89f4c0ac7fd616286e5c1ecc2a56910fffca13eaa13d8a2.31z4F0JmvMeR4yqk5pjs_OQ3MypnM2miZI9al4wuc8c
75-
channels-taskfeedrow--agent-origin--dark:
76-
hash: v1.k4693efd2.82f8c70a399c9ea768201933e202fcc2fde74332c5153aeabf6835ace79beee6.-SVboDRIZ_-nh4PtMliCv7e7iCtdUm-1m2xQ9Xzcdfw
77-
channels-taskfeedrow--agent-origin--light:
78-
hash: v1.k4693efd2.4c1db3470d5ec9b4db9872d0e048314d0078979e8fbdb602b8c648ba70d7209f._KPUvj46p33u7A4Xndz-git-4rp7VKca1MpeTztlB6w
79-
channels-taskfeedrow--human-email-only--dark:
80-
hash: v1.k4693efd2.0f4dece4643b7375b77746347658f4fe65b37817cdfa9ad1ba697067e619f428.f0jKZ4OYqsmeJjXffjt-amK9cwE81JF0lgSMqtn37PA
81-
channels-taskfeedrow--human-email-only--light:
82-
hash: v1.k4693efd2.14db0510f7ea5c57985cf0cb60ab08f5e146c7243eeb1b0b963f74e85c7d60f4.ijHQLK0lZjjUlOQuzYzisImU9DWsNmZM7i5GYfWU4E0
83-
channels-taskfeedrow--human-started--dark:
84-
hash: v1.k4693efd2.e0abdae2e8ac2ef29793ea7afc2a2580c6610e07a3c640ad782af7fd3c12592a.YUgF_BTPzZoqnWXvyLBRs3w9C_P3Yfh4-cP4mUqs2NQ
85-
channels-taskfeedrow--human-started--light:
86-
hash: v1.k4693efd2.84c26deb1a587fe061238b3982b555167575893bacc9cd2667d4d3f74646261f.abqc0Voe6FIQFflzDJTv1KewcZ4XxcDz1a0mFlJu7oM
87-
channels-taskfeedrow--long-prompt--dark:
88-
hash: v1.k4693efd2.876d34660bc267af79d39a971a681ba279a870061dba10b905412112c2a5e4dd.rSw1X068udMs8Arglu_WgX5RL8WZlpAc8_kVONPQhF4
89-
channels-taskfeedrow--long-prompt--light:
90-
hash: v1.k4693efd2.d0d6e4b6bfa257c3f46d991777f72c345437b0be2ee16a182fa925d3ece7dc9e.6PGeOlMxVauJSQia0FAIirzaYio79-I7H4x-9LvEUbw
91-
channels-taskfeedrow--no-prompt--dark:
92-
hash: v1.k4693efd2.9fa967f1a9acdeba0c50a9e45ae649f118ee26938037dbefd1bb0577067b03d4.va1lGsscqLW86-5yCKc3H6pQ8Az7_HBItkgGTnTQiYU
93-
channels-taskfeedrow--no-prompt--light:
94-
hash: v1.k4693efd2.a02339aecdb6fc327fdf6586e28ceeebf8490442fe217eb8bb46f1ce66bff78a.2l7XdvdW3D0GlLmPwDdTFgjZWdav68bZX_LYResVKwo
95-
channels-taskfeedrow--no-starter--dark:
96-
hash: v1.k4693efd2.66fff212f14afa9dd3bc532698a042383014a0cabd4331d30eff878148e1773d.RiectZxFTqk1husLYI-UV5ko1uZ4lpbjWstE7Hy3MJE
97-
channels-taskfeedrow--no-starter--light:
98-
hash: v1.k4693efd2.9ee4ad64ed7d2c5c62d0cd682d903b09da643d462d49aae0858afa500e92256b.OhGj64l-o7iWtxzJo1qt0A_CU9ME0ZRr_5e455RGUJQ
9975
components-permissions-permissionselector--create-new-file--dark:
10076
hash: v1.k4693efd2.c54203a4e636b83b3d24d7ed9c4ace8659db87cd8f231d9dc2ecc03320e31646.epDm7LebiLzlp0uuZBrE-Obt_anAn0xsE8bHFnm5vos
10177
components-permissions-permissionselector--create-new-file--light:
@@ -708,6 +684,30 @@ snapshots:
708684
hash: v1.k4693efd2.cfff4c6bbb0acef1c9941b21d2f69c24b234c2c206282acf6fcada0cb2bd2386.xILjVPp8ecQmGbPjGDdhbRyi9KFPpA_1nfYF3bfjwuc
709685
skill-buttons-skillbuttonsmenu--default--light:
710686
hash: v1.k4693efd2.eb9bd49b9700641f6f3c90653447c9767c52e997afec9843e8649b90752faad3.VdxEtBwtkU3ioy2evcNbrEKxPjQ1GDUpZAQzFe-Uho4
687+
spaces-taskfeedrow--agent-origin--dark:
688+
hash: v1.k4693efd2.82f8c70a399c9ea768201933e202fcc2fde74332c5153aeabf6835ace79beee6.yEZq_qnKxItv5u0Ydcqa1B4VCwv8lwXUvcaFScE2XO0
689+
spaces-taskfeedrow--agent-origin--light:
690+
hash: v1.k4693efd2.4c1db3470d5ec9b4db9872d0e048314d0078979e8fbdb602b8c648ba70d7209f.wt_Ufkw30jHFnxEMFXE0wQG8ignuCURuUUYIvNMnJ6U
691+
spaces-taskfeedrow--human-email-only--dark:
692+
hash: v1.k4693efd2.611794921d5c6270a211f4a6bc3d91fd63a74a58f0c905a79271cbc7d9c73eb8.balV69yNKs9biJzweJYwpk5e1UjZ11fFersE9UMhvEU
693+
spaces-taskfeedrow--human-email-only--light:
694+
hash: v1.k4693efd2.961288ce410b6376bab92428ad148b881c768e9edea4821db4818d13682f50c4.79pzkpuXwARCmUS4HtZRvb2n7dM8HUZZs-ErIXX4wwg
695+
spaces-taskfeedrow--human-started--dark:
696+
hash: v1.k4693efd2.6e1cf0720234b1c64ba04f6f3af965d32438551771bc3680c4391bb8463b8eee.4O-iWXxbDv-L9MsuPt6KoDvK7C95R0Jj6LTYjDwwEhk
697+
spaces-taskfeedrow--human-started--light:
698+
hash: v1.k4693efd2.7156c1e5b2359b5f792529aa9041ad9277842d51c3423bda4f0c029c58dcef28.PF-JYrCTfHxEzFG9dnabJxDYS47wQXnxurx8Ev_LfkY
699+
spaces-taskfeedrow--long-prompt--dark:
700+
hash: v1.k4693efd2.00acb2487749ee5a1db2c95da8ae0e2fb2de0815d653973e60c7ceb3ff808135.pdV864kTAUM1IgcuME846VwInafp3f-WFFxxxE4H_oY
701+
spaces-taskfeedrow--long-prompt--light:
702+
hash: v1.k4693efd2.8a6b372abdf9c60a558c59a703f8e7c8f65fcb55fd99e6c97c6eca23e6a76781.HbZ7kfuE60F5SWucdbYLhy98POhk8MPPX2TLA_r2hz8
703+
spaces-taskfeedrow--no-prompt--dark:
704+
hash: v1.k4693efd2.ff77770c9b5c656b213c139270322defa38306d714abeee6b5fe4e17e2056f88.KnYgUDb_jYEfH5aSoJbt1cwyJXAfDo_eKmIfn0fxjjc
705+
spaces-taskfeedrow--no-prompt--light:
706+
hash: v1.k4693efd2.d3d755ee7ea6eccbed93ea18e6dcbccbd9f77f019f5c153cb0278131b08c324f.l_KwHWHFTzODefftPGtlWsXLFi6tbH78ZRQeeY7X4Z0
707+
spaces-taskfeedrow--no-starter--dark:
708+
hash: v1.k4693efd2.66fff212f14afa9dd3bc532698a042383014a0cabd4331d30eff878148e1773d.-FvbS5RpndI7QXNTpQhbQShDktPrOV_jdcWDY4MUYF4
709+
spaces-taskfeedrow--no-starter--light:
710+
hash: v1.k4693efd2.9ee4ad64ed7d2c5c62d0cd682d903b09da643d462d49aae0858afa500e92256b.lCc1Q5KBHSLnYp7N9k6HEBxDPjf0SblH9zDLHQ7dYo8
711711
task-detail-continueclisessions--importing--dark:
712712
hash: v1.k4693efd2.3196e21b9daa2f183c79eaf2c70a5775365dbee3f1d920c64927e20a0fadcbf9.FjHhjEX3urGbycQiDehA9JQ7H2zfyl1id9rDSVjrtoI
713713
task-detail-continueclisessions--importing--light:

‎packages/core/src/canvas/runStatus.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,17 @@ export function runStatusVariant(
3737
return status ? RUN_STATUS_VARIANTS[status] : "default";
3838
}
3939

40+
/**
41+
* Whether the run is still in flight, and so worth animating in a list. Canvases
42+
* and tasks that never started carry no run to wait on, and `not_started` is a
43+
* queued-but-unclaimed task rather than one doing work.
44+
*/
45+
export function isRunStatusActive(
46+
status: TaskRunStatus | null | undefined,
47+
): boolean {
48+
return status === "queued" || status === "in_progress";
49+
}
50+
4051
export const RUN_STATUS_FILTER_OPTIONS: readonly {
4152
value: TaskRunStatus | null;
4253
label: string;

‎packages/core/src/command-center/grid.test.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
22
import {
33
BRAINROT_CELL,
44
clampZoom,
5+
countActiveTaskCells,
56
getCellCount,
67
getCellSessionId,
78
getGridDimensions,
@@ -97,3 +98,34 @@ describe("getCellSessionId", () => {
9798
expect(getCellSessionId(2)).toBe("cc-cell-2");
9899
});
99100
});
101+
102+
describe("countActiveTaskCells", () => {
103+
const live = new Set(["task-1", "task-2"]);
104+
105+
it("counts only cells whose task still exists", () => {
106+
expect(countActiveTaskCells(["task-1", "task-2"], live)).toBe(2);
107+
});
108+
109+
// Cells are persisted and only pruned on archive, so a deleted task's id
110+
// lingers forever — counting the array's non-empty entries would never drop.
111+
it("ignores a task that has since been deleted", () => {
112+
expect(countActiveTaskCells(["task-1", "deleted-task"], live)).toBe(1);
113+
});
114+
115+
it.each([
116+
{ name: "empty cells", cells: [null, null] },
117+
{ name: "the brainrot sentinel", cells: [BRAINROT_CELL] },
118+
{ name: "terminal cells", cells: [makeTerminalCellValue("abc123")] },
119+
])("does not count $name", ({ cells }) => {
120+
expect(countActiveTaskCells(cells, live)).toBe(0);
121+
});
122+
123+
it("counts a mixed grid correctly", () => {
124+
expect(
125+
countActiveTaskCells(
126+
[null, BRAINROT_CELL, "task-1", "deleted", makeTerminalCellValue("t")],
127+
live,
128+
),
129+
).toBe(1);
130+
});
131+
});

‎packages/core/src/command-center/grid.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,21 @@ export function getTerminalCellCwd(value: string | null): string | null {
4949
return colon === -1 ? null : decodeURIComponent(rest.slice(colon + 1));
5050
}
5151

52+
/**
53+
* How many cells hold a task that still exists.
54+
*
55+
* Cells are persisted and only pruned when a task is archived — deleting one
56+
* leaves its id behind forever — so a count has to be taken against the live
57+
* task list rather than trusting the array's length. Excludes the brainrot and
58+
* terminal sentinels, which are ambient chrome rather than parked work.
59+
*/
60+
export function countActiveTaskCells(
61+
cells: readonly (string | null)[],
62+
liveTaskIds: ReadonlySet<string>,
63+
): number {
64+
return cells.filter((cell) => cell != null && liveTaskIds.has(cell)).length;
65+
}
66+
5267
export function getGridDimensions(preset: LayoutPreset): GridDimensions {
5368
const [cols, rows] = preset.split("x").map(Number);
5469
return { cols, rows };

‎packages/shared/src/analytics-events.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -891,6 +891,8 @@ export type ChannelActionType =
891891
| "leave_feedback"
892892
| "nav_click"
893893
| "open_channel"
894+
/** Slid the sidebar back from a channel to the channel list. */
895+
| "browse_channels"
894896
| "collapse_channel"
895897
| "view_more_tasks"
896898
| "create"

‎packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
useChannelMutations,
3030
useChannels,
3131
} from "@posthog/ui/features/canvas/hooks/useChannels";
32+
import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout";
3233
import {
3334
useDashboard,
3435
useDashboards,
@@ -145,6 +146,7 @@ function isAppView(value: string): value is AppView {
145146
}
146147

147148
export function BrowserTabStrip() {
149+
const spacesLayout = useChannelsLayout();
148150
const logger = useService<RootLogger>(ROOT_LOGGER);
149151
const snapshot = useTabsSnapshot();
150152
const navigate = useNavigate();
@@ -509,7 +511,8 @@ export function BrowserTabStrip() {
509511
const meta = channelSectionFor(section);
510512
return {
511513
id: t.id,
512-
label: meta?.label ?? channel ?? "Channel",
514+
label:
515+
meta?.label ?? channel ?? (spacesLayout ? "Space" : "Channel"),
513516
icon: <HashIcon size={14} />,
514517
channelName: channel,
515518
// No section meta → the channel's index page.
@@ -545,6 +548,7 @@ export function BrowserTabStrip() {
545548
params.taskId,
546549
routeChannelSection,
547550
routeAppView,
551+
spacesLayout,
548552
]);
549553

550554
// Navigate to a tab, tagging the history entry with its id so the switch is
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { render } from "@testing-library/react";
2+
import { describe, expect, it } from "vitest";
3+
import { TabShortcutFallback } from "./TabShortcutFallback";
4+
5+
function pressCloseTab(): KeyboardEvent {
6+
const event = new KeyboardEvent("keydown", {
7+
key: "w",
8+
code: "KeyW",
9+
metaKey: true,
10+
bubbles: true,
11+
cancelable: true,
12+
});
13+
document.dispatchEvent(event);
14+
return event;
15+
}
16+
17+
describe("TabShortcutFallback", () => {
18+
// Without a preventDefault here the key reaches Electron's Window ▸ Close
19+
// role and takes the window — and everything unsaved in it — with it.
20+
it("swallows Cmd+W so the host menu never sees it", () => {
21+
render(<TabShortcutFallback enabled />);
22+
expect(pressCloseTab().defaultPrevented).toBe(true);
23+
});
24+
25+
// Disabled is how the BrowserTabStrip keeps ownership where it is mounted.
26+
it("leaves the key alone when disabled", () => {
27+
render(<TabShortcutFallback enabled={false} />);
28+
expect(pressCloseTab().defaultPrevented).toBe(false);
29+
});
30+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { SHORTCUTS } from "@posthog/ui/features/command/keyboard-shortcuts";
2+
import { useHotkeys } from "react-hotkeys-hook";
3+
4+
/**
5+
* Renders nothing — claims Cmd/Ctrl+W wherever BrowserTabStrip isn't mounted.
6+
*
7+
* The strip's own CLOSE_TAB handler preventDefaults unconditionally, because
8+
* otherwise the key reaches Electron's Window ▸ Close role (`{ role:
9+
* "windowMenu" }` in the host menu) and closes the window, losing everything in
10+
* it. Any route that renders the app without the strip — the whole channels
11+
* layout, and the settings shell either way — needs someone else to hold the key.
12+
*
13+
* The task view's editor panel keeps closing its own tab from
14+
* usePanelKeyboardShortcuts; that handler runs too, and this one only swallows.
15+
*/
16+
export function TabShortcutFallback({ enabled }: { enabled: boolean }) {
17+
useHotkeys(
18+
SHORTCUTS.CLOSE_TAB,
19+
(event) => {
20+
event.preventDefault();
21+
},
22+
{ enabled, enableOnFormTags: true, enableOnContentEditable: true },
23+
);
24+
25+
return null;
26+
}

‎packages/ui/src/features/canvas/AGENTS.md‎

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,25 +25,44 @@ The root `AGENTS.md` architecture rules still apply.
2525
- **Suffix `…` on anything that opens another step.** A menu item or button whose
2626
click opens a follow-up surface — a dialog, a nested menu, a picker, a
2727
confirmation — gets a trailing ellipsis (`…`, the character, not three dots) to
28-
signal it isn't the final action: `New…`, `Rename channel…`, `Delete channel…`,
28+
signal it isn't the final action: `New…`, `Rename space…`, `Delete space…`,
2929
`Choose a template…`. A label that performs its action immediately or navigates
3030
straight to a destination gets **no** ellipsis (`Edit CONTEXT.md`, `Star
31-
channel`). When in doubt: does clicking it ask for more input or confirmation
31+
space`). When in doubt: does clicking it ask for more input or confirmation
3232
before anything happens? If yes, add the `…`.
3333

3434
## Spaces & chrome
3535

36-
- Channels is a **top-level space** reached through the app rail (`AppNav`),
36+
- Spaces is a **top-level space** reached through the app rail (`AppNav`),
3737
gated behind `project-bluebird` and wired in `routes/__root.tsx`. The rail's
38-
spaces are Code (`/code`), Inbox (`/inbox`), and Channels (`/website`).
39-
- The Channels space has **its own chrome**: rail + a persistent channel-list
38+
spaces are Code (`/code`), Inbox (`/inbox`), and Spaces (`/website`).
39+
- The Spaces UI has **its own chrome**: rail + a persistent channel-list
4040
sidebar (`ChannelsList`, rendered in `__root`) + the `WebsiteLayout` outlet. It
4141
does NOT use the code `HeaderRow`/`MainSidebar`, so breadcrumbs render in
4242
`WebsiteLayout`'s own top bar (below).
43+
- Under the channels layout the sidebar is a **master/detail slider**
44+
(`ChannelPanes` in `ChannelsSidebar.tsx`): the searchable channel list, and the
45+
channel you're in (`ChannelSidebar`, headed by `ChannelBackRow`). Both panes
46+
stay mounted — the offscreen one is `inert` — so the slide has something to
47+
slide and returning to the list doesn't rebuild every row. A two-finger
48+
horizontal swipe moves between them (`useChannelPaneSwipe`, wheel `deltaX`
49+
accumulated per gesture and locked until the wheel goes quiet).
50+
- In the list, "Starred"/"Channels" are headings, not parents: under the layout
51+
the rows sit at the heading's level (no indent) and the "#"/lock glyph belongs
52+
to the rows. The alpha's indented tree is unchanged.
53+
- One `ChannelsFab` serves both panes: given a `channelId` it creates inside
54+
that channel (task, canvas), and either way it can create a channel. Off the
55+
layout it keeps its original two-item menu. Archived moves out of the sidebar
56+
and into the account menu (`ProjectSwitcher`), beside Settings.
57+
- **Which pane shows is view state, not a route.** `channelPaneStore` holds it,
58+
separately from the scoped channel (`currentChannelStore`): "back to channels"
59+
browses the list while the route, the main pane and the scoped channel stay
60+
put. Every way into a channel — a row click, a deep link, a mention, ⌘1-9 —
61+
ends at `showChannelPane()`, directly or through the route effect.
4362

4463
## Breadcrumbs
4564

46-
- **`WebsiteLayout` renders its own top bar.** The Channels space has no code
65+
- **`WebsiteLayout` renders its own top bar.** The Spaces UI has no code
4766
`HeaderRow`, so breadcrumbs (and the dashboard controls) are a local bar inside
4867
`WebsiteLayout`, not pushed through the header store.
4968
- **A page does not get its own crumb — its H1 is the title.** A view that

‎packages/ui/src/features/canvas/components/ActivityView.tsx‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar";
2626
import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser";
2727
import { MentionText } from "@posthog/ui/features/canvas/components/MentionText";
2828
import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels";
29+
import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout";
2930
import { useMarkTaskActivityRead } from "@posthog/ui/features/canvas/hooks/useMarkTaskActivityRead";
3031
import { useTaskActivity } from "@posthog/ui/features/canvas/hooks/useTaskActivity";
3132
import { normalizeChannelName } from "@posthog/ui/features/canvas/hooks/useTaskChannels";
@@ -222,6 +223,7 @@ function ActivityRow({
222223
// in, or messaged in — newest activity first. Rows clear as they are opened, not
223224
// when the page is; merely landing here shouldn't dismiss what you haven't read.
224225
export function ActivityView() {
226+
const spacesLayout = useChannelsLayout();
225227
const client = useOptionalAuthenticatedClient();
226228
const { data: currentUser } = useCurrentUser({ client });
227229
const {
@@ -285,7 +287,8 @@ export function ActivityView() {
285287
Activity
286288
</Text>
287289
<Text size="2" className="block text-muted-foreground">
288-
Tasks you're involved in across channels.
290+
Tasks you're involved in across{" "}
291+
{spacesLayout ? "spaces" : "channels"}.
289292
</Text>
290293
</div>
291294
{unreadCount > 0 && (
@@ -314,8 +317,8 @@ export function ActivityView() {
314317
</EmptyMedia>
315318
<EmptyTitle>No activity yet</EmptyTitle>
316319
<EmptyDescription>
317-
Tasks you create, get tagged in, or reply to across channels
318-
land here.
320+
Tasks you create, get tagged in, or reply to across{" "}
321+
{spacesLayout ? "spaces" : "channels"} land here.
319322
</EmptyDescription>
320323
</EmptyHeader>
321324
</Empty>

0 commit comments

Comments
 (0)