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 39bf7053ec..807281207e 100644 --- a/packages/core/src/canvas/dashboardSchemas.ts +++ b/packages/core/src/canvas/dashboardSchemas.ts @@ -89,7 +89,6 @@ export const dashboardSummarySchema = z.object({ export type DashboardSummary = z.infer; 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), 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..637c8ace08 --- /dev/null +++ b/packages/core/src/recents/recentsService.test.ts @@ -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>) { + 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" }), + }), + ); + }); + + 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 new file mode 100644 index 0000000000..797849e76c --- /dev/null +++ b/packages/core/src/recents/recentsService.ts @@ -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 { + 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 || entry.type !== "dashboard") + 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/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/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..dbd224203f 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("@posthog/ui/features/recents/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..a3ced8e975 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, @@ -28,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"; @@ -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)} + /> + )} + s.syncFromRecord); + const recordEngagement = useRecordRecentEngagement(); const threadId = `dashboard:${dashboardId}`; + 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 // version a generation task just published. 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/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 new file mode 100644 index 0000000000..0ee75112eb --- /dev/null +++ b/packages/ui/src/features/recents/RecentsHoverCard.tsx @@ -0,0 +1,103 @@ +import { ClockCounterClockwiseIcon, FileTextIcon } from "@phosphor-icons/react"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, + PopoverContent, + Spinner, +} from "@posthog/quill"; +import { formatRelativeTimeShort } from "@posthog/shared"; +import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon"; +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, + side = "right", +}: { + onClose: () => void; + side?: "bottom" | "right"; +}) { + const { data: items = [], error, isLoading } = useRecents(); + return ( + +
+ Recents +
+
+ {isLoading && items.length === 0 ? ( +
+ +
+ ) : error ? ( + + + + + + Couldn't load recents + + Check your connection and try again. + + + + ) : 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/recents/useRecents.ts b/packages/ui/src/features/recents/useRecents.ts new file mode 100644 index 0000000000..7ebcc631bd --- /dev/null +++ b/packages/ui/src/features/recents/useRecents.ts @@ -0,0 +1,42 @@ +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, useEffect } from "react"; + +const log = logger.scope("recents"); + +export function useRecents() { + const trpc = useHostTRPC(); + 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(): ( + input: RecentEngagementInput, +) => 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()), + onError: (error, input) => { + log.warn("Failed to record recent engagement", { error, input }); + }, + }); + 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/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", 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) => {