Skip to content

Commit 9849254

Browse files
authored
Merge branch 'main' into posthog-code/autoresearch-task-switch-latency
2 parents efc78d7 + e02e0c1 commit 9849254

19 files changed

Lines changed: 679 additions & 74 deletions

apps/code/runtime-dependencies.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ export const requiredNativeModules = [
3737
"better-sqlite3",
3838
];
3939

40-
// file-icon (and its p-map dependency) is only used on macOS.
41-
export const macOnlyNativeModules = ["file-icon", "p-map"];
40+
// file-icon is only used on macOS.
41+
export const macOnlyNativeModules = ["file-icon"];
4242

4343
// The subset that ships compiled .node binaries and must be unpacked from asar.
4444
const asarUnpackModules = [
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { describe, expect, it } from "vitest";
2+
import { resolveMacFileIconBinary } from "./electron-file-icon";
3+
4+
describe("resolveMacFileIconBinary", () => {
5+
it("resolves the packaged extractor outside the ASAR", () => {
6+
expect(
7+
resolveMacFileIconBinary(
8+
"/Applications/PostHog Code.app/Contents/Resources/app.asar",
9+
true,
10+
"/unused/node_modules/file-icon/index.js",
11+
),
12+
).toBe(
13+
"/Applications/PostHog Code.app/Contents/Resources/app.asar.unpacked/node_modules/file-icon/file-icon",
14+
);
15+
});
16+
});
Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,29 @@
1+
import { execFile } from "node:child_process";
2+
import path from "node:path";
13
import type { IFileIcon } from "@posthog/platform/file-icon";
24
import { app } from "electron";
35
import { injectable } from "inversify";
46

5-
type FileIconModule = typeof import("file-icon");
7+
const FILE_ICON_MAX_BUFFER_BYTES = 100 * 1024 * 1024;
8+
9+
export function resolveMacFileIconBinary(
10+
appPath: string,
11+
isPackaged: boolean,
12+
modulePath: string,
13+
): string {
14+
const resolvedModulePath = isPackaged
15+
? path.join(`${appPath}.unpacked`, "node_modules", "file-icon", "index.js")
16+
: modulePath;
17+
return path.join(path.dirname(resolvedModulePath), "file-icon");
18+
}
619

720
@injectable()
821
export class ElectronFileIcon implements IFileIcon {
9-
private fileIconModule: FileIconModule | undefined;
10-
1122
public async getAsDataUrl(filePath: string): Promise<string | null> {
1223
try {
1324
if (process.platform === "darwin") {
14-
const mod = await this.loadFileIconModule();
15-
const uint8Array = await mod.fileIconToBuffer(filePath, { size: 64 });
16-
const base64 = Buffer.from(uint8Array).toString("base64");
25+
const buffer = await this.getMacFileIcon(filePath);
26+
const base64 = buffer.toString("base64");
1727
return `data:image/png;base64,${base64}`;
1828
}
1929

@@ -25,10 +35,27 @@ export class ElectronFileIcon implements IFileIcon {
2535
}
2636
}
2737

28-
private async loadFileIconModule(): Promise<FileIconModule> {
29-
if (!this.fileIconModule) {
30-
this.fileIconModule = await import("file-icon");
31-
}
32-
return this.fileIconModule;
38+
private getMacFileIcon(filePath: string): Promise<Buffer> {
39+
const binaryPath = resolveMacFileIconBinary(
40+
app.getAppPath(),
41+
app.isPackaged,
42+
require.resolve("file-icon"),
43+
);
44+
const input = JSON.stringify([{ appOrPID: filePath, size: 64 }]);
45+
46+
return new Promise((resolve, reject) => {
47+
execFile(
48+
binaryPath,
49+
[input],
50+
{ encoding: "buffer", maxBuffer: FILE_ICON_MAX_BUFFER_BYTES },
51+
(error, stdout) => {
52+
if (error) {
53+
reject(error);
54+
return;
55+
}
56+
resolve(Buffer.from(stdout));
57+
},
58+
);
59+
});
3360
}
3461
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import {
2+
latestActivityForChannel,
3+
unreadChannelIds,
4+
} from "@posthog/core/canvas/channelUnread";
5+
import type { MentionActivityItem } from "@posthog/core/canvas/mentionActivity";
6+
import { describe, expect, it } from "vitest";
7+
8+
function mention(
9+
overrides: Partial<MentionActivityItem> & { createdAt: string },
10+
): MentionActivityItem {
11+
return {
12+
messageId: `m-${overrides.createdAt}`,
13+
taskId: "t1",
14+
taskTitle: "Task",
15+
channelId: "c1",
16+
channelName: "mobile",
17+
author: null,
18+
content: "hey @adam",
19+
...overrides,
20+
};
21+
}
22+
23+
describe("unreadChannelIds", () => {
24+
const cases: {
25+
name: string;
26+
lastSeen: Record<string, string>;
27+
expected: string[];
28+
}[] = [
29+
{
30+
name: "a channel never seen is unread",
31+
lastSeen: {},
32+
expected: ["c1"],
33+
},
34+
{
35+
name: "activity newer than the last visit is unread",
36+
lastSeen: { c1: "2026-07-16T09:00:00.000Z" },
37+
expected: ["c1"],
38+
},
39+
{
40+
name: "activity older than the last visit is read",
41+
lastSeen: { c1: "2026-07-16T11:00:00.000Z" },
42+
expected: [],
43+
},
44+
{
45+
name: "activity exactly at the last visit is read",
46+
lastSeen: { c1: "2026-07-16T10:00:00.000Z" },
47+
expected: [],
48+
},
49+
];
50+
it.each(cases)("$name", ({ lastSeen, expected }) => {
51+
const items = [mention({ createdAt: "2026-07-16T10:00:00.000Z" })];
52+
expect([...unreadChannelIds(items, lastSeen)]).toEqual(expected);
53+
});
54+
55+
it("compares each channel against its own last visit", () => {
56+
const items = [
57+
mention({ channelId: "c1", createdAt: "2026-07-16T10:00:00.000Z" }),
58+
mention({ channelId: "c2", createdAt: "2026-07-16T10:00:00.000Z" }),
59+
];
60+
const unread = unreadChannelIds(items, {
61+
c1: "2026-07-16T11:00:00.000Z",
62+
c2: "2026-07-16T09:00:00.000Z",
63+
});
64+
expect([...unread]).toEqual(["c2"]);
65+
});
66+
67+
it("uses the newest item in a channel, whatever the order", () => {
68+
const items = [
69+
mention({ messageId: "old", createdAt: "2026-07-16T08:00:00.000Z" }),
70+
mention({ messageId: "new", createdAt: "2026-07-16T12:00:00.000Z" }),
71+
];
72+
expect([
73+
...unreadChannelIds(items, { c1: "2026-07-16T10:00:00.000Z" }),
74+
]).toEqual(["c1"]);
75+
});
76+
77+
it("ignores channel-less mentions", () => {
78+
const items = [
79+
mention({ channelId: null, createdAt: "2026-07-16T10:00Z" }),
80+
];
81+
expect([...unreadChannelIds(items, {})]).toEqual([]);
82+
});
83+
});
84+
85+
describe("latestActivityForChannel", () => {
86+
it("returns the newest timestamp for that channel only", () => {
87+
const items = [
88+
mention({ channelId: "c1", createdAt: "2026-07-16T08:00:00.000Z" }),
89+
mention({ channelId: "c1", createdAt: "2026-07-16T12:00:00.000Z" }),
90+
mention({ channelId: "c2", createdAt: "2026-07-16T13:00:00.000Z" }),
91+
];
92+
expect(latestActivityForChannel(items, "c1")).toBe(
93+
"2026-07-16T12:00:00.000Z",
94+
);
95+
});
96+
97+
it("is undefined for a channel with no activity, or no channel", () => {
98+
expect(latestActivityForChannel([], "c1")).toBeUndefined();
99+
expect(latestActivityForChannel([], undefined)).toBeUndefined();
100+
});
101+
});
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import type { MentionActivityItem } from "@posthog/core/canvas/mentionActivity";
2+
3+
/**
4+
* Which channels have activity the viewer hasn't seen — the signal behind the
5+
* sidebar's bold channel names.
6+
*
7+
* "Activity" is currently an @-mention: that's the only cross-channel,
8+
* all-users feed the client has (the mentions index), and it's what
9+
* "notification" means elsewhere in the app. The backend exposes no per-channel
10+
* activity timestamp, so a broader "any new message" signal would mean polling
11+
* every user's full task list — the app's heaviest poll, deliberately retired.
12+
* If that timestamp lands, only `latestActivityByChannel` changes shape; the
13+
* unread comparison and the seen bookkeeping stay as they are.
14+
*
15+
* Keyed by backend channel id rather than name, so renaming a channel doesn't
16+
* silently mark it unread again.
17+
*/
18+
19+
/** Newest activity per channel id. Ignores items with no channel. */
20+
export function latestActivityByChannel(
21+
items: readonly MentionActivityItem[],
22+
): Map<string, string> {
23+
const latest = new Map<string, string>();
24+
for (const item of items) {
25+
if (!item.channelId) continue;
26+
const current = latest.get(item.channelId);
27+
if (!current || item.createdAt > current) {
28+
latest.set(item.channelId, item.createdAt);
29+
}
30+
}
31+
return latest;
32+
}
33+
34+
/**
35+
* Channel ids whose newest activity postdates the viewer's last visit. A
36+
* channel never visited is unread as soon as it has any activity.
37+
*/
38+
export function unreadChannelIds(
39+
items: readonly MentionActivityItem[],
40+
lastSeenByChannel: Readonly<Record<string, string>>,
41+
): Set<string> {
42+
const unread = new Set<string>();
43+
for (const [channelId, activityAt] of latestActivityByChannel(items)) {
44+
const seenAt = lastSeenByChannel[channelId];
45+
if (!seenAt || activityAt > seenAt) unread.add(channelId);
46+
}
47+
return unread;
48+
}
49+
50+
/**
51+
* The newest activity in one channel, for stamping it seen while it's open.
52+
* Scans for the one channel rather than reusing `latestActivityByChannel`,
53+
* which would build (and throw away) a map of every other channel to answer.
54+
*/
55+
export function latestActivityForChannel(
56+
items: readonly MentionActivityItem[],
57+
channelId: string | undefined,
58+
): string | undefined {
59+
if (!channelId) return undefined;
60+
let latest: string | undefined;
61+
for (const item of items) {
62+
if (item.channelId !== channelId) continue;
63+
if (!latest || item.createdAt > latest) latest = item.createdAt;
64+
}
65+
return latest;
66+
}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { HashIcon } from "@phosphor-icons/react";
22
import { Button, cn } from "@posthog/quill";
33
import { ChannelTabs } from "@posthog/ui/features/canvas/components/ChannelTabs";
44
import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels";
5+
import { useMarkChannelSeen } from "@posthog/ui/features/canvas/hooks/useMarkChannelSeen";
56
import { Text } from "@radix-ui/themes";
67
import { useNavigate, useRouterState } from "@tanstack/react-router";
78

@@ -17,6 +18,9 @@ export function ChannelHeader({ channelId }: { channelId: string }) {
1718
const channelName = channels.find((c) => c.id === channelId)?.name;
1819
const pathname = useRouterState({ select: (s) => s.location.pathname });
1920
const isHome = pathname === `/website/${channelId}`;
21+
// Every channel surface renders this header, so it is where "the viewer is
22+
// in this channel" is known — and therefore where the channel is marked read.
23+
useMarkChannelSeen(channelName);
2024

2125
return (
2226
<div className="flex min-w-0 items-center gap-2">

0 commit comments

Comments
 (0)