Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/core/src/canvas/runStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,17 @@ export function runStatusVariant(
return status ? RUN_STATUS_VARIANTS[status] : "default";
}

/**
* Whether the run is still in flight, and so worth animating in a list. Canvases
* and tasks that never started carry no run to wait on, and `not_started` is a
* queued-but-unclaimed task rather than one doing work.
*/
export function isRunStatusActive(
status: TaskRunStatus | null | undefined,
): boolean {
return status === "queued" || status === "in_progress";
}

export const RUN_STATUS_FILTER_OPTIONS: readonly {
value: TaskRunStatus | null;
label: string;
Expand Down
32 changes: 32 additions & 0 deletions packages/core/src/command-center/grid.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
BRAINROT_CELL,
clampZoom,
countActiveTaskCells,
getCellCount,
getCellSessionId,
getGridDimensions,
Expand Down Expand Up @@ -97,3 +98,34 @@ describe("getCellSessionId", () => {
expect(getCellSessionId(2)).toBe("cc-cell-2");
});
});

describe("countActiveTaskCells", () => {
const live = new Set(["task-1", "task-2"]);

it("counts only cells whose task still exists", () => {
expect(countActiveTaskCells(["task-1", "task-2"], live)).toBe(2);
});

// Cells are persisted and only pruned on archive, so a deleted task's id
// lingers forever — counting the array's non-empty entries would never drop.
it("ignores a task that has since been deleted", () => {
expect(countActiveTaskCells(["task-1", "deleted-task"], live)).toBe(1);
});

it.each([
{ name: "empty cells", cells: [null, null] },
{ name: "the brainrot sentinel", cells: [BRAINROT_CELL] },
{ name: "terminal cells", cells: [makeTerminalCellValue("abc123")] },
])("does not count $name", ({ cells }) => {
expect(countActiveTaskCells(cells, live)).toBe(0);
});

it("counts a mixed grid correctly", () => {
expect(
countActiveTaskCells(
[null, BRAINROT_CELL, "task-1", "deleted", makeTerminalCellValue("t")],
live,
),
).toBe(1);
});
});
15 changes: 15 additions & 0 deletions packages/core/src/command-center/grid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,21 @@ export function getTerminalCellCwd(value: string | null): string | null {
return colon === -1 ? null : decodeURIComponent(rest.slice(colon + 1));
}

/**
* How many cells hold a task that still exists.
*
* Cells are persisted and only pruned when a task is archived — deleting one
* leaves its id behind forever — so a count has to be taken against the live
* task list rather than trusting the array's length. Excludes the brainrot and
* terminal sentinels, which are ambient chrome rather than parked work.
*/
export function countActiveTaskCells(
cells: readonly (string | null)[],
liveTaskIds: ReadonlySet<string>,
): number {
return cells.filter((cell) => cell != null && liveTaskIds.has(cell)).length;
}

