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

Commit 54ec82c

Browse files
authored
feat(channels): add the flag-gated spaces layout
Activate the complete channel-scoped sidebar, switcher, navigation, shortcuts, and shell chrome behind the spaces flag. The existing Thread panel remains in place until the next stack PR. Generated-By: PostHog Code Task-Id: 6190e713-9b80-43d9-a05c-5e3b1ecdf297
1 parent f60bbca commit 54ec82c

40 files changed

Lines changed: 2077 additions & 184 deletions

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 };
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/components/ChannelBreadcrumb.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import { HashIcon } from "@phosphor-icons/react";
21
import {
32
Button,
43
Tooltip,
54
TooltipContent,
65
TooltipTrigger,
76
} from "@posthog/quill";
7+
import { channelGlyph } from "@posthog/ui/features/canvas/components/channelGlyph";
88
import { HeaderTitleEditor } from "@posthog/ui/features/task-detail/HeaderTitleEditor";
99
import { Flex, Text } from "@radix-ui/themes";
1010
import { useNavigate } from "@tanstack/react-router";
@@ -52,7 +52,10 @@ export function ChannelBreadcrumb({
5252

5353
const channelSegment = (
5454
<>
55-
<HashIcon size={12} className="mt-px shrink-0 text-muted-foreground/80" />
55+
{channelGlyph(channelName, {
56+
size: 12,
57+
className: "mt-px shrink-0 text-muted-foreground/80",
58+
})}
5659
<Text
5760
className="min-w-0 truncate whitespace-nowrap font-medium text-[13px]"
5861
title={channelName}

packages/ui/src/features/canvas/components/ChannelFeedView.tsx

Lines changed: 6 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ import {
66
RobotIcon,
77
} from "@phosphor-icons/react";
88
import { taskFeedRunStatus } from "@posthog/core/canvas/channelFeed";
9+
import {
10+
RUN_STATUS_LABELS,
11+
runStatusVariant,
12+
} from "@posthog/core/canvas/runStatus";
913
import { xmlToPlainText } from "@posthog/core/message-editor/content";
1014
import {
1115
Avatar,
@@ -75,17 +79,6 @@ import {
7579
// shared query key means an open panel naturally speeds the row up too.
7680
const FEED_REPLIES_POLL_INTERVAL_MS = 15_000;
7781

78-
const STATUS_LABELS: Record<TaskRunStatus, string> = {
79-
not_started: "Not started",
80-
queued: "Queued",
81-
in_progress: "In progress",
82-
// "Ready", not "Completed": the agent has finished its work and the task is
83-
// ready to look at, but the change itself isn't necessarily shipped/done.
84-
completed: "Ready",
85-
failed: "Failed",
86-
cancelled: "Cancelled",
87-
};
88-
8982
// Once a PR exists its GitHub state is the truest top-line status — more
9083
// accurate than the run status, which routinely lingers on "in_progress"
9184
// (or a stale cloud status) after the agent opens the PR. Mirrors the PR
@@ -101,18 +94,10 @@ const PR_STATE_LABELS: Record<
10194
};
10295

10396
function statusBadge(status: TaskRunStatus) {
104-
const variant =
105-
status === "completed"
106-
? "success"
107-
: status === "failed"
108-
? "destructive"
109-
: status === "in_progress"
110-
? "info"
111-
: "default";
11297
return (
113-
<Badge variant={variant}>
98+
<Badge variant={runStatusVariant(status)}>
11499
{status === "in_progress" && <Spinner className="size-2.5" />}
115-
{STATUS_LABELS[status]}
100+
{RUN_STATUS_LABELS[status]}
116101
</Badge>
117102
);
118103
}
Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,57 @@
1-
import { HashIcon } from "@phosphor-icons/react";
1+
import { StarIcon } from "@phosphor-icons/react";
22
import { Button, cn } from "@posthog/quill";
3+
import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events";
34
import { ChannelTabs } from "@posthog/ui/features/canvas/components/ChannelTabs";
4-
import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels";
5+
import { channelGlyph } from "@posthog/ui/features/canvas/components/channelGlyph";
6+
import { useChannelStarToggle } from "@posthog/ui/features/canvas/hooks/useChannelStars";
7+
import {
8+
type Channel,
9+
useChannels,
10+
} from "@posthog/ui/features/canvas/hooks/useChannels";
11+
import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout";
512
import { useMarkChannelSeen } from "@posthog/ui/features/canvas/hooks/useMarkChannelSeen";
13+
import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels";
14+
import { track } from "@posthog/ui/shell/analytics";
615
import { Text } from "@radix-ui/themes";
716
import { useNavigate, useRouterState } from "@tanstack/react-router";
817

9-
// The shared channel header: a clickable "# channel" that doubles as the Home
10-
// item — it routes to the channel home (`/website/$channelId`, like the sidebar
11-
// channel row) and highlights `bg-fill-selected` while you're there, the same
12-
// pathname-driven active state the rest of the channel tab strip uses. Followed
13-
// by that strip (Artifacts / Recents / CONTEXT.md), rendered into the
14-
// header bar by every channel view so the tabs stay in view.
18+
// The feed-side counterpart to the switcher's hover star.
19+
function ChannelStarButton({ channel }: { channel: Channel }) {
20+
const { isStarred, toggleStar } = useChannelStarToggle(channel);
21+
return (
22+
<Button
23+
type="button"
24+
size="icon-sm"
25+
aria-label={isStarred ? "Unstar channel" : "Star channel"}
26+
onClick={() => {
27+
track(ANALYTICS_EVENTS.CHANNEL_ACTION, {
28+
action_type: isStarred ? "unstar" : "star",
29+
surface: "channel_home",
30+
channel_id: channel.id,
31+
});
32+
toggleStar();
33+
}}
34+
>
35+
<StarIcon
36+
size={14}
37+
weight={isStarred ? "fill" : "regular"}
38+
className={isStarred ? undefined : "text-muted-foreground/80"}
39+
/>
40+
</Button>
41+
);
42+
}
43+
44+
// The shared channel header. The new layout drops the section tab strip — the
45+
// channel sidebar carries those entries — while flag off keeps it.
1546
export function ChannelHeader({ channelId }: { channelId: string }) {
1647
const navigate = useNavigate();
48+
const channelsLayout = useChannelsLayout();
1749
const { channels } = useChannels();
18-
const channelName = channels.find((c) => c.id === channelId)?.name;
50+
const channel = channels.find((c) => c.id === channelId);
51+
const channelName = channel?.name;
1952
const pathname = useRouterState({ select: (s) => s.location.pathname });
2053
const isHome = pathname === `/website/${channelId}`;
21-
// Every channel surface renders this header, so it is where "the viewer is
22-
// in this channel" is known — and therefore where the channel is marked read.
54+
// Every channel surface renders this header, so mark the channel read here.
2355
useMarkChannelSeen(channelName);
2456

2557
return (
@@ -33,12 +65,18 @@ export function ChannelHeader({ channelId }: { channelId: string }) {
3365
size="sm"
3466
className={cn("min-w-0", isHome ? "bg-fill-selected" : "")}
3567
>
36-
<HashIcon size={20} className="shrink-0 text-muted-foreground/80" />
68+
{channelGlyph(channelName, {
69+
size: 20,
70+
className: "shrink-0 text-muted-foreground/80",
71+
})}
3772
<Text className="min-w-0 truncate font-medium" title={channelName}>
3873
{channelName ?? "Channel"}
3974
</Text>
4075
</Button>
41-
<ChannelTabs channelId={channelId} />
76+
{channelsLayout && channel && channel.name !== PERSONAL_CHANNEL_NAME && (
77+
<ChannelStarButton channel={channel} />
78+
)}
79+
{!channelsLayout && <ChannelTabs channelId={channelId} />}
4280
</div>
4381
);
4482
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { render } from "@testing-library/react";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
4+
const mocks = vi.hoisted(() => ({
5+
channelsLayout: true,
6+
slots: [] as { id: string; name: string; path: string }[],
7+
navigateToChannel: vi.fn(),
8+
}));
9+
10+
vi.mock("@posthog/ui/features/canvas/hooks/useChannelsLayout", () => ({
11+
useChannelsLayout: () => mocks.channelsLayout,
12+
}));
13+
vi.mock("@posthog/ui/features/canvas/hooks/useStarredChannelSlots", () => ({
14+
useStarredChannelSlots: () => ({
15+
slots: mocks.slots,
16+
rest: [],
17+
slotFor: () => undefined,
18+
}),
19+
}));
20+
vi.mock("@posthog/ui/router/navigationBridge", () => ({
21+
navigateToChannel: (...args: unknown[]) => mocks.navigateToChannel(...args),
22+
}));
23+
vi.mock("@posthog/ui/shell/analytics", () => ({ track: vi.fn() }));
24+
25+
import { useCurrentChannelStore } from "@posthog/ui/features/canvas/stores/currentChannelStore";
26+
import { ChannelHotkeys } from "./ChannelHotkeys";
27+
28+
function press(digit: string, modifiers: Partial<KeyboardEventInit> = {}) {
29+
document.dispatchEvent(
30+
new KeyboardEvent("keydown", {
31+
key: digit,
32+
code: `Digit${digit}`,
33+
bubbles: true,
34+
cancelable: true,
35+
...modifiers,
36+
}),
37+
);
38+
}
39+
40+
describe("ChannelHotkeys", () => {
41+
beforeEach(() => {
42+
vi.clearAllMocks();
43+
mocks.channelsLayout = true;
44+
mocks.slots = [
45+
{ id: "me-id", name: "me", path: "/me" },
46+
{ id: "eng-id", name: "eng", path: "/eng" },
47+
];
48+
useCurrentChannelStore.setState({ currentChannelId: null });
49+
});
50+
51+
// The regression: this component is rendered ALONE — no sidebar, no switcher.
52+
// Binding the keys inside the switcher left them unowned exactly when the
53+
// channel list hadn't resolved yet.
54+
it("switches channels without the sidebar being mounted", () => {
55+
render(<ChannelHotkeys />);
56+
57+
press("1", { metaKey: true });
58+
59+
expect(mocks.navigateToChannel).toHaveBeenCalledWith("me-id");
60+
expect(useCurrentChannelStore.getState().currentChannelId).toBe("me-id");
61+
});
62+
63+
it("maps slot 2 to the first starred channel", () => {
64+
render(<ChannelHotkeys />);
65+
press("2", { metaKey: true });
66+
expect(mocks.navigateToChannel).toHaveBeenCalledWith("eng-id");
67+
});
68+
69+
// mod+0 belongs to the host's "Actual Size" accelerator.
70+
it("ignores mod+0", () => {
71+
render(<ChannelHotkeys />);
72+
press("0", { metaKey: true });
73+
expect(mocks.navigateToChannel).not.toHaveBeenCalled();
74+
});
75+
76+
// ctrl+1-9 is the editor-panel tab switcher on every platform.
77+
it("leaves pure ctrl presses to the panel tab switcher", () => {
78+
render(<ChannelHotkeys />);
79+
press("1", { ctrlKey: true });
80+
expect(mocks.navigateToChannel).not.toHaveBeenCalled();
81+
});
82+
83+
it("does nothing for a slot with no channel behind it", () => {
84+
mocks.slots = [{ id: "me-id", name: "me", path: "/me" }];
85+
render(<ChannelHotkeys />);
86+
press("5", { metaKey: true });
87+
expect(mocks.navigateToChannel).not.toHaveBeenCalled();
88+
});
89+
90+
it("stays out of the way when the layout is off", () => {
91+
mocks.channelsLayout = false;
92+
render(<ChannelHotkeys />);
93+
press("1", { metaKey: true });
94+
expect(mocks.navigateToChannel).not.toHaveBeenCalled();
95+
});
96+
});

0 commit comments

Comments
 (0)