From da7565997f892f4453a615eb0e44cb3b997eadac Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Tue, 30 Jun 2026 05:51:22 -0700 Subject: [PATCH 1/6] feat(channels): add "Recent tasks" nav option Add a "Recent tasks" item to the Channels-space nav (below "Files") that opens a cross-channel list of every task, most recent first. Each row shows the task's channel, its last-updated time (human-readable), and the same status icon the sidebar and command palette render (TaskIcon). Gated by project-bluebird via the /website space. Generated-By: PostHog Code Task-Id: 51e058ce-6cca-43fa-b056-95ae0ff5f5b6 --- packages/shared/src/analytics-events.ts | 2 + .../canvas/components/ChannelsSidebar.tsx | 15 +++ .../features/recent-tasks/RecentTasksView.tsx | 127 ++++++++++++++++++ packages/ui/src/router/navigationBridge.ts | 5 + packages/ui/src/router/routeTree.gen.ts | 21 +++ .../router/routes/website/recent-tasks.tsx | 8 ++ packages/ui/src/router/useAppView.ts | 3 + 7 files changed, 181 insertions(+) create mode 100644 packages/ui/src/features/recent-tasks/RecentTasksView.tsx create mode 100644 packages/ui/src/router/routes/website/recent-tasks.tsx 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/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..987ddee998 --- /dev/null +++ b/packages/ui/src/features/recent-tasks/RecentTasksView.tsx @@ -0,0 +1,127 @@ +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 { TaskIcon } from "@posthog/ui/features/sidebar/components/items/TaskIcon"; +import { useTasks } from "@posthog/ui/features/tasks/useTasks"; +import { + navigateToChannelTask, + navigateToTaskDetail, +} from "@posthog/ui/router/navigationBridge"; +import { track } from "@posthog/ui/shell/analytics"; +import { Text } from "@radix-ui/themes"; +import { useEffect, useMemo } from "react"; + +type RecentTaskRow = { + task: Task; + channel: Channel | undefined; + updatedAt: number; +}; + +// 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 } = useTasks(); + const { channels } = useChannels(); + const taskChannelMap = useTaskChannelMap(channels); + const archivedTaskIds = useArchivedTaskIds(); + + useEffect(() => { + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "view_recent_tasks", + surface: "recent_tasks", + }); + }, []); + + const rows = useMemo(() => { + return (tasks ?? []) + .filter((task) => !archivedTaskIds.has(task.id)) + .map((task) => ({ + task, + channel: taskChannelMap.get(task.id), + updatedAt: Date.parse(task.updated_at) || 0, + })) + .sort((a, b) => b.updatedAt - a.updatedAt); + }, [tasks, taskChannelMap, archivedTaskIds]); + + return ( +
+
+ + Recent tasks + + {rows.length === 0 ? ( +
+ + No tasks yet + + + Tasks you create show up here, most recent first. + +
+ ) : ( +
+ {rows.map((row) => ( + + ))} +
+ )} +
+
+ ); +} + +function RecentTaskItemRow({ row }: { row: RecentTaskRow }) { + const { task, channel, updatedAt } = 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 ( + + ); +} diff --git a/packages/ui/src/router/navigationBridge.ts b/packages/ui/src/router/navigationBridge.ts index 5a9849a6c4..8b3297bedb 100644 --- a/packages/ui/src/router/navigationBridge.ts +++ b/packages/ui/src/router/navigationBridge.ts @@ -192,6 +192,11 @@ export function navigateToWebsiteSkills(): void { void getRouterOrNull()?.navigate({ to: "/website/skills" }); } +// The cross-channel "Recent tasks" list, a Channels-space surface. +export function navigateToRecentTasks(): void { + void getRouterOrNull()?.navigate({ to: "/website/recent-tasks" }); +} + export function navigateToWebsiteMcpServers(): void { void getRouterOrNull()?.navigate({ to: "/website/mcp-servers" }); } diff --git a/packages/ui/src/router/routeTree.gen.ts b/packages/ui/src/router/routeTree.gen.ts index 83e9deea9e..adb6682148 100644 --- a/packages/ui/src/router/routeTree.gen.ts +++ b/packages/ui/src/router/routeTree.gen.ts @@ -18,6 +18,7 @@ import { Route as WebsiteIndexRouteImport } from './routes/website/index' import { Route as SettingsIndexRouteImport } from './routes/settings/index' import { Route as CodeIndexRouteImport } from './routes/code/index' import { Route as WebsiteSkillsRouteImport } from './routes/website/skills' +import { Route as WebsiteRecentTasksRouteImport } from './routes/website/recent-tasks' import { Route as WebsiteNewRouteImport } from './routes/website/new' import { Route as WebsiteMcpServersRouteImport } from './routes/website/mcp-servers' import { Route as WebsiteHomeRouteImport } from './routes/website/home' @@ -118,6 +119,11 @@ const WebsiteSkillsRoute = WebsiteSkillsRouteImport.update({ path: '/skills', getParentRoute: () => WebsiteRoute, } as any) +const WebsiteRecentTasksRoute = WebsiteRecentTasksRouteImport.update({ + id: '/recent-tasks', + path: '/recent-tasks', + getParentRoute: () => WebsiteRoute, +} as any) const WebsiteNewRoute = WebsiteNewRouteImport.update({ id: '/new', path: '/new', @@ -426,6 +432,7 @@ export interface FileRoutesByFullPath { '/website/home': typeof WebsiteHomeRoute '/website/mcp-servers': typeof WebsiteMcpServersRoute '/website/new': typeof WebsiteNewRoute + '/website/recent-tasks': typeof WebsiteRecentTasksRoute '/website/skills': typeof WebsiteSkillsRoute '/code/': typeof CodeIndexRoute '/settings/': typeof SettingsIndexRoute @@ -488,6 +495,7 @@ export interface FileRoutesByTo { '/website/home': typeof WebsiteHomeRoute '/website/mcp-servers': typeof WebsiteMcpServersRoute '/website/new': typeof WebsiteNewRoute + '/website/recent-tasks': typeof WebsiteRecentTasksRoute '/website/skills': typeof WebsiteSkillsRoute '/code': typeof CodeIndexRoute '/settings': typeof SettingsIndexRoute @@ -546,6 +554,7 @@ export interface FileRoutesById { '/website/home': typeof WebsiteHomeRoute '/website/mcp-servers': typeof WebsiteMcpServersRoute '/website/new': typeof WebsiteNewRoute + '/website/recent-tasks': typeof WebsiteRecentTasksRoute '/website/skills': typeof WebsiteSkillsRoute '/code/': typeof CodeIndexRoute '/settings/': typeof SettingsIndexRoute @@ -613,6 +622,7 @@ export interface FileRouteTypes { | '/website/home' | '/website/mcp-servers' | '/website/new' + | '/website/recent-tasks' | '/website/skills' | '/code/' | '/settings/' @@ -675,6 +685,7 @@ export interface FileRouteTypes { | '/website/home' | '/website/mcp-servers' | '/website/new' + | '/website/recent-tasks' | '/website/skills' | '/code' | '/settings' @@ -732,6 +743,7 @@ export interface FileRouteTypes { | '/website/home' | '/website/mcp-servers' | '/website/new' + | '/website/recent-tasks' | '/website/skills' | '/code/' | '/settings/' @@ -865,6 +877,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof WebsiteSkillsRouteImport parentRoute: typeof WebsiteRoute } + '/website/recent-tasks': { + id: '/website/recent-tasks' + path: '/recent-tasks' + fullPath: '/website/recent-tasks' + preLoaderRoute: typeof WebsiteRecentTasksRouteImport + parentRoute: typeof WebsiteRoute + } '/website/new': { id: '/website/new' path: '/new' @@ -1251,6 +1270,7 @@ interface WebsiteRouteChildren { WebsiteHomeRoute: typeof WebsiteHomeRoute WebsiteMcpServersRoute: typeof WebsiteMcpServersRoute WebsiteNewRoute: typeof WebsiteNewRoute + WebsiteRecentTasksRoute: typeof WebsiteRecentTasksRoute WebsiteSkillsRoute: typeof WebsiteSkillsRoute WebsiteIndexRoute: typeof WebsiteIndexRoute WebsiteChannelIdArtifactsRoute: typeof WebsiteChannelIdArtifactsRoute @@ -1269,6 +1289,7 @@ const WebsiteRouteChildren: WebsiteRouteChildren = { WebsiteHomeRoute: WebsiteHomeRoute, WebsiteMcpServersRoute: WebsiteMcpServersRoute, WebsiteNewRoute: WebsiteNewRoute, + WebsiteRecentTasksRoute: WebsiteRecentTasksRoute, WebsiteSkillsRoute: WebsiteSkillsRoute, WebsiteIndexRoute: WebsiteIndexRoute, WebsiteChannelIdArtifactsRoute: WebsiteChannelIdArtifactsRoute, diff --git a/packages/ui/src/router/routes/website/recent-tasks.tsx b/packages/ui/src/router/routes/website/recent-tasks.tsx new file mode 100644 index 0000000000..81e3eab834 --- /dev/null +++ b/packages/ui/src/router/routes/website/recent-tasks.tsx @@ -0,0 +1,8 @@ +import { RecentTasksView } from "@posthog/ui/features/recent-tasks/RecentTasksView"; +import { createFileRoute } from "@tanstack/react-router"; + +// The cross-channel "Recent tasks" list. A Channels-space route so navigating +// here keeps the channels chrome (rail + channel sidebar). +export const Route = createFileRoute("/website/recent-tasks")({ + component: RecentTasksView, +}); diff --git a/packages/ui/src/router/useAppView.ts b/packages/ui/src/router/useAppView.ts index c94a0a12e3..81427d6161 100644 --- a/packages/ui/src/router/useAppView.ts +++ b/packages/ui/src/router/useAppView.ts @@ -18,6 +18,7 @@ export type AppViewType = | "command-center" | "skills" | "mcp-servers" + | "recent-tasks" | "settings"; export interface AppView { @@ -79,6 +80,8 @@ function deriveFromMatches(matches: Match[]): AppView { case "/mcp-servers": case "/website/mcp-servers": return { type: "mcp-servers" }; + case "/website/recent-tasks": + return { type: "recent-tasks" }; case "/settings/$category": case "/settings/": return { type: "settings" }; From bfee62a9db73fda4472bd532e9ee883447880a12 Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Tue, 30 Jun 2026 06:19:12 -0700 Subject: [PATCH 2/6] fix(channels): guard recent-tasks loading and date edge cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address code-review feedback on the Recent tasks view: - Don't flash the "No tasks yet" empty state while the task list is still loading (useTasks() returns undefined in flight) — guard it with isLoading. - Omit the relative time when updated_at can't be parsed, instead of showing a misleading epoch (1970) date. - Build the rows in a single filter+map pass before sorting. Generated-By: PostHog Code Task-Id: 51e058ce-6cca-43fa-b056-95ae0ff5f5b6 --- .../features/recent-tasks/RecentTasksView.tsx | 48 ++++++++++++------- 1 file changed, 30 insertions(+), 18 deletions(-) diff --git a/packages/ui/src/features/recent-tasks/RecentTasksView.tsx b/packages/ui/src/features/recent-tasks/RecentTasksView.tsx index 987ddee998..4f07afd00c 100644 --- a/packages/ui/src/features/recent-tasks/RecentTasksView.tsx +++ b/packages/ui/src/features/recent-tasks/RecentTasksView.tsx @@ -18,7 +18,9 @@ import { useEffect, useMemo } from "react"; type RecentTaskRow = { task: Task; channel: Channel | undefined; - updatedAt: number; + // 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; }; // A cross-channel list of every task, most recent first. Each row shows the @@ -26,7 +28,7 @@ type RecentTaskRow = { // and command palette render. Reached from the Channels-space nav ("Recent // tasks", below "Files"). export function RecentTasksView() { - const { data: tasks } = useTasks(); + const { data: tasks, isLoading } = useTasks(); const { channels } = useChannels(); const taskChannelMap = useTaskChannelMap(channels); const archivedTaskIds = useArchivedTaskIds(); @@ -39,14 +41,19 @@ export function RecentTasksView() { }, []); const rows = useMemo(() => { - return (tasks ?? []) - .filter((task) => !archivedTaskIds.has(task.id)) - .map((task) => ({ + // 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); + result.push({ task, channel: taskChannelMap.get(task.id), - updatedAt: Date.parse(task.updated_at) || 0, - })) - .sort((a, b) => b.updatedAt - a.updatedAt); + updatedAt: Number.isNaN(parsed) ? null : parsed, + }); + } + return result.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); }, [tasks, taskChannelMap, archivedTaskIds]); return ( @@ -56,14 +63,19 @@ export function RecentTasksView() { Recent tasks {rows.length === 0 ? ( -
- - No tasks yet - - - Tasks you create show up here, most recent first. - -
+ // 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) => ( @@ -118,8 +130,8 @@ function RecentTaskItemRow({ row }: { row: RecentTaskRow }) { {task.title || "Untitled task"} - {channel ? channel.name : "No channel"} ·{" "} - {formatRelativeTimeLong(updatedAt)} + {channel ? channel.name : "No channel"} + {updatedAt !== null ? ` · ${formatRelativeTimeLong(updatedAt)}` : ""} From 95c205111e2c3635c8f0c018f39b3353639e2ccc Mon Sep 17 00:00:00 2001 From: Raquel Smith Date: Tue, 30 Jun 2026 07:10:49 -0700 Subject: [PATCH 3/6] feat(channels): move recent-task metadata left, add artifact icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Prefix the channel with "#" and keep the channel + last-updated metadata on the left of each row. - Show small clickable icons on the right for artifacts a task produced: canvases it generated (via the dashboard's durable generationTaskId) and the pull request it opened (latest_run.output.pr_url). Clicking a canvas opens it in its channel; clicking the PR opens it in the browser. No PostHog data-model change was needed — both associations already exist. Generated-By: PostHog Code Task-Id: 51e058ce-6cca-43fa-b056-95ae0ff5f5b6 --- .../features/recent-tasks/RecentTasksView.tsx | 65 ++++++++++++++++++- .../recent-tasks/useTaskCanvasArtifacts.ts | 48 ++++++++++++++ 2 files changed, 110 insertions(+), 3 deletions(-) create mode 100644 packages/ui/src/features/recent-tasks/useTaskCanvasArtifacts.ts diff --git a/packages/ui/src/features/recent-tasks/RecentTasksView.tsx b/packages/ui/src/features/recent-tasks/RecentTasksView.tsx index 4f07afd00c..313ae21c45 100644 --- a/packages/ui/src/features/recent-tasks/RecentTasksView.tsx +++ b/packages/ui/src/features/recent-tasks/RecentTasksView.tsx @@ -1,3 +1,4 @@ +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"; @@ -5,13 +6,21 @@ import { useArchivedTaskIds } from "@posthog/ui/features/archive/useArchivedTask 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"; @@ -21,6 +30,8 @@ type RecentTaskRow = { // 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 @@ -31,6 +42,7 @@ export function RecentTasksView() { const { data: tasks, isLoading } = useTasks(); const { channels } = useChannels(); const taskChannelMap = useTaskChannelMap(channels); + const canvasArtifacts = useTaskCanvasArtifacts(channels); const archivedTaskIds = useArchivedTaskIds(); useEffect(() => { @@ -47,14 +59,17 @@ export function RecentTasksView() { 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, archivedTaskIds]); + }, [tasks, taskChannelMap, canvasArtifacts, archivedTaskIds]); return (
@@ -89,7 +104,7 @@ export function RecentTasksView() { } function RecentTaskItemRow({ row }: { row: RecentTaskRow }) { - const { task, channel, updatedAt } = row; + 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 @@ -130,10 +145,54 @@ function RecentTaskItemRow({ row }: { row: RecentTaskRow }) { {task.title || "Untitled task"} - {channel ? channel.name : "No channel"} + {channel ? `#${channel.name}` : "No channel"} {updatedAt !== null ? ` · ${formatRelativeTimeLong(updatedAt)}` : ""} + ); } + +// Small clickable icons for the artifacts a task produced — canvases it +// generated and the pull request it opened. Each opens its artifact; they sit +// to the right of the row, opposite the metadata. NestedButton keeps these +// interactive without nesting a ); } // Small clickable icons for the artifacts a task produced — canvases it // generated and the pull request it opened. Each opens its artifact; they sit -// to the right of the row, opposite the metadata. NestedButton keeps these -// interactive without nesting a ); } // Small clickable icons for the artifacts a task produced — canvases it // generated and the pull request it opened. Each opens its artifact; they sit -// at the far right of the row, after the channel/time metadata. NestedButton -// keeps these interactive without nesting a