export function getGridDimensions(preset: LayoutPreset): GridDimensions {
const [cols, rows] = preset.split("x").map(Number);
return { cols, rows };
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/analytics-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -891,6 +891,8 @@ export type ChannelActionType =
| "leave_feedback"
| "nav_click"
| "open_channel"
/** Slid the sidebar back from a channel to the channel list. */
| "browse_channels"
| "collapse_channel"
| "view_more_tasks"
| "create"
Expand Down
30 changes: 30 additions & 0 deletions packages/ui/src/features/browser-tabs/TabShortcutFallback.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { TabShortcutFallback } from "./TabShortcutFallback";

function pressCloseTab(): KeyboardEvent {
const event = new KeyboardEvent("keydown", {
key: "w",
code: "KeyW",
metaKey: true,
bubbles: true,
cancelable: true,
});
document.dispatchEvent(event);
return event;
}

describe("TabShortcutFallback", () => {
// Without a preventDefault here the key reaches Electron's Window ▸ Close
// role and takes the window — and everything unsaved in it — with it.
it("swallows Cmd+W so the host menu never sees it", () => {
render(<TabShortcutFallback enabled />);
expect(pressCloseTab().defaultPrevented).toBe(true);
});

// Disabled is how the BrowserTabStrip keeps ownership where it is mounted.
it("leaves the key alone when disabled", () => {
render(<TabShortcutFallback enabled={false} />);
expect(pressCloseTab().defaultPrevented).toBe(false);
});
});
26 changes: 26 additions & 0 deletions packages/ui/src/features/browser-tabs/TabShortcutFallback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { SHORTCUTS } from "@posthog/ui/features/command/keyboard-shortcuts";
import { useHotkeys } from "react-hotkeys-hook";

/**
* Renders nothing — claims Cmd/Ctrl+W wherever BrowserTabStrip isn't mounted.
*
* The strip's own CLOSE_TAB handler preventDefaults unconditionally, because
* otherwise the key reaches Electron's Window ▸ Close role (`{ role:
* "windowMenu" }` in the host menu) and closes the window, losing everything in
* it. Any route that renders the app without the strip — the whole channels
* layout, and the settings shell either way — needs someone else to hold the key.
*
* The task view's editor panel keeps closing its own tab from
* usePanelKeyboardShortcuts; that handler runs too, and this one only swallows.
*/
export function TabShortcutFallback({ enabled }: { enabled: boolean }) {
useHotkeys(
SHORTCUTS.CLOSE_TAB,
(event) => {
event.preventDefault();
},
{ enabled, enableOnFormTags: true, enableOnContentEditable: true },
);

return null;
}
19 changes: 19 additions & 0 deletions packages/ui/src/features/canvas/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,25 @@ The root `AGENTS.md` architecture rules still apply.
sidebar (`ChannelsList`, rendered in `__root`) + the `WebsiteLayout` outlet. It
does NOT use the code `HeaderRow`/`MainSidebar`, so breadcrumbs render in
`WebsiteLayout`'s own top bar (below).
- Under the channels layout the sidebar is a **master/detail slider**
(`ChannelPanes` in `ChannelsSidebar.tsx`): the searchable channel list, and the
channel you're in (`ChannelSidebar`, headed by `ChannelBackRow`). Both panes
stay mounted — the offscreen one is `inert` — so the slide has something to
slide and returning to the list doesn't rebuild every row. A two-finger
horizontal swipe moves between them (`useChannelPaneSwipe`, wheel `deltaX`
accumulated per gesture and locked until the wheel goes quiet).
- In the list, "Starred"/"Channels" are headings, not parents: under the layout
the rows sit at the heading's level (no indent) and the "#"/lock glyph belongs
to the rows. The alpha's indented tree is unchanged.
- One `ChannelsFab` serves both panes: given a `channelId` it creates inside
that channel (task, canvas), and either way it can create a channel. Off the
layout it keeps its original two-item menu. Archived moves out of the sidebar
and into the account menu (`ProjectSwitcher`), beside Settings.
- **Which pane shows is view state, not a route.** `channelPaneStore` holds it,
separately from the scoped channel (`currentChannelStore`): "back to channels"
browses the list while the route, the main pane and the scoped channel stay
put. Every way into a channel — a row click, a deep link, a mention, ⌘1-9 —
ends at `showChannelPane()`, directly or through the route effect.

## Breadcrumbs

Expand Down
78 changes: 78 additions & 0 deletions packages/ui/src/features/canvas/components/ChannelBackRow.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { Theme } from "@radix-ui/themes";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";

const mocks = vi.hoisted(() => ({
channels: [] as { id: string; name: string; path: string }[],
isLoading: false,
toggleStar: vi.fn(),
}));

vi.mock("@posthog/ui/shell/analytics", () => ({ track: vi.fn() }));
vi.mock("@posthog/ui/features/canvas/hooks/useChannels", () => ({
useChannels: () => ({ channels: mocks.channels, isLoading: mocks.isLoading }),
}));
vi.mock("@posthog/ui/features/canvas/hooks/useChannelStars", () => ({
useChannelStarToggle: () => ({
isStarred: false,
toggleStar: mocks.toggleStar,
}),
}));

import { useChannelPaneStore } from "@posthog/ui/features/canvas/stores/channelPaneStore";
import { ChannelBackRow } from "./ChannelBackRow";

const ENG = { id: "eng-id", name: "engineering", path: "/engineering" };
const ME = { id: "me-id", name: "me", path: "/me" };

function renderRow(channelId: string) {
return render(
<Theme>
<ChannelBackRow channelId={channelId} />
</Theme>,
);
}

describe("ChannelBackRow", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.channels = [ME, ENG];
mocks.isLoading = false;
useChannelPaneStore.setState({ pane: "channel" });
});

it("names the channel you're in", () => {
renderRow(ENG.id);
expect(screen.getByText("engineering")).toBeTruthy();
});

it("slides back to the channel list", async () => {
const user = userEvent.setup();
renderRow(ENG.id);

await user.click(screen.getByRole("button", { name: "Back to channels" }));

expect(useChannelPaneStore.getState().pane).toBe("list");
});

// #me can't be unstarred, but the well stays filled so the row doesn't
// change height (and everything below it shift) when you switch channels.
it("offers a star on shared channels only", () => {
renderRow(ENG.id);
expect(screen.getByRole("button", { name: "Star channel" })).toBeTruthy();

renderRow(ME.id);
expect(
screen.getAllByRole("button", { name: "Star channel" }),
).toHaveLength(1);
});

// A channel the project doesn't have must not read as one that's still
// loading, or the sidebar looks stuck rather than wrong.
it("says so when the channel can't be resolved", () => {
mocks.channels = [];
renderRow("gone");
expect(screen.getByText("Unavailable")).toBeTruthy();
});
});
109 changes: 109 additions & 0 deletions packages/ui/src/features/canvas/components/ChannelBackRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { CaretLeftIcon, StarIcon } from "@phosphor-icons/react";
import { Skeleton } from "@posthog/quill";
import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events";
import { channelGlyph } from "@posthog/ui/features/canvas/components/channelGlyph";
import { useChannelStarToggle } from "@posthog/ui/features/canvas/hooks/useChannelStars";
import {
type Channel,
useChannels,
} from "@posthog/ui/features/canvas/hooks/useChannels";
import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels";
import { showChannelList } from "@posthog/ui/features/canvas/stores/channelPaneStore";
import { Tooltip } from "@posthog/ui/primitives/Tooltip";
import { track } from "@posthog/ui/shell/analytics";

// An overlay rather than a sibling: the back button fills the row, and nesting
// the star inside it would be a button within a button.
function RowStar({ channel }: { channel: Channel }) {
const { isStarred, toggleStar } = useChannelStarToggle(channel);
return (
<button
type="button"
aria-label={isStarred ? "Unstar channel" : "Star channel"}
onClick={() => {
track(ANALYTICS_EVENTS.CHANNEL_ACTION, {
action_type: isStarred ? "unstar" : "star",
surface: "sidebar",
channel_id: channel.id,
});
toggleStar();
}}
// Parks in the row's reserved well: 8px padding + 6px gap = 14px from the
// right edge.
className="-translate-y-1/2 absolute top-1/2 right-[6px] flex size-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-fill-hover hover:text-foreground"
>
<StarIcon size={14} weight={isStarred ? "fill" : "regular"} />
</button>
);
}

/**
* The channel pane's header: the channel you're in, and the way back out of it.
*
* Clicking anywhere on the row slides the sidebar back to the channel list —
* the list is where switching happens, so this row only has to be the door to
* it. Leaving the channel scoped means the route (and the main pane) stay put.
*/
export function ChannelBackRow({ channelId }: { channelId: string }) {
const { channels, isLoading } = useChannels();
const current = channels.find((c) => c.id === channelId);
const showStar = current != null && current.name !== PERSONAL_CHANNEL_NAME;

return (
<div className="relative mx-2 mt-1">
<Tooltip content="Back to channels" side="bottom">
<button
type="button"
aria-label="Back to channels"
onClick={() => {
track(ANALYTICS_EVENTS.CHANNEL_ACTION, {
action_type: "browse_channels",
surface: "sidebar",
channel_id: channelId,
});
showChannelList();
}}
// Fixed height with an unconditional star well: sized off its
// contents, a starrable channel ran 4px taller than #me and
// everything below shifted on switch. No border — it's a row in the
// sidebar like the ones under it, not a control sitting on top.
className="flex h-8 w-full items-center gap-1.5 rounded-md px-2 text-left transition-colors hover:bg-fill-hover"
>
<CaretLeftIcon
size={12}
className="shrink-0 text-muted-foreground"
weight="bold"
/>
<span className="flex w-4 shrink-0 items-center justify-center">
{channelGlyph(current?.name, {
size: 14,
className: "text-muted-foreground",
})}
</span>
<span className="min-w-0 flex-1 truncate font-semibold text-[13px] text-foreground">
{current ? (
current.name
) : isLoading ? (
// A placeholder word here would read as a real channel named
// "channel"; a skeleton says "still loading" honestly.
<Skeleton className="h-3.5 w-24" />
) : (
"Unavailable"
)}
</span>
<span aria-hidden className="size-6 shrink-0" />
</button>
</Tooltip>
{showStar && current && <RowStar channel={current} />}
{/* Inert star for #me, so the row reads the same on every channel. */}
{current && !showStar && (
<span
aria-hidden
className="-translate-y-1/2 pointer-events-none absolute top-1/2 right-[6px] flex size-6 items-center justify-center text-muted-foreground opacity-40"
>
<StarIcon size={14} weight="fill" />
</span>
)}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { HashIcon } from "@phosphor-icons/react";
import {
Button,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@posthog/quill";
import { channelGlyph } from "@posthog/ui/features/canvas/components/channelGlyph";
import { HeaderTitleEditor } from "@posthog/ui/features/task-detail/HeaderTitleEditor";
import { Flex, Text } from "@radix-ui/themes";
import { useNavigate } from "@tanstack/react-router";
Expand Down Expand Up @@ -52,7 +52,10 @@ export function ChannelBreadcrumb({

const channelSegment = (
<>
<HashIcon size={12} className="mt-px shrink-0 text-muted-foreground/80" />
{channelGlyph(channelName, {
size: 12,
className: "mt-px shrink-0 text-muted-foreground/80",
})}
<Text
className="min-w-0 truncate whitespace-nowrap font-medium text-[13px]"
title={channelName}
Expand Down
Loading
Loading