Skip to content
Open
Show file tree
Hide file tree
Changes from all 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);
});
94 changes: 94 additions & 0 deletions packages/core/src/recents/recentsService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
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" }),
}),
);
});

it("does not rewrite a non-canvas row supplied as a canvas id", async () => {
const { service, getEntry, fetch } = serviceWith([]);
getEntry.mockResolvedValue({
id: "task-row",
path: "Tasks/task-row",
type: "task",
ref: "task-row",
} as never);

await expect(
service.record({ kind: "canvas", id: "task-row" }),
).rejects.toThrow("Canvas not found");

expect(fetch).not.toHaveBeenCalled();
});
});
93 changes: 93 additions & 0 deletions packages/core/src/recents/recentsService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
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 || entry.type !== "dashboard")
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