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

Commit c6860a2

Browse files
authored
feat(activity): use authoritative task activity state
Generated-By: PostHog Code Task-Id: c4f5025f-8edf-40d4-a163-7174899045d1
1 parent cfa10cb commit c6860a2

9 files changed

Lines changed: 87 additions & 201 deletions

File tree

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

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ import type {
8484
SuggestedReviewersArtefact,
8585
SuggestedReviewerWriteEntry,
8686
Task,
87-
TaskActivity,
87+
TaskActivityPage,
8888
TaskChannel,
8989
TaskMention,
9090
TaskRun,
@@ -2634,13 +2634,10 @@ export class PostHogAPIClient {
26342634

26352635
// Tasks the current user is involved in (created, mentioned, or messaged),
26362636
// one row per task, newest activity first.
2637-
async getTaskActivity(options?: { since?: string }): Promise<TaskActivity[]> {
2637+
async getTaskActivity(): Promise<TaskActivityPage> {
26382638
const teamId = await this.getTeamId();
26392639
const urlPath = `/api/projects/${teamId}/task_activity/`;
26402640
const url = new URL(`${this.api.baseUrl}${urlPath}`);
2641-
if (options?.since) {
2642-
url.searchParams.set("since", options.since);
2643-
}
26442641
const response = await this.api.fetcher.fetch({
26452642
method: "get",
26462643
url,
@@ -2649,7 +2646,22 @@ export class PostHogAPIClient {
26492646
if (!response.ok) {
26502647
throw new Error(`Failed to fetch task activity: ${response.statusText}`);
26512648
}
2652-
return (await response.json()) as TaskActivity[];
2649+
return (await response.json()) as TaskActivityPage;
2650+
}
2651+
2652+
async markTaskActivityRead(): Promise<void> {
2653+
const teamId = await this.getTeamId();
2654+
const urlPath = `/api/projects/${teamId}/task_activity/mark_read/`;
2655+
const response = await this.api.fetcher.fetch({
2656+
method: "post",
2657+
url: new URL(`${this.api.baseUrl}${urlPath}`),
2658+
path: urlPath,
2659+
});
2660+
if (!response.ok) {
2661+
throw new Error(
2662+
`Failed to mark task activity read: ${response.statusText}`,
2663+
);
2664+
}
26532665
}
26542666

26552667
async getTaskThreadMessages(taskId: string): Promise<TaskThreadMessage[]> {
Lines changed: 9 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
11
import type { TaskActivity, UserBasic } from "@posthog/shared/domain-types";
22
import { describe, expect, it } from "vitest";
3-
import {
4-
countUnseenActivity,
5-
mergeTaskActivity,
6-
toTaskActivityItems,
7-
} from "./taskActivity";
3+
import { toTaskActivityItems } from "./taskActivity";
84

95
const ann: UserBasic = {
106
id: 2,
@@ -15,6 +11,7 @@ const ann: UserBasic = {
1511

1612
function activity(overrides: Partial<TaskActivity> = {}): TaskActivity {
1713
return {
14+
id: "activity-1",
1815
task_id: "t1",
1916
task_title: "Task t1",
2017
channel_id: "c1",
@@ -24,14 +21,16 @@ function activity(overrides: Partial<TaskActivity> = {}): TaskActivity {
2421
snippet: "ping @[Me](me@posthog.com)",
2522
latest_author: ann,
2623
latest_message_id: "m1",
24+
is_unread: true,
2725
...overrides,
2826
};
2927
}
3028

3129
describe("toTaskActivityItems", () => {
32-
it("maps activity DTOs to feed items", () => {
30+
it("maps the authoritative activity and unread state", () => {
3331
expect(toTaskActivityItems([activity()])).toEqual([
3432
{
33+
id: "activity-1",
3534
taskId: "t1",
3635
taskTitle: "Task t1",
3736
channelId: "c1",
@@ -41,12 +40,13 @@ describe("toTaskActivityItems", () => {
4140
snippet: "ping @[Me](me@posthog.com)",
4241
author: ann,
4342
messageId: "m1",
43+
isUnread: true,
4444
},
4545
]);
4646
});
4747

48-
it("labels untitled tasks and tolerates missing channel/author/message", () => {
49-
const items = toTaskActivityItems([
48+
it("labels untitled tasks and tolerates missing optional values", () => {
49+
const [item] = toTaskActivityItems([
5050
activity({
5151
task_title: "",
5252
channel_id: null,
@@ -57,7 +57,7 @@ describe("toTaskActivityItems", () => {
5757
snippet: "",
5858
}),
5959
]);
60-
expect(items[0]).toMatchObject({
60+
expect(item).toMatchObject({
6161
taskTitle: "Untitled task",
6262
channelId: null,
6363
channelName: null,
@@ -66,87 +66,3 @@ describe("toTaskActivityItems", () => {
6666
});
6767
});
6868
});
69-
70-
describe("countUnseenActivity", () => {
71-
const items = toTaskActivityItems([
72-
activity({ task_id: "t2", activity_at: "2026-07-03T10:00:00Z" }),
73-
activity({ task_id: "t1", activity_at: "2026-07-01T10:00:00Z" }),
74-
]);
75-
76-
it("counts everything when never seen", () => {
77-
expect(countUnseenActivity(items, null)).toBe(2);
78-
});
79-
80-
it("counts only rows with activity after the last-seen timestamp", () => {
81-
expect(countUnseenActivity(items, "2026-07-02T00:00:00Z")).toBe(1);
82-
expect(countUnseenActivity(items, "2026-07-04T00:00:00Z")).toBe(0);
83-
});
84-
});
85-
86-
describe("mergeTaskActivity", () => {
87-
it("prepends newly-active tasks ahead of the previous page", () => {
88-
const previous = [
89-
activity({ task_id: "t1", activity_at: "2026-07-01T10:00:00Z" }),
90-
];
91-
const incoming = [
92-
activity({ task_id: "t2", activity_at: "2026-07-02T10:00:00Z" }),
93-
];
94-
expect(mergeTaskActivity(previous, incoming).map((r) => r.task_id)).toEqual(
95-
["t2", "t1"],
96-
);
97-
});
98-
99-
it("replaces a task's row when its activity advances instead of duplicating it", () => {
100-
const previous = [
101-
activity({
102-
task_id: "t1",
103-
activity_kind: "mention",
104-
activity_at: "2026-07-01T10:00:00Z",
105-
}),
106-
];
107-
const incoming = [
108-
activity({
109-
task_id: "t1",
110-
activity_kind: "message",
111-
snippet: "replied",
112-
activity_at: "2026-07-02T10:00:00Z",
113-
}),
114-
];
115-
const merged = mergeTaskActivity(previous, incoming);
116-
expect(merged).toHaveLength(1);
117-
expect(merged[0].activity_kind).toBe("message");
118-
expect(merged[0].activity_at).toBe("2026-07-02T10:00:00Z");
119-
});
120-
121-
it("keeps the newer row when an older duplicate arrives out of order", () => {
122-
const previous = [
123-
activity({ task_id: "t1", activity_at: "2026-07-05T10:00:00Z" }),
124-
];
125-
const incoming = [
126-
activity({ task_id: "t1", activity_at: "2026-07-01T10:00:00Z" }),
127-
];
128-
expect(mergeTaskActivity(previous, incoming)[0].activity_at).toBe(
129-
"2026-07-05T10:00:00Z",
130-
);
131-
});
132-
133-
it("returns the previous page unchanged when there is nothing new", () => {
134-
const previous = [activity({ task_id: "t1" })];
135-
expect(mergeTaskActivity(previous, [])).toEqual(previous);
136-
});
137-
138-
it("caps the merged result so a long session can't grow it unbounded", () => {
139-
const previous = Array.from({ length: 300 }, (_, i) =>
140-
activity({
141-
task_id: `old-${i}`,
142-
activity_at: `2026-06-01T${String(i % 24).padStart(2, "0")}:00:00Z`,
143-
}),
144-
);
145-
const incoming = [
146-
activity({ task_id: "newest", activity_at: "2026-07-05T10:00:00Z" }),
147-
];
148-
const merged = mergeTaskActivity(previous, incoming);
149-
expect(merged).toHaveLength(300);
150-
expect(merged[0].task_id).toBe("newest");
151-
});
152-
});

packages/core/src/canvas/taskActivity.ts

Lines changed: 4 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {
1212
*/
1313

1414
export interface TaskActivityItem {
15+
id: string;
1516
taskId: string;
1617
taskTitle: string;
1718
/** Backend channel (tasks product Channel UUID); null for channel-less tasks. */
@@ -24,13 +25,15 @@ export interface TaskActivityItem {
2425
snippet: string;
2526
author: UserBasic | null;
2627
messageId: string | null;
28+
isUnread: boolean;
2729
}
2830

2931
/** Map activity DTOs (already newest-first from the backend) to feed items. */
3032
export function toTaskActivityItems(
3133
activity: readonly TaskActivity[],
3234
): TaskActivityItem[] {
3335
return activity.map((row) => ({
36+
id: row.id,
3437
taskId: row.task_id,
3538
taskTitle: row.task_title || "Untitled task",
3639
channelId: row.channel_id ?? null,
@@ -40,42 +43,6 @@ export function toTaskActivityItems(
4043
snippet: row.snippet,
4144
author: row.latest_author ?? null,
4245
messageId: row.latest_message_id ?? null,
46+
isUnread: row.is_unread,
4347
}));
4448
}
45-
46-
/** How many rows have activity after the viewer last opened the Activity page. */
47-
export function countUnseenActivity(
48-
items: readonly TaskActivityItem[],
49-
lastSeenAt: string | null,
50-
): number {
51-
if (!lastSeenAt) return items.length;
52-
return items.filter((item) => item.activityAt > lastSeenAt).length;
53-
}
54-
55-
// Bounds the cache so a long-running session's accumulated feed can't grow
56-
// without limit.
57-
const MAX_CACHED_ACTIVITY = 300;
58-
59-
/**
60-
* Fold a page of freshly-fetched activity into the previously cached set —
61-
* dedupe by task (newest activity wins), keep newest first. Lets repolls fetch
62-
* only what's new (via `since`) instead of re-fetching the whole top page every
63-
* time. A task whose activity advanced comes back on the next poll and replaces
64-
* its stale row rather than duplicating it.
65-
*/
66-
export function mergeTaskActivity(
67-
previous: readonly TaskActivity[],
68-
incoming: readonly TaskActivity[],
69-
): TaskActivity[] {
70-
if (incoming.length === 0) return [...previous];
71-
const byTaskId = new Map(previous.map((row) => [row.task_id, row]));
72-
for (const row of incoming) {
73-
const existing = byTaskId.get(row.task_id);
74-
if (!existing || row.activity_at > existing.activity_at) {
75-
byTaskId.set(row.task_id, row);
76-
}
77-
}
78-
return Array.from(byTaskId.values())
79-
.sort((a, b) => (a.activity_at < b.activity_at ? 1 : -1))
80-
.slice(0, MAX_CACHED_ACTIVITY);
81-
}

packages/shared/src/domain-types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ export type TaskActivityKind =
151151
* `TaskActivityDTO`.
152152
*/
153153
export interface TaskActivity {
154+
id: string;
154155
task_id: string;
155156
task_title: string;
156157
channel_id?: string | null;
@@ -160,6 +161,12 @@ export interface TaskActivity {
160161
snippet: string;
161162
latest_author?: UserBasic | null;
162163
latest_message_id?: string | null;
164+
is_unread: boolean;
165+
}
166+
167+
export interface TaskActivityPage {
168+
results: TaskActivity[];
169+
unread_count: number;
163170
}
164171

165172
export type TaskRunStatus =

packages/ui/src/features/canvas/components/ActivityView.tsx

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@ import { useCurrentUser } from "@posthog/ui/features/auth/useCurrentUser";
1818
import { getUserInitials } from "@posthog/ui/features/auth/userInitials";
1919
import { MentionText } from "@posthog/ui/features/canvas/components/MentionText";
2020
import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels";
21+
import { useMarkTaskActivityRead } from "@posthog/ui/features/canvas/hooks/useMarkTaskActivityRead";
2122
import { useTaskActivity } from "@posthog/ui/features/canvas/hooks/useTaskActivity";
2223
import { normalizeChannelName } from "@posthog/ui/features/canvas/hooks/useTaskChannels";
23-
import { useActivitySeenStore } from "@posthog/ui/features/canvas/stores/activitySeenStore";
2424
import { copyChannelLink } from "@posthog/ui/features/canvas/utils/copyChannelLink";
2525
import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay";
2626
import {
@@ -30,7 +30,7 @@ import {
3030
import { track } from "@posthog/ui/shell/analytics";
3131
import { Text } from "@radix-ui/themes";
3232
import type { ReactNode } from "react";
33-
import { useEffect, useMemo, useState } from "react";
33+
import { useEffect, useMemo } from "react";
3434

3535
function ChannelSuffix({ channelName }: { channelName: string | null }) {
3636
if (!channelName) return null;
@@ -168,6 +168,7 @@ export function ActivityView() {
168168
const client = useOptionalAuthenticatedClient();
169169
const { data: currentUser } = useCurrentUser({ client });
170170
const { items, isLoading } = useTaskActivity();
171+
const { mutate: markRead } = useMarkTaskActivityRead();
171172
// Items carry backend channel names only; the desktop folder-channel id
172173
// (needed for /website navigation and copy-link) is resolved here, where
173174
// the single useChannels subscription lives.
@@ -186,25 +187,16 @@ export function ActivityView() {
186187
channelName
187188
? (folderIdByName.get(normalizeChannelName(channelName)) ?? null)
188189
: null;
189-
const markSeen = useActivitySeenStore((s) => s.markSeen);
190-
// Snapshot before marking seen so rows that were new on arrival keep their
191-
// dot for this visit.
192-
const [seenAtOpen] = useState(
193-
() => useActivitySeenStore.getState().lastSeenAt,
194-
);
195-
196190
useEffect(() => {
197191
track(ANALYTICS_EVENTS.CHANNEL_ACTION, {
198192
action_type: "view_activity",
199193
surface: "activity",
200194
});
201195
}, []);
202196

203-
// Re-mark as items stream in so the badge stays cleared while reading.
204-
// biome-ignore lint/correctness/useExhaustiveDependencies: re-run per new item
205197
useEffect(() => {
206-
markSeen();
207-
}, [markSeen, items.length]);
198+
if (items.some((item) => item.isUnread)) markRead();
199+
}, [items, markRead]);
208200

209201
return (
210202
<div className="h-full overflow-y-auto bg-gray-1">
@@ -240,7 +232,7 @@ export function ActivityView() {
240232
key={item.taskId}
241233
item={item}
242234
folderChannelId={folderChannelIdFor(item.channelName)}
243-
isNew={!seenAtOpen || item.activityAt > seenAtOpen}
235+
isNew={item.isUnread}
244236
currentUserEmail={currentUser?.email}
245237
/>
246238
))}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { TaskActivityPage } from "@posthog/shared/domain-types";
2+
import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient";
3+
import { useMutation, useQueryClient } from "@tanstack/react-query";
4+
5+
const TASK_ACTIVITY_QUERY_KEY = ["task-activity"] as const;
6+
7+
export function useMarkTaskActivityRead() {
8+
const client = useOptionalAuthenticatedClient();
9+
const queryClient = useQueryClient();
10+
return useMutation({
11+
mutationFn: async () => {
12+
if (!client) throw new Error("Not authenticated");
13+
await client.markTaskActivityRead();
14+
},
15+
onSuccess: () => {
16+
queryClient.setQueryData<TaskActivityPage>(
17+
TASK_ACTIVITY_QUERY_KEY,
18+
(page) =>
19+
page
20+
? {
21+
...page,
22+
unread_count: 0,
23+
results: page.results.map((row) => ({
24+
...row,
25+
is_unread: false,
26+
})),
27+
}
28+
: page,
29+
);
30+
},
31+
});
32+
}

0 commit comments

Comments
 (0)