diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index b174c15a1f..0e55be562c 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -789,6 +789,7 @@ export type ChannelsSurface = | "channel_history" | "channel_artifacts" | "channel_inbox" + | "recent_tasks" | "pinned" | "dashboards_grid" | "canvas" @@ -814,6 +815,7 @@ export type ChannelActionType = | "view_history" | "view_artifacts" | "view_inbox" + | "view_recent_tasks" | "open_artifact" | "file_task" | "unfile_task" diff --git a/packages/shared/src/browser-tabs-schemas.ts b/packages/shared/src/browser-tabs-schemas.ts index a307947c1b..b20c1405ca 100644 --- a/packages/shared/src/browser-tabs-schemas.ts +++ b/packages/shared/src/browser-tabs-schemas.ts @@ -25,6 +25,13 @@ export const browserTabSchema = z.object({ * blank). Pairs with `channelId`: the two together identify a channel tab. */ channelSection: z.string().nullable().default(null), + /** + * Standalone Channels-space route this tab fronts when it isn't a canvas, + * task, or channel — e.g. `/website/recent-tasks`. Null for every id-backed + * tab and for a blank tab. Lets a route-only view be a first-class tab + * (labelled, deduped, restored) without a channel/task/canvas reference. + */ + routeId: z.string().nullable().default(null), /** Gap-spaced ordering key within a window. Reindexed on collision. */ position: z.number(), /** diff --git a/packages/shared/src/browser-tabs.test.ts b/packages/shared/src/browser-tabs.test.ts index 7186b647bd..a4e095a846 100644 --- a/packages/shared/src/browser-tabs.test.ts +++ b/packages/shared/src/browser-tabs.test.ts @@ -106,6 +106,31 @@ describe("openOrFocusTab", () => { expect(inboxAgain.tabId).toBe(inbox.tabId); }); + it("dedups a route-only tab (e.g. recent tasks) within a window", () => { + const first = openOrFocusTab(snapshot(), { + windowId: "w1", + dashboardId: null, + taskId: null, + channelId: null, + routeId: "/website/recent-tasks", + makeId, + now, + }); + expect(first.opened).toBe(true); + const again = openOrFocusTab(first.snapshot, { + windowId: "w1", + dashboardId: null, + taskId: null, + channelId: null, + routeId: "/website/recent-tasks", + makeId, + now, + }); + expect(again.opened).toBe(false); + expect(again.tabId).toBe(first.tabId); + expect(again.snapshot.tabs).toHaveLength(1); + }); + it("appends new tabs after existing ones", () => { const a = open(snapshot(), "w1", "dash-a"); const b = open(a.snapshot, "w1", "dash-b"); @@ -174,6 +199,7 @@ describe("reorderTab", () => { taskId: null, channelId: null, channelSection: null, + routeId: null, position: 1, scrollState: null, createdAt: 0, @@ -186,6 +212,7 @@ describe("reorderTab", () => { taskId: null, channelId: null, channelSection: null, + routeId: null, position: 2, scrollState: null, createdAt: 0, @@ -319,6 +346,7 @@ describe("decideTabNavigation", () => { taskId: null, channelId: "c1", channelSection: null, + routeId: null, stampTabId: "tab-a", }); }); @@ -342,6 +370,7 @@ describe("decideTabNavigation", () => { taskId: null, channelId: "c1", channelSection: null, + routeId: null, stampTabId: "tab-a", }); }); @@ -359,6 +388,7 @@ describe("decideTabNavigation", () => { taskId: null, channelId: "c1", channelSection: null, + routeId: null, stampTabId: null, }); }); @@ -386,6 +416,7 @@ describe("decideTabNavigation", () => { taskId: null, channelId: "c1", channelSection: "artifacts", + routeId: null, stampTabId: "tab-a", }); }); @@ -403,6 +434,7 @@ describe("decideTabNavigation", () => { taskId: null, channelId: "c1", channelSection: "inbox", + routeId: null, stampTabId: null, }); }); @@ -446,6 +478,59 @@ describe("decideTabNavigation", () => { }), ).toEqual({ type: "noop" }); }); + + it("opens a route-only tab (e.g. recent tasks) when there is no active tab", () => { + expect( + decideTabNavigation({ + ...base, + routeRouteId: "/website/recent-tasks", + }), + ).toEqual({ + type: "open", + dashboardId: null, + taskId: null, + channelId: null, + channelSection: null, + routeId: "/website/recent-tasks", + stampTabId: null, + }); + }); + + it("replaces the active tab when navigating to a route-only view", () => { + expect( + decideTabNavigation({ + ...base, + serverActiveTabId: "tab-a", + activeTab: { id: "tab-a", dashboardId: "d1", taskId: null }, + routeRouteId: "/website/recent-tasks", + }), + ).toEqual({ + type: "replace", + tabId: "tab-a", + dashboardId: null, + taskId: null, + channelId: null, + channelSection: null, + routeId: "/website/recent-tasks", + stampTabId: "tab-a", + }); + }); + + it("only stamps when the active tab already shows the route-only view", () => { + expect( + decideTabNavigation({ + ...base, + serverActiveTabId: "tab-a", + activeTab: { + id: "tab-a", + dashboardId: null, + taskId: null, + routeId: "/website/recent-tasks", + }, + routeRouteId: "/website/recent-tasks", + }), + ).toEqual({ type: "stamp", stampTabId: "tab-a" }); + }); }); function openChannel(s: TabsSnapshot, windowId: string, channelId: string) { @@ -475,6 +560,19 @@ describe("activeTabIsBlank", () => { expect(activeTabIsBlank(t.snapshot)).toBe(false); }); + it("is false when the active tab is a route-only tab (recent tasks)", () => { + const t = openOrFocusTab(snapshot(), { + windowId: "w1", + dashboardId: null, + taskId: null, + channelId: null, + routeId: "/website/recent-tasks", + makeId, + now, + }); + expect(activeTabIsBlank(t.snapshot)).toBe(false); + }); + it("is false when there is no active tab", () => { expect(activeTabIsBlank(snapshot())).toBe(false); }); diff --git a/packages/shared/src/browser-tabs.ts b/packages/shared/src/browser-tabs.ts index 1454635b51..8c76388fd9 100644 --- a/packages/shared/src/browser-tabs.ts +++ b/packages/shared/src/browser-tabs.ts @@ -44,7 +44,11 @@ export function activeTabIsBlank(snapshot: TabsSnapshot): boolean { if (!w?.activeTabId) return false; const t = snapshot.tabs.find((x) => x.id === w.activeTabId); return ( - !!t && t.dashboardId == null && t.taskId == null && t.channelId == null + !!t && + t.dashboardId == null && + t.taskId == null && + t.channelId == null && + t.routeId == null ); } @@ -89,6 +93,8 @@ export type TabIdentity = { taskId: string | null; channelId: string | null; channelSection: string | null; + /** A standalone route (e.g. recent tasks) when the tab has no id target. */ + routeId: string | null; }; function sameIdentity(a: TabIdentity, b: TabIdentity): boolean { @@ -96,7 +102,8 @@ function sameIdentity(a: TabIdentity, b: TabIdentity): boolean { a.dashboardId === b.dashboardId && a.taskId === b.taskId && a.channelId === b.channelId && - a.channelSection === b.channelSection + a.channelSection === b.channelSection && + a.routeId === b.routeId ); } @@ -111,16 +118,24 @@ export function openOrFocusTab( windowId: string; channelId: string | null; channelSection?: string | null; + routeId?: string | null; makeId: IdFactory; now: Clock; }, ): OpenTabResult { const { windowId, dashboardId, taskId, channelId, makeId, now } = input; const channelSection = input.channelSection ?? null; + const routeId = input.routeId ?? null; const existing = snapshot.tabs.find( (t) => t.windowId === windowId && - sameIdentity(t, { dashboardId, taskId, channelId, channelSection }), + sameIdentity(t, { + dashboardId, + taskId, + channelId, + channelSection, + routeId, + }), ); if (existing) { const ts = now(); @@ -143,6 +158,7 @@ export function openOrFocusTab( taskId, channelId, channelSection, + routeId, makeId, now, }); @@ -154,6 +170,7 @@ function appendTab( windowId: string; channelId: string | null; channelSection?: string | null; + routeId?: string | null; makeId: IdFactory; now: Clock; }, @@ -169,6 +186,7 @@ function appendTab( taskId, channelId, channelSection: input.channelSection ?? null, + routeId: input.routeId ?? null, position: lastPos + POSITION_GAP, scrollState: null, createdAt: ts, @@ -212,6 +230,7 @@ export function setTabTarget( tabId: string; channelId: string | null; channelSection?: string | null; + routeId?: string | null; now: Clock; }, ): TabsSnapshot { @@ -228,6 +247,7 @@ export function setTabTarget( taskId: input.taskId, channelId: input.channelId, channelSection: input.channelSection ?? null, + routeId: input.routeId ?? null, lastActiveAt: ts, } : t, @@ -348,6 +368,7 @@ export type TabNavDecision = taskId: string | null; channelId: string | null; channelSection: string | null; + routeId: string | null; stampTabId: string | null; } | { @@ -356,6 +377,7 @@ export type TabNavDecision = taskId: string | null; channelId: string | null; channelSection: string | null; + routeId: string | null; stampTabId: string | null; } | { type: "stamp"; stampTabId: string } @@ -373,6 +395,7 @@ export function decideTabNavigation(input: { taskId: string | null; channelId?: string | null; channelSection?: string | null; + routeId?: string | null; } | null; /** Canvas in the current route, if any. */ routeDashboardId: string | null; @@ -381,6 +404,8 @@ export function decideTabNavigation(input: { routeChannelId: string | null; /** Channel sub-section in the current route, if any. */ routeChannelSection?: string | null; + /** Standalone Channels-space route (e.g. recent tasks) with no id target. */ + routeRouteId?: string | null; }): TabNavDecision { const { historyTabId, @@ -391,6 +416,7 @@ export function decideTabNavigation(input: { routeChannelId, } = input; const routeChannelSection = input.routeChannelSection ?? null; + const routeRouteId = input.routeRouteId ?? null; // Tagged entry for a DIFFERENT tab → a tab switch or a back/forward replay. // Focus it (this is how "back returns to the previous tab" resolves). When @@ -409,8 +435,9 @@ export function decideTabNavigation(input: { taskId: routeTaskId, channelId: routeChannelId, channelSection: routeChannelSection, + routeId: routeRouteId, }; - if (!routeDashboardId && !routeTaskId && !routeChannelId) { + if (!routeDashboardId && !routeTaskId && !routeChannelId && !routeRouteId) { return { type: "noop" }; } @@ -422,6 +449,7 @@ export function decideTabNavigation(input: { taskId: activeTab.taskId, channelId: activeTab.channelId ?? null, channelSection: activeTab.channelSection ?? null, + routeId: activeTab.routeId ?? null, }, routeIdentity, ) @@ -433,6 +461,7 @@ export function decideTabNavigation(input: { taskId: routeTaskId, channelId: routeChannelId, channelSection: routeChannelSection, + routeId: routeRouteId, stampTabId: serverActiveTabId, }; } @@ -443,6 +472,7 @@ export function decideTabNavigation(input: { taskId: routeTaskId, channelId: routeChannelId, channelSection: routeChannelSection, + routeId: routeRouteId, stampTabId: serverActiveTabId, }; } diff --git a/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx b/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx index c7afe91001..c2e81a765a 100644 --- a/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx +++ b/packages/ui/src/features/browser-tabs/BrowserTabStrip.tsx @@ -1,4 +1,4 @@ -import { HashIcon } from "@phosphor-icons/react"; +import { ClockCounterClockwiseIcon, HashIcon } from "@phosphor-icons/react"; import { browserTabsStore } from "@posthog/core/browser-tabs/browserTabsStore"; import { useHostTRPC } from "@posthog/host-router/react"; import { decideTabNavigation, type TabsSnapshot } from "@posthog/shared"; @@ -18,7 +18,7 @@ import { useRouter, useRouterState, } from "@tanstack/react-router"; -import { useEffect, useMemo } from "react"; +import { type ReactNode, useEffect, useMemo } from "react"; import { TabStrip, type TabView } from "./TabStrip"; import { TaskTabIcon } from "./TaskTabIcon"; import { useTabsSnapshot } from "./useBrowserTabs"; @@ -56,12 +56,28 @@ function primaryWindow(snapshot: TabsSnapshot) { return snapshot.windows.find((w) => w.isPrimary) ?? snapshot.windows[0]; } +/** + * Channels-space routes that are first-class tabs without a canvas/task/channel + * target (so they get a real label and survive a tab switch). Keyed by the + * route's pathname, which doubles as the tab's stored `routeId`. + */ +const STANDALONE_ROUTE_TABS: Record< + string, + { label: string; icon: ReactNode } +> = { + "/website/recent-tasks": { + label: "Recent tasks", + icon: , + }, +}; + type TabRef = { id: string; dashboardId: string | null; taskId: string | null; channelId: string | null; channelSection: string | null; + routeId: string | null; }; export function BrowserTabStrip() { @@ -81,6 +97,11 @@ export function BrowserTabStrip() { const { channels } = useChannels(); + // A standalone Channels-space route (e.g. recent tasks) the current location + // maps to — null for canvas/task/channel/blank routes. Drives a route-only + // tab's identity, label, and navigation. + const routeTabId = STANDALONE_ROUTE_TABS[pathname] ? pathname : null; + // The active channel sub-section (inbox/artifacts/history/context) is the // route segment after the channelId. Null when on the channel home or a // non-section route (canvas/task), so a channel-home tab labels by name. @@ -168,12 +189,14 @@ export function BrowserTabStrip() { taskId: activeTab.taskId, channelId: activeTab.channelId, channelSection: activeTab.channelSection, + routeId: activeTab.routeId, } : null, routeDashboardId: params.dashboardId ?? null, routeTaskId: params.taskId ?? null, routeChannelId: params.channelId ?? null, routeChannelSection, + routeRouteId: routeTabId, }); switch (decision.type) { case "activate": @@ -186,6 +209,7 @@ export function BrowserTabStrip() { taskId: decision.taskId, channelId: decision.channelId, channelSection: decision.channelSection, + routeId: decision.routeId, }); if (decision.stampTabId) stamp(decision.stampTabId); break; @@ -196,6 +220,7 @@ export function BrowserTabStrip() { taskId: decision.taskId, channelId: decision.channelId, channelSection: decision.channelSection, + routeId: decision.routeId, }); if (decision.stampTabId) stamp(decision.stampTabId); break; @@ -211,6 +236,7 @@ export function BrowserTabStrip() { params.dashboardId, params.taskId, routeChannelSection, + routeTabId, activeTab, openOrFocus.mutate, setTabTarget.mutate, @@ -253,6 +279,7 @@ export function BrowserTabStrip() { const dashId = isActive ? (params.dashboardId ?? null) : t.dashboardId; const channelId = isActive ? (params.channelId ?? null) : t.channelId; const section = isActive ? routeChannelSection : t.channelSection; + const routeId = isActive ? routeTabId : t.routeId; const channel = channelName(channelId); if (taskId) { const task = findTask(taskId); @@ -284,6 +311,17 @@ export function BrowserTabStrip() { channelName: channel, }; } + // A standalone route tab (e.g. Recent tasks): label + icon from the + // registry, no channel context. + if (routeId) { + const meta = STANDALONE_ROUTE_TABS[routeId]; + return { + id: t.id, + label: meta?.label ?? "Tab", + icon: meta?.icon, + channelName: null, + }; + } return { id: t.id, label: "New tab", channelName: null }; }); }, [ @@ -299,6 +337,7 @@ export function BrowserTabStrip() { params.dashboardId, params.taskId, routeChannelSection, + routeTabId, ]); // Navigate to a tab, tagging the history entry with its id so the switch is @@ -336,6 +375,8 @@ export function BrowserTabStrip() { default: navigate({ to: "/website/$channelId", params, state }); } + } else if (tab.routeId === "/website/recent-tasks") { + navigate({ to: "/website/recent-tasks", state }); } else { navigate({ to: "/website", state }); } @@ -391,6 +432,7 @@ export function BrowserTabStrip() { taskId: null, channelId: null, channelSection: null, + routeId: null, }); } }, diff --git a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx index 1f2cdc8a11..1b39fc1f2e 100644 --- a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx @@ -1,5 +1,6 @@ import { BrainIcon, + ClockCounterClockwiseIcon, GearSixIcon, HouseIcon, RobotIcon, @@ -20,6 +21,7 @@ import { navigateToAgents, navigateToCanvas, navigateToInbox, + navigateToRecentTasks, navigateToSkills, navigateToWebsiteHome, } from "@posthog/ui/router/navigationBridge"; @@ -46,6 +48,7 @@ const NON_CANVAS_WEBSITE_PREFIXES = [ "/website/skills", "/website/mcp-servers", "/website/command-center", + "/website/recent-tasks", ]; // The global nav brought over from the Code app — a single icon+label row each, @@ -131,6 +134,18 @@ function ChannelsNav() { isActive={view.type === "skills"} onClick={() => trackNav("files", navigateToSkills)} /> + + } + label="Recent tasks" + isActive={view.type === "recent-tasks"} + onClick={() => trackNav("recent-tasks", navigateToRecentTasks)} + /> ); } diff --git a/packages/ui/src/features/recent-tasks/RecentTasksView.tsx b/packages/ui/src/features/recent-tasks/RecentTasksView.tsx new file mode 100644 index 0000000000..ba61dd7a19 --- /dev/null +++ b/packages/ui/src/features/recent-tasks/RecentTasksView.tsx @@ -0,0 +1,200 @@ +import { GitPullRequestIcon, SquaresFourIcon } from "@phosphor-icons/react"; +import type { Task, WorkspaceMode } from "@posthog/shared"; +import { formatRelativeTimeLong } from "@posthog/shared"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { useArchivedTaskIds } from "@posthog/ui/features/archive/useArchivedTaskIds"; +import type { Channel } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; +import { useTaskChannelMap } from "@posthog/ui/features/canvas/hooks/useTaskChannelMap"; +import { + type CanvasArtifact, + useTaskCanvasArtifacts, +} from "@posthog/ui/features/recent-tasks/useTaskCanvasArtifacts"; +import { TaskIcon } from "@posthog/ui/features/sidebar/components/items/TaskIcon"; +import { useTasks } from "@posthog/ui/features/tasks/useTasks"; +import { NestedButton } from "@posthog/ui/primitives/NestedButton"; +import { Tooltip } from "@posthog/ui/primitives/Tooltip"; +import { + navigateToChannelDashboard, + navigateToChannelTask, + navigateToTaskDetail, +} from "@posthog/ui/router/navigationBridge"; +import { track } from "@posthog/ui/shell/analytics"; +import { openExternalUrl } from "@posthog/ui/shell/openExternal"; +import { Text } from "@radix-ui/themes"; +import { useEffect, useMemo } from "react"; + +type RecentTaskRow = { + task: Task; + channel: Channel | undefined; + // null when `updated_at` can't be parsed — the row then omits the time + // rather than showing a misleading epoch (1970) date. + updatedAt: number | null; + canvases: CanvasArtifact[]; + prUrl: string | undefined; +}; + +// A cross-channel list of every task, most recent first. Each row shows the +// task's channel, its last-updated time, and the same status icon the sidebar +// and command palette render. Reached from the Channels-space nav ("Recent +// tasks", below "Files"). +export function RecentTasksView() { + const { data: tasks, isLoading } = useTasks(); + const { channels } = useChannels(); + const taskChannelMap = useTaskChannelMap(channels); + const canvasArtifacts = useTaskCanvasArtifacts(channels); + const archivedTaskIds = useArchivedTaskIds(); + + useEffect(() => { + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "view_recent_tasks", + surface: "recent_tasks", + }); + }, []); + + const rows = useMemo(() => { + // Filter + map in a single pass, then sort most-recent-first. A null + // `updatedAt` (unparseable date) sorts last. + const result: RecentTaskRow[] = []; + for (const task of tasks ?? []) { + if (archivedTaskIds.has(task.id)) continue; + const parsed = Date.parse(task.updated_at); + const prUrl = task.latest_run?.output?.pr_url; + result.push({ + task, + channel: taskChannelMap.get(task.id), + updatedAt: Number.isNaN(parsed) ? null : parsed, + canvases: canvasArtifacts.get(task.id) ?? [], + prUrl: typeof prUrl === "string" ? prUrl : undefined, + }); + } + return result.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); + }, [tasks, taskChannelMap, canvasArtifacts, archivedTaskIds]); + + return ( +
+
+ + Recent tasks + + {rows.length === 0 ? ( + // Only the loaded-but-empty state shows the message; while the task + // list is still in flight the area stays blank so "No tasks yet" + // doesn't flash on every mount. + isLoading ? null : ( +
+ + No tasks yet + + + Tasks you create show up here, most recent first. + +
+ ) + ) : ( +
+ {rows.map((row) => ( + + ))} +
+ )} +
+
+ ); +} + +function RecentTaskItemRow({ row }: { row: RecentTaskRow }) { + const { task, channel, updatedAt, canvases, prUrl } = row; + + // The status icon is derived straight from the task's latest_run, rather than + // the sidebar's richer per-task session/workspace state, which isn't loaded + // for this cross-channel list. + const latestRun = task.latest_run; + const workspaceMode: WorkspaceMode | undefined = + latestRun?.environment === "cloud" ? "cloud" : undefined; + const slackThreadUrl = + typeof latestRun?.state?.slack_thread_url === "string" + ? latestRun.state.slack_thread_url + : undefined; + + const onClick = () => { + if (channel) { + navigateToChannelTask(channel.id, task.id); + } else { + navigateToTaskDetail(task.id); + } + }; + + return ( + + ); +} + +// Small clickable icons for the artifacts a task produced — canvases it +// generated and the pull request it opened. Each opens its artifact; they sit +// next to the task name. Colour-coded chips matching the channel artifacts/ +// history rows (canvas = violet, PR = green) so they read as artifacts at a +// glance. NestedButton keeps these interactive without nesting a