Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
491440e
docs: add spaces layout prototype plan
puemos Jul 25, 2026
63a6cca
feat(canvas): spaces layout prototype (Arc-style spaces + task Activi…
puemos Jul 25, 2026
f933f9d
fix(canvas): spaces feedback — channel pickers, me-first space, hover…
puemos Jul 25, 2026
f0b1871
fix(canvas): in-place channel browsing and Arc-style draft new space
puemos Jul 25, 2026
21fb493
fix(canvas): dot-row polish — emoji context menu, one-per-gesture swi…
puemos Jul 25, 2026
8c272d1
fix(canvas): real hover card for sidebar items, no duplicate tooltip
puemos Jul 25, 2026
f3b5861
feat(canvas): recent-section search/filter and task-style hover actions
puemos Jul 25, 2026
c00dc13
fix(canvas): me-space ownership, full task history in Timeline, gestu…
puemos Jul 25, 2026
e96ddc7
fix(canvas): clamp space cycling, snappier swipe re-arm, native filte…
puemos Jul 25, 2026
50784f9
fix(canvas): move New task below the space nav
puemos Jul 25, 2026
ad3c478
fix(canvas): re-arm swipe on new intent bursts mid-inertia
puemos Jul 25, 2026
2cd92f5
fix(canvas): remove the Configure gear from the sidebar footer
puemos Jul 25, 2026
5d8bb23
fix(canvas): show teammates' tasks in space lists, stop multi-space s…
puemos Jul 25, 2026
c011034
fix(canvas): source space tasks from the channel feed so all of them …
puemos Jul 25, 2026
714c7e3
fix(canvas): dead-simple one-swipe-one-space gate
puemos Jul 25, 2026
7defeac
fix(canvas): refine Timeline rows, pin channel on browse-click, harde…
puemos Jul 25, 2026
f84e59b
fix(canvas): all-channels is a preview directory, not a pin/space action
puemos Jul 25, 2026
f53bee2
fix(canvas): unify Activity timeline rows to one shape
puemos Jul 25, 2026
af4d86a
feat(canvas): collect run-uploaded files in the task Artifacts tab
puemos Jul 25, 2026
d48c75a
feat(canvas): timeline rail (direction A) + curate Artifacts to PRs/c…
puemos Jul 25, 2026
642a132
feat(canvas): move account menu to a compact avatar in the title bar
puemos Jul 25, 2026
c623016
fix(canvas): show current project name in the compact account trigger
puemos Jul 25, 2026
c170d7b
fix(canvas): label Activity/Inbox, align account pill with sibling bu…
puemos Jul 25, 2026
20e19e6
fix(canvas): left-align title-bar search, pad account avatar
puemos Jul 25, 2026
70bd9bf
fix(canvas): align title-bar search left edge with the content pane
puemos Jul 25, 2026
b4f0185
fix(task-detail): never return undefined from useRefreshedTask
puemos Jul 25, 2026
de1cbf5
fix(canvas): drop New canvas from + menu, stop Soon badge clipping
puemos Jul 25, 2026
ab69ec7
feat: gate the Spaces layout behind code-spaces-layout, with analytics
puemos Jul 25, 2026
8a84de1
fix(canvas): read the spaces-layout flag from PostHog in dev too
puemos Jul 25, 2026
975ef6c
chore(canvas): tighten comments, split ActivityPanel/SpaceSidebar, dr…
puemos Jul 25, 2026
e375382
chore(canvas): cut more low-value comments in sidebar/useSpaces
puemos Jul 25, 2026
bef32f2
refactor(canvas): channels, not spaces — switcher popover, sidebar na…
puemos Jul 25, 2026
04269bc
refactor(canvas): channel switcher — always #, bordered trigger, tigh…
puemos Jul 25, 2026
86273b9
feat(canvas): sidebar polish — account back in sidebar, ⌘1-4 starred …
puemos Jul 25, 2026
ace3d2e
feat(canvas): ⌘0 opens #me, starred channels extend to ⌘9
puemos Jul 25, 2026
57e83cb
feat(canvas): star/unstar in switcher rows + channel header, Feed nav…
puemos Jul 25, 2026
66cf620
feat(canvas): Arc-style nav tiles with Command Center, switcher star …
puemos Jul 25, 2026
c6ce91b
fix(canvas): fixed-size captioned nav tiles, star inside switcher, po…
puemos Jul 25, 2026
5dc6b0e
design(canvas): 2x2 labelled launcher pills, New task split out below…
puemos Jul 25, 2026
ce3559b
design(canvas): search moves to the title bar, nav collapses to one i…
puemos Jul 25, 2026
4b6b4db
fix(canvas): stable switcher height, star on #me, elevated New task, …
puemos Jul 25, 2026
569d337
design(canvas): New task becomes a floating action button over the ch…
puemos Jul 25, 2026
5e491de
feat(canvas): FAB opens a New task / New canvas menu
puemos Jul 25, 2026
a0c7aae
fix(sidebar): raise minimum width so the title bar strip breathes, se…
puemos Jul 25, 2026
b395fce
fix(canvas): creating a task inside a channel keeps it in that channel
puemos Jul 25, 2026
975ecc8
design(canvas): dim the app behind the open channel switcher
puemos Jul 25, 2026
3789e7e
refactor(canvas): use Base UI's popover backdrop for the switcher dim…
puemos Jul 25, 2026
b58f9ed
chore(canvas): trim comments, drop dead compact switcher, align empty…
puemos Jul 25, 2026
dd7aaea
fix(sidebar): keep the flag-off layout unchanged, and cover it with a…
puemos Jul 25, 2026
6673b94
feat(canvas): lock glyph for private channels, and theme-token colour…
puemos Jul 25, 2026
89657e3
fix(channels): make the spaces layout production-ready
puemos Jul 25, 2026
2c0c0c5
fix(channels): preserve channel and PR selection
puemos Jul 25, 2026
f16d664
fix(channels): keep personal items private while loading
puemos Jul 25, 2026
f5d9f5c
refactor(panels): keep terminal creation direct
puemos Jul 25, 2026
d239704
fix(analytics): track channels space re-entry
puemos Jul 25, 2026
7c00148
test(channels): focus item coverage on behavior
puemos Jul 25, 2026
9c0f299
chore(channels): trim narrative comments
puemos Jul 25, 2026
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
5 changes: 5 additions & 0 deletions apps/code/src/renderer/platform-adapters/auth-side-effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
} from "@posthog/ui/features/auth/authQueries";
import { useAuthUiStateStore } from "@posthog/ui/features/auth/authUiStateStore";
import type { IAuthSideEffects } from "@posthog/ui/features/auth/identifiers";
import { resetCurrentChannel } from "@posthog/ui/features/canvas/stores/currentChannelStore";
import { useOnboardingStore } from "@posthog/ui/features/onboarding/onboardingStore";
import { resetSessionService } from "@posthog/ui/features/sessions/sessionServiceHost";
import { openTaskInput } from "@posthog/ui/router/useOpenTask";
Expand All @@ -30,6 +31,9 @@ export class RendererAuthSideEffects implements IAuthSideEffects {
onProjectSelected(): void {
clearAuthScopedQueries();
void refreshAuthStateQuery();
// Before openTaskInput, which files a new task into the scoped channel —
// a channel id from the project we just left.
resetCurrentChannel();
openTaskInput();
}

Expand All @@ -40,6 +44,7 @@ export class RendererAuthSideEffects implements IAuthSideEffects {
if (previousRegion) {
useAuthUiStateStore.getState().setStaleRegion(previousRegion);
}
resetCurrentChannel();
openTaskInput();
useOnboardingStore.getState().resetSelections();
}
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/web-auth-side-effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
} from "@posthog/ui/features/auth/authQueries";
import { useAuthUiStateStore } from "@posthog/ui/features/auth/authUiStateStore";
import type { IAuthSideEffects } from "@posthog/ui/features/auth/identifiers";
import { resetCurrentChannel } from "@posthog/ui/features/canvas/stores/currentChannelStore";
import { useOnboardingStore } from "@posthog/ui/features/onboarding/onboardingStore";
import { openTaskInput } from "@posthog/ui/router/useOpenTask";
import { injectable } from "inversify";
Expand All @@ -24,6 +25,9 @@ export class WebAuthSideEffects implements IAuthSideEffects {
onProjectSelected(): void {
clearAuthScopedQueries();
void refreshAuthStateQuery();
// Before openTaskInput, which files a new task into the scoped channel —
// a channel id from the project we just left.
resetCurrentChannel();
openTaskInput();
}

Expand All @@ -32,6 +36,7 @@ export class WebAuthSideEffects implements IAuthSideEffects {
if (previousRegion) {
useAuthUiStateStore.getState().setStaleRegion(previousRegion);
}
resetCurrentChannel();
openTaskInput();
useOnboardingStore.getState().resetSelections();
}
Expand Down
211 changes: 211 additions & 0 deletions packages/core/src/canvas/channelItems.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import type { Task, UserBasic } from "@posthog/shared/domain-types";
import { describe, expect, it } from "vitest";
import {
buildChannelItems,
type ChannelItemModel,
filterChannelItems,
} from "./channelItems";
import type { DashboardSummary } from "./dashboardSchemas";

const ME: UserBasic = {
id: 1,
uuid: "me-uuid",
distinct_id: "me",
first_name: "Ada",
last_name: "Lovelace",
email: "ada@posthog.com",
};

const OTHER: UserBasic = {
id: 2,
uuid: "other-uuid",
distinct_id: "other",
first_name: "Grace",
last_name: "Hopper",
email: "grace@posthog.com",
};

function canvas(over: Partial<DashboardSummary> = {}): DashboardSummary {
return {
id: "d1",
channelId: "c1",
name: "Canvas",
templateId: "freeform",
createdAt: 0,
updatedAt: 1_000,
...over,
} as DashboardSummary;
}

function task(over: Partial<Task> = {}): Task {
return {
id: "t1",
title: "Task",
updated_at: new Date(2_000).toISOString(),
created_by: ME,
...over,
} as Task;
}

const NONE: ReadonlySet<string> = new Set();

function build(options: Partial<Parameters<typeof buildChannelItems>[0]> = {}) {
return buildChannelItems({
dashboards: [],
feedTasks: [],
archivedTaskIds: NONE,
pinnedTaskIds: NONE,
ownedBy: null,
...options,
});
}

describe("buildChannelItems", () => {
it("merges canvases and tasks newest-first", () => {
const items = build({
dashboards: [canvas({ id: "old", updatedAt: 1_000 })],
feedTasks: [
task({ id: "new", updated_at: new Date(5_000).toISOString() }),
],
});
expect(items.map((i) => i.key)).toEqual(["task:new", "canvas:old"]);
});

it("drops archived tasks but keeps canvases", () => {
const items = build({
dashboards: [canvas()],
feedTasks: [task({ id: "gone" })],
archivedTaskIds: new Set(["gone"]),
});
expect(items.map((i) => i.kind)).toEqual(["canvas"]);
});

it("marks pinned state from each source's own signal", () => {
const items = build({
dashboards: [canvas({ id: "pinned-canvas", pinnedAt: 42 })],
feedTasks: [task({ id: "pinned-task" })],
pinnedTaskIds: new Set(["pinned-task"]),
});
expect(items.every((i) => i.pinned)).toBe(true);
});

it("falls back to a placeholder title for untitled tasks", () => {
const [item] = build({
feedTasks: [task({ title: "" })],
});
expect(item.title).toBe("Untitled task");
});

it("treats an unparseable updated_at as epoch rather than NaN", () => {
const [item] = build({
feedTasks: [task({ updated_at: "not a date" })],
});
expect(item.ts).toBe(0);
});

it("returns everything when the owner is unknown", () => {
const items = build({
dashboards: [canvas({ createdBy: "Grace Hopper" })],
feedTasks: [task({ created_by: OTHER })],
});
expect(items).toHaveLength(2);
});

it("filters to the owner for the personal channel", () => {
const items = build({
dashboards: [
canvas({ id: "mine", createdBy: "Ada Lovelace" }),
canvas({ id: "theirs", createdBy: "Grace Hopper" }),
],
feedTasks: [
task({ id: "mine-task", created_by: ME }),
task({ id: "their-task", created_by: OTHER }),
],
ownedBy: { uuid: ME.uuid, name: "Ada Lovelace" },
});
expect(items.map((i) => i.id).sort()).toEqual(["mine", "mine-task"]);
});

it("keeps items whose author is unknown", () => {
const items = build({
dashboards: [canvas({ id: "orphan", createdBy: undefined })],
feedTasks: [task({ id: "orphan-task", created_by: null })],
ownedBy: { uuid: ME.uuid, name: "Ada Lovelace" },
});
expect(items).toHaveLength(2);
});
});

function model(over: Partial<ChannelItemModel> = {}): ChannelItemModel {
return {
key: "task:t1",
kind: "task",
id: "t1",
title: "Ship the thing",
ts: 0,
pinned: false,
rawStatus: null,
authorUser: ME,
authorName: null,
templateId: null,
...over,
};
}

describe("filterChannelItems", () => {
const me = { uuid: ME.uuid, name: "Ada Lovelace" };

it("matches titles case-insensitively", () => {
const items = [model({ title: "Ship IT" }), model({ title: "Other" })];
const result = filterChannelItems(items, {
query: " ship ",
createdBy: "anyone",
status: null,
me,
});
expect(result.map((i) => i.title)).toEqual(["Ship IT"]);
});

it.each([
["me", ["mine"]],
["others", ["theirs"]],
["anyone", ["mine", "theirs"]],
] as const)("filters createdBy=%s", (createdBy, expected) => {
const items = [
model({ id: "mine", authorUser: ME }),
model({ id: "theirs", authorUser: OTHER }),
];
const result = filterChannelItems(items, {
query: "",
createdBy,
status: null,
me,
});
expect(result.map((i) => i.id)).toEqual(expected);
});

it("filters by run status, including not_started", () => {
const items = [
model({ id: "fresh", rawStatus: "not_started" }),
model({ id: "done", rawStatus: "completed" }),
];
const result = filterChannelItems(items, {
query: "",
createdBy: "anyone",
status: "not_started",
me,
});
expect(result.map((i) => i.id)).toEqual(["fresh"]);
});

it("excludes canvases when a run status is selected", () => {
const items = [model({ kind: "canvas", rawStatus: null })];
const result = filterChannelItems(items, {
query: "",
createdBy: "anyone",
status: "completed",
me,
});
expect(result).toEqual([]);
});
});
115 changes: 115 additions & 0 deletions packages/core/src/canvas/channelItems.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import type {
Task,
TaskRunStatus,
UserBasic,
} from "@posthog/shared/domain-types";
import type { DashboardSummary } from "./dashboardSchemas";

export interface ChannelItemModel {
key: string;
kind: "task" | "canvas";
id: string;
title: string;
ts: number;
pinned: boolean;
rawStatus: TaskRunStatus | null;
authorUser: UserBasic | null;
authorName: string | null;
templateId: string | null;
}

export interface ChannelItemOwner {
uuid: string | null;
name: string | null;
}

function isOwnedBy(
item: Pick<ChannelItemModel, "authorUser" | "authorName">,
owner: ChannelItemOwner,
): boolean {
if (item.authorUser) return item.authorUser.uuid === owner.uuid;
if (item.authorName && owner.name) return item.authorName === owner.name;
return true;
}

export function buildChannelItems({
dashboards,
feedTasks,
archivedTaskIds,
pinnedTaskIds,
ownedBy,
}: {
dashboards: readonly DashboardSummary[];
feedTasks: readonly Task[];
archivedTaskIds: ReadonlySet<string>;
pinnedTaskIds: ReadonlySet<string>;
ownedBy: ChannelItemOwner | null;
}): ChannelItemModel[] {
const canvasItems: ChannelItemModel[] = dashboards.map((d) => ({
key: `canvas:${d.id}`,
kind: "canvas",
id: d.id,
title: d.name,
ts: d.updatedAt,
pinned: d.pinnedAt != null,
rawStatus: null,
authorUser: null,
authorName: d.createdBy ?? null,
templateId: d.templateId,
}));

const taskItems: ChannelItemModel[] = feedTasks.flatMap((task) =>
archivedTaskIds.has(task.id)
? []
: [
{
key: `task:${task.id}`,
kind: "task" as const,
id: task.id,
title: task.title || "Untitled task",
ts: Date.parse(task.updated_at) || 0,
pinned: pinnedTaskIds.has(task.id),
rawStatus: task.latest_run?.status ?? null,
authorUser: task.created_by ?? null,
authorName: null,
templateId: null,
},
],
);

const all = [...canvasItems, ...taskItems].sort((a, b) => b.ts - a.ts);
return ownedBy ? all.filter((item) => isOwnedBy(item, ownedBy)) : all;
}

export type CreatedByFilter = "anyone" | "me" | "others";

export function filterChannelItems(
items: readonly ChannelItemModel[],
{
query,
createdBy,
status,
me,
}: {
query: string;
createdBy: CreatedByFilter;
status: TaskRunStatus | null;
me: ChannelItemOwner;
},
): ChannelItemModel[] {
const normalizedQuery = query.trim().toLowerCase();
return items.filter((item) => {
if (
normalizedQuery &&
!item.title.toLowerCase().includes(normalizedQuery)
) {
return false;
}
if (createdBy !== "anyone") {
const mine = isOwnedBy(item, me);
if (createdBy === "me" ? !mine : mine) return false;
}
if (status && item.rawStatus !== status) return false;
return true;
});
}
Loading
Loading