From 08f8de607245f135fdb5fe942f81870887b0c80d Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Thu, 30 Jul 2026 17:27:15 +0100 Subject: [PATCH 1/4] feat: add recents global nav action Generated-By: PostHog Code Task-Id: f68869f0-4c94-4017-add1-ce18b6a323b3 --- packages/core/src/canvas/dashboardSchemas.ts | 3 + .../core/src/canvas/dashboardsService.test.ts | 24 +++++ packages/core/src/canvas/dashboardsService.ts | 36 ++++++++ packages/core/src/canvas/recentItems.test.ts | 61 +++++++++++++ packages/core/src/canvas/recentItems.ts | 65 +++++++++++++ packages/core/src/canvas/services.ts | 1 + .../src/routers/dashboards.router.ts | 9 ++ packages/shared/src/analytics-events.ts | 1 + .../canvas/components/ChannelNav.test.tsx | 13 +++ .../features/canvas/components/ChannelNav.tsx | 27 ++++++ .../canvas/components/RecentsHoverCard.tsx | 91 +++++++++++++++++++ .../canvas/components/WebsiteDashboard.tsx | 4 + .../features/canvas/hooks/useDashboards.ts | 17 ++++ .../features/canvas/hooks/useRecentItems.ts | 29 ++++++ .../canvas/stores/recentCanvasStore.ts | 26 ++++++ .../CustomizeSidebarDialog.test.tsx | 1 + .../components/CustomizeSidebarDialog.tsx | 4 +- .../components/SidebarNavSection.test.tsx | 4 + .../sidebar/components/SidebarNavSection.tsx | 8 ++ .../sidebar/components/items/RecentsItem.tsx | 33 +++++++ .../ui/src/features/sidebar/constants.test.ts | 1 + packages/ui/src/features/sidebar/constants.ts | 6 ++ 22 files changed, 463 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/canvas/recentItems.test.ts create mode 100644 packages/core/src/canvas/recentItems.ts create mode 100644 packages/ui/src/features/canvas/components/RecentsHoverCard.tsx create mode 100644 packages/ui/src/features/canvas/hooks/useRecentItems.ts create mode 100644 packages/ui/src/features/canvas/stores/recentCanvasStore.ts create mode 100644 packages/ui/src/features/sidebar/components/items/RecentsItem.tsx diff --git a/packages/core/src/canvas/dashboardSchemas.ts b/packages/core/src/canvas/dashboardSchemas.ts index 39bf7053ec..2c2faba34f 100644 --- a/packages/core/src/canvas/dashboardSchemas.ts +++ b/packages/core/src/canvas/dashboardSchemas.ts @@ -89,6 +89,9 @@ export const dashboardSummarySchema = z.object({ export type DashboardSummary = z.infer; export const listDashboardsInput = z.object({ channelId: z.string().min(1) }); +export const listRecentDashboardsInput = z.object({ + limit: z.number().int().min(1).max(100).default(20), +}); export const createDashboardInput = z.object({ channelId: z.string().min(1), diff --git a/packages/core/src/canvas/dashboardsService.test.ts b/packages/core/src/canvas/dashboardsService.test.ts index 5f1ca54c71..b14448dc95 100644 --- a/packages/core/src/canvas/dashboardsService.test.ts +++ b/packages/core/src/canvas/dashboardsService.test.ts @@ -95,6 +95,30 @@ describe("DashboardsService.list", () => { }); }); +describe("DashboardsService.listRecent", () => { + it("fetches dashboards across channels and caps them newest first", async () => { + const rows = Array.from({ length: 24 }, (_, index) => + dashboardRow( + `canvas-${index}`, + `Canvas ${index}`, + `chan-${index % 2}`, + index, + ), + ); + const { fs, listByQuery } = fakeFs(rows); + + const result = await new DashboardsService(fs, {} as never).listRecent(20); + + expect(listByQuery).toHaveBeenCalledWith( + "type=dashboard", + "recent dashboards", + ); + expect(result).toHaveLength(20); + expect(result[0].id).toBe("canvas-23"); + expect(result.at(-1)?.id).toBe("canvas-4"); + }); +}); + // Fake exposing getEntry (resolves the row to rename) + fetch (the PATCH). function fakeFsForRename(entry: FsEntryBase) { const fetch = vi.fn(async (_path: string, init?: RequestInit) => ({ diff --git a/packages/core/src/canvas/dashboardsService.ts b/packages/core/src/canvas/dashboardsService.ts index 181f2346b9..a8c66acb4b 100644 --- a/packages/core/src/canvas/dashboardsService.ts +++ b/packages/core/src/canvas/dashboardsService.ts @@ -109,6 +109,42 @@ export class DashboardsService { ); } + async listRecent(limit: number): Promise { + const entries = await this.fs.listByQuery( + `type=${DASHBOARD_TYPE}`, + "recent dashboards", + ); + return entries + .map((entry) => toRecord(entry)) + .sort((a, b) => b.updatedAt - a.updatedAt) + .slice(0, limit) + .map( + ({ + id, + channelId, + name, + templateId, + createdBy, + createdByUuid, + updatedAt, + code, + generationTaskId, + pinnedAt, + }) => ({ + id, + channelId, + name, + templateId, + createdBy, + createdByUuid, + updatedAt, + code, + generationTaskId, + pinnedAt, + }), + ); + } + async get(id: string): Promise { const entry = await this.getEntry(id); return entry ? toRecord(entry) : null; diff --git a/packages/core/src/canvas/recentItems.test.ts b/packages/core/src/canvas/recentItems.test.ts new file mode 100644 index 0000000000..f7b3b1a1d9 --- /dev/null +++ b/packages/core/src/canvas/recentItems.test.ts @@ -0,0 +1,61 @@ +import type { Task } from "@posthog/shared/domain-types"; +import { describe, expect, it } from "vitest"; +import type { DashboardSummary } from "./dashboardSchemas"; +import { buildRecentItems } from "./recentItems"; + +describe("buildRecentItems", () => { + it("returns the 20 most recently engaged tasks and canvases", () => { + const tasks = Array.from({ length: 12 }, (_, index) => ({ + id: `task-${index}`, + title: `Task ${index}`, + updated_at: new Date(index * 1_000).toISOString(), + })) as Task[]; + const dashboards = Array.from({ length: 12 }, (_, index) => ({ + id: `canvas-${index}`, + channelId: "channel-1", + name: `Canvas ${index}`, + templateId: "freeform", + updatedAt: (index + 12) * 1_000, + })) as DashboardSummary[]; + + const result = buildRecentItems({ + tasks, + dashboards, + taskTimestamps: Object.fromEntries( + tasks.map((task, index) => [ + task.id, + { lastViewedAt: index * 1_000, lastActivityAt: null }, + ]), + ), + canvasViewedAt: Object.fromEntries( + dashboards.map((dashboard, index) => [ + dashboard.id, + (index + 12) * 1_000, + ]), + ), + }); + + expect(result).toHaveLength(20); + expect(result[0]).toMatchObject({ kind: "canvas", id: "canvas-11" }); + expect(result.at(-1)).toMatchObject({ kind: "task", id: "task-4" }); + }); + + it("uses the latest view, activity, or update and excludes untouched tasks", () => { + const tasks = [ + { id: "viewed", title: "Viewed", updated_at: "2020-01-01" }, + { id: "prompted", title: "Prompted", updated_at: "2020-01-01" }, + { id: "untouched", title: "Untouched", updated_at: "2026-01-01" }, + ] as Task[]; + + expect( + buildRecentItems({ + tasks, + dashboards: [], + taskTimestamps: { + viewed: { lastViewedAt: 2_000, lastActivityAt: 1_000 }, + prompted: { lastViewedAt: 1_000, lastActivityAt: 3_000 }, + }, + }).map((item) => item.id), + ).toEqual(["prompted", "viewed"]); + }); +}); diff --git a/packages/core/src/canvas/recentItems.ts b/packages/core/src/canvas/recentItems.ts new file mode 100644 index 0000000000..226e64a0ae --- /dev/null +++ b/packages/core/src/canvas/recentItems.ts @@ -0,0 +1,65 @@ +import type { Task } from "@posthog/shared/domain-types"; +import type { TaskTimestamps } from "../sidebar/taskMeta"; +import type { DashboardSummary } from "./dashboardSchemas"; + +export const RECENT_ITEMS_LIMIT = 20; + +export type RecentItem = + | { + kind: "task"; + id: string; + title: string; + engagedAt: number; + } + | { + kind: "canvas"; + id: string; + channelId: string; + title: string; + templateId: string; + engagedAt: number; + }; + +export function buildRecentItems({ + tasks, + dashboards, + taskTimestamps, + canvasViewedAt = {}, + limit = RECENT_ITEMS_LIMIT, +}: { + tasks: readonly Task[]; + dashboards: readonly DashboardSummary[]; + taskTimestamps: Readonly>; + canvasViewedAt?: Readonly>; + limit?: number; +}): RecentItem[] { + const taskItems = tasks.flatMap((task) => { + const timestamps = taskTimestamps[task.id]; + const engagedAt = Math.max( + timestamps?.lastViewedAt ?? 0, + timestamps?.lastActivityAt ?? 0, + ); + return engagedAt > 0 + ? [{ kind: "task", id: task.id, title: task.title, engagedAt }] + : []; + }); + const canvasItems = dashboards.flatMap((dashboard) => { + const engagedAt = canvasViewedAt[dashboard.id] ?? 0; + return engagedAt > 0 + ? [ + { + kind: "canvas", + id: dashboard.id, + channelId: dashboard.channelId, + title: dashboard.name, + templateId: dashboard.templateId, + engagedAt, + }, + ] + : []; + }); + + return [...taskItems, ...canvasItems] + .sort((a, b) => b.engagedAt - a.engagedAt) + .slice(0, limit); +} diff --git a/packages/core/src/canvas/services.ts b/packages/core/src/canvas/services.ts index abae8e389a..2f6e7f5414 100644 --- a/packages/core/src/canvas/services.ts +++ b/packages/core/src/canvas/services.ts @@ -27,6 +27,7 @@ export interface ICanvasTemplatesService { export interface IDashboardsService { list(channelId: string): Promise; + listRecent(limit: number): Promise; get(id: string): Promise; create(input: { channelId: string; diff --git a/packages/host-router/src/routers/dashboards.router.ts b/packages/host-router/src/routers/dashboards.router.ts index 2f5996794b..df62799d22 100644 --- a/packages/host-router/src/routers/dashboards.router.ts +++ b/packages/host-router/src/routers/dashboards.router.ts @@ -5,6 +5,7 @@ import { dashboardSummarySchema, ensureHomeCanvasInput, listDashboardsInput, + listRecentDashboardsInput, renameDashboardInput, saveFreeformInput, setGenerationTaskInput, @@ -24,6 +25,14 @@ export const dashboardsRouter = router({ .get(DASHBOARDS_SERVICE) .list(input.channelId), ), + listRecent: publicProcedure + .input(listRecentDashboardsInput) + .output(z.array(dashboardSummarySchema)) + .query(({ ctx, input }) => + ctx.container + .get(DASHBOARDS_SERVICE) + .listRecent(input.limit), + ), get: publicProcedure .input(dashboardIdInput) .output(dashboardRecordSchema.nullable()) diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index 2952a5e012..c1b5a1ee0a 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -250,6 +250,7 @@ export type SidebarNavItem = | "command_center" | "contexts" | "activity" + | "recents" | "configure" | "loops" | "more" diff --git a/packages/ui/src/features/canvas/components/ChannelNav.test.tsx b/packages/ui/src/features/canvas/components/ChannelNav.test.tsx index fe2da703cb..936d074664 100644 --- a/packages/ui/src/features/canvas/components/ChannelNav.test.tsx +++ b/packages/ui/src/features/canvas/components/ChannelNav.test.tsx @@ -31,6 +31,9 @@ vi.mock("@posthog/ui/shell/analytics", () => ({ track: vi.fn() })); vi.mock("./ActivityHoverCard", () => ({ ActivityHoverCard: () =>
Recent activity card
, })); +vi.mock("./RecentsHoverCard", () => ({ + RecentsHoverCard: () =>
Recent items card
, +})); import { ChannelNav } from "./ChannelNav"; @@ -51,6 +54,16 @@ describe("ChannelNav", () => { ).toBeInTheDocument(); }); + it("opens recents after the hover delay", async () => { + const user = userEvent.setup(); + render(); + + 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(); diff --git a/packages/ui/src/features/canvas/components/ChannelNav.tsx b/packages/ui/src/features/canvas/components/ChannelNav.tsx index fe76afeb19..e452e3b1f2 100644 --- a/packages/ui/src/features/canvas/components/ChannelNav.tsx +++ b/packages/ui/src/features/canvas/components/ChannelNav.tsx @@ -1,5 +1,6 @@ import { BellIcon, + ClockCounterClockwiseIcon, EnvelopeSimple, Lightning, SlidersHorizontal, @@ -41,6 +42,7 @@ import { useAppView } from "@posthog/ui/router/useAppView"; import { track } from "@posthog/ui/shell/analytics"; import { type ComponentPropsWithRef, type ReactNode, useState } from "react"; import { ActivityHoverCard } from "./ActivityHoverCard"; +import { RecentsHoverCard } from "./RecentsHoverCard"; const INBOX_REFETCH_INTERVAL_MS = 60_000; @@ -137,6 +139,7 @@ export function ChannelNav() { const view = useAppView(); const loopsEnabled = useFeatureFlag(LOOPS_FLAG, import.meta.env.DEV); const [activityOpen, setActivityOpen] = useState(false); + const [recentsOpen, setRecentsOpen] = useState(false); const { counts } = useInboxAllReports({ ignoreFilters: true, @@ -216,6 +219,30 @@ export function ChannelNav() { /> )} + + } + label="Recents" + isActive={false} + onClick={() => { + withTrack("recents", () => {})(); + setRecentsOpen((value) => !value); + }} + /> + } + /> + {recentsOpen && ( + setRecentsOpen(false)} + /> + )} + void; + side?: "bottom" | "right"; +}) { + const { items, isLoading } = useRecentItems(); + return ( + +
+ Recents +
+
+ {isLoading && items.length === 0 ? ( +
+ +
+ ) : items.length === 0 ? ( + + + + + + No recents yet + + Tasks and canvases you engage with will appear here. + + + + ) : ( +
+ {items.map((item) => ( + + ) + } + label={{item.title || "Untitled task"}} + isActive={false} + endContent={ + + {formatRelativeTimeShort(item.engagedAt)} + + } + onClick={() => { + onClose(); + if (item.kind === "canvas") + navigateToChannelDashboard(item.channelId, item.id); + else navigateToTaskDetail(item.id); + }} + /> + ))} +
+ )} +
+
+ ); +} diff --git a/packages/ui/src/features/canvas/components/WebsiteDashboard.tsx b/packages/ui/src/features/canvas/components/WebsiteDashboard.tsx index 0af43d10cf..0f3918d856 100644 --- a/packages/ui/src/features/canvas/components/WebsiteDashboard.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteDashboard.tsx @@ -2,6 +2,7 @@ import { FreeformCanvasView } from "@posthog/ui/features/canvas/freeform/Freefor import { useDashboard } from "@posthog/ui/features/canvas/hooks/useDashboards"; import { useIsDashboardEditing } from "@posthog/ui/features/canvas/stores/dashboardEditStore"; import { useFreeformChatStore } from "@posthog/ui/features/canvas/stores/freeformChatStore"; +import { useRecentCanvasStore } from "@posthog/ui/features/canvas/stores/recentCanvasStore"; import { useEffect } from "react"; // Renders a canvas's React app in a sandboxed iframe (view + edit). Edit mode @@ -10,9 +11,12 @@ export function WebsiteDashboard({ dashboardId }: { dashboardId: string }) { const editing = useIsDashboardEditing(dashboardId); const { dashboard } = useDashboard(dashboardId); const syncFromRecord = useFreeformChatStore((s) => s.syncFromRecord); + const markViewed = useRecentCanvasStore((s) => s.markViewed); const threadId = `dashboard:${dashboardId}`; + useEffect(() => markViewed(dashboardId), [dashboardId, markViewed]); + // Seed the thread from the saved record (code + version history) when its data // lands, so undo/redo and the live render reflect what's stored — and adopt a // version a generation task just published. diff --git a/packages/ui/src/features/canvas/hooks/useDashboards.ts b/packages/ui/src/features/canvas/hooks/useDashboards.ts index 808c645cb0..036c07e6df 100644 --- a/packages/ui/src/features/canvas/hooks/useDashboards.ts +++ b/packages/ui/src/features/canvas/hooks/useDashboards.ts @@ -58,6 +58,23 @@ export function useDashboards( return { dashboards: data ?? [], isLoading }; } +export function useRecentDashboards(limit = 100): { + dashboards: DashboardSummary[]; + isLoading: boolean; +} { + const trpc = useHostTRPC(); + const { data, isLoading } = useQuery( + trpc.dashboards.listRecent.queryOptions( + { limit }, + { + meta: AUTH_SCOPED_QUERY_META, + staleTime: SPACE_QUERY_STALE_TIME_MS, + }, + ), + ); + return { dashboards: data ?? [], isLoading }; +} + /** * Warm the dashboards-list cache for a channel ahead of opening it (e.g. on * hover), so expanding the channel shows its canvases without a cold fetch. diff --git a/packages/ui/src/features/canvas/hooks/useRecentItems.ts b/packages/ui/src/features/canvas/hooks/useRecentItems.ts new file mode 100644 index 0000000000..c781bb64a8 --- /dev/null +++ b/packages/ui/src/features/canvas/hooks/useRecentItems.ts @@ -0,0 +1,29 @@ +import { buildRecentItems } from "@posthog/core/canvas/recentItems"; +import { useRecentDashboards } from "@posthog/ui/features/canvas/hooks/useDashboards"; +import { useRecentCanvasStore } from "@posthog/ui/features/canvas/stores/recentCanvasStore"; +import { useTaskViewed } from "@posthog/ui/features/sidebar/useTaskViewed"; +import { useTasks } from "@posthog/ui/features/tasks/useTasks"; +import { useMemo } from "react"; + +export function useRecentItems() { + const { data: tasks = [], isLoading: tasksLoading } = useTasks({ + showAllUsers: true, + }); + const { timestamps, isLoading: timestampsLoading } = useTaskViewed(); + const { dashboards, isLoading: dashboardsLoading } = useRecentDashboards(); + const canvasViewedAt = useRecentCanvasStore((state) => state.viewedAt); + const items = useMemo( + () => + buildRecentItems({ + tasks, + dashboards, + taskTimestamps: timestamps, + canvasViewedAt, + }), + [tasks, dashboards, timestamps, canvasViewedAt], + ); + return { + items, + isLoading: tasksLoading || timestampsLoading || dashboardsLoading, + }; +} diff --git a/packages/ui/src/features/canvas/stores/recentCanvasStore.ts b/packages/ui/src/features/canvas/stores/recentCanvasStore.ts new file mode 100644 index 0000000000..1ec425f8ee --- /dev/null +++ b/packages/ui/src/features/canvas/stores/recentCanvasStore.ts @@ -0,0 +1,26 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +interface RecentCanvasState { + viewedAt: Record; + markViewed: (dashboardId: string) => void; +} + +export const useRecentCanvasStore = create()( + persist( + (set) => ({ + viewedAt: {}, + markViewed: (dashboardId) => + set((state) => { + const entries = Object.entries({ + ...state.viewedAt, + [dashboardId]: Date.now(), + }) + .sort(([, a], [, b]) => b - a) + .slice(0, 100); + return { viewedAt: Object.fromEntries(entries) }; + }), + }), + { name: "posthog-code-recent-canvases" }, + ), +); diff --git a/packages/ui/src/features/sidebar/components/CustomizeSidebarDialog.test.tsx b/packages/ui/src/features/sidebar/components/CustomizeSidebarDialog.test.tsx index 811b2f4d6c..aa8610e604 100644 --- a/packages/ui/src/features/sidebar/components/CustomizeSidebarDialog.test.tsx +++ b/packages/ui/src/features/sidebar/components/CustomizeSidebarDialog.test.tsx @@ -173,6 +173,7 @@ describe("CustomizeSidebarSettings", () => { "loops", "inbox", "activity", + "recents", "command-center", "configure", ]); diff --git a/packages/ui/src/features/sidebar/components/CustomizeSidebarDialog.tsx b/packages/ui/src/features/sidebar/components/CustomizeSidebarDialog.tsx index 00d855ff46..afbe3114dd 100644 --- a/packages/ui/src/features/sidebar/components/CustomizeSidebarDialog.tsx +++ b/packages/ui/src/features/sidebar/components/CustomizeSidebarDialog.tsx @@ -2,6 +2,7 @@ import { type DragDropEvents, DragDropProvider } from "@dnd-kit/react"; import { useSortable } from "@dnd-kit/react/sortable"; import { Bell, + ClockCounterClockwise, DotsSixVertical, EnvelopeSimple, Lightning, @@ -31,6 +32,7 @@ const ITEM_ICONS: Record< inbox: EnvelopeSimple, "command-center": Lightning, activity: Bell, + recents: ClockCounterClockwise, configure: SlidersHorizontal, loops: LoopIcon, }; @@ -72,7 +74,7 @@ export function CustomizeSidebarSettings() { const items = orderedNavItems(previewOrder ?? navItemOrder).filter( ({ id }) => { if (id === "loops") return loopsEnabled; - if (id === "activity") return bluebirdEnabled; + if (id === "activity" || id === "recents") return bluebirdEnabled; return true; }, ); diff --git a/packages/ui/src/features/sidebar/components/SidebarNavSection.test.tsx b/packages/ui/src/features/sidebar/components/SidebarNavSection.test.tsx index 47da00ef51..8e183f7ac1 100644 --- a/packages/ui/src/features/sidebar/components/SidebarNavSection.test.tsx +++ b/packages/ui/src/features/sidebar/components/SidebarNavSection.test.tsx @@ -75,6 +75,9 @@ vi.mock("@posthog/ui/features/tasks/useTasks", () => ({ vi.mock("@posthog/ui/features/canvas/hooks/useTaskActivity", () => ({ useTaskActivity: () => ({ items: [], unreadCount: 0, isLoading: false }), })); +vi.mock("./items/RecentsItem", () => ({ + RecentsItem: () => , +})); vi.mock("@tanstack/react-router", () => ({ useRouterState: () => false, })); @@ -114,6 +117,7 @@ describe("SidebarNavSection", () => { ["inbox", "Inbox"], ["command-center", "Command Center"], ["activity", "Activity"], + ["recents", "Recents"], ["configure", "Configure"], ["loops", "Loops"], ] as const)("removes %s from the sidebar when hidden", (id, label) => { diff --git a/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx b/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx index 8901c91a0a..ac66dd35b8 100644 --- a/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx +++ b/packages/ui/src/features/sidebar/components/SidebarNavSection.tsx @@ -34,6 +34,7 @@ import { ConfigureItem } from "./items/ConfigureItem"; import { InboxItem } from "./items/InboxItem"; import { LoopsItem } from "./items/LoopsItem"; import { NewTaskItem } from "./items/NewTaskItem"; +import { RecentsItem } from "./items/RecentsItem"; import { SearchItem } from "./items/SearchItem"; const SIDEBAR_INBOX_REFETCH_INTERVAL_MS = 60_000; @@ -137,6 +138,7 @@ export function SidebarNavSection({ inbox: true, "command-center": true, activity: bluebirdEnabled, + recents: bluebirdEnabled, configure: true, loops: loopsEnabled, }; @@ -170,6 +172,12 @@ export function SidebarNavSection({ onClick={withNavTrack("activity", navigateToActivity, depth)} /> ), + recents: (depth) => ( + {}, depth)} + /> + ), configure: (depth) => ( void; +}) { + const [open, setOpen] = useState(false); + const item = ( + } + label="Recents" + isActive={false} + onClick={() => { + onClick(); + setOpen((value) => !value); + }} + /> + ); + return ( + + + {open && setOpen(false)} />} + + ); +} diff --git a/packages/ui/src/features/sidebar/constants.test.ts b/packages/ui/src/features/sidebar/constants.test.ts index 6a09f96131..341f73f985 100644 --- a/packages/ui/src/features/sidebar/constants.test.ts +++ b/packages/ui/src/features/sidebar/constants.test.ts @@ -49,6 +49,7 @@ describe("orderedNavItems", () => { "inbox", "loops", "command-center", + "recents", "configure", ]); }); diff --git a/packages/ui/src/features/sidebar/constants.ts b/packages/ui/src/features/sidebar/constants.ts index ef08be90bd..117b8c857a 100644 --- a/packages/ui/src/features/sidebar/constants.ts +++ b/packages/ui/src/features/sidebar/constants.ts @@ -18,6 +18,12 @@ export const CUSTOMIZABLE_NAV_ITEMS = [ analyticsId: "activity", defaultVisible: true, }, + { + id: "recents", + label: "Recents", + analyticsId: "recents", + defaultVisible: true, + }, { id: "loops", label: "Loops", From 89ec09546929a9b0055d9cfaf9ca613ef8cd0be9 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Thu, 30 Jul 2026 17:57:14 +0100 Subject: [PATCH 2/4] refactor: back recents with server engagement history Generated-By: PostHog Code Task-Id: f68869f0-4c94-4017-add1-ce18b6a323b3 --- apps/code/src/main/di/container.ts | 2 + apps/code/src/main/trpc/router.ts | 2 + apps/web/src/web-container.ts | 2 + apps/web/src/web-host-router.ts | 2 + packages/core/src/canvas/dashboardSchemas.ts | 4 - .../core/src/canvas/dashboardsService.test.ts | 24 ----- packages/core/src/canvas/dashboardsService.ts | 36 -------- packages/core/src/canvas/recentItems.test.ts | 61 ------------ packages/core/src/canvas/recentItems.ts | 65 ------------- packages/core/src/canvas/services.ts | 1 - packages/core/src/recents/identifiers.ts | 4 + packages/core/src/recents/recents.module.ts | 8 ++ .../core/src/recents/recentsService.test.ts | 78 ++++++++++++++++ packages/core/src/recents/recentsService.ts | 92 +++++++++++++++++++ packages/core/src/recents/schemas.ts | 25 +++++ packages/host-router/src/router.ts | 2 + .../src/routers/dashboards.router.ts | 9 -- .../host-router/src/routers/recents.router.ts | 24 +++++ .../canvas/components/ChannelNav.test.tsx | 2 +- .../features/canvas/components/ChannelNav.tsx | 2 +- .../canvas/components/WebsiteDashboard.tsx | 8 +- .../features/canvas/hooks/useDashboards.ts | 17 ---- .../canvas/hooks/useGenerateFreeformCanvas.ts | 4 + .../features/canvas/hooks/useRecentItems.ts | 29 ------ .../canvas/stores/recentCanvasStore.ts | 26 ------ .../RecentsHoverCard.tsx | 4 +- .../ui/src/features/recents/useRecents.ts | 30 ++++++ .../hooks/useSessionCallbacks.test.tsx | 5 + .../sessions/hooks/useSessionCallbacks.ts | 4 + .../sidebar/components/items/RecentsItem.tsx | 2 +- .../task-detail/components/TaskDetail.tsx | 6 ++ 31 files changed, 300 insertions(+), 280 deletions(-) delete mode 100644 packages/core/src/canvas/recentItems.test.ts delete mode 100644 packages/core/src/canvas/recentItems.ts create mode 100644 packages/core/src/recents/identifiers.ts create mode 100644 packages/core/src/recents/recents.module.ts create mode 100644 packages/core/src/recents/recentsService.test.ts create mode 100644 packages/core/src/recents/recentsService.ts create mode 100644 packages/core/src/recents/schemas.ts create mode 100644 packages/host-router/src/routers/recents.router.ts delete mode 100644 packages/ui/src/features/canvas/hooks/useRecentItems.ts delete mode 100644 packages/ui/src/features/canvas/stores/recentCanvasStore.ts rename packages/ui/src/features/{canvas/components => recents}/RecentsHoverCard.tsx (95%) create mode 100644 packages/ui/src/features/recents/useRecents.ts diff --git a/apps/code/src/main/di/container.ts b/apps/code/src/main/di/container.ts index 04c245598f..b91d5fd8e6 100644 --- a/apps/code/src/main/di/container.ts +++ b/apps/code/src/main/di/container.ts @@ -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"; @@ -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. diff --git a/apps/code/src/main/trpc/router.ts b/apps/code/src/main/trpc/router.ts index 362a9de753..4294b41aa0 100644 --- a/apps/code/src/main/trpc/router.ts +++ b/apps/code/src/main/trpc/router.ts @@ -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"; @@ -71,6 +72,7 @@ export const trpcRouter = router({ channelTasks: channelTasksRouter, claudeCliSessions: claudeCliSessionsRouter, dashboards: dashboardsRouter, + recents: recentsRouter, cloudTask: cloudTaskRouter, connectivity: connectivityRouter, contextMenu: contextMenuRouter, diff --git a/apps/web/src/web-container.ts b/apps/web/src/web-container.ts index 1321eafa37..72685cc1a3 100644 --- a/apps/web/src/web-container.ts +++ b/apps/web/src/web-container.ts @@ -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, @@ -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 diff --git a/apps/web/src/web-host-router.ts b/apps/web/src/web-host-router.ts index 07f148b42d..8615163b3b 100644 --- a/apps/web/src/web-host-router.ts +++ b/apps/web/src/web-host-router.ts @@ -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, @@ -494,6 +495,7 @@ export const webHostRouter = router({ channelTasks: channelTasksRouter, cloudTask: cloudTaskRouter, dashboards: dashboardsRouter, + recents: recentsRouter, deepLink: deepLinkStubRouter, folders: foldersStubRouter, fs: fsStubRouter, diff --git a/packages/core/src/canvas/dashboardSchemas.ts b/packages/core/src/canvas/dashboardSchemas.ts index 2c2faba34f..807281207e 100644 --- a/packages/core/src/canvas/dashboardSchemas.ts +++ b/packages/core/src/canvas/dashboardSchemas.ts @@ -89,10 +89,6 @@ export const dashboardSummarySchema = z.object({ export type DashboardSummary = z.infer; export const listDashboardsInput = z.object({ channelId: z.string().min(1) }); -export const listRecentDashboardsInput = z.object({ - limit: z.number().int().min(1).max(100).default(20), -}); - export const createDashboardInput = z.object({ channelId: z.string().min(1), name: z.string().min(1), diff --git a/packages/core/src/canvas/dashboardsService.test.ts b/packages/core/src/canvas/dashboardsService.test.ts index b14448dc95..5f1ca54c71 100644 --- a/packages/core/src/canvas/dashboardsService.test.ts +++ b/packages/core/src/canvas/dashboardsService.test.ts @@ -95,30 +95,6 @@ describe("DashboardsService.list", () => { }); }); -describe("DashboardsService.listRecent", () => { - it("fetches dashboards across channels and caps them newest first", async () => { - const rows = Array.from({ length: 24 }, (_, index) => - dashboardRow( - `canvas-${index}`, - `Canvas ${index}`, - `chan-${index % 2}`, - index, - ), - ); - const { fs, listByQuery } = fakeFs(rows); - - const result = await new DashboardsService(fs, {} as never).listRecent(20); - - expect(listByQuery).toHaveBeenCalledWith( - "type=dashboard", - "recent dashboards", - ); - expect(result).toHaveLength(20); - expect(result[0].id).toBe("canvas-23"); - expect(result.at(-1)?.id).toBe("canvas-4"); - }); -}); - // Fake exposing getEntry (resolves the row to rename) + fetch (the PATCH). function fakeFsForRename(entry: FsEntryBase) { const fetch = vi.fn(async (_path: string, init?: RequestInit) => ({ diff --git a/packages/core/src/canvas/dashboardsService.ts b/packages/core/src/canvas/dashboardsService.ts index a8c66acb4b..181f2346b9 100644 --- a/packages/core/src/canvas/dashboardsService.ts +++ b/packages/core/src/canvas/dashboardsService.ts @@ -109,42 +109,6 @@ export class DashboardsService { ); } - async listRecent(limit: number): Promise { - const entries = await this.fs.listByQuery( - `type=${DASHBOARD_TYPE}`, - "recent dashboards", - ); - return entries - .map((entry) => toRecord(entry)) - .sort((a, b) => b.updatedAt - a.updatedAt) - .slice(0, limit) - .map( - ({ - id, - channelId, - name, - templateId, - createdBy, - createdByUuid, - updatedAt, - code, - generationTaskId, - pinnedAt, - }) => ({ - id, - channelId, - name, - templateId, - createdBy, - createdByUuid, - updatedAt, - code, - generationTaskId, - pinnedAt, - }), - ); - } - async get(id: string): Promise { const entry = await this.getEntry(id); return entry ? toRecord(entry) : null; diff --git a/packages/core/src/canvas/recentItems.test.ts b/packages/core/src/canvas/recentItems.test.ts deleted file mode 100644 index f7b3b1a1d9..0000000000 --- a/packages/core/src/canvas/recentItems.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { Task } from "@posthog/shared/domain-types"; -import { describe, expect, it } from "vitest"; -import type { DashboardSummary } from "./dashboardSchemas"; -import { buildRecentItems } from "./recentItems"; - -describe("buildRecentItems", () => { - it("returns the 20 most recently engaged tasks and canvases", () => { - const tasks = Array.from({ length: 12 }, (_, index) => ({ - id: `task-${index}`, - title: `Task ${index}`, - updated_at: new Date(index * 1_000).toISOString(), - })) as Task[]; - const dashboards = Array.from({ length: 12 }, (_, index) => ({ - id: `canvas-${index}`, - channelId: "channel-1", - name: `Canvas ${index}`, - templateId: "freeform", - updatedAt: (index + 12) * 1_000, - })) as DashboardSummary[]; - - const result = buildRecentItems({ - tasks, - dashboards, - taskTimestamps: Object.fromEntries( - tasks.map((task, index) => [ - task.id, - { lastViewedAt: index * 1_000, lastActivityAt: null }, - ]), - ), - canvasViewedAt: Object.fromEntries( - dashboards.map((dashboard, index) => [ - dashboard.id, - (index + 12) * 1_000, - ]), - ), - }); - - expect(result).toHaveLength(20); - expect(result[0]).toMatchObject({ kind: "canvas", id: "canvas-11" }); - expect(result.at(-1)).toMatchObject({ kind: "task", id: "task-4" }); - }); - - it("uses the latest view, activity, or update and excludes untouched tasks", () => { - const tasks = [ - { id: "viewed", title: "Viewed", updated_at: "2020-01-01" }, - { id: "prompted", title: "Prompted", updated_at: "2020-01-01" }, - { id: "untouched", title: "Untouched", updated_at: "2026-01-01" }, - ] as Task[]; - - expect( - buildRecentItems({ - tasks, - dashboards: [], - taskTimestamps: { - viewed: { lastViewedAt: 2_000, lastActivityAt: 1_000 }, - prompted: { lastViewedAt: 1_000, lastActivityAt: 3_000 }, - }, - }).map((item) => item.id), - ).toEqual(["prompted", "viewed"]); - }); -}); diff --git a/packages/core/src/canvas/recentItems.ts b/packages/core/src/canvas/recentItems.ts deleted file mode 100644 index 226e64a0ae..0000000000 --- a/packages/core/src/canvas/recentItems.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { Task } from "@posthog/shared/domain-types"; -import type { TaskTimestamps } from "../sidebar/taskMeta"; -import type { DashboardSummary } from "./dashboardSchemas"; - -export const RECENT_ITEMS_LIMIT = 20; - -export type RecentItem = - | { - kind: "task"; - id: string; - title: string; - engagedAt: number; - } - | { - kind: "canvas"; - id: string; - channelId: string; - title: string; - templateId: string; - engagedAt: number; - }; - -export function buildRecentItems({ - tasks, - dashboards, - taskTimestamps, - canvasViewedAt = {}, - limit = RECENT_ITEMS_LIMIT, -}: { - tasks: readonly Task[]; - dashboards: readonly DashboardSummary[]; - taskTimestamps: Readonly>; - canvasViewedAt?: Readonly>; - limit?: number; -}): RecentItem[] { - const taskItems = tasks.flatMap((task) => { - const timestamps = taskTimestamps[task.id]; - const engagedAt = Math.max( - timestamps?.lastViewedAt ?? 0, - timestamps?.lastActivityAt ?? 0, - ); - return engagedAt > 0 - ? [{ kind: "task", id: task.id, title: task.title, engagedAt }] - : []; - }); - const canvasItems = dashboards.flatMap((dashboard) => { - const engagedAt = canvasViewedAt[dashboard.id] ?? 0; - return engagedAt > 0 - ? [ - { - kind: "canvas", - id: dashboard.id, - channelId: dashboard.channelId, - title: dashboard.name, - templateId: dashboard.templateId, - engagedAt, - }, - ] - : []; - }); - - return [...taskItems, ...canvasItems] - .sort((a, b) => b.engagedAt - a.engagedAt) - .slice(0, limit); -} diff --git a/packages/core/src/canvas/services.ts b/packages/core/src/canvas/services.ts index 2f6e7f5414..abae8e389a 100644 --- a/packages/core/src/canvas/services.ts +++ b/packages/core/src/canvas/services.ts @@ -27,7 +27,6 @@ export interface ICanvasTemplatesService { export interface IDashboardsService { list(channelId: string): Promise; - listRecent(limit: number): Promise; get(id: string): Promise; create(input: { channelId: string; diff --git a/packages/core/src/recents/identifiers.ts b/packages/core/src/recents/identifiers.ts new file mode 100644 index 0000000000..9b43d006b6 --- /dev/null +++ b/packages/core/src/recents/identifiers.ts @@ -0,0 +1,4 @@ +import type { RecentsService } from "./recentsService"; + +export const RECENTS_SERVICE = Symbol.for("posthog.core.recents.service"); +export type IRecentsService = Pick; diff --git a/packages/core/src/recents/recents.module.ts b/packages/core/src/recents/recents.module.ts new file mode 100644 index 0000000000..7241ceed72 --- /dev/null +++ b/packages/core/src/recents/recents.module.ts @@ -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); +}); diff --git a/packages/core/src/recents/recentsService.test.ts b/packages/core/src/recents/recentsService.test.ts new file mode 100644 index 0000000000..1f5d29db48 --- /dev/null +++ b/packages/core/src/recents/recentsService.test.ts @@ -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>) { + 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¬_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" }), + }), + ); + }); +}); diff --git a/packages/core/src/recents/recentsService.ts b/packages/core/src/recents/recentsService.ts new file mode 100644 index 0000000000..a868cae71e --- /dev/null +++ b/packages/core/src/recents/recentsService.ts @@ -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 { + const entries = await this.fs.listByQuery( + `order_by=-last_viewed_at&limit=${RECENTS_SCAN_LIMIT}¬_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 { + 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 { + const entry = await this.fs.getEntry(id, "canvas"); + if (!entry) throw new Error("Canvas not found"); + if (entry.ref === id) return; + 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 []; + } +} diff --git a/packages/core/src/recents/schemas.ts b/packages/core/src/recents/schemas.ts new file mode 100644 index 0000000000..f41b7042e7 --- /dev/null +++ b/packages/core/src/recents/schemas.ts @@ -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; + +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; diff --git a/packages/host-router/src/router.ts b/packages/host-router/src/router.ts index fe5934f569..94262194c8 100644 --- a/packages/host-router/src/router.ts +++ b/packages/host-router/src/router.ts @@ -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"; @@ -67,6 +68,7 @@ export const hostRouter = router({ connectivity: connectivityRouter, contextMenu: contextMenuRouter, dashboards: dashboardsRouter, + recents: recentsRouter, deepLink: deepLinkRouter, enrichment: enrichmentRouter, environment: environmentRouter, diff --git a/packages/host-router/src/routers/dashboards.router.ts b/packages/host-router/src/routers/dashboards.router.ts index df62799d22..2f5996794b 100644 --- a/packages/host-router/src/routers/dashboards.router.ts +++ b/packages/host-router/src/routers/dashboards.router.ts @@ -5,7 +5,6 @@ import { dashboardSummarySchema, ensureHomeCanvasInput, listDashboardsInput, - listRecentDashboardsInput, renameDashboardInput, saveFreeformInput, setGenerationTaskInput, @@ -25,14 +24,6 @@ export const dashboardsRouter = router({ .get(DASHBOARDS_SERVICE) .list(input.channelId), ), - listRecent: publicProcedure - .input(listRecentDashboardsInput) - .output(z.array(dashboardSummarySchema)) - .query(({ ctx, input }) => - ctx.container - .get(DASHBOARDS_SERVICE) - .listRecent(input.limit), - ), get: publicProcedure .input(dashboardIdInput) .output(dashboardRecordSchema.nullable()) diff --git a/packages/host-router/src/routers/recents.router.ts b/packages/host-router/src/routers/recents.router.ts new file mode 100644 index 0000000000..048a0c3cbc --- /dev/null +++ b/packages/host-router/src/routers/recents.router.ts @@ -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(RECENTS_SERVICE).list(), + ), + record: publicProcedure + .input(recentEngagementInputSchema) + .output(z.void()) + .mutation(({ ctx, input }) => + ctx.container.get(RECENTS_SERVICE).record(input), + ), +}); diff --git a/packages/ui/src/features/canvas/components/ChannelNav.test.tsx b/packages/ui/src/features/canvas/components/ChannelNav.test.tsx index 936d074664..dbd224203f 100644 --- a/packages/ui/src/features/canvas/components/ChannelNav.test.tsx +++ b/packages/ui/src/features/canvas/components/ChannelNav.test.tsx @@ -31,7 +31,7 @@ vi.mock("@posthog/ui/shell/analytics", () => ({ track: vi.fn() })); vi.mock("./ActivityHoverCard", () => ({ ActivityHoverCard: () =>
Recent activity card
, })); -vi.mock("./RecentsHoverCard", () => ({ +vi.mock("@posthog/ui/features/recents/RecentsHoverCard", () => ({ RecentsHoverCard: () =>
Recent items card
, })); diff --git a/packages/ui/src/features/canvas/components/ChannelNav.tsx b/packages/ui/src/features/canvas/components/ChannelNav.tsx index e452e3b1f2..a3ced8e975 100644 --- a/packages/ui/src/features/canvas/components/ChannelNav.tsx +++ b/packages/ui/src/features/canvas/components/ChannelNav.tsx @@ -29,6 +29,7 @@ import { import { useCommandCenterActiveCount } from "@posthog/ui/features/command-center/useCommandCenterActiveCount"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { useInboxAllReports } from "@posthog/ui/features/inbox/hooks/useInboxAllReports"; +import { RecentsHoverCard } from "@posthog/ui/features/recents/RecentsHoverCard"; import { openSettings } from "@posthog/ui/features/settings/hooks/useOpenSettings"; import { CountBadge } from "@posthog/ui/primitives/CountBadge"; import { LoopIcon } from "@posthog/ui/primitives/LoopIcon"; @@ -42,7 +43,6 @@ import { useAppView } from "@posthog/ui/router/useAppView"; import { track } from "@posthog/ui/shell/analytics"; import { type ComponentPropsWithRef, type ReactNode, useState } from "react"; import { ActivityHoverCard } from "./ActivityHoverCard"; -import { RecentsHoverCard } from "./RecentsHoverCard"; const INBOX_REFETCH_INTERVAL_MS = 60_000; diff --git a/packages/ui/src/features/canvas/components/WebsiteDashboard.tsx b/packages/ui/src/features/canvas/components/WebsiteDashboard.tsx index 0f3918d856..9526beae6d 100644 --- a/packages/ui/src/features/canvas/components/WebsiteDashboard.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteDashboard.tsx @@ -2,7 +2,7 @@ import { FreeformCanvasView } from "@posthog/ui/features/canvas/freeform/Freefor import { useDashboard } from "@posthog/ui/features/canvas/hooks/useDashboards"; import { useIsDashboardEditing } from "@posthog/ui/features/canvas/stores/dashboardEditStore"; import { useFreeformChatStore } from "@posthog/ui/features/canvas/stores/freeformChatStore"; -import { useRecentCanvasStore } from "@posthog/ui/features/canvas/stores/recentCanvasStore"; +import { useRecordRecentEngagement } from "@posthog/ui/features/recents/useRecents"; import { useEffect } from "react"; // Renders a canvas's React app in a sandboxed iframe (view + edit). Edit mode @@ -11,11 +11,13 @@ export function WebsiteDashboard({ dashboardId }: { dashboardId: string }) { const editing = useIsDashboardEditing(dashboardId); const { dashboard } = useDashboard(dashboardId); const syncFromRecord = useFreeformChatStore((s) => s.syncFromRecord); - const markViewed = useRecentCanvasStore((s) => s.markViewed); + const recordEngagement = useRecordRecentEngagement(); const threadId = `dashboard:${dashboardId}`; - useEffect(() => markViewed(dashboardId), [dashboardId, markViewed]); + useEffect(() => { + recordEngagement({ kind: "canvas", id: dashboardId }); + }, [dashboardId, recordEngagement]); // Seed the thread from the saved record (code + version history) when its data // lands, so undo/redo and the live render reflect what's stored — and adopt a diff --git a/packages/ui/src/features/canvas/hooks/useDashboards.ts b/packages/ui/src/features/canvas/hooks/useDashboards.ts index 036c07e6df..808c645cb0 100644 --- a/packages/ui/src/features/canvas/hooks/useDashboards.ts +++ b/packages/ui/src/features/canvas/hooks/useDashboards.ts @@ -58,23 +58,6 @@ export function useDashboards( return { dashboards: data ?? [], isLoading }; } -export function useRecentDashboards(limit = 100): { - dashboards: DashboardSummary[]; - isLoading: boolean; -} { - const trpc = useHostTRPC(); - const { data, isLoading } = useQuery( - trpc.dashboards.listRecent.queryOptions( - { limit }, - { - meta: AUTH_SCOPED_QUERY_META, - staleTime: SPACE_QUERY_STALE_TIME_MS, - }, - ), - ); - return { dashboards: data ?? [], isLoading }; -} - /** * Warm the dashboards-list cache for a channel ahead of opening it (e.g. on * hover), so expanding the channel shows its canvases without a cold fetch. diff --git a/packages/ui/src/features/canvas/hooks/useGenerateFreeformCanvas.ts b/packages/ui/src/features/canvas/hooks/useGenerateFreeformCanvas.ts index 3ae5404d7b..b1425d7cc2 100644 --- a/packages/ui/src/features/canvas/hooks/useGenerateFreeformCanvas.ts +++ b/packages/ui/src/features/canvas/hooks/useGenerateFreeformCanvas.ts @@ -25,6 +25,7 @@ import { import { useFolderInstructions } from "@posthog/ui/features/canvas/hooks/useFolderInstructions"; import { useCanvasGenerationTrackerStore } from "@posthog/ui/features/canvas/stores/canvasGenerationTrackerStore"; import { toastError } from "@posthog/ui/features/notifications/errorDetails"; +import { useRecordRecentEngagement } from "@posthog/ui/features/recents/useRecents"; import { useCreateTask } from "@posthog/ui/features/tasks/useTaskCrudMutations"; import { toast } from "@posthog/ui/primitives/toast"; import { useQueryClient } from "@tanstack/react-query"; @@ -61,6 +62,7 @@ export function useGenerateFreeformCanvas(args: { const { invalidateTasks } = useCreateTask(); const { fileTask } = useChannelTaskMutations(); const { setGenerationTask, renameDashboard } = useDashboardMutations(); + const recordEngagement = useRecordRecentEngagement(); // The channel's CONTEXT.md, passed to the agent as optional background so the // generated canvas starts with the shared context. Absent/empty is fine. const callerOwnsContext = "channelContext" in args; @@ -113,6 +115,7 @@ export function useGenerateFreeformCanvas(args: { // local build of these features before merging. workspaceMode = "cloud", } = opts; + recordEngagement({ kind: "canvas", id: dashboardId }); setIsStarting(true); try { // A cloud run requires an explicit adapter + model (the API rejects a @@ -221,6 +224,7 @@ export function useGenerateFreeformCanvas(args: { fileTask, setGenerationTask, renameDashboard, + recordEngagement, channelId, channelName, channelContext, diff --git a/packages/ui/src/features/canvas/hooks/useRecentItems.ts b/packages/ui/src/features/canvas/hooks/useRecentItems.ts deleted file mode 100644 index c781bb64a8..0000000000 --- a/packages/ui/src/features/canvas/hooks/useRecentItems.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { buildRecentItems } from "@posthog/core/canvas/recentItems"; -import { useRecentDashboards } from "@posthog/ui/features/canvas/hooks/useDashboards"; -import { useRecentCanvasStore } from "@posthog/ui/features/canvas/stores/recentCanvasStore"; -import { useTaskViewed } from "@posthog/ui/features/sidebar/useTaskViewed"; -import { useTasks } from "@posthog/ui/features/tasks/useTasks"; -import { useMemo } from "react"; - -export function useRecentItems() { - const { data: tasks = [], isLoading: tasksLoading } = useTasks({ - showAllUsers: true, - }); - const { timestamps, isLoading: timestampsLoading } = useTaskViewed(); - const { dashboards, isLoading: dashboardsLoading } = useRecentDashboards(); - const canvasViewedAt = useRecentCanvasStore((state) => state.viewedAt); - const items = useMemo( - () => - buildRecentItems({ - tasks, - dashboards, - taskTimestamps: timestamps, - canvasViewedAt, - }), - [tasks, dashboards, timestamps, canvasViewedAt], - ); - return { - items, - isLoading: tasksLoading || timestampsLoading || dashboardsLoading, - }; -} diff --git a/packages/ui/src/features/canvas/stores/recentCanvasStore.ts b/packages/ui/src/features/canvas/stores/recentCanvasStore.ts deleted file mode 100644 index 1ec425f8ee..0000000000 --- a/packages/ui/src/features/canvas/stores/recentCanvasStore.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { create } from "zustand"; -import { persist } from "zustand/middleware"; - -interface RecentCanvasState { - viewedAt: Record; - markViewed: (dashboardId: string) => void; -} - -export const useRecentCanvasStore = create()( - persist( - (set) => ({ - viewedAt: {}, - markViewed: (dashboardId) => - set((state) => { - const entries = Object.entries({ - ...state.viewedAt, - [dashboardId]: Date.now(), - }) - .sort(([, a], [, b]) => b - a) - .slice(0, 100); - return { viewedAt: Object.fromEntries(entries) }; - }), - }), - { name: "posthog-code-recent-canvases" }, - ), -); diff --git a/packages/ui/src/features/canvas/components/RecentsHoverCard.tsx b/packages/ui/src/features/recents/RecentsHoverCard.tsx similarity index 95% rename from packages/ui/src/features/canvas/components/RecentsHoverCard.tsx rename to packages/ui/src/features/recents/RecentsHoverCard.tsx index bc23a568ad..ee0faebb4d 100644 --- a/packages/ui/src/features/canvas/components/RecentsHoverCard.tsx +++ b/packages/ui/src/features/recents/RecentsHoverCard.tsx @@ -10,12 +10,12 @@ import { } from "@posthog/quill"; import { formatRelativeTimeShort } from "@posthog/shared"; import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon"; -import { useRecentItems } from "@posthog/ui/features/canvas/hooks/useRecentItems"; import { SidebarItem } from "@posthog/ui/features/sidebar/components/SidebarItem"; import { navigateToChannelDashboard, navigateToTaskDetail, } from "@posthog/ui/router/navigationBridge"; +import { useRecents } from "./useRecents"; export function RecentsHoverCard({ onClose, @@ -24,7 +24,7 @@ export function RecentsHoverCard({ onClose: () => void; side?: "bottom" | "right"; }) { - const { items, isLoading } = useRecentItems(); + const { data: items = [], isLoading } = useRecents(); return ( void { + const trpc = useHostTRPC(); + const client = useHostTRPCClient(); + const queryClient = useQueryClient(); + const { mutate } = useMutation({ + mutationFn: (input: RecentEngagementInput) => + client.recents.record.mutate(input), + onSuccess: () => + queryClient.invalidateQueries(trpc.recents.list.pathFilter()), + }); + return useCallback((input: RecentEngagementInput) => mutate(input), [mutate]); +} diff --git a/packages/ui/src/features/sessions/hooks/useSessionCallbacks.test.tsx b/packages/ui/src/features/sessions/hooks/useSessionCallbacks.test.tsx index 672e733655..1d15e1d63c 100644 --- a/packages/ui/src/features/sessions/hooks/useSessionCallbacks.test.tsx +++ b/packages/ui/src/features/sessions/hooks/useSessionCallbacks.test.tsx @@ -41,6 +41,10 @@ const taskViewed = vi.hoisted(() => ({ vi.mock("@posthog/ui/features/sidebar/useTaskViewed", () => ({ useTaskViewed: () => taskViewed, })); +const recordEngagement = vi.hoisted(() => vi.fn()); +vi.mock("@posthog/ui/features/recents/useRecents", () => ({ + useRecordRecentEngagement: () => recordEngagement, +})); vi.mock("@posthog/ui/features/sessions/hooks/useMessagingMode", () => ({ useMessagingMode: () => "queue", @@ -143,6 +147,7 @@ describe("useSessionCallbacks.handleSendPrompt while editing a queued message", expect(sessionService.sendPrompt).toHaveBeenCalledWith(TASK, "my edit", { steer: false, }); + expect(recordEngagement).toHaveBeenCalledWith({ kind: "task", id: TASK }); }); it("updates in place and never sends when the edit saves", async () => { diff --git a/packages/ui/src/features/sessions/hooks/useSessionCallbacks.ts b/packages/ui/src/features/sessions/hooks/useSessionCallbacks.ts index f4b29a3e90..e8cc1f3756 100644 --- a/packages/ui/src/features/sessions/hooks/useSessionCallbacks.ts +++ b/packages/ui/src/features/sessions/hooks/useSessionCallbacks.ts @@ -20,6 +20,7 @@ import { tryExecuteCodeCommand, } from "@posthog/ui/features/message-editor/commands"; import { useDraftStore } from "@posthog/ui/features/message-editor/draftStore"; +import { useRecordRecentEngagement } from "@posthog/ui/features/recents/useRecents"; import { useMessagingMode } from "@posthog/ui/features/sessions/hooks/useMessagingMode"; import { type AgentSession, @@ -54,6 +55,7 @@ export function useSessionCallbacks({ const shellClient = useService(SHELL_CLIENT); const hostClient = useHostTRPCClient(); const { markActivity, markAsViewed } = useTaskViewed(); + const recordEngagement = useRecordRecentEngagement(); const { requestFocus, setPendingContent } = useDraftStore((s) => s.actions); const sessionRef = useRef(session); @@ -132,6 +134,7 @@ export function useSessionCallbacks({ } try { + recordEngagement({ kind: "task", id: taskId }); markAsViewed(taskId); markActivity(taskId); await sessionService.sendPrompt(taskId, promptText ?? text, { @@ -164,6 +167,7 @@ export function useSessionCallbacks({ messagingMode, setPendingContent, requestFocus, + recordEngagement, ], ); diff --git a/packages/ui/src/features/sidebar/components/items/RecentsItem.tsx b/packages/ui/src/features/sidebar/components/items/RecentsItem.tsx index a0fbfb31a5..97ae4b6bac 100644 --- a/packages/ui/src/features/sidebar/components/items/RecentsItem.tsx +++ b/packages/ui/src/features/sidebar/components/items/RecentsItem.tsx @@ -1,6 +1,6 @@ import { ClockCounterClockwiseIcon } from "@phosphor-icons/react"; import { Popover, PopoverTrigger } from "@posthog/quill"; -import { RecentsHoverCard } from "@posthog/ui/features/canvas/components/RecentsHoverCard"; +import { RecentsHoverCard } from "@posthog/ui/features/recents/RecentsHoverCard"; import { useState } from "react"; import { SidebarItem } from "../SidebarItem"; diff --git a/packages/ui/src/features/task-detail/components/TaskDetail.tsx b/packages/ui/src/features/task-detail/components/TaskDetail.tsx index 458b6d954a..eaf0d458cd 100644 --- a/packages/ui/src/features/task-detail/components/TaskDetail.tsx +++ b/packages/ui/src/features/task-detail/components/TaskDetail.tsx @@ -19,6 +19,7 @@ import { PanelLayout } from "../../panels/components/PanelLayout"; import { usePanelLayoutStore } from "../../panels/panelLayoutStore"; import { getLeafPanel, parseTabId } from "../../panels/panelStoreHelpers"; import { PiSessionView } from "../../pi-sessions/PiSessionView"; +import { useRecordRecentEngagement } from "../../recents/useRecents"; import { MIN_CHAT_WIDTH } from "../../sessions/constants"; import { useCwd } from "../../sidebar/useCwd"; import { useRenameTask } from "../../tasks/useTaskMutations"; @@ -51,10 +52,15 @@ export function TaskDetail({ channelId, }: TaskDetailProps) { const taskId = initialTask.id; + const recordEngagement = useRecordRecentEngagement(); const { task } = useTaskData({ taskId, initialTask }); const runtime = task.runtime === "pi" ? "pi" : "acp"; const selectedTaskRunId = task.latest_run?.id; + useEffect(() => { + recordEngagement({ kind: "task", id: taskId }); + }, [recordEngagement, taskId]); + const effectiveRepoPath = useCwd(taskId); const activeRelativePath = usePanelLayoutStore((state) => { From 23f0333dc6d8dac01760395fbb166bb4e57ef68c Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Thu, 30 Jul 2026 18:45:07 +0100 Subject: [PATCH 3/4] fix: validate and diagnose recents writes Generated-By: PostHog Code Task-Id: f68869f0-4c94-4017-add1-ce18b6a323b3 --- packages/core/src/recents/recentsService.test.ts | 16 ++++++++++++++++ packages/core/src/recents/recentsService.ts | 3 ++- packages/ui/src/features/recents/useRecents.ts | 6 ++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/core/src/recents/recentsService.test.ts b/packages/core/src/recents/recentsService.test.ts index 1f5d29db48..637c8ace08 100644 --- a/packages/core/src/recents/recentsService.test.ts +++ b/packages/core/src/recents/recentsService.test.ts @@ -75,4 +75,20 @@ describe("RecentsService", () => { }), ); }); + + 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(); + }); }); diff --git a/packages/core/src/recents/recentsService.ts b/packages/core/src/recents/recentsService.ts index a868cae71e..797849e76c 100644 --- a/packages/core/src/recents/recentsService.ts +++ b/packages/core/src/recents/recentsService.ts @@ -54,7 +54,8 @@ export class RecentsService { private async ensureCanvasReference(id: string): Promise { const entry = await this.fs.getEntry(id, "canvas"); - if (!entry) throw new Error("Canvas not found"); + if (!entry || entry.type !== "dashboard") + throw new Error("Canvas not found"); if (entry.ref === id) return; const response = await this.fs.fetch(`${encodeURIComponent(id)}/`, { method: "PATCH", diff --git a/packages/ui/src/features/recents/useRecents.ts b/packages/ui/src/features/recents/useRecents.ts index a4d71a2060..8a075b34ed 100644 --- a/packages/ui/src/features/recents/useRecents.ts +++ b/packages/ui/src/features/recents/useRecents.ts @@ -1,9 +1,12 @@ import type { RecentEngagementInput } from "@posthog/core/recents/schemas"; import { useHostTRPC, useHostTRPCClient } from "@posthog/host-router/react"; import { AUTH_SCOPED_QUERY_META } from "@posthog/ui/features/auth/useCurrentUser"; +import { logger } from "@posthog/ui/shell/logger"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback } from "react"; +const log = logger.scope("recents"); + export function useRecents() { const trpc = useHostTRPC(); return useQuery( @@ -25,6 +28,9 @@ export function useRecordRecentEngagement(): ( client.recents.record.mutate(input), onSuccess: () => queryClient.invalidateQueries(trpc.recents.list.pathFilter()), + onError: (error, input) => { + log.warn("Failed to record recent engagement", { error, input }); + }, }); return useCallback((input: RecentEngagementInput) => mutate(input), [mutate]); } From 2dbf48b50c0acd82d61f04e15b08dfc5bd38894f Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Thu, 30 Jul 2026 18:52:12 +0100 Subject: [PATCH 4/4] fix: surface recents loading failures Generated-By: PostHog Code Task-Id: f68869f0-4c94-4017-add1-ce18b6a323b3 --- .../recents/RecentsHoverCard.test.tsx | 38 +++++++++++++++++++ .../src/features/recents/RecentsHoverCard.tsx | 14 ++++++- .../ui/src/features/recents/useRecents.ts | 10 ++++- 3 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 packages/ui/src/features/recents/RecentsHoverCard.test.tsx diff --git a/packages/ui/src/features/recents/RecentsHoverCard.test.tsx b/packages/ui/src/features/recents/RecentsHoverCard.test.tsx new file mode 100644 index 0000000000..dc899bda72 --- /dev/null +++ b/packages/ui/src/features/recents/RecentsHoverCard.test.tsx @@ -0,0 +1,38 @@ +import { render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@posthog/quill", () => ({ + Empty: ({ children }: { children: ReactNode }) =>
{children}
, + EmptyDescription: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + EmptyHeader: ({ children }: { children: ReactNode }) =>
{children}
, + EmptyMedia: ({ children }: { children: ReactNode }) =>
{children}
, + EmptyTitle: ({ children }: { children: ReactNode }) =>
{children}
, + PopoverContent: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + Spinner: () =>
Loading
, +})); +vi.mock("@posthog/ui/features/sidebar/components/SidebarItem", () => ({ + SidebarItem: () =>
Recent item
, +})); +vi.mock("./useRecents", () => ({ + useRecents: () => ({ + data: [], + error: new Error("backend unavailable"), + isLoading: false, + }), +})); + +import { RecentsHoverCard } from "./RecentsHoverCard"; + +describe("RecentsHoverCard", () => { + it("distinguishes a failed query from an empty history", () => { + render(); + + expect(screen.getByText("Couldn't load recents")).toBeInTheDocument(); + expect(screen.queryByText("No recents yet")).not.toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/features/recents/RecentsHoverCard.tsx b/packages/ui/src/features/recents/RecentsHoverCard.tsx index ee0faebb4d..0ee75112eb 100644 --- a/packages/ui/src/features/recents/RecentsHoverCard.tsx +++ b/packages/ui/src/features/recents/RecentsHoverCard.tsx @@ -24,7 +24,7 @@ export function RecentsHoverCard({ onClose: () => void; side?: "bottom" | "right"; }) { - const { data: items = [], isLoading } = useRecents(); + const { data: items = [], error, isLoading } = useRecents(); return ( + ) : error ? ( + + + + + + Couldn't load recents + + Check your connection and try again. + + + ) : items.length === 0 ? ( diff --git a/packages/ui/src/features/recents/useRecents.ts b/packages/ui/src/features/recents/useRecents.ts index 8a075b34ed..7ebcc631bd 100644 --- a/packages/ui/src/features/recents/useRecents.ts +++ b/packages/ui/src/features/recents/useRecents.ts @@ -3,18 +3,24 @@ import { useHostTRPC, useHostTRPCClient } from "@posthog/host-router/react"; import { AUTH_SCOPED_QUERY_META } from "@posthog/ui/features/auth/useCurrentUser"; import { logger } from "@posthog/ui/shell/logger"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useCallback } from "react"; +import { useCallback, useEffect } from "react"; const log = logger.scope("recents"); export function useRecents() { const trpc = useHostTRPC(); - return useQuery( + const query = useQuery( trpc.recents.list.queryOptions(undefined, { meta: AUTH_SCOPED_QUERY_META, staleTime: 30_000, }), ); + useEffect(() => { + if (query.error) { + log.warn("Failed to load recents", { error: query.error }); + } + }, [query.error]); + return query; } export function useRecordRecentEngagement(): (