Skip to content
Merged
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
48 changes: 24 additions & 24 deletions apps/code/snapshots.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,30 +72,6 @@ snapshots:
hash: v1.k4693efd2.1d199b2c4bba8034cb18fb5b49866b64a6eb3311591add5f58ec288710dce2e9.FCc9egSaO1Onih417yciSYKNROe8zqDZyyS6xuXCZGw
billing-usagemeter--zero-spend-limit--light:
hash: v1.k4693efd2.b50c18736bbde45fc89f4c0ac7fd616286e5c1ecc2a56910fffca13eaa13d8a2.31z4F0JmvMeR4yqk5pjs_OQ3MypnM2miZI9al4wuc8c
channels-taskfeedrow--agent-origin--dark:
hash: v1.k4693efd2.82f8c70a399c9ea768201933e202fcc2fde74332c5153aeabf6835ace79beee6.-SVboDRIZ_-nh4PtMliCv7e7iCtdUm-1m2xQ9Xzcdfw
channels-taskfeedrow--agent-origin--light:
hash: v1.k4693efd2.4c1db3470d5ec9b4db9872d0e048314d0078979e8fbdb602b8c648ba70d7209f._KPUvj46p33u7A4Xndz-git-4rp7VKca1MpeTztlB6w
channels-taskfeedrow--human-email-only--dark:
hash: v1.k4693efd2.0f4dece4643b7375b77746347658f4fe65b37817cdfa9ad1ba697067e619f428.f0jKZ4OYqsmeJjXffjt-amK9cwE81JF0lgSMqtn37PA
channels-taskfeedrow--human-email-only--light:
hash: v1.k4693efd2.14db0510f7ea5c57985cf0cb60ab08f5e146c7243eeb1b0b963f74e85c7d60f4.ijHQLK0lZjjUlOQuzYzisImU9DWsNmZM7i5GYfWU4E0
channels-taskfeedrow--human-started--dark:
hash: v1.k4693efd2.e0abdae2e8ac2ef29793ea7afc2a2580c6610e07a3c640ad782af7fd3c12592a.YUgF_BTPzZoqnWXvyLBRs3w9C_P3Yfh4-cP4mUqs2NQ
channels-taskfeedrow--human-started--light:
hash: v1.k4693efd2.84c26deb1a587fe061238b3982b555167575893bacc9cd2667d4d3f74646261f.abqc0Voe6FIQFflzDJTv1KewcZ4XxcDz1a0mFlJu7oM
channels-taskfeedrow--long-prompt--dark:
hash: v1.k4693efd2.876d34660bc267af79d39a971a681ba279a870061dba10b905412112c2a5e4dd.rSw1X068udMs8Arglu_WgX5RL8WZlpAc8_kVONPQhF4
channels-taskfeedrow--long-prompt--light:
hash: v1.k4693efd2.d0d6e4b6bfa257c3f46d991777f72c345437b0be2ee16a182fa925d3ece7dc9e.6PGeOlMxVauJSQia0FAIirzaYio79-I7H4x-9LvEUbw
channels-taskfeedrow--no-prompt--dark:
hash: v1.k4693efd2.9fa967f1a9acdeba0c50a9e45ae649f118ee26938037dbefd1bb0577067b03d4.va1lGsscqLW86-5yCKc3H6pQ8Az7_HBItkgGTnTQiYU
channels-taskfeedrow--no-prompt--light:
hash: v1.k4693efd2.a02339aecdb6fc327fdf6586e28ceeebf8490442fe217eb8bb46f1ce66bff78a.2l7XdvdW3D0GlLmPwDdTFgjZWdav68bZX_LYResVKwo
channels-taskfeedrow--no-starter--dark:
hash: v1.k4693efd2.66fff212f14afa9dd3bc532698a042383014a0cabd4331d30eff878148e1773d.RiectZxFTqk1husLYI-UV5ko1uZ4lpbjWstE7Hy3MJE
channels-taskfeedrow--no-starter--light:
hash: v1.k4693efd2.9ee4ad64ed7d2c5c62d0cd682d903b09da643d462d49aae0858afa500e92256b.OhGj64l-o7iWtxzJo1qt0A_CU9ME0ZRr_5e455RGUJQ
components-permissions-permissionselector--create-new-file--dark:
hash: v1.k4693efd2.c54203a4e636b83b3d24d7ed9c4ace8659db87cd8f231d9dc2ecc03320e31646.epDm7LebiLzlp0uuZBrE-Obt_anAn0xsE8bHFnm5vos
components-permissions-permissionselector--create-new-file--light:
Expand Down Expand Up @@ -708,6 +684,30 @@ snapshots:
hash: v1.k4693efd2.cfff4c6bbb0acef1c9941b21d2f69c24b234c2c206282acf6fcada0cb2bd2386.xILjVPp8ecQmGbPjGDdhbRyi9KFPpA_1nfYF3bfjwuc
skill-buttons-skillbuttonsmenu--default--light:
hash: v1.k4693efd2.eb9bd49b9700641f6f3c90653447c9767c52e997afec9843e8649b90752faad3.VdxEtBwtkU3ioy2evcNbrEKxPjQ1GDUpZAQzFe-Uho4
spaces-taskfeedrow--agent-origin--dark:
hash: v1.k4693efd2.82f8c70a399c9ea768201933e202fcc2fde74332c5153aeabf6835ace79beee6.yEZq_qnKxItv5u0Ydcqa1B4VCwv8lwXUvcaFScE2XO0
spaces-taskfeedrow--agent-origin--light:
hash: v1.k4693efd2.4c1db3470d5ec9b4db9872d0e048314d0078979e8fbdb602b8c648ba70d7209f.wt_Ufkw30jHFnxEMFXE0wQG8ignuCURuUUYIvNMnJ6U
spaces-taskfeedrow--human-email-only--dark:
hash: v1.k4693efd2.611794921d5c6270a211f4a6bc3d91fd63a74a58f0c905a79271cbc7d9c73eb8.balV69yNKs9biJzweJYwpk5e1UjZ11fFersE9UMhvEU
spaces-taskfeedrow--human-email-only--light:
hash: v1.k4693efd2.961288ce410b6376bab92428ad148b881c768e9edea4821db4818d13682f50c4.79pzkpuXwARCmUS4HtZRvb2n7dM8HUZZs-ErIXX4wwg
spaces-taskfeedrow--human-started--dark:
hash: v1.k4693efd2.6e1cf0720234b1c64ba04f6f3af965d32438551771bc3680c4391bb8463b8eee.4O-iWXxbDv-L9MsuPt6KoDvK7C95R0Jj6LTYjDwwEhk
spaces-taskfeedrow--human-started--light:
hash: v1.k4693efd2.7156c1e5b2359b5f792529aa9041ad9277842d51c3423bda4f0c029c58dcef28.PF-JYrCTfHxEzFG9dnabJxDYS47wQXnxurx8Ev_LfkY
spaces-taskfeedrow--long-prompt--dark:
hash: v1.k4693efd2.00acb2487749ee5a1db2c95da8ae0e2fb2de0815d653973e60c7ceb3ff808135.pdV864kTAUM1IgcuME846VwInafp3f-WFFxxxE4H_oY
spaces-taskfeedrow--long-prompt--light:
hash: v1.k4693efd2.8a6b372abdf9c60a558c59a703f8e7c8f65fcb55fd99e6c97c6eca23e6a76781.HbZ7kfuE60F5SWucdbYLhy98POhk8MPPX2TLA_r2hz8
spaces-taskfeedrow--no-prompt--dark:
hash: v1.k4693efd2.ff77770c9b5c656b213c139270322defa38306d714abeee6b5fe4e17e2056f88.KnYgUDb_jYEfH5aSoJbt1cwyJXAfDo_eKmIfn0fxjjc
spaces-taskfeedrow--no-prompt--light:
hash: v1.k4693efd2.d3d755ee7ea6eccbed93ea18e6dcbccbd9f77f019f5c153cb0278131b08c324f.l_KwHWHFTzODefftPGtlWsXLFi6tbH78ZRQeeY7X4Z0
spaces-taskfeedrow--no-starter--dark:
hash: v1.k4693efd2.66fff212f14afa9dd3bc532698a042383014a0cabd4331d30eff878148e1773d.-FvbS5RpndI7QXNTpQhbQShDktPrOV_jdcWDY4MUYF4
spaces-taskfeedrow--no-starter--light:
hash: v1.k4693efd2.9ee4ad64ed7d2c5c62d0cd682d903b09da643d462d49aae0858afa500e92256b.lCc1Q5KBHSLnYp7N9k6HEBxDPjf0SblH9zDLHQ7dYo8
task-detail-continueclisessions--importing--dark:
hash: v1.k4693efd2.3196e21b9daa2f183c79eaf2c70a5775365dbee3f1d920c64927e20a0fadcbf9.FjHhjEX3urGbycQiDehA9JQ7H2zfyl1id9rDSVjrtoI
task-detail-continueclisessions--importing--light:
Expand Down
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
6 changes: 5 additions & 1 deletion packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
useChannelMutations,
useChannels,
} from "@posthog/ui/features/canvas/hooks/useChannels";
import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout";
import {
useDashboard,
useDashboards,
Expand Down Expand Up @@ -145,6 +146,7 @@ function isAppView(value: string): value is AppView {
}

export function BrowserTabStrip() {
const spacesLayout = useChannelsLayout();
const logger = useService<RootLogger>(ROOT_LOGGER);
const snapshot = useTabsSnapshot();
const navigate = useNavigate();
Expand Down Expand Up @@ -509,7 +511,8 @@ export function BrowserTabStrip() {
const meta = channelSectionFor(section);
return {
id: t.id,
label: meta?.label ?? channel ?? "Channel",
label:
meta?.label ?? channel ?? (spacesLayout ? "Space" : "Channel"),
icon: <HashIcon size={14} />,
channelName: channel,
// No section meta → the channel's index page.
Expand Down Expand Up @@ -545,6 +548,7 @@ export function BrowserTabStrip() {
params.taskId,
routeChannelSection,
routeAppView,
spacesLayout,
]);

// Navigate to a tab, tagging the history entry with its id so the switch is
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;
}
31 changes: 25 additions & 6 deletions packages/ui/src/features/canvas/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,25 +25,44 @@ The root `AGENTS.md` architecture rules still apply.
- **Suffix `…` on anything that opens another step.** A menu item or button whose
click opens a follow-up surface — a dialog, a nested menu, a picker, a
confirmation — gets a trailing ellipsis (`…`, the character, not three dots) to
signal it isn't the final action: `New…`, `Rename channel…`, `Delete channel…`,
signal it isn't the final action: `New…`, `Rename space…`, `Delete space…`,
`Choose a template…`. A label that performs its action immediately or navigates
straight to a destination gets **no** ellipsis (`Edit CONTEXT.md`, `Star
channel`). When in doubt: does clicking it ask for more input or confirmation
space`). When in doubt: does clicking it ask for more input or confirmation
before anything happens? If yes, add the `…`.

## Spaces & chrome

- Channels is a **top-level space** reached through the app rail (`AppNav`),
- Spaces is a **top-level space** reached through the app rail (`AppNav`),
gated behind `project-bluebird` and wired in `routes/__root.tsx`. The rail's
spaces are Code (`/code`), Inbox (`/inbox`), and Channels (`/website`).
- The Channels space has **its own chrome**: rail + a persistent channel-list
spaces are Code (`/code`), Inbox (`/inbox`), and Spaces (`/website`).
- The Spaces UI has **its own chrome**: rail + a persistent channel-list
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

- **`WebsiteLayout` renders its own top bar.** The Channels space has no code
- **`WebsiteLayout` renders its own top bar.** The Spaces UI has no code
`HeaderRow`, so breadcrumbs (and the dashboard controls) are a local bar inside
`WebsiteLayout`, not pushed through the header store.
- **A page does not get its own crumb — its H1 is the title.** A view that
Expand Down
9 changes: 6 additions & 3 deletions packages/ui/src/features/canvas/components/ActivityView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar";
import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser";
import { MentionText } from "@posthog/ui/features/canvas/components/MentionText";
import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels";
import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout";
import { useMarkTaskActivityRead } from "@posthog/ui/features/canvas/hooks/useMarkTaskActivityRead";
import { useTaskActivity } from "@posthog/ui/features/canvas/hooks/useTaskActivity";
import { normalizeChannelName } from "@posthog/ui/features/canvas/hooks/useTaskChannels";
Expand Down Expand Up @@ -222,6 +223,7 @@ function ActivityRow({
// in, or messaged in — newest activity first. Rows clear as they are opened, not
// when the page is; merely landing here shouldn't dismiss what you haven't read.
export function ActivityView() {
const spacesLayout = useChannelsLayout();
const client = useOptionalAuthenticatedClient();
const { data: currentUser } = useCurrentUser({ client });
const {
Expand Down Expand Up @@ -285,7 +287,8 @@ export function ActivityView() {
Activity
</Text>
<Text size="2" className="block text-muted-foreground">
Tasks you're involved in across channels.
Tasks you're involved in across{" "}
{spacesLayout ? "spaces" : "channels"}.
</Text>
</div>
{unreadCount > 0 && (
Expand Down Expand Up @@ -314,8 +317,8 @@ export function ActivityView() {
</EmptyMedia>
<EmptyTitle>No activity yet</EmptyTitle>
<EmptyDescription>
Tasks you create, get tagged in, or reply to across channels
land here.
Tasks you create, get tagged in, or reply to across{" "}
{spacesLayout ? "spaces" : "channels"} land here.
</EmptyDescription>
</EmptyHeader>
</Empty>
Expand Down
Loading
Loading