Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
3c05d68
feat(canvas): task-centric activity feed
k11kirky Jul 26, 2026
da9575c
feat(activity): use authoritative task activity state
k11kirky Jul 26, 2026
0bf14e1
fix(activity): clear activity per task, wherever the task is opened
k11kirky Jul 26, 2026
512e314
fix(sidebar): mock the hook ActivityItem actually uses in nav tests
k11kirky Jul 26, 2026
d379054
Merge branch 'main' into posthog-code/task-activity-feed
k11kirky Jul 26, 2026
1ca2f78
fix(canvas): reconcile paginated task activity
k11kirky Jul 26, 2026
021959a
fix(canvas): clarify and refresh unread activity
k11kirky Jul 27, 2026
8d672b1
fix(canvas): isolate activity refresh from notifications
k11kirky Jul 27, 2026
1ca48ea
fix(canvas): track channel task completion
k11kirky Jul 27, 2026
6dbcc41
refactor(canvas): move activity coordination out of React
k11kirky Jul 27, 2026
c29a64d
fix(canvas): watch channel tasks through event stream
k11kirky Jul 27, 2026
4f27c62
fix(canvas): project task events into activity
k11kirky Jul 27, 2026
50c3e38
fix(canvas): scope activity read state to opened tasks
k11kirky Jul 27, 2026
e5b9a18
fix(canvas): mark channel tasks read when opened
k11kirky Jul 27, 2026
dd399a1
chore(visual): update storybook baselines
posthog[bot] Jul 27, 2026
185068b
fix(canvas): scope activity updates to authenticated cache
k11kirky Jul 27, 2026
3f9d947
chore(canvas): organize activity test imports
k11kirky Jul 27, 2026
f15aa11
Merging 3f9d9474b3d2b31dd0c83de5646f512dfcf6f937 into trunk-temp/pr-3…
trunk-io[bot] Jul 27, 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
2 changes: 2 additions & 0 deletions apps/code/src/renderer/desktop-contributions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { agentUiModule } from "@posthog/ui/features/agent/agent.module";
import { authUiModule } from "@posthog/ui/features/auth/auth.module";
import { billingUiModule } from "@posthog/ui/features/billing/billing.module";
import { browserTabsUiModule } from "@posthog/ui/features/browser-tabs/browser-tabs.module";
import { taskActivityUiModule } from "@posthog/ui/features/canvas/task-activity/taskActivity.module";
import { cloneUiModule } from "@posthog/ui/features/clone/clone.module";
import { connectivityUiModule } from "@posthog/ui/features/connectivity/connectivity.module";
import { discordPresenceUiModule } from "@posthog/ui/features/discord-presence/discordPresence.module";
Expand All @@ -36,6 +37,7 @@ export function registerDesktopContributions(): void {
authUiModule,
autoresearchCoreModule,
billingUiModule,
taskActivityUiModule,
taskThreadCoreModule,
browserTabsUiModule,
cloneUiModule,
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/web-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ import {
BROWSER_TABS_CLIENT,
type BrowserTabsClient,
} from "@posthog/ui/features/browser-tabs/browserTabsClient";
import { taskActivityUiModule } from "@posthog/ui/features/canvas/task-activity/taskActivity.module";
import {
REVIEW_HOST,
type ReviewHost,
Expand Down Expand Up @@ -779,6 +780,7 @@ container.bind(REVIEW_HOST).toConstantValue(webReviewHost);
// (notificationsUiModule) is resolved by SessionService on task events and by
// the settings test harness; it needs these three providers.
container.load(notificationsUiModule);
container.load(taskActivityUiModule);
container.bind(NOTIFICATIONS_SERVICE).toConstantValue(webNotifications);
container
.bind(NOTIFICATION_SETTINGS_PROVIDER)
Expand Down
50 changes: 50 additions & 0 deletions packages/api-client/src/posthog-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ import type {
SuggestedReviewersArtefact,
SuggestedReviewerWriteEntry,
Task,
TaskActivityMarkReadResult,
TaskActivityPage,
TaskActivityReadMarker,
TaskChannel,
TaskMention,
TaskRun,
Expand Down Expand Up @@ -2506,6 +2509,53 @@ export class PostHogAPIClient {
return (await response.json()) as TaskMention[];
}

// Tasks the current user is involved in (created, mentioned, or messaged),
// one row per task, newest activity first.
async getTaskActivity(options?: {
before?: string;
beforeId?: string;
}): Promise<TaskActivityPage> {
const teamId = await this.getTeamId();
const urlPath = `/api/projects/${teamId}/task_activity/`;
const url = new URL(`${this.api.baseUrl}${urlPath}`);
if (options?.before && options.beforeId) {
url.searchParams.set("before", options.before);
url.searchParams.set("before_id", options.beforeId);
}
const response = await this.api.fetcher.fetch({
method: "get",
url,
path: urlPath,
});
if (!response.ok) {
throw new Error(`Failed to fetch task activity: ${response.statusText}`);
}
return (await response.json()) as TaskActivityPage;
}

// Read state is per task, so callers name the tasks the user has seen rather than
// clearing the whole feed.
async markTaskActivityRead(
activities: TaskActivityReadMarker[],
): Promise<TaskActivityMarkReadResult> {
const teamId = await this.getTeamId();
const urlPath = `/api/projects/${teamId}/task_activity/mark_read/`;
const response = await this.api.fetcher.fetch({
method: "post",
url: new URL(`${this.api.baseUrl}${urlPath}`),
path: urlPath,
overrides: {
body: JSON.stringify({ activities }),
},
});
if (!response.ok) {
throw new Error(
`Failed to mark task activity read: ${response.statusText}`,
);
}
return (await response.json()) as TaskActivityMarkReadResult;
}

async getTaskThreadMessages(taskId: string): Promise<TaskThreadMessage[]> {
const teamId = await this.getTeamId();
const urlPath = `/api/projects/${teamId}/tasks/${taskId}/thread_messages/`;
Expand Down
68 changes: 68 additions & 0 deletions packages/core/src/canvas/taskActivity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type { TaskActivity, UserBasic } from "@posthog/shared/domain-types";
import { describe, expect, it } from "vitest";
import { toTaskActivityItems } from "./taskActivity";

const ann: UserBasic = {
id: 2,
uuid: "ann-uuid",
email: "ann@posthog.com",
first_name: "Ann",
};

function activity(overrides: Partial<TaskActivity> = {}): TaskActivity {
return {
id: "activity-1",
task_id: "t1",
task_title: "Task t1",
channel_id: "c1",
channel_name: "general",
activity_at: "2026-07-01T10:00:00Z",
activity_kind: "mention",
snippet: "ping @[Me](me@posthog.com)",
latest_author: ann,
latest_message_id: "m1",
is_unread: true,
...overrides,
};
}

describe("toTaskActivityItems", () => {
it("maps the authoritative activity and unread state", () => {
expect(toTaskActivityItems([activity()])).toEqual([
{
id: "activity-1",
taskId: "t1",
taskTitle: "Task t1",
channelId: "c1",
channelName: "general",
activityAt: "2026-07-01T10:00:00Z",
activityKind: "mention",
snippet: "ping @[Me](me@posthog.com)",
author: ann,
messageId: "m1",
isUnread: true,
},
]);
});

it("labels untitled tasks and tolerates missing optional values", () => {
const [item] = toTaskActivityItems([
activity({
task_title: "",
channel_id: null,
channel_name: null,
latest_author: null,
latest_message_id: null,
activity_kind: "created",
snippet: "",
}),
]);
expect(item).toMatchObject({
taskTitle: "Untitled task",
channelId: null,
channelName: null,
author: null,
messageId: null,
});
});
});
48 changes: 48 additions & 0 deletions packages/core/src/canvas/taskActivity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type {
TaskActivity,
TaskActivityKind,
UserBasic,
} from "@posthog/shared/domain-types";

/**
* The Activity feed — tasks the current user is involved in (created, mentioned
* in, or messaged in) — as served by the backend task-activity index
* (`getTaskActivity`). One row per task, newest activity first; the client only
* maps DTOs to items.
*/

export interface TaskActivityItem {
id: string;
taskId: string;
taskTitle: string;
/** Backend channel (tasks product Channel UUID); null for channel-less tasks. */
channelId: string | null;
/** Backend channel name, for the "#channel" label. */
channelName: string | null;
activityAt: string;
activityKind: TaskActivityKind;
/** Content of the message tied to the latest activity; empty for created rows. */
snippet: string;
author: UserBasic | null;
messageId: string | null;
isUnread: boolean;
}

/** Map activity DTOs (already newest-first from the backend) to feed items. */
export function toTaskActivityItems(
activity: readonly TaskActivity[],
): TaskActivityItem[] {
return activity.map((row) => ({
id: row.id,
taskId: row.task_id,
taskTitle: row.task_title || "Untitled task",
channelId: row.channel_id ?? null,
channelName: row.channel_name ?? null,
activityAt: row.activity_at,
activityKind: row.activity_kind,
snippet: row.snippet,
author: row.latest_author ?? null,
messageId: row.latest_message_id ?? null,
isUnread: row.is_unread,
}));
}
31 changes: 31 additions & 0 deletions packages/core/src/sessions/sessionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6830,6 +6830,37 @@ export class SessionService {
return () => {};
}

public async watchCreatedCloudTask(task: Task): Promise<void> {
const run = task.latest_run;
if (run?.environment !== "cloud") return;

const authStatus = await this.getAuthCredentialsStatus();
if (authStatus.kind !== "ready") return;

this.updateSessionTaskTitle(
task.id,
task.title || task.description || "Cloud Task",
);
this.watchCloudTask(
task.id,
run.id,
authStatus.auth.apiHost,
authStatus.auth.projectId,
undefined,
run.log_url,
typeof run.state?.initial_permission_mode === "string"
? run.state.initial_permission_mode
: undefined,
run.runtime_adapter === "codex" ? "codex" : "claude",
run.model ?? undefined,
task.description ?? undefined,
undefined,
run.status,
run.reasoning_effort ?? undefined,
run.state,
);
}

private logReconcileSkipOnce(
taskId: string,
reason: string,
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/task-detail/taskCreationSaga.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const host = mockHost as unknown as ITaskCreationHost;
const sessionService = {
connectToTask: vi.fn(),
disconnectFromTask: vi.fn(),
watchCreatedCloudTask: vi.fn(),
rememberInitialCloudPrompt: vi.fn(),
markTaskCreationInFlight: vi.fn(),
} as unknown as SessionService;
Expand Down Expand Up @@ -186,6 +187,9 @@ describe("TaskCreationSaga", () => {
pendingUserArtifactIds: undefined,
});
expect(sendRunCommandMock).not.toHaveBeenCalled();
expect(sessionService.watchCreatedCloudTask).toHaveBeenCalledWith(
startedTask,
);
expect(onTaskReady).toHaveBeenCalledTimes(1);
expect(onTaskReady.mock.calls[0][0].task.latest_run?.branch).toBe(
"release/remembered-branch",
Expand Down Expand Up @@ -633,6 +637,9 @@ describe("TaskCreationSaga", () => {
// Warm-activated at create time: no fresh run is created or started.
expect(createTaskRunMock).not.toHaveBeenCalled();
expect(startTaskRunMock).not.toHaveBeenCalled();
expect(sessionService.watchCreatedCloudTask).toHaveBeenCalledWith(
warmActivatedTask,
);
});

it("suppresses warm reuse when attachments exist but no warm lease is known", async () => {
Expand Down
12 changes: 8 additions & 4 deletions packages/core/src/task-detail/taskCreationSaga.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,8 +319,11 @@ export class TaskCreationSaga extends Saga<
);
}

if (!hasProvisioning && !shouldStartCloudRun && this.deps.onTaskReady) {
this.deps.onTaskReady({ task, workspace });
if (!hasProvisioning && !shouldStartCloudRun) {
if (!taskId && workspaceMode === "cloud") {
await this.deps.sessionService.watchCreatedCloudTask(task);
}
this.deps.onTaskReady?.({ task, workspace });
}

if (hasProvisioning) {
Expand Down Expand Up @@ -469,8 +472,9 @@ export class TaskCreationSaga extends Saga<
},
});

if (!hasProvisioning && this.deps.onTaskReady) {
this.deps.onTaskReady({ task, workspace });
if (!hasProvisioning) {
await this.deps.sessionService.watchCreatedCloudTask(task);
this.deps.onTaskReady?.({ task, workspace });
}
}

Expand Down
45 changes: 45 additions & 0 deletions packages/shared/src/domain-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,51 @@ export interface TaskMention {
created_at: string;
}

/** Which signal produced an activity row; mirrors the backend `activity_kind`. */
export type TaskActivityKind =
| "awaiting_input"
| "completed"
| "message"
| "mention"
| "created";

/**
* One task the current user is involved in, from the backend task-activity feed
* (`/task_activity/`). One row per task, newest activity first. Mirrors
* `TaskActivityDTO`.
*/
export interface TaskActivity {
id: string;
task_id: string;
task_title: string;
channel_id?: string | null;
channel_name?: string | null;
activity_at: string;
activity_kind: TaskActivityKind;
snippet: string;
latest_author?: UserBasic | null;
latest_message_id?: string | null;
is_unread: boolean;
}

export interface TaskActivityPage {
results: TaskActivity[];
/** Unread tasks across the whole feed, not just this page. Backs the sidebar badge. */
unread_count: number;
next_before?: string | null;
next_before_id?: string | null;
}

export interface TaskActivityReadMarker {
task_id: string;
seen_before: string;
}

export interface TaskActivityMarkReadResult {
marked_read: number;
unread_count: number;
}

export type TaskRunStatus =
| "not_started"
| "queued"
Expand Down
Loading
Loading