Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
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/main/di/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ import {
import { oauthModule } from "@posthog/core/oauth/oauth.module";
import { PROVISIONING_SERVICE } from "@posthog/core/provisioning/identifiers";
import { ProvisioningService } from "@posthog/core/provisioning/provisioning";
import { recentsCoreModule } from "@posthog/core/recents/recents.module";
import { SLEEP_SERVICE } from "@posthog/core/sleep/identifiers";
import { SleepService } from "@posthog/core/sleep/sleep";
import { UI_AUTH } from "@posthog/core/ui/identifiers";
Expand Down Expand Up @@ -808,6 +809,7 @@ container.bind(MAIN_DISCORD_PRESENCE_SERVICE).to(DiscordPresenceService);
// live in @posthog/core (bound via canvasCoreModule) and resolve through
// ctx.container in the host-router routers.
container.load(canvasCoreModule);
container.load(recentsCoreModule);

// Browser tabs for the Channels canvas surface. Authoritative sqlite-backed
// service in the main process; resolved by the host-router browserTabs router.
Expand Down
2 changes: 2 additions & 0 deletions apps/code/src/main/trpc/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { osRouter } from "@posthog/host-router/routers/os.router";
import { piSessionRouter } from "@posthog/host-router/routers/pi-session.router";
import { processTrackingRouter } from "@posthog/host-router/routers/process-tracking.router";
import { provisioningRouter } from "@posthog/host-router/routers/provisioning.router";
import { recentsRouter } from "@posthog/host-router/routers/recents.router";
import { releaseFeedRouter } from "@posthog/host-router/routers/release-feed.router";
import { secureStoreRouter } from "@posthog/host-router/routers/secure-store.router";
import { shellRouter } from "@posthog/host-router/routers/shell.router";
Expand Down Expand Up @@ -71,6 +72,7 @@ export const trpcRouter = router({
channelTasks: channelTasksRouter,
claudeCliSessions: claudeCliSessionsRouter,
dashboards: dashboardsRouter,
recents: recentsRouter,
cloudTask: cloudTaskRouter,
connectivity: connectivityRouter,
contextMenu: contextMenuRouter,
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 @@ -99,6 +99,7 @@ import {
type PiSessionFactory,
type PiSessionProvider,
} from "@posthog/core/pi-runtime/piSessionController";
import { recentsCoreModule } from "@posthog/core/recents/recents.module";
import {
type BundleLocalSkill,
CLOUD_ARTIFACT_BUNDLE_LOCAL_SKILL,
Expand Down Expand Up @@ -488,6 +489,7 @@ container.bind(CLOUD_TASK_AUTH).toDynamicValue((ctx) => ({
// API), so the web host binds them by loading the same core module desktop does;
// the web host router forwards its canvas routers to these.
container.load(canvasCoreModule);
container.load(recentsCoreModule);
container.load(taskThreadCoreModule);

// SessionService is built from host-agnostic deps (host tRPC client + UI
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/web-host-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { canvasTemplatesRouter } from "@posthog/host-router/routers/canvas-templ
import { channelTasksRouter } from "@posthog/host-router/routers/channel-tasks.router";
import { cloudTaskRouter } from "@posthog/host-router/routers/cloud-task.router";
import { dashboardsRouter } from "@posthog/host-router/routers/dashboards.router";
import { recentsRouter } from "@posthog/host-router/routers/recents.router";
import { publicProcedure, router } from "@posthog/host-trpc/trpc";
import {
type CloudRegion,
Expand Down Expand Up @@ -494,6 +495,7 @@ export const webHostRouter = router({
channelTasks: channelTasksRouter,
cloudTask: cloudTaskRouter,
dashboards: dashboardsRouter,
recents: recentsRouter,
deepLink: deepLinkStubRouter,
folders: foldersStubRouter,
fs: fsStubRouter,
Expand Down
1 change: 0 additions & 1 deletion packages/core/src/canvas/dashboardSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,6 @@ export const dashboardSummarySchema = z.object({
export type DashboardSummary = z.infer<typeof dashboardSummarySchema>;

export const listDashboardsInput = z.object({ channelId: z.string().min(1) });

export const createDashboardInput = z.object({
channelId: z.string().min(1),
name: z.string().min(1),
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/recents/identifiers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import type { RecentsService } from "./recentsService";

export const RECENTS_SERVICE = Symbol.for("posthog.core.recents.service");
export type IRecentsService = Pick<RecentsService, "list" | "record">;
8 changes: 8 additions & 0 deletions packages/core/src/recents/recents.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { ContainerModule } from "inversify";
import { RECENTS_SERVICE } from "./identifiers";
import { RecentsService } from "./recentsService";

export const recentsCoreModule = new ContainerModule(({ bind }) => {
bind(RecentsService).toSelf().inSingletonScope();
bind(RECENTS_SERVICE).toService(RecentsService);
});
78 changes: 78 additions & 0 deletions packages/core/src/recents/recentsService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, expect, it, vi } from "vitest";
import type { DesktopFsClient, FsEntryBase } from "../canvas/desktopFsClient";
import { RecentsService } from "./recentsService";

function serviceWith(rows: Array<FsEntryBase & Record<string, unknown>>) {
const listByQuery = vi.fn(async () => rows);
const getEntry = vi.fn(async (id: string) => ({
id,
path: `Canvases/${id}`,
type: "dashboard",
ref: null,
}));
const fetch = vi.fn(async () => new Response(null, { status: 204 }));
const fs = { listByQuery, getEntry, fetch } as unknown as DesktopFsClient;
return { service: new RecentsService(fs), listByQuery, getEntry, fetch };
}

describe("RecentsService", () => {
it("returns the latest 20 task and canvas engagements from the backend", async () => {
const rows = Array.from({ length: 24 }, (_, index) => ({
id: `row-${index}`,
path: `Space/Item ${index}`,
type: index % 2 === 0 ? "task" : "dashboard",
ref: `entity-${index}`,
last_viewed_at: new Date(index * 1_000).toISOString(),
meta: { channelId: "channel-1", templateId: "freeform" },
}));
const { service, listByQuery } = serviceWith(rows);

const result = await service.list();

expect(listByQuery).toHaveBeenCalledWith(
"order_by=-last_viewed_at&limit=100&not_type=folder",
"recent items",
);
expect(result).toHaveLength(20);
expect(result[0]).toMatchObject({ id: "entity-23", kind: "canvas" });
expect(result.at(-1)).toMatchObject({ id: "entity-4", kind: "task" });
});

it("makes a canvas hydratable before logging its engagement", async () => {
const { service, fetch } = serviceWith([]);

await service.record({ kind: "canvas", id: "canvas-1" });

expect(fetch).toHaveBeenNthCalledWith(
1,
"canvas-1/",
expect.objectContaining({
method: "PATCH",
body: JSON.stringify({ ref: "canvas-1" }),
}),
);
expect(fetch).toHaveBeenNthCalledWith(
2,
"log_view/",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ type: "dashboard", ref: "canvas-1" }),
}),
);
});

it("logs task engagement without rewriting its existing file-system row", async () => {
const { service, getEntry, fetch } = serviceWith([]);

await service.record({ kind: "task", id: "task-1" });

expect(getEntry).not.toHaveBeenCalled();
expect(fetch).toHaveBeenCalledOnce();
expect(fetch).toHaveBeenCalledWith(
"log_view/",
expect.objectContaining({
body: JSON.stringify({ type: "task", ref: "task-1" }),
}),
);
});
});
92 changes: 92 additions & 0 deletions packages/core/src/recents/recentsService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import {
DESKTOP_FS_CLIENT,
type DesktopFsClient,
type FsEntryBase,
} from "@posthog/core/canvas/desktopFsClient";
import { inject, injectable } from "inversify";
import type { RecentEngagementInput, RecentItem } from "./schemas";

const RECENTS_LIMIT = 20;
const RECENTS_SCAN_LIMIT = 100;

interface RecentFsEntry extends FsEntryBase {
last_viewed_at?: string | null;
meta?: {
channelId?: string;
templateId?: string;
} | null;
}

@injectable()
export class RecentsService {
constructor(
@inject(DESKTOP_FS_CLIENT)
private readonly fs: DesktopFsClient,
) {}

async list(): Promise<RecentItem[]> {
const entries = await this.fs.listByQuery<RecentFsEntry>(
`order_by=-last_viewed_at&limit=${RECENTS_SCAN_LIMIT}&not_type=folder`,
"recent items",
);
return entries
.flatMap((entry) => this.toRecentItem(entry))
.sort((a, b) => b.engagedAt - a.engagedAt)
.slice(0, RECENTS_LIMIT);
}

async record(input: RecentEngagementInput): Promise<void> {
const type = input.kind === "canvas" ? "dashboard" : "task";
if (input.kind === "canvas") {
await this.ensureCanvasReference(input.id);
}
const response = await this.fs.fetch("log_view/", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type, ref: input.id }),
});
if (!response.ok) {
throw new Error(
`Failed to record recent engagement (${response.status})`,
);
}
}

private async ensureCanvasReference(id: string): Promise<void> {
const entry = await this.fs.getEntry<RecentFsEntry>(id, "canvas");
if (!entry) throw new Error("Canvas not found");
if (entry.ref === id) return;
Comment thread
veria-ai[bot] marked this conversation as resolved.
const response = await this.fs.fetch(`${encodeURIComponent(id)}/`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ref: id }),
});
if (!response.ok) {
throw new Error(`Failed to prepare canvas recents (${response.status})`);
}
}

private toRecentItem(entry: RecentFsEntry): RecentItem[] {
const engagedAt = entry.last_viewed_at
? Date.parse(entry.last_viewed_at)
: 0;
if (!entry.ref || engagedAt <= 0) return [];
const title = entry.path.slice(entry.path.lastIndexOf("/") + 1);
if (entry.type === "task") {
return [{ kind: "task", id: entry.ref, title, engagedAt }];
}
if (entry.type === "dashboard" && entry.meta?.channelId) {
return [
{
kind: "canvas",
id: entry.ref,
channelId: entry.meta.channelId,
title,
templateId: entry.meta.templateId ?? "freeform",
engagedAt,
},
];
}
return [];
}
}
25 changes: 25 additions & 0 deletions packages/core/src/recents/schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { z } from "zod";

export const recentEngagementInputSchema = z.object({
kind: z.enum(["task", "canvas"]),
id: z.string().min(1),
});
export type RecentEngagementInput = z.infer<typeof recentEngagementInputSchema>;

export const recentItemSchema = z.discriminatedUnion("kind", [
z.object({
kind: z.literal("task"),
id: z.string(),
title: z.string(),
engagedAt: z.number(),
}),
z.object({
kind: z.literal("canvas"),
id: z.string(),
channelId: z.string(),
title: z.string(),
templateId: z.string(),
engagedAt: z.number(),
}),
]);
export type RecentItem = z.infer<typeof recentItemSchema>;
2 changes: 2 additions & 0 deletions packages/host-router/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { osRouter } from "./routers/os.router";
import { piSessionRouter } from "./routers/pi-session.router";
import { processTrackingRouter } from "./routers/process-tracking.router";
import { provisioningRouter } from "./routers/provisioning.router";
import { recentsRouter } from "./routers/recents.router";
import { releaseFeedRouter } from "./routers/release-feed.router";
import { secureStoreRouter } from "./routers/secure-store.router";
import { shellRouter } from "./routers/shell.router";
Expand Down Expand Up @@ -67,6 +68,7 @@ export const hostRouter = router({
connectivity: connectivityRouter,
contextMenu: contextMenuRouter,
dashboards: dashboardsRouter,
recents: recentsRouter,
deepLink: deepLinkRouter,
enrichment: enrichmentRouter,
environment: environmentRouter,
Expand Down
24 changes: 24 additions & 0 deletions packages/host-router/src/routers/recents.router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import {
type IRecentsService,
RECENTS_SERVICE,
} from "@posthog/core/recents/identifiers";
import {
recentEngagementInputSchema,
recentItemSchema,
} from "@posthog/core/recents/schemas";
import { publicProcedure, router } from "@posthog/host-trpc/trpc";
import { z } from "zod";

export const recentsRouter = router({
list: publicProcedure
.output(z.array(recentItemSchema))
.query(({ ctx }) =>
ctx.container.get<IRecentsService>(RECENTS_SERVICE).list(),
),
record: publicProcedure
.input(recentEngagementInputSchema)
.output(z.void())
.mutation(({ ctx, input }) =>
ctx.container.get<IRecentsService>(RECENTS_SERVICE).record(input),
),
});
1 change: 1 addition & 0 deletions packages/shared/src/analytics-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@ export type SidebarNavItem =
| "command_center"
| "contexts"
| "activity"
| "recents"
| "configure"
| "loops"
| "more"
Expand Down
13 changes: 13 additions & 0 deletions packages/ui/src/features/canvas/components/ChannelNav.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ vi.mock("@posthog/ui/shell/analytics", () => ({ track: vi.fn() }));
vi.mock("./ActivityHoverCard", () => ({
ActivityHoverCard: () => <div>Recent activity card</div>,
}));
vi.mock("@posthog/ui/features/recents/RecentsHoverCard", () => ({
RecentsHoverCard: () => <div>Recent items card</div>,
}));

import { ChannelNav } from "./ChannelNav";

Expand All @@ -51,6 +54,16 @@ describe("ChannelNav", () => {
).toBeInTheDocument();
});

it("opens recents after the hover delay", async () => {
const user = userEvent.setup();
render(<ChannelNav />);

await user.hover(screen.getByLabelText("Recents"));
expect(
await screen.findByText("Recent items card", {}, { timeout: 1_000 }),
).toBeInTheDocument();
});

it("closes promptly after the pointer leaves", async () => {
const user = userEvent.setup();
render(<ChannelNav />);
Expand Down
Loading
Loading