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

Commit e917958

Browse files
authored
feat(channels): add scoped channel foundations
Add the dormant channel identity, item-list, and task-routing foundations with flag definitions and project-safe cleanup. Visible layout activation remains upstack. Generated-By: PostHog Code Task-Id: 6190e713-9b80-43d9-a05c-5e3b1ecdf297
1 parent a3b8af9 commit e917958

22 files changed

Lines changed: 1361 additions & 7 deletions

apps/code/src/renderer/platform-adapters/auth-side-effects.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
} from "@posthog/ui/features/auth/authQueries";
77
import { useAuthUiStateStore } from "@posthog/ui/features/auth/authUiStateStore";
88
import type { IAuthSideEffects } from "@posthog/ui/features/auth/identifiers";
9+
import { resetCurrentChannel } from "@posthog/ui/features/canvas/stores/currentChannelStore";
910
import { useOnboardingStore } from "@posthog/ui/features/onboarding/onboardingStore";
1011
import { resetSessionService } from "@posthog/ui/features/sessions/sessionServiceHost";
1112
import { openTaskInput } from "@posthog/ui/router/useOpenTask";
@@ -30,6 +31,9 @@ export class RendererAuthSideEffects implements IAuthSideEffects {
3031
onProjectSelected(): void {
3132
clearAuthScopedQueries();
3233
void refreshAuthStateQuery();
34+
// Before openTaskInput, which files a new task into the scoped channel —
35+
// a channel id from the project we just left.
36+
resetCurrentChannel();
3337
openTaskInput();
3438
}
3539

@@ -40,6 +44,7 @@ export class RendererAuthSideEffects implements IAuthSideEffects {
4044
if (previousRegion) {
4145
useAuthUiStateStore.getState().setStaleRegion(previousRegion);
4246
}
47+
resetCurrentChannel();
4348
openTaskInput();
4449
useOnboardingStore.getState().resetSelections();
4550
}

apps/web/src/web-auth-side-effects.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
} from "@posthog/ui/features/auth/authQueries";
66
import { useAuthUiStateStore } from "@posthog/ui/features/auth/authUiStateStore";
77
import type { IAuthSideEffects } from "@posthog/ui/features/auth/identifiers";
8+
import { resetCurrentChannel } from "@posthog/ui/features/canvas/stores/currentChannelStore";
89
import { useOnboardingStore } from "@posthog/ui/features/onboarding/onboardingStore";
910
import { openTaskInput } from "@posthog/ui/router/useOpenTask";
1011
import { injectable } from "inversify";
@@ -24,6 +25,9 @@ export class WebAuthSideEffects implements IAuthSideEffects {
2425
onProjectSelected(): void {
2526
clearAuthScopedQueries();
2627
void refreshAuthStateQuery();
28+
// Before openTaskInput, which files a new task into the scoped channel —
29+
// a channel id from the project we just left.
30+
resetCurrentChannel();
2731
openTaskInput();
2832
}
2933

@@ -32,6 +36,7 @@ export class WebAuthSideEffects implements IAuthSideEffects {
3236
if (previousRegion) {
3337
useAuthUiStateStore.getState().setStaleRegion(previousRegion);
3438
}
39+
resetCurrentChannel();
3540
openTaskInput();
3641
useOnboardingStore.getState().resetSelections();
3742
}
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import type { Task, UserBasic } from "@posthog/shared/domain-types";
2+
import { describe, expect, it } from "vitest";
3+
import {
4+
buildChannelItems,
5+
type ChannelItemModel,
6+
filterChannelItems,
7+
} from "./channelItems";
8+
import type { DashboardSummary } from "./dashboardSchemas";
9+
10+
const ME: UserBasic = {
11+
id: 1,
12+
uuid: "me-uuid",
13+
distinct_id: "me",
14+
first_name: "Ada",
15+
last_name: "Lovelace",
16+
email: "ada@posthog.com",
17+
};
18+
19+
const OTHER: UserBasic = {
20+
id: 2,
21+
uuid: "other-uuid",
22+
distinct_id: "other",
23+
first_name: "Grace",
24+
last_name: "Hopper",
25+
email: "grace@posthog.com",
26+
};
27+
28+
function canvas(over: Partial<DashboardSummary> = {}): DashboardSummary {
29+
return {
30+
id: "d1",
31+
channelId: "c1",
32+
name: "Canvas",
33+
templateId: "freeform",
34+
createdAt: 0,
35+
updatedAt: 1_000,
36+
...over,
37+
} as DashboardSummary;
38+
}
39+
40+
function task(over: Partial<Task> = {}): Task {
41+
return {
42+
id: "t1",
43+
title: "Task",
44+
updated_at: new Date(2_000).toISOString(),
45+
created_by: ME,
46+
...over,
47+
} as Task;
48+
}
49+
50+
const NONE: ReadonlySet<string> = new Set();
51+
52+
function build(options: Partial<Parameters<typeof buildChannelItems>[0]> = {}) {
53+
return buildChannelItems({
54+
dashboards: [],
55+
feedTasks: [],
56+
archivedTaskIds: NONE,
57+
pinnedTaskIds: NONE,
58+
ownedBy: null,
59+
...options,
60+
});
61+
}
62+
63+
describe("buildChannelItems", () => {
64+
it("merges canvases and tasks newest-first", () => {
65+
const items = build({
66+
dashboards: [canvas({ id: "old", updatedAt: 1_000 })],
67+
feedTasks: [
68+
task({ id: "new", updated_at: new Date(5_000).toISOString() }),
69+
],
70+
});
71+
expect(items.map((i) => i.key)).toEqual(["task:new", "canvas:old"]);
72+
});
73+
74+
it("drops archived tasks but keeps canvases", () => {
75+
const items = build({
76+
dashboards: [canvas()],
77+
feedTasks: [task({ id: "gone" })],
78+
archivedTaskIds: new Set(["gone"]),
79+
});
80+
expect(items.map((i) => i.kind)).toEqual(["canvas"]);
81+
});
82+
83+
it("marks pinned state from each source's own signal", () => {
84+
const items = build({
85+
dashboards: [canvas({ id: "pinned-canvas", pinnedAt: 42 })],
86+
feedTasks: [task({ id: "pinned-task" })],
87+
pinnedTaskIds: new Set(["pinned-task"]),
88+
});
89+
expect(items.every((i) => i.pinned)).toBe(true);
90+
});
91+
92+
it("falls back to a placeholder title for untitled tasks", () => {
93+
const [item] = build({
94+
feedTasks: [task({ title: "" })],
95+
});
96+
expect(item.title).toBe("Untitled task");
97+
});
98+
99+
it("treats an unparseable updated_at as epoch rather than NaN", () => {
100+
const [item] = build({
101+
feedTasks: [task({ updated_at: "not a date" })],
102+
});
103+
expect(item.ts).toBe(0);
104+
});
105+
106+
it("returns everything when the owner is unknown", () => {
107+
const items = build({
108+
dashboards: [canvas({ createdBy: "Grace Hopper" })],
109+
feedTasks: [task({ created_by: OTHER })],
110+
});
111+
expect(items).toHaveLength(2);
112+
});
113+
114+
it("filters to the owner for the personal channel", () => {
115+
const items = build({
116+
dashboards: [
117+
canvas({ id: "mine", createdBy: "Ada Lovelace" }),
118+
canvas({ id: "theirs", createdBy: "Grace Hopper" }),
119+
],
120+
feedTasks: [
121+
task({ id: "mine-task", created_by: ME }),
122+
task({ id: "their-task", created_by: OTHER }),
123+
],
124+
ownedBy: { uuid: ME.uuid, name: "Ada Lovelace" },
125+
});
126+
expect(items.map((i) => i.id).sort()).toEqual(["mine", "mine-task"]);
127+
});
128+
129+
it("keeps items whose author is unknown", () => {
130+
const items = build({
131+
dashboards: [canvas({ id: "orphan", createdBy: undefined })],
132+
feedTasks: [task({ id: "orphan-task", created_by: null })],
133+
ownedBy: { uuid: ME.uuid, name: "Ada Lovelace" },
134+
});
135+
expect(items).toHaveLength(2);
136+
});
137+
});
138+
139+
function model(over: Partial<ChannelItemModel> = {}): ChannelItemModel {
140+
return {
141+
key: "task:t1",
142+
kind: "task",
143+
id: "t1",
144+
title: "Ship the thing",
145+
ts: 0,
146+
pinned: false,
147+
rawStatus: null,
148+
authorUser: ME,
149+
authorName: null,
150+
templateId: null,
151+
...over,
152+
};
153+
}
154+
155+
describe("filterChannelItems", () => {
156+
const me = { uuid: ME.uuid, name: "Ada Lovelace" };
157+
158+
it("matches titles case-insensitively", () => {
159+
const items = [model({ title: "Ship IT" }), model({ title: "Other" })];
160+
const result = filterChannelItems(items, {
161+
query: " ship ",
162+
createdBy: "anyone",
163+
status: null,
164+
me,
165+
});
166+
expect(result.map((i) => i.title)).toEqual(["Ship IT"]);
167+
});
168+
169+
it.each([
170+
["me", ["mine"]],
171+
["others", ["theirs"]],
172+
["anyone", ["mine", "theirs"]],
173+
] as const)("filters createdBy=%s", (createdBy, expected) => {
174+
const items = [
175+
model({ id: "mine", authorUser: ME }),
176+
model({ id: "theirs", authorUser: OTHER }),
177+
];
178+
const result = filterChannelItems(items, {
179+
query: "",
180+
createdBy,
181+
status: null,
182+
me,
183+
});
184+
expect(result.map((i) => i.id)).toEqual(expected);
185+
});
186+
187+
it("filters by run status, including not_started", () => {
188+
const items = [
189+
model({ id: "fresh", rawStatus: "not_started" }),
190+
model({ id: "done", rawStatus: "completed" }),
191+
];
192+
const result = filterChannelItems(items, {
193+
query: "",
194+
createdBy: "anyone",
195+
status: "not_started",
196+
me,
197+
});
198+
expect(result.map((i) => i.id)).toEqual(["fresh"]);
199+
});
200+
201+
it("excludes canvases when a run status is selected", () => {
202+
const items = [model({ kind: "canvas", rawStatus: null })];
203+
const result = filterChannelItems(items, {
204+
query: "",
205+
createdBy: "anyone",
206+
status: "completed",
207+
me,
208+
});
209+
expect(result).toEqual([]);
210+
});
211+
});
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import type {
2+
Task,
3+
TaskRunStatus,
4+
UserBasic,
5+
} from "@posthog/shared/domain-types";
6+
import type { DashboardSummary } from "./dashboardSchemas";
7+
8+
export interface ChannelItemModel {
9+
key: string;
10+
kind: "task" | "canvas";
11+
id: string;
12+
title: string;
13+
ts: number;
14+
pinned: boolean;
15+
rawStatus: TaskRunStatus | null;
16+
authorUser: UserBasic | null;
17+
authorName: string | null;
18+
templateId: string | null;
19+
}
20+
21+
export interface ChannelItemOwner {
22+
uuid: string | null;
23+
name: string | null;
24+
}
25+
26+
function isOwnedBy(
27+
item: Pick<ChannelItemModel, "authorUser" | "authorName">,
28+
owner: ChannelItemOwner,
29+
): boolean {
30+
if (item.authorUser) return item.authorUser.uuid === owner.uuid;
31+
if (item.authorName && owner.name) return item.authorName === owner.name;
32+
return true;
33+
}
34+
35+
export function buildChannelItems({
36+
dashboards,
37+
feedTasks,
38+
archivedTaskIds,
39+
pinnedTaskIds,
40+
ownedBy,
41+
}: {
42+
dashboards: readonly DashboardSummary[];
43+
feedTasks: readonly Task[];
44+
archivedTaskIds: ReadonlySet<string>;
45+
pinnedTaskIds: ReadonlySet<string>;
46+
ownedBy: ChannelItemOwner | null;
47+
}): ChannelItemModel[] {
48+
const canvasItems: ChannelItemModel[] = dashboards.map((d) => ({
49+
key: `canvas:${d.id}`,
50+
kind: "canvas",
51+
id: d.id,
52+
title: d.name,
53+
ts: d.updatedAt,
54+
pinned: d.pinnedAt != null,
55+
rawStatus: null,
56+
authorUser: null,
57+
authorName: d.createdBy ?? null,
58+
templateId: d.templateId,
59+
}));
60+
61+
const taskItems: ChannelItemModel[] = feedTasks.flatMap((task) =>
62+
archivedTaskIds.has(task.id)
63+
? []
64+
: [
65+
{
66+
key: `task:${task.id}`,
67+
kind: "task" as const,
68+
id: task.id,
69+
title: task.title || "Untitled task",
70+
ts: Date.parse(task.updated_at) || 0,
71+
pinned: pinnedTaskIds.has(task.id),
72+
rawStatus: task.latest_run?.status ?? null,
73+
authorUser: task.created_by ?? null,
74+
authorName: null,
75+
templateId: null,
76+
},
77+
],
78+
);
79+
80+
const all = [...canvasItems, ...taskItems].sort((a, b) => b.ts - a.ts);
81+
return ownedBy ? all.filter((item) => isOwnedBy(item, ownedBy)) : all;
82+
}
83+
84+
export type CreatedByFilter = "anyone" | "me" | "others";
85+
86+
export function filterChannelItems(
87+
items: readonly ChannelItemModel[],
88+
{
89+
query,
90+
createdBy,
91+
status,
92+
me,
93+
}: {
94+
query: string;
95+
createdBy: CreatedByFilter;
96+
status: TaskRunStatus | null;
97+
me: ChannelItemOwner;
98+
},
99+
): ChannelItemModel[] {
100+
const normalizedQuery = query.trim().toLowerCase();
101+
return items.filter((item) => {
102+
if (
103+
normalizedQuery &&
104+
!item.title.toLowerCase().includes(normalizedQuery)
105+
) {
106+
return false;
107+
}
108+
if (createdBy !== "anyone") {
109+
const mine = isOwnedBy(item, me);
110+
if (createdBy === "me" ? !mine : mine) return false;
111+
}
112+
if (status && item.rawStatus !== status) return false;
113+
return true;
114+
});
115+
}

0 commit comments

Comments
 (0)