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

Commit 113eae9

Browse files
raquelmsmithk11kirkyposthog[bot]
authored
feat(canvas): task-centric activity feed (#3671)
Co-authored-by: Peter Kirkham <k11kirky@gmail.com> Co-authored-by: posthog[bot] <206114724+posthog[bot]@users.noreply.github.com> Co-authored-by: k11kirky <4571455+k11kirky@users.noreply.github.com>
1 parent f632bfb commit 113eae9

28 files changed

Lines changed: 1156 additions & 119 deletions

apps/code/src/renderer/desktop-contributions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { agentUiModule } from "@posthog/ui/features/agent/agent.module";
1313
import { authUiModule } from "@posthog/ui/features/auth/auth.module";
1414
import { billingUiModule } from "@posthog/ui/features/billing/billing.module";
1515
import { browserTabsUiModule } from "@posthog/ui/features/browser-tabs/browser-tabs.module";
16+
import { taskActivityUiModule } from "@posthog/ui/features/canvas/task-activity/taskActivity.module";
1617
import { cloneUiModule } from "@posthog/ui/features/clone/clone.module";
1718
import { connectivityUiModule } from "@posthog/ui/features/connectivity/connectivity.module";
1819
import { discordPresenceUiModule } from "@posthog/ui/features/discord-presence/discordPresence.module";
@@ -36,6 +37,7 @@ export function registerDesktopContributions(): void {
3637
authUiModule,
3738
autoresearchCoreModule,
3839
billingUiModule,
40+
taskActivityUiModule,
3941
taskThreadCoreModule,
4042
browserTabsUiModule,
4143
cloneUiModule,

apps/web/src/web-container.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ import {
190190
BROWSER_TABS_CLIENT,
191191
type BrowserTabsClient,
192192
} from "@posthog/ui/features/browser-tabs/browserTabsClient";
193+
import { taskActivityUiModule } from "@posthog/ui/features/canvas/task-activity/taskActivity.module";
193194
import {
194195
REVIEW_HOST,
195196
type ReviewHost,
@@ -779,6 +780,7 @@ container.bind(REVIEW_HOST).toConstantValue(webReviewHost);
779780
// (notificationsUiModule) is resolved by SessionService on task events and by
780781
// the settings test harness; it needs these three providers.
781782
container.load(notificationsUiModule);
783+
container.load(taskActivityUiModule);
782784
container.bind(NOTIFICATIONS_SERVICE).toConstantValue(webNotifications);
783785
container
784786
.bind(NOTIFICATION_SETTINGS_PROVIDER)

packages/api-client/src/posthog-client.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ import type {
8686
SuggestedReviewersArtefact,
8787
SuggestedReviewerWriteEntry,
8888
Task,
89+
TaskActivityMarkReadResult,
90+
TaskActivityPage,
91+
TaskActivityReadMarker,
8992
TaskChannel,
9093
TaskMention,
9194
TaskRun,
@@ -2506,6 +2509,53 @@ export class PostHogAPIClient {
25062509
return (await response.json()) as TaskMention[];
25072510
}
25082511

2512+
// Tasks the current user is involved in (created, mentioned, or messaged),
2513+
// one row per task, newest activity first.
2514+
async getTaskActivity(options?: {
2515+
before?: string;
2516+
beforeId?: string;
2517+
}): Promise<TaskActivityPage> {
2518+
const teamId = await this.getTeamId();
2519+
const urlPath = `/api/projects/${teamId}/task_activity/`;
2520+
const url = new URL(`${this.api.baseUrl}${urlPath}`);
2521+
if (options?.before && options.beforeId) {
2522+
url.searchParams.set("before", options.before);
2523+
url.searchParams.set("before_id", options.beforeId);
2524+
}
2525+
const response = await this.api.fetcher.fetch({
2526+
method: "get",
2527+
url,
2528+
path: urlPath,
2529+
});
2530+
if (!response.ok) {
2531+
throw new Error(`Failed to fetch task activity: ${response.statusText}`);
2532+
}
2533+
return (await response.json()) as TaskActivityPage;
2534+
}
2535+
2536+
// Read state is per task, so callers name the tasks the user has seen rather than
2537+
// clearing the whole feed.
2538+
async markTaskActivityRead(
2539+
activities: TaskActivityReadMarker[],
2540+
): Promise<TaskActivityMarkReadResult> {
2541+
const teamId = await this.getTeamId();
2542+
const urlPath = `/api/projects/${teamId}/task_activity/mark_read/`;
2543+
const response = await this.api.fetcher.fetch({
2544+
method: "post",
2545+
url: new URL(`${this.api.baseUrl}${urlPath}`),
2546+
path: urlPath,
2547+
overrides: {
2548+
body: JSON.stringify({ activities }),
2549+
},
2550+
});
2551+
if (!response.ok) {
2552+
throw new Error(
2553+
`Failed to mark task activity read: ${response.statusText}`,
2554+
);
2555+
}
2556+
return (await response.json()) as TaskActivityMarkReadResult;
2557+
}
2558+
25092559
async getTaskThreadMessages(taskId: string): Promise<TaskThreadMessage[]> {
25102560
const teamId = await this.getTeamId();
25112561
const urlPath = `/api/projects/${teamId}/tasks/${taskId}/thread_messages/`;
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import type { TaskActivity, UserBasic } from "@posthog/shared/domain-types";
2+
import { describe, expect, it } from "vitest";
3+
import { toTaskActivityItems } from "./taskActivity";
4+
5+
const ann: UserBasic = {
6+
id: 2,
7+
uuid: "ann-uuid",
8+
email: "ann@posthog.com",
9+
first_name: "Ann",
10+
};
11+
12+
function activity(overrides: Partial<TaskActivity> = {}): TaskActivity {
13+
return {
14+
id: "activity-1",
15+
task_id: "t1",
16+
task_title: "Task t1",
17+
channel_id: "c1",
18+
channel_name: "general",
19+
activity_at: "2026-07-01T10:00:00Z",
20+
activity_kind: "mention",
21+
snippet: "ping @[Me](me@posthog.com)",
22+
latest_author: ann,
23+
latest_message_id: "m1",
24+
is_unread: true,
25+
...overrides,
26+
};
27+
}
28+
29+
describe("toTaskActivityItems", () => {
30+
it("maps the authoritative activity and unread state", () => {
31+
expect(toTaskActivityItems([activity()])).toEqual([
32+
{
33+
id: "activity-1",
34+
taskId: "t1",
35+
taskTitle: "Task t1",
36+
channelId: "c1",
37+
channelName: "general",
38+
activityAt: "2026-07-01T10:00:00Z",
39+
activityKind: "mention",
40+
snippet: "ping @[Me](me@posthog.com)",
41+
author: ann,
42+
messageId: "m1",
43+
isUnread: true,
44+
},
45+
]);
46+
});
47+
48+
it("labels untitled tasks and tolerates missing optional values", () => {
49+
const [item] = toTaskActivityItems([
50+
activity({
51+
task_title: "",
52+
channel_id: null,
53+
channel_name: null,
54+
latest_author: null,
55+
latest_message_id: null,
56+
activity_kind: "created",
57+
snippet: "",
58+
}),
59+
]);
60+
expect(item).toMatchObject({
61+
taskTitle: "Untitled task",
62+
channelId: null,
63+
channelName: null,
64+
author: null,
65+
messageId: null,
66+
});
67+
});
68+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type {
2+
TaskActivity,
3+
TaskActivityKind,
4+
UserBasic,
5+
} from "@posthog/shared/domain-types";
6+
7+
/**
8+
* The Activity feed — tasks the current user is involved in (created, mentioned
9+
* in, or messaged in) — as served by the backend task-activity index
10+
* (`getTaskActivity`). One row per task, newest activity first; the client only
11+
* maps DTOs to items.
12+
*/
13+
14+
export interface TaskActivityItem {
15+
id: string;
16+
taskId: string;
17+
taskTitle: string;
18+
/** Backend channel (tasks product Channel UUID); null for channel-less tasks. */
19+
channelId: string | null;
20+
/** Backend channel name, for the "#channel" label. */
21+
channelName: string | null;
22+
activityAt: string;
23+
activityKind: TaskActivityKind;
24+
/** Content of the message tied to the latest activity; empty for created rows. */
25+
snippet: string;
26+
author: UserBasic | null;
27+
messageId: string | null;
28+
isUnread: boolean;
29+
}
30+
31+
/** Map activity DTOs (already newest-first from the backend) to feed items. */
32+
export function toTaskActivityItems(
33+
activity: readonly TaskActivity[],
34+
): TaskActivityItem[] {
35+
return activity.map((row) => ({
36+
id: row.id,
37+
taskId: row.task_id,
38+
taskTitle: row.task_title || "Untitled task",
39+
channelId: row.channel_id ?? null,
40+
channelName: row.channel_name ?? null,
41+
activityAt: row.activity_at,
42+
activityKind: row.activity_kind,
43+
snippet: row.snippet,
44+
author: row.latest_author ?? null,
45+
messageId: row.latest_message_id ?? null,
46+
isUnread: row.is_unread,
47+
}));
48+
}

packages/core/src/sessions/sessionService.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6830,6 +6830,37 @@ export class SessionService {
68306830
return () => {};
68316831
}
68326832

6833+
public async watchCreatedCloudTask(task: Task): Promise<void> {
6834+
const run = task.latest_run;
6835+
if (run?.environment !== "cloud") return;
6836+
6837+
const authStatus = await this.getAuthCredentialsStatus();
6838+
if (authStatus.kind !== "ready") return;
6839+
6840+
this.updateSessionTaskTitle(
6841+
task.id,
6842+
task.title || task.description || "Cloud Task",
6843+
);
6844+
this.watchCloudTask(
6845+
task.id,
6846+
run.id,
6847+
authStatus.auth.apiHost,
6848+
authStatus.auth.projectId,
6849+
undefined,
6850+
run.log_url,
6851+
typeof run.state?.initial_permission_mode === "string"
6852+
? run.state.initial_permission_mode
6853+
: undefined,
6854+
run.runtime_adapter === "codex" ? "codex" : "claude",
6855+
run.model ?? undefined,
6856+
task.description ?? undefined,
6857+
undefined,
6858+
run.status,
6859+
run.reasoning_effort ?? undefined,
6860+
run.state,
6861+
);
6862+
}
6863+
68336864
private logReconcileSkipOnce(
68346865
taskId: string,
68356866
reason: string,

packages/core/src/task-detail/taskCreationSaga.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ const host = mockHost as unknown as ITaskCreationHost;
4747
const sessionService = {
4848
connectToTask: vi.fn(),
4949
disconnectFromTask: vi.fn(),
50+
watchCreatedCloudTask: vi.fn(),
5051
rememberInitialCloudPrompt: vi.fn(),
5152
markTaskCreationInFlight: vi.fn(),
5253
} as unknown as SessionService;
@@ -186,6 +187,9 @@ describe("TaskCreationSaga", () => {
186187
pendingUserArtifactIds: undefined,
187188
});
188189
expect(sendRunCommandMock).not.toHaveBeenCalled();
190+
expect(sessionService.watchCreatedCloudTask).toHaveBeenCalledWith(
191+
startedTask,
192+
);
189193
expect(onTaskReady).toHaveBeenCalledTimes(1);
190194
expect(onTaskReady.mock.calls[0][0].task.latest_run?.branch).toBe(
191195
"release/remembered-branch",
@@ -633,6 +637,9 @@ describe("TaskCreationSaga", () => {
633637
// Warm-activated at create time: no fresh run is created or started.
634638
expect(createTaskRunMock).not.toHaveBeenCalled();
635639
expect(startTaskRunMock).not.toHaveBeenCalled();
640+
expect(sessionService.watchCreatedCloudTask).toHaveBeenCalledWith(
641+
warmActivatedTask,
642+
);
636643
});
637644

638645
it("suppresses warm reuse when attachments exist but no warm lease is known", async () => {

packages/core/src/task-detail/taskCreationSaga.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -319,8 +319,11 @@ export class TaskCreationSaga extends Saga<
319319
);
320320
}
321321

322-
if (!hasProvisioning && !shouldStartCloudRun && this.deps.onTaskReady) {
323-
this.deps.onTaskReady({ task, workspace });
322+
if (!hasProvisioning && !shouldStartCloudRun) {
323+
if (!taskId && workspaceMode === "cloud") {
324+
await this.deps.sessionService.watchCreatedCloudTask(task);
325+
}
326+
this.deps.onTaskReady?.({ task, workspace });
324327
}
325328

326329
if (hasProvisioning) {
@@ -469,8 +472,9 @@ export class TaskCreationSaga extends Saga<
469472
},
470473
});
471474

472-
if (!hasProvisioning && this.deps.onTaskReady) {
473-
this.deps.onTaskReady({ task, workspace });
475+
if (!hasProvisioning) {
476+
await this.deps.sessionService.watchCreatedCloudTask(task);
477+
this.deps.onTaskReady?.({ task, workspace });
474478
}
475479
}
476480

packages/shared/src/domain-types.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,51 @@ export interface TaskMention {
139139
created_at: string;
140140
}
141141

142+
/** Which signal produced an activity row; mirrors the backend `activity_kind`. */
143+
export type TaskActivityKind =
144+
| "awaiting_input"
145+
| "completed"
146+
| "message"
147+
| "mention"
148+
| "created";
149+
150+
/**
151+
* One task the current user is involved in, from the backend task-activity feed
152+
* (`/task_activity/`). One row per task, newest activity first. Mirrors
153+
* `TaskActivityDTO`.
154+
*/
155+
export interface TaskActivity {
156+
id: string;
157+
task_id: string;
158+
task_title: string;
159+
channel_id?: string | null;
160+
channel_name?: string | null;
161+
activity_at: string;
162+
activity_kind: TaskActivityKind;
163+
snippet: string;
164+
latest_author?: UserBasic | null;
165+
latest_message_id?: string | null;
166+
is_unread: boolean;
167+
}
168+
169+
export interface TaskActivityPage {
170+
results: TaskActivity[];
171+
/** Unread tasks across the whole feed, not just this page. Backs the sidebar badge. */
172+
unread_count: number;
173+
next_before?: string | null;
174+
next_before_id?: string | null;
175+
}
176+
177+
export interface TaskActivityReadMarker {
178+
task_id: string;
179+
seen_before: string;
180+
}
181+
182+
export interface TaskActivityMarkReadResult {
183+
marked_read: number;
184+
unread_count: number;
185+
}
186+
142187
export type TaskRunStatus =
143188
| "not_started"
144189
| "queued"

0 commit comments

Comments
 (0)