From 4297f192f9181aa1ba35413f1a0004ad220638d0 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Tue, 28 Jul 2026 12:39:09 +0200 Subject: [PATCH 1/5] feat(channels): add the structured Activity view Replace the flag-gated task thread with Timeline, Artifacts, and Comments views. Include multi-run artifacts and selected historical pull request review while leaving flag-off behavior unchanged. Generated-By: PostHog Code Task-Id: 6190e713-9b80-43d9-a05c-5e3b1ecdf297 --- .../src/canvas/runArtifactSchemas.test.ts | 36 ++ .../core/src/canvas/runArtifactSchemas.ts | 17 + .../canvas/components/ActivityPanel.tsx | 265 ++++++++++++++ .../canvas/components/ActivityTimeline.tsx | 262 ++++++++++++++ .../components/TaskArtifactsList.test.tsx | 63 ++++ .../canvas/components/TaskArtifactsList.tsx | 328 ++++++++++++++++++ .../canvas/components/ThreadSidebar.tsx | 16 +- .../src/features/canvas/hooks/useTaskRuns.ts | 20 ++ .../components/CloudReviewPage.tsx | 5 +- .../code-review/components/ReviewPage.tsx | 5 +- .../hooks/useEffectiveDiffSource.ts | 10 +- .../code-review/reviewNavigationStore.test.ts | 17 + .../code-review/reviewNavigationStore.ts | 15 + .../task-detail/hooks/useCloudChangedFiles.ts | 5 +- 14 files changed, 1051 insertions(+), 13 deletions(-) create mode 100644 packages/core/src/canvas/runArtifactSchemas.test.ts create mode 100644 packages/core/src/canvas/runArtifactSchemas.ts create mode 100644 packages/ui/src/features/canvas/components/ActivityPanel.tsx create mode 100644 packages/ui/src/features/canvas/components/ActivityTimeline.tsx create mode 100644 packages/ui/src/features/canvas/components/TaskArtifactsList.test.tsx create mode 100644 packages/ui/src/features/canvas/components/TaskArtifactsList.tsx create mode 100644 packages/ui/src/features/canvas/hooks/useTaskRuns.ts diff --git a/packages/core/src/canvas/runArtifactSchemas.test.ts b/packages/core/src/canvas/runArtifactSchemas.test.ts new file mode 100644 index 0000000000..e2db2fe283 --- /dev/null +++ b/packages/core/src/canvas/runArtifactSchemas.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { parseRunPlans } from "./runArtifactSchemas"; + +describe("parseRunPlans", () => { + it.each([ + { name: "undefined", raw: undefined }, + { name: "null", raw: null }, + { name: "an object", raw: { artifacts: [] } }, + { name: "a string", raw: "plan" }, + ])("reads $name as no artifacts", ({ raw }) => { + expect(parseRunPlans(raw)).toEqual([]); + }); + + it("keeps only plan artifacts", () => { + const plans = parseRunPlans([ + { id: "a", type: "plan", name: "Plan A" }, + { id: "b", type: "upload", name: "internal blob" }, + ]); + expect(plans).toEqual([{ id: "a", type: "plan", name: "Plan A" }]); + }); + + // One bad entry shouldn't take the Artifacts tab down with it. + it("drops entries that don't match the shape", () => { + const plans = parseRunPlans([ + { id: 42, type: "plan" }, + null, + "not an object", + { type: "plan", storage_path: "runs/1/plan.md" }, + ]); + expect(plans).toEqual([{ type: "plan", storage_path: "runs/1/plan.md" }]); + }); + + it("ignores an artifact with no type", () => { + expect(parseRunPlans([{ id: "a", name: "mystery" }])).toEqual([]); + }); +}); diff --git a/packages/core/src/canvas/runArtifactSchemas.ts b/packages/core/src/canvas/runArtifactSchemas.ts new file mode 100644 index 0000000000..8d95e86e32 --- /dev/null +++ b/packages/core/src/canvas/runArtifactSchemas.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; + +export const runArtifactSchema = z.object({ + id: z.string().optional(), + name: z.string().optional(), + type: z.string().optional(), + storage_path: z.string().optional(), +}); +export type RunArtifact = z.infer; + +export function parseRunPlans(raw: unknown): RunArtifact[] { + if (!Array.isArray(raw)) return []; + return raw.flatMap((entry) => { + const parsed = runArtifactSchema.safeParse(entry); + return parsed.success && parsed.data.type === "plan" ? [parsed.data] : []; + }); +} diff --git a/packages/ui/src/features/canvas/components/ActivityPanel.tsx b/packages/ui/src/features/canvas/components/ActivityPanel.tsx new file mode 100644 index 0000000000..06d075f578 --- /dev/null +++ b/packages/ui/src/features/canvas/components/ActivityPanel.tsx @@ -0,0 +1,265 @@ +import { CaretRightIcon } from "@phosphor-icons/react"; +import { Button, cn, Tabs, TabsList, TabsTrigger } from "@posthog/quill"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import type { Task } from "@posthog/shared/domain-types"; +import { ActivityTimeline } from "@posthog/ui/features/canvas/components/ActivityTimeline"; +import { TaskCard } from "@posthog/ui/features/canvas/components/ChannelFeedView"; +import { TaskArtifactsList } from "@posthog/ui/features/canvas/components/TaskArtifactsList"; +import { + AgentStatusLine, + ThreadLoadingState, + ThreadPanelHeader, + ThreadReplyComposer, + ThreadTimeline, +} from "@posthog/ui/features/canvas/components/ThreadPanel"; +import { useThreadConversation } from "@posthog/ui/features/canvas/hooks/useThreadConversation"; +import { buildConversationItems } from "@posthog/ui/features/sessions/components/buildConversationItems"; +import { taskDetailQuery } from "@posthog/ui/features/tasks/queries"; +import { track } from "@posthog/ui/shell/analytics"; +import { useQuery } from "@tanstack/react-query"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +type ActivityTab = "timeline" | "artifacts" | "comments"; + +const ACTIVITY_TABS: readonly { key: ActivityTab; label: string }[] = [ + { key: "timeline", label: "Timeline" }, + { key: "artifacts", label: "Artifacts" }, + { key: "comments", label: "Comments" }, +] as const; + +const TABS_WITH_COMPOSER: ReadonlySet = new Set([ + "timeline", + "comments", +]); + +const TIMESTAMP_END_CLASS = + "[&_[data-slot=thread-item-timestamp]]:ml-auto [&_[data-slot=thread-item-timestamp]]:shrink-0 [&_[data-slot=thread-item-timestamp]]:pl-2"; + +function ActivityTabsRow({ + tab, + onTabChange, +}: { + tab: ActivityTab; + onTabChange: (tab: ActivityTab) => void; +}) { + return ( +
+ onTabChange(value as ActivityTab)} + > + + {ACTIVITY_TABS.map((t) => ( + + {t.label} + + ))} + + +
+ ); +} + +function ActivityConversation({ + task, + channelId, + onClose, + onToggleCollapsed, + onOpenFull, + showTaskSummary, +}: { + task: Task; + channelId: string; + onClose?: () => void; + onToggleCollapsed?: () => void; + onOpenFull?: () => void; + showTaskSummary: boolean; +}) { + const taskId = task.id; + const { + timeline, + agentStatus, + events, + isPromptPending, + isReady, + members, + currentUser, + isTaskAuthor, + canForward, + draft, + setDraft, + isSubmitDisabled, + submit, + sendMessageToAgent, + deleteMessage, + onMentionInsert, + } = useThreadConversation(task, { surface: "activity_panel" }); + + const [tab, setTab] = useState("timeline"); + const handleTabChange = useCallback( + (next: ActivityTab) => { + setTab(next); + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "activity_tab_change", + surface: "activity_panel", + task_id: taskId, + tab: next, + }); + }, + [taskId], + ); + + const commentRows = useMemo( + () => timeline.filter((row) => row.kind === "human"), + [timeline], + ); + const conversationItems = useMemo( + () => + tab === "timeline" + ? buildConversationItems(events, isPromptPending).items + : [], + [tab, events, isPromptPending], + ); + + const scrollRef = useRef(null); + // biome-ignore lint/correctness/useExhaustiveDependencies: scroll when rendered thread content changes + useEffect(() => { + scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }); + }, [timeline, events.length, agentStatus?.phase, tab]); + + const showComposer = TABS_WITH_COMPOSER.has(tab); + + const body = () => { + if (tab === "artifacts") { + return ; + } + if (tab === "comments") { + return ( + + ); + } + if (!isReady) return ; + return ( + + ); + }; + + return ( +
+ + + + {showTaskSummary && ( +
+ +
+ )} +
+ {body()} +
+ + {showComposer && agentStatus && } + + {showComposer && ( + + )} +
+ ); +} + +export function ActivityPanel({ + taskId, + channelId, + task: taskProp, + onClose, + collapsed, + onToggleCollapsed, + onOpenFull, + showTaskSummary = true, +}: { + taskId: string; + channelId: string; + task?: Task; + onClose?: () => void; + collapsed?: boolean; + onToggleCollapsed?: () => void; + onOpenFull?: () => void; + showTaskSummary?: boolean; +}) { + const { data: fetchedTask } = useQuery({ + ...taskDetailQuery(taskId), + enabled: !taskProp && !collapsed, + }); + const task = taskProp ?? fetchedTask; + + if (collapsed) { + return ( +
+ +
+ ); + } + + if (!task) { + return ; + } + + return ( + + ); +} diff --git a/packages/ui/src/features/canvas/components/ActivityTimeline.tsx b/packages/ui/src/features/canvas/components/ActivityTimeline.tsx new file mode 100644 index 0000000000..482aa35c50 --- /dev/null +++ b/packages/ui/src/features/canvas/components/ActivityTimeline.tsx @@ -0,0 +1,262 @@ +import { CheckCircleIcon, XCircleIcon } from "@phosphor-icons/react"; +import type { ThreadTimelineRow } from "@posthog/core/canvas/threadTimeline"; +import { + ThreadItem, + ThreadItemAuthor, + ThreadItemBody, + ThreadItemContent, + ThreadItemGroup, + ThreadItemGutter, + ThreadItemHeader, +} from "@posthog/quill"; +import type { + Task, + TaskThreadMessage, + UserBasic, +} from "@posthog/shared/domain-types"; +import { isTerminalStatus } from "@posthog/shared/domain-types"; +import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar"; +import { + ThreadArtifactRow, + ThreadMessageRow, +} from "@posthog/ui/features/canvas/components/ThreadPanel"; +import { ThreadTimestamp } from "@posthog/ui/features/canvas/components/ThreadTimestamp"; +import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; +import type { buildConversationItems } from "@posthog/ui/features/sessions/components/buildConversationItems"; +import { Fragment, type ReactNode, useMemo } from "react"; + +type ConversationItem = ReturnType< + typeof buildConversationItems +>["items"][number]; + +function ActivityEventRow({ + node, + title, + action, + timestamp, +}: { + node: ReactNode; + title: string; + action?: string; + timestamp: string; +}) { + return ( +
+
+
{node}
+
+ + {title} + {action && {action}} + + +
+ ); +} + +function EventNode({ icon }: { icon: ReactNode }) { + return ( + + {icon} + + ); +} + +function UserMessageRow({ + author, + content, + timestamp, +}: { + author?: UserBasic | null; + content: string; + timestamp: string; +}) { + return ( + + + + + + + + {author ? userDisplayName(author) : "You"} + + + + + + {content} + + + + + ); +} + +export function ActivityTimeline({ + task, + timeline, + conversationItems, + currentUserUuid, + currentUserEmail, + isTaskAuthor, + canForward, + onSendToAgent, + onDelete, +}: { + task: Task; + timeline: ThreadTimelineRow[]; + conversationItems: ConversationItem[]; + currentUserUuid?: string; + currentUserEmail?: string | null; + isTaskAuthor: boolean; + canForward: boolean; + onSendToAgent: (messageId: string) => void; + onDelete: (messageId: string) => void; +}) { + const nodes = useMemo(() => { + const entries: { key: string; ts: number; node: ReactNode }[] = []; + const createdTs = Date.parse(task.created_at) || 0; + entries.push({ + key: "task-created", + ts: createdTs, + node: ( + + } + title={task.created_by ? userDisplayName(task.created_by) : "Someone"} + action="created this task" + timestamp={task.created_at} + /> + ), + }); + + for (const item of conversationItems) { + if (item.type !== "user_message") continue; + entries.push({ + key: `user-message-${item.id}`, + ts: item.timestamp, + node: ( + + ), + }); + } + + let hasPrArtifact = false; + for (const row of timeline) { + if (row.kind === "artifact" && row.artifact.kind === "pr") { + hasPrArtifact = true; + } + entries.push({ + key: `thread-${row.message.id}`, + ts: row.timestamp, + node: + row.kind === "human" ? ( + onSendToAgent(row.message.id)} + onDelete={() => onDelete(row.message.id)} + /> + ) : ( + + ), + }); + } + + const updatedTs = Date.parse(task.updated_at) || createdTs; + const outputPr = task.latest_run?.output?.pr_url; + if (typeof outputPr === "string" && outputPr && !hasPrArtifact) { + entries.push({ + key: "output-pr", + ts: updatedTs, + node: ( + + ), + }); + } + + const runStatus = task.latest_run?.status; + if (runStatus && isTerminalStatus(runStatus)) { + const succeeded = runStatus === "completed"; + entries.push({ + key: "run-status", + ts: updatedTs + 1, + node: ( + + ) : ( + + ) + } + /> + } + title={`Task ${runStatus.replace(/_/g, " ")}`} + timestamp={task.updated_at} + /> + ), + }); + } + + return entries.sort((a, b) => a.ts - b.ts); + }, [ + conversationItems, + timeline, + task, + isTaskAuthor, + canForward, + currentUserUuid, + currentUserEmail, + onSendToAgent, + onDelete, + ]); + + return ( +
+
+
+ + {nodes.map((entry) => ( + {entry.node} + ))} + +
+
+ ); +} diff --git a/packages/ui/src/features/canvas/components/TaskArtifactsList.test.tsx b/packages/ui/src/features/canvas/components/TaskArtifactsList.test.tsx new file mode 100644 index 0000000000..42782087fc --- /dev/null +++ b/packages/ui/src/features/canvas/components/TaskArtifactsList.test.tsx @@ -0,0 +1,63 @@ +import type { Task, TaskRun } from "@posthog/shared/domain-types"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + runs: [] as TaskRun[], +})); + +vi.mock("@posthog/ui/features/canvas/hooks/useTaskRuns", () => ({ + useTaskRuns: () => ({ runs: mocks.runs, isLoading: false }), +})); +vi.mock("@posthog/ui/features/git-interaction/usePrArtifact", () => ({ + usePrArtifact: (url: string) => ({ + safeUrl: url, + title: `Pull request #${url.split("/").at(-1)}`, + stateLabel: "Open", + Icon: () => , + iconColor: "currentColor", + }), +})); +vi.mock("@posthog/ui/features/pr-review/usePrComments", () => ({ + usePrComments: () => ({ data: undefined }), +})); +vi.mock("@posthog/ui/features/pr-review/usePrReviewThreads", () => ({ + usePrReviewThreads: () => ({ data: undefined }), +})); + +import { useReviewNavigationStore } from "@posthog/ui/features/code-review/reviewNavigationStore"; +import { TaskArtifactsList } from "./TaskArtifactsList"; + +const task = { + id: "task-1", + latest_run: null, +} as unknown as Task; + +function run(id: string, prNumber: number): TaskRun { + return { + id, + output: { pr_url: `https://github.com/acme/repo/pull/${prNumber}` }, + } as unknown as TaskRun; +} + +describe("TaskArtifactsList", () => { + beforeEach(() => { + mocks.runs = [run("run-1", 1), run("run-2", 2)]; + useReviewNavigationStore.setState({ + reviewModes: {}, + selectedPrUrls: {}, + }); + }); + + it("opens the PR represented by the selected historical row", () => { + render(); + + fireEvent.click(screen.getByText("Pull request #2")); + + const state = useReviewNavigationStore.getState(); + expect(state.selectedPrUrls[task.id]).toBe( + "https://github.com/acme/repo/pull/2", + ); + expect(state.reviewModes[task.id]).toBe("split"); + }); +}); diff --git a/packages/ui/src/features/canvas/components/TaskArtifactsList.tsx b/packages/ui/src/features/canvas/components/TaskArtifactsList.tsx new file mode 100644 index 0000000000..0f50a76fbe --- /dev/null +++ b/packages/ui/src/features/canvas/components/TaskArtifactsList.tsx @@ -0,0 +1,328 @@ +import { + ArrowSquareOutIcon, + ClipboardTextIcon, + PackageIcon, + SlackLogoIcon, +} from "@phosphor-icons/react"; +import { + parseRunPlans, + type RunArtifact, +} from "@posthog/core/canvas/runArtifactSchemas"; +import type { ThreadTimelineRow } from "@posthog/core/canvas/threadTimeline"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@posthog/quill"; +import type { + Task, + TaskRun, + TaskThreadMessage, +} from "@posthog/shared/domain-types"; +import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon"; +import { useTaskRuns } from "@posthog/ui/features/canvas/hooks/useTaskRuns"; +import { useReviewNavigationStore } from "@posthog/ui/features/code-review/reviewNavigationStore"; +import { usePrArtifact } from "@posthog/ui/features/git-interaction/usePrArtifact"; +import { usePrComments } from "@posthog/ui/features/pr-review/usePrComments"; +import { usePrReviewThreads } from "@posthog/ui/features/pr-review/usePrReviewThreads"; +import { toast } from "@posthog/ui/primitives/toast"; +import { openExternalUrl } from "@posthog/ui/shell/openExternal"; +import { parseHttpsUrl, parseShareLink } from "@posthog/ui/utils/posthogLinks"; +import { navigateToShareTarget } from "@posthog/ui/utils/shareLinks"; +import { getPostHogUrl } from "@posthog/ui/utils/urls"; +import { type ReactNode, useMemo, useState } from "react"; + +type ArtifactRow = + | { kind: "pr"; key: string; url: string } + | { kind: "canvas"; key: string; name: string; url: string | null } + | { + kind: "plan"; + key: string; + name: string; + storagePath: string | null; + runId: string | null; + } + | { kind: "slack"; key: string; url: string }; + +function readRunPlans(run: TaskRun): RunArtifact[] { + return parseRunPlans((run as { artifacts?: unknown }).artifacts); +} + +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i; +function planTitle(name: string | undefined): string { + if (!name || UUID_RE.test(name)) return "Plan"; + return name; +} + +function buildRows( + task: Task, + timeline: ThreadTimelineRow[], + runs: TaskRun[], +): ArtifactRow[] { + const rows: ArtifactRow[] = []; + const seenPrUrls = new Set(); + const seenPlanKeys = new Set(); + + const addPr = (url: string, key: string) => { + if (seenPrUrls.has(url)) return; + seenPrUrls.add(url); + rows.push({ kind: "pr", key, url }); + }; + + for (const row of timeline) { + if (row.kind !== "artifact") continue; + if (row.artifact.kind === "pr") { + addPr(row.artifact.url, row.message.id); + } else { + rows.push({ + kind: "canvas", + key: row.message.id, + name: row.artifact.name, + url: row.artifact.url, + }); + } + } + + const allRuns = + runs.length > 0 ? runs : task.latest_run ? [task.latest_run] : []; + for (const run of allRuns) { + const outputPr = run.output?.pr_url; + if (typeof outputPr === "string" && outputPr) { + addPr(outputPr, `output-pr:${outputPr}`); + } + for (const plan of readRunPlans(run)) { + const key = `plan:${plan.id ?? plan.storage_path ?? plan.name}`; + if (seenPlanKeys.has(key)) continue; + seenPlanKeys.add(key); + rows.push({ + kind: "plan", + key, + name: planTitle(plan.name), + storagePath: plan.storage_path ?? null, + runId: run.id, + }); + } + } + + const slackUrl = task.latest_run?.state?.slack_thread_url; + if (typeof slackUrl === "string" && slackUrl) { + rows.push({ kind: "slack", key: "slack-thread", url: slackUrl }); + } + + return rows; +} + +function ArtifactListRow({ + icon, + title, + detail, + external, + onOpen, + onHoverStart, +}: { + icon: ReactNode; + title: string; + detail?: string | null; + external?: boolean; + onOpen?: () => void; + onHoverStart?: () => void; +}) { + return ( + + ); +} + +function PrRow({ url, taskId }: { url: string; taskId: string }) { + const { safeUrl, title, stateLabel, Icon, iconColor } = usePrArtifact(url); + const setReviewMode = useReviewNavigationStore((s) => s.setReviewMode); + const setSelectedPrUrl = useReviewNavigationStore((s) => s.setSelectedPrUrl); + + const [countsWanted, setCountsWanted] = useState(false); + const comments = usePrComments(countsWanted ? safeUrl : null); + const threads = usePrReviewThreads(countsWanted ? safeUrl : null); + + const commentCount = + (comments.data?.length ?? 0) + + (threads.data ?? []).reduce( + (sum, thread) => sum + thread.comments.length, + 0, + ); + const detailParts = [ + stateLabel, + comments.data || threads.data + ? `${commentCount} ${commentCount === 1 ? "comment" : "comments"}` + : null, + ].filter(Boolean); + + return ( + + } + title={title} + detail={detailParts.join(" · ") || null} + onHoverStart={() => setCountsWanted(true)} + onOpen={ + safeUrl + ? () => { + setSelectedPrUrl(taskId, safeUrl); + setReviewMode(taskId, "split"); + } + : undefined + } + /> + ); +} + +function CanvasRow({ name, url }: { name: string; url: string | null }) { + const parsed = url ? parseHttpsUrl(url) : null; + const target = parsed ? parseShareLink(parsed.href) : null; + const open = + parsed && target + ? () => { + const currentPostHogUrl = getPostHogUrl("/"); + const currentPostHogOrigin = currentPostHogUrl + ? parseHttpsUrl(currentPostHogUrl)?.origin + : null; + if (parsed.origin === currentPostHogOrigin) { + navigateToShareTarget(target); + } else { + openExternalUrl(parsed.href); + } + } + : undefined; + return ( + + ); +} + +function PlanRow({ + taskId, + runId, + name, + storagePath, +}: { + taskId: string; + runId: string | null; + name: string; + storagePath: string | null; +}) { + const client = useOptionalAuthenticatedClient(); + const canOpen = !!client && !!runId && !!storagePath; + const onOpen = canOpen + ? () => { + client + .presignTaskRunArtifact( + taskId, + runId as string, + storagePath as string, + ) + .then((url) => openExternalUrl(url)) + .catch((error: unknown) => { + toast.error("Couldn't open plan", { + description: + error instanceof Error ? error.message : String(error), + }); + }); + } + : undefined; + return ( + } + title={name} + detail="Plan" + external={canOpen} + onOpen={onOpen} + /> + ); +} + +export function TaskArtifactsList({ + task, + timeline, +}: { + task: Task; + timeline: ThreadTimelineRow[]; +}) { + const { runs } = useTaskRuns(task.id); + const rows = useMemo( + () => buildRows(task, timeline, runs), + [task, timeline, runs], + ); + + if (rows.length === 0) { + return ( + + + + + + No artifacts yet + + Pull requests and canvases produced while working on this task show + up here. + + + + ); + } + + return ( +
+ {rows.map((row) => + row.kind === "pr" ? ( + + ) : row.kind === "canvas" ? ( + + ) : row.kind === "plan" ? ( + + ) : ( + } + title="Slack thread" + detail="External" + external + onOpen={() => openExternalUrl(row.url)} + /> + ), + )} +
+ ); +} diff --git a/packages/ui/src/features/canvas/components/ThreadSidebar.tsx b/packages/ui/src/features/canvas/components/ThreadSidebar.tsx index 4a81cf8700..1dfa13cb29 100644 --- a/packages/ui/src/features/canvas/components/ThreadSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ThreadSidebar.tsx @@ -1,15 +1,15 @@ import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import type { Task } from "@posthog/shared/domain-types"; +import { ActivityPanel } from "@posthog/ui/features/canvas/components/ActivityPanel"; import { ThreadPanel } from "@posthog/ui/features/canvas/components/ThreadPanel"; +import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { useThreadPanelStore } from "@posthog/ui/features/canvas/stores/threadPanelStore"; import { ResizableSidebar } from "@posthog/ui/primitives/ResizableSidebar"; import { track } from "@posthog/ui/shell/analytics"; import { useState } from "react"; -// The right-hand dock hosting a task's ThreadPanel: a thin rail when -// collapsed, a resizable sidebar otherwise. Shared by the channel feed and the -// task detail route; owns the panel-store size/collapse reads so parents don't -// re-render on every resize tick. +// The right-hand dock for a task's thread (collapsible, resizable). Flag on +// swaps the legacy ThreadPanel for the tabbed ActivityPanel. export function ThreadSidebar({ taskId, channelId, @@ -31,19 +31,21 @@ export function ThreadSidebar({ const setWidth = useThreadPanelStore((s) => s.setWidth); const setCollapsed = useThreadPanelStore((s) => s.setCollapsed); const [isResizing, setIsResizing] = useState(false); + const channelsLayout = useChannelsLayout(); + const Panel = channelsLayout ? ActivityPanel : ThreadPanel; const toggleCollapsed = (next: boolean) => { setCollapsed(next); track(ANALYTICS_EVENTS.CHANNEL_ACTION, { action_type: next ? "collapse_thread" : "expand_thread", - surface: "thread_panel", + surface: channelsLayout ? "activity_panel" : "thread_panel", task_id: taskId, }); }; if (collapsed) { return ( - - ( + ["task-runs", taskId ?? "none"], + (client) => client.listTaskRuns(taskId as string), + { + enabled: !!taskId, + refetchInterval: TASK_RUNS_POLL_INTERVAL_MS, + }, + ); + return { runs: query.data ?? [], isLoading: query.isLoading }; +} diff --git a/packages/ui/src/features/code-review/components/CloudReviewPage.tsx b/packages/ui/src/features/code-review/components/CloudReviewPage.tsx index db6e01f571..39e4a3694e 100644 --- a/packages/ui/src/features/code-review/components/CloudReviewPage.tsx +++ b/packages/ui/src/features/code-review/components/CloudReviewPage.tsx @@ -25,6 +25,9 @@ export function CloudReviewPage({ task }: CloudReviewPageProps) { const isReviewOpen = useReviewNavigationStore( (s) => (s.reviewModes[taskId] ?? "closed") !== "closed", ); + const selectedPrUrl = useReviewNavigationStore( + (s) => s.selectedPrUrls[taskId], + ); const showReviewComments = useDiffViewerStore((s) => s.showReviewComments); const { effectiveBranch, @@ -34,7 +37,7 @@ export function CloudReviewPage({ task }: CloudReviewPageProps) { reviewFiles, toolCalls, isLoading, - } = useCloudChangedFiles(taskId, task, isReviewOpen); + } = useCloudChangedFiles(taskId, task, isReviewOpen, selectedPrUrl); const { commentThreads, commentsLoading } = usePrDetails(prUrl, { includeComments: isReviewOpen && showReviewComments, }); diff --git a/packages/ui/src/features/code-review/components/ReviewPage.tsx b/packages/ui/src/features/code-review/components/ReviewPage.tsx index 4954d2ccb4..931f3d9ecb 100644 --- a/packages/ui/src/features/code-review/components/ReviewPage.tsx +++ b/packages/ui/src/features/code-review/components/ReviewPage.tsx @@ -97,6 +97,9 @@ export function ReviewPage({ task }: ReviewPageProps) { const isReviewOpen = useReviewNavigationStore( (s) => (s.reviewModes[taskId] ?? "closed") !== "closed", ); + const selectedPrUrl = useReviewNavigationStore( + (s) => s.selectedPrUrls[taskId], + ); const { effectiveSource, @@ -105,7 +108,7 @@ export function ReviewPage({ task }: ReviewPageProps) { defaultBranch, branchSourceAvailable, prSourceAvailable, - } = useEffectiveDiffSource(taskId); + } = useEffectiveDiffSource(taskId, selectedPrUrl); const showReviewComments = useDiffViewerStore((s) => s.showReviewComments); const { commentThreads, commentsLoading } = usePrDetails(prUrl, { diff --git a/packages/ui/src/features/code-review/hooks/useEffectiveDiffSource.ts b/packages/ui/src/features/code-review/hooks/useEffectiveDiffSource.ts index e9915ca5b2..1b878c6b31 100644 --- a/packages/ui/src/features/code-review/hooks/useEffectiveDiffSource.ts +++ b/packages/ui/src/features/code-review/hooks/useEffectiveDiffSource.ts @@ -23,7 +23,10 @@ export interface EffectiveDiffSource { diffStats: DiffStats; } -export function useEffectiveDiffSource(taskId: string): EffectiveDiffSource { +export function useEffectiveDiffSource( + taskId: string, + prUrlOverride?: string, +): EffectiveDiffSource { const trpc = useHostTRPC(); const repoPath = useCwd(taskId); const workspace = useWorkspace(taskId); @@ -54,7 +57,8 @@ export function useEffectiveDiffSource(taskId: string): EffectiveDiffSource { const hasLocalChanges = diffStats.filesChanged > 0; const branchSourceAvailable = !!linkedBranch && aheadOfDefault > 0; - const prUrl = useTaskPrUrl(taskId, workspace?.mode === "cloud"); + const taskPrUrl = useTaskPrUrl(taskId, workspace?.mode === "cloud"); + const prUrl = prUrlOverride ?? taskPrUrl; const prSourceAvailable = !!prUrl; const repoSlug = repoInfo @@ -62,7 +66,7 @@ export function useEffectiveDiffSource(taskId: string): EffectiveDiffSource { : null; const effectiveSource = resolveDiffSource({ - configured, + configured: prUrlOverride ? "pr" : configured, hasLocalChanges, linkedBranch, aheadOfDefault, diff --git a/packages/ui/src/features/code-review/reviewNavigationStore.test.ts b/packages/ui/src/features/code-review/reviewNavigationStore.test.ts index b6d3f413c1..f6fc0dd004 100644 --- a/packages/ui/src/features/code-review/reviewNavigationStore.test.ts +++ b/packages/ui/src/features/code-review/reviewNavigationStore.test.ts @@ -7,10 +7,27 @@ describe("reviewNavigationStore", () => { activeFilePaths: {}, scrollRequests: {}, reviewModes: {}, + selectedPrUrls: {}, commentFileFilters: {}, }); }); + it("keeps a selected PR until the review closes", () => { + const store = useReviewNavigationStore.getState(); + store.setSelectedPrUrl("task-1", "https://github.com/acme/repo/pull/2"); + store.setReviewMode("task-1", "split"); + + expect(useReviewNavigationStore.getState().selectedPrUrls["task-1"]).toBe( + "https://github.com/acme/repo/pull/2", + ); + + store.setReviewMode("task-1", "closed"); + + expect( + useReviewNavigationStore.getState().selectedPrUrls["task-1"], + ).toBeUndefined(); + }); + it("clears the comment filter when navigating to a file", () => { const store = useReviewNavigationStore.getState(); store.setCommentFileFilter("task-1", "unresolved"); diff --git a/packages/ui/src/features/code-review/reviewNavigationStore.ts b/packages/ui/src/features/code-review/reviewNavigationStore.ts index cd0bf6f739..dba01207ce 100644 --- a/packages/ui/src/features/code-review/reviewNavigationStore.ts +++ b/packages/ui/src/features/code-review/reviewNavigationStore.ts @@ -7,6 +7,7 @@ interface ReviewNavigationStoreState { activeFilePaths: Record; scrollRequests: Record; reviewModes: Record; + selectedPrUrls: Record; commentFileFilters: Record; } @@ -16,6 +17,7 @@ interface ReviewNavigationStoreActions { clearScrollRequest: (taskId: string) => void; clearTask: (taskId: string) => void; setReviewMode: (taskId: string, mode: ReviewMode) => void; + setSelectedPrUrl: (taskId: string, url: string) => void; setCommentFileFilter: (taskId: string, filter: CommentFileFilter) => void; getReviewMode: (taskId: string) => ReviewMode; } @@ -28,6 +30,7 @@ export const useReviewNavigationStore = create()( activeFilePaths: {}, scrollRequests: {}, reviewModes: {}, + selectedPrUrls: {}, commentFileFilters: {}, setActiveFilePath: (taskId, path) => @@ -57,11 +60,23 @@ export const useReviewNavigationStore = create()( ...state.commentFileFilters, [taskId]: "none", }, + selectedPrUrls: { ...state.selectedPrUrls, [taskId]: undefined }, })), setReviewMode: (taskId, mode) => set((state) => ({ reviewModes: { ...state.reviewModes, [taskId]: mode }, + // A row-selected historical PR only applies to this review visit. + // Closing restores the task's normal primary-PR resolution. + selectedPrUrls: + mode === "closed" + ? { ...state.selectedPrUrls, [taskId]: undefined } + : state.selectedPrUrls, + })), + + setSelectedPrUrl: (taskId, url) => + set((state) => ({ + selectedPrUrls: { ...state.selectedPrUrls, [taskId]: url }, })), setCommentFileFilter: (taskId, filter) => diff --git a/packages/ui/src/features/task-detail/hooks/useCloudChangedFiles.ts b/packages/ui/src/features/task-detail/hooks/useCloudChangedFiles.ts index 07e9960495..9ffd6d608d 100644 --- a/packages/ui/src/features/task-detail/hooks/useCloudChangedFiles.ts +++ b/packages/ui/src/features/task-detail/hooks/useCloudChangedFiles.ts @@ -12,9 +12,11 @@ export function useCloudChangedFiles( taskId: string, task: Task, isActive = true, + prUrlOverride?: string, ) { const cloudRunState = useCloudRunState(taskId, task); - const { prUrl, effectiveBranch, repo, isRunActive } = cloudRunState; + const prUrl = prUrlOverride ?? cloudRunState.prUrl; + const { effectiveBranch, repo, isRunActive } = cloudRunState; const { data: prFiles, @@ -45,6 +47,7 @@ export function useCloudChangedFiles( return { ...cloudRunState, + prUrl, changedFiles, remoteFiles, reviewFiles: changedFiles, From 4969440eaf4aec250d6ccecff38d60a0825f4005 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Tue, 28 Jul 2026 12:39:10 +0200 Subject: [PATCH 2/5] fix(channels): align the Activity header with the tab bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Activity panel's header was padding-sized (~41px) while the pane to its left — TabbedPanel's tab bar — is a fixed 32px row, so the two bottom borders never lined up. The panel also stacked a title row on top of a tabs row where the left pane has a single strip. Give ActivityPanel its own 32px header carrying the tabs directly: no separate "Activity" title row, the tab list fills the row so the line indicator lands on the bottom border, and the window controls sit right-aligned. Matches TabbedPanel and ReviewToolbar, which share the same height and border token. The shared ThreadPanelHeader is now the legacy panel's alone, so it stays exactly as it was — flag-off chrome is unchanged. Generated-By: PostHog Code Task-Id: 8ccf3555-2c68-4dbb-8b9f-1a96fd85d95a --- .../canvas/components/ActivityPanel.tsx | 91 +++++++++++++++---- .../canvas/components/ThreadPanel.tsx | 6 +- 2 files changed, 76 insertions(+), 21 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ActivityPanel.tsx b/packages/ui/src/features/canvas/components/ActivityPanel.tsx index 06d075f578..67216759df 100644 --- a/packages/ui/src/features/canvas/components/ActivityPanel.tsx +++ b/packages/ui/src/features/canvas/components/ActivityPanel.tsx @@ -1,4 +1,8 @@ -import { CaretRightIcon } from "@phosphor-icons/react"; +import { + ArrowSquareOutIcon, + CaretRightIcon, + XIcon, +} from "@phosphor-icons/react"; import { Button, cn, Tabs, TabsList, TabsTrigger } from "@posthog/quill"; import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import type { Task } from "@posthog/shared/domain-types"; @@ -8,7 +12,6 @@ import { TaskArtifactsList } from "@posthog/ui/features/canvas/components/TaskAr import { AgentStatusLine, ThreadLoadingState, - ThreadPanelHeader, ThreadReplyComposer, ThreadTimeline, } from "@posthog/ui/features/canvas/components/ThreadPanel"; @@ -35,27 +38,76 @@ const TABS_WITH_COMPOSER: ReadonlySet = new Set([ const TIMESTAMP_END_CLASS = "[&_[data-slot=thread-item-timestamp]]:ml-auto [&_[data-slot=thread-item-timestamp]]:shrink-0 [&_[data-slot=thread-item-timestamp]]:pl-2"; -function ActivityTabsRow({ +/** The 32px row this panel leads with: the tabs are the header, so the strip + * lines up with the tab bar of the pane on its left (TabbedPanel) and the + * review toolbar, which are the same fixed height and border. */ +function ActivityHeader({ tab, onTabChange, + onClose, + onToggleCollapsed, + onOpenFull, }: { tab: ActivityTab; onTabChange: (tab: ActivityTab) => void; + onClose?: () => void; + onToggleCollapsed?: () => void; + onOpenFull?: () => void; }) { return ( -
+
onTabChange(value as ActivityTab)} > - + {/* Nothing spells out "Activity" any more, so the strip carries the name. + The list fills the row's 32px and drops quill's 3px inset so the + line-variant indicator (bottom: 0 of the list) lands on the row's + bottom border, the way the left pane's tabs underline does. */} + {ACTIVITY_TABS.map((t) => ( - + {t.label} ))} +
+ {onOpenFull && ( + + )} + {onToggleCollapsed && ( + + )} + {onClose && ( + + )} +
); } @@ -170,13 +222,13 @@ function ActivityConversation({ TIMESTAMP_END_CLASS, )} > - - {showTaskSummary && (
@@ -235,15 +287,18 @@ export function ActivityPanel({ if (collapsed) { return ( -
- +
+ {/* Same 32px band as the expanded header, so the button doesn't jump. */} +
+ +
); } diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.tsx index 6e54d9e7f8..1070a6ae0b 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.tsx @@ -289,9 +289,9 @@ export function ThreadLoadingState() { ); } -/** The panel's title row and window controls. Shared with ActivityPanel, which - * is the same chrome under a different title. */ -export function ThreadPanelHeader({ +/** The panel's title row and window controls. ActivityPanel has its own header + * (the tabs are its title row), so this is the legacy panel's alone. */ +function ThreadPanelHeader({ title, onClose, onToggleCollapsed, From 5dd11d87ecc6d0acbf742705fe9147d079492d59 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Tue, 28 Jul 2026 12:39:12 +0200 Subject: [PATCH 3/5] fix(channels): drop the star button from the channel header Starring a channel is already reachable from the sidebar back row's hover star and the channel list's context menu, so the header's copy was a third affordance for the same action sitting next to the channel name chip. Generated-By: PostHog Code Task-Id: 8ccf3555-2c68-4dbb-8b9f-1a96fd85d95a --- .../canvas/components/ChannelHeader.tsx | 45 ++----------------- 1 file changed, 4 insertions(+), 41 deletions(-) diff --git a/packages/ui/src/features/canvas/components/ChannelHeader.tsx b/packages/ui/src/features/canvas/components/ChannelHeader.tsx index 69dc6d35bf..8cb92f620c 100644 --- a/packages/ui/src/features/canvas/components/ChannelHeader.tsx +++ b/packages/ui/src/features/canvas/components/ChannelHeader.tsx @@ -1,54 +1,20 @@ -import { StarIcon } from "@phosphor-icons/react"; import { Button, cn } from "@posthog/quill"; -import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; import { ChannelTabs } from "@posthog/ui/features/canvas/components/ChannelTabs"; import { channelGlyph } from "@posthog/ui/features/canvas/components/channelGlyph"; -import { useChannelStarToggle } from "@posthog/ui/features/canvas/hooks/useChannelStars"; -import { - type Channel, - useChannels, -} from "@posthog/ui/features/canvas/hooks/useChannels"; +import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useChannelsLayout } from "@posthog/ui/features/canvas/hooks/useChannelsLayout"; import { useMarkChannelSeen } from "@posthog/ui/features/canvas/hooks/useMarkChannelSeen"; -import { PERSONAL_CHANNEL_NAME } from "@posthog/ui/features/canvas/hooks/useTaskChannels"; -import { track } from "@posthog/ui/shell/analytics"; import { Text } from "@radix-ui/themes"; import { useNavigate, useRouterState } from "@tanstack/react-router"; -// The feed-side counterpart to the sidebar back row's hover star. -function ChannelStarButton({ channel }: { channel: Channel }) { - const { isStarred, toggleStar } = useChannelStarToggle(channel); - return ( - - ); -} - // The shared channel header. The new layout drops the section tab strip — the -// channel sidebar carries those entries — while flag off keeps it. +// channel sidebar carries those entries — while flag off keeps it. Starring +// lives on the sidebar back row and the channel list, not here. export function ChannelHeader({ channelId }: { channelId: string }) { const navigate = useNavigate(); const channelsLayout = useChannelsLayout(); const { channels } = useChannels(); - const channel = channels.find((c) => c.id === channelId); - const channelName = channel?.name; + const channelName = channels.find((c) => c.id === channelId)?.name; const pathname = useRouterState({ select: (s) => s.location.pathname }); const isHome = pathname === `/website/${channelId}`; // Every channel surface renders this header, so mark the channel read here. @@ -73,9 +39,6 @@ export function ChannelHeader({ channelId }: { channelId: string }) { {channelName ?? (channelsLayout ? "Space" : "Channel")} - {channelsLayout && channel && channel.name !== PERSONAL_CHANNEL_NAME && ( - - )} {!channelsLayout && }
); From 1b70b3a718401793f92acefee9359b4e4a4dc3f0 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Tue, 28 Jul 2026 12:39:13 +0200 Subject: [PATCH 4/5] fix(channels): show uploaded files in the task Artifacts pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pane read the run artifact manifest and then dropped everything on `type === "plan"` — a type nothing in the product has ever written. The only deliverable type agents actually produce is `output`, via the `upload_artifact` tool, so the manifest branch was unreachable and files handed back by a run never appeared. Verified against a live run's manifest: 6 `output`, 2 `user_attachment`, zero `plan`. The `plan` row was speculative — down to a bare-UUID fallback for names that never arrive — so it goes rather than shipping a UI affordance for data that doesn't exist. It can come back with a producer. Same inversion is why the conversation's Files box showed these while the pane said "No artifacts yet": it filters to `output`, the exact complement. - `parseRunPlans` becomes `parseRunArtifacts(raw, types)`, and reads the `size`/`content_type`/`uploaded_at` the rows need. - Re-uploads collapse to the newest per name: agents revise a deliverable and upload it again, and keeping every copy buries the current one under its own drafts. - `formatFileSize` moves out of CloudArtifactDownloads so both surfaces format sizes the same way. Generated-By: PostHog Code Task-Id: 8d187d00-0633-4706-8443-f79130b65f9f --- .../src/canvas/runArtifactSchemas.test.ts | 64 ++++++++--- .../core/src/canvas/runArtifactSchemas.ts | 20 +++- .../components/TaskArtifactsList.test.tsx | 106 +++++++++++++++++- .../canvas/components/TaskArtifactsList.tsx | 76 +++++++------ .../components/CloudArtifactDownloads.tsx | 8 +- packages/ui/src/utils/formatFileSize.ts | 7 ++ 6 files changed, 220 insertions(+), 61 deletions(-) create mode 100644 packages/ui/src/utils/formatFileSize.ts diff --git a/packages/core/src/canvas/runArtifactSchemas.test.ts b/packages/core/src/canvas/runArtifactSchemas.test.ts index e2db2fe283..71d61af599 100644 --- a/packages/core/src/canvas/runArtifactSchemas.test.ts +++ b/packages/core/src/canvas/runArtifactSchemas.test.ts @@ -1,36 +1,68 @@ import { describe, expect, it } from "vitest"; -import { parseRunPlans } from "./runArtifactSchemas"; +import { OUTPUT_ARTIFACT_TYPES, parseRunArtifacts } from "./runArtifactSchemas"; -describe("parseRunPlans", () => { +describe("parseRunArtifacts", () => { it.each([ { name: "undefined", raw: undefined }, { name: "null", raw: null }, { name: "an object", raw: { artifacts: [] } }, - { name: "a string", raw: "plan" }, + { name: "a string", raw: "output" }, ])("reads $name as no artifacts", ({ raw }) => { - expect(parseRunPlans(raw)).toEqual([]); + expect(parseRunArtifacts(raw, OUTPUT_ARTIFACT_TYPES)).toEqual([]); }); - it("keeps only plan artifacts", () => { - const plans = parseRunPlans([ - { id: "a", type: "plan", name: "Plan A" }, - { id: "b", type: "upload", name: "internal blob" }, + // A run's manifest mixes deliverables with plumbing nobody asked to see. + it("keeps only the requested types", () => { + const outputs = parseRunArtifacts( + [ + { id: "a", type: "output", name: "report.md" }, + { id: "b", type: "user_attachment", name: "clipboard.png" }, + { id: "c", type: "skill_bundle", name: "skills.zip" }, + ], + OUTPUT_ARTIFACT_TYPES, + ); + expect(outputs).toEqual([{ id: "a", type: "output", name: "report.md" }]); + }); + + it("reads the size and upload time the row renders", () => { + const artifact = { + id: "a", + type: "output", + name: "report.md", + size: 16861, + content_type: "text/markdown", + uploaded_at: "2026-07-27T08:27:26.896719+00:00", + }; + expect(parseRunArtifacts([artifact], OUTPUT_ARTIFACT_TYPES)).toEqual([ + artifact, ]); - expect(plans).toEqual([{ id: "a", type: "plan", name: "Plan A" }]); + }); + + it("keeps an artifact that omits the optional fields", () => { + expect( + parseRunArtifacts([{ type: "output" }], OUTPUT_ARTIFACT_TYPES), + ).toEqual([{ type: "output" }]); }); // One bad entry shouldn't take the Artifacts tab down with it. it("drops entries that don't match the shape", () => { - const plans = parseRunPlans([ - { id: 42, type: "plan" }, - null, - "not an object", - { type: "plan", storage_path: "runs/1/plan.md" }, + const outputs = parseRunArtifacts( + [ + { id: 42, type: "output" }, + null, + "not an object", + { type: "output", storage_path: "runs/1/report.md" }, + ], + OUTPUT_ARTIFACT_TYPES, + ); + expect(outputs).toEqual([ + { type: "output", storage_path: "runs/1/report.md" }, ]); - expect(plans).toEqual([{ type: "plan", storage_path: "runs/1/plan.md" }]); }); it("ignores an artifact with no type", () => { - expect(parseRunPlans([{ id: "a", name: "mystery" }])).toEqual([]); + expect( + parseRunArtifacts([{ id: "a", name: "mystery" }], OUTPUT_ARTIFACT_TYPES), + ).toEqual([]); }); }); diff --git a/packages/core/src/canvas/runArtifactSchemas.ts b/packages/core/src/canvas/runArtifactSchemas.ts index 8d95e86e32..878075a68e 100644 --- a/packages/core/src/canvas/runArtifactSchemas.ts +++ b/packages/core/src/canvas/runArtifactSchemas.ts @@ -4,14 +4,30 @@ export const runArtifactSchema = z.object({ id: z.string().optional(), name: z.string().optional(), type: z.string().optional(), + size: z.number().optional(), + content_type: z.string().optional(), storage_path: z.string().optional(), + uploaded_at: z.string().optional(), }); export type RunArtifact = z.infer; -export function parseRunPlans(raw: unknown): RunArtifact[] { +/** Artifacts the agent hands back as deliverables, via the `upload_artifact` tool. */ +export const OUTPUT_ARTIFACT_TYPES = ["output"] as const; + +/** + * The run's artifacts of the given types, in manifest order. A run's manifest + * also carries plumbing the user never asked for — skill bundles, their own + * attachments — so callers name the types they want. + */ +export function parseRunArtifacts( + raw: unknown, + types: readonly string[], +): RunArtifact[] { if (!Array.isArray(raw)) return []; return raw.flatMap((entry) => { const parsed = runArtifactSchema.safeParse(entry); - return parsed.success && parsed.data.type === "plan" ? [parsed.data] : []; + if (!parsed.success) return []; + const { type } = parsed.data; + return type && types.includes(type) ? [parsed.data] : []; }); } diff --git a/packages/ui/src/features/canvas/components/TaskArtifactsList.test.tsx b/packages/ui/src/features/canvas/components/TaskArtifactsList.test.tsx index 42782087fc..88f9eb3d65 100644 --- a/packages/ui/src/features/canvas/components/TaskArtifactsList.test.tsx +++ b/packages/ui/src/features/canvas/components/TaskArtifactsList.test.tsx @@ -1,14 +1,20 @@ -import type { Task, TaskRun } from "@posthog/shared/domain-types"; +import type { Task, TaskRun, TaskRunArtifact } from "@posthog/shared"; import { fireEvent, render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ runs: [] as TaskRun[], + presignTaskRunArtifact: vi.fn(), })); vi.mock("@posthog/ui/features/canvas/hooks/useTaskRuns", () => ({ useTaskRuns: () => ({ runs: mocks.runs, isLoading: false }), })); +vi.mock("@posthog/ui/features/auth/authClient", () => ({ + useOptionalAuthenticatedClient: () => ({ + presignTaskRunArtifact: mocks.presignTaskRunArtifact, + }), +})); vi.mock("@posthog/ui/features/git-interaction/usePrArtifact", () => ({ usePrArtifact: (url: string) => ({ safeUrl: url, @@ -33,16 +39,35 @@ const task = { latest_run: null, } as unknown as Task; -function run(id: string, prNumber: number): TaskRun { +function run( + id: string, + options: { prNumber?: number; artifacts?: Partial[] } = {}, +): TaskRun { return { id, - output: { pr_url: `https://github.com/acme/repo/pull/${prNumber}` }, + output: options.prNumber + ? { pr_url: `https://github.com/acme/repo/pull/${options.prNumber}` } + : null, + artifacts: options.artifacts, } as unknown as TaskRun; } +function outputFile( + overrides: Partial, +): Partial { + return { + type: "output", + name: "report.md", + storage_path: "runs/1/report.md", + ...overrides, + }; +} + describe("TaskArtifactsList", () => { beforeEach(() => { - mocks.runs = [run("run-1", 1), run("run-2", 2)]; + mocks.runs = [run("run-1", { prNumber: 1 }), run("run-2", { prNumber: 2 })]; + mocks.presignTaskRunArtifact.mockReset(); + mocks.presignTaskRunArtifact.mockResolvedValue("https://signed.example/x"); useReviewNavigationStore.setState({ reviewModes: {}, selectedPrUrls: {}, @@ -60,4 +85,77 @@ describe("TaskArtifactsList", () => { ); expect(state.reviewModes[task.id]).toBe("split"); }); + + it("lists the files the agent uploaded, with their size", () => { + mocks.runs = [ + run("run-1", { artifacts: [outputFile({ id: "a", size: 16861 })] }), + ]; + + render(); + + expect(screen.getByText("report.md")).toBeTruthy(); + expect(screen.getByText("File · 17 KB")).toBeTruthy(); + }); + + it("presigns the artifact of the run that produced it", () => { + mocks.runs = [ + run("run-1", { artifacts: [outputFile({ id: "a", name: "old.md" })] }), + run("run-2", { + artifacts: [ + outputFile({ + id: "b", + name: "new.md", + storage_path: "runs/2/new.md", + }), + ], + }), + ]; + + render(); + fireEvent.click(screen.getByText("new.md")); + + expect(mocks.presignTaskRunArtifact).toHaveBeenCalledWith( + "task-1", + "run-2", + "runs/2/new.md", + ); + }); + + // Agents revise a deliverable and upload it again under the same name. + it("keeps only the newest upload of a repeatedly revised file", () => { + mocks.runs = [ + run("run-1", { + artifacts: [ + outputFile({ + id: "a", + size: 1000, + uploaded_at: "2026-07-27T08:00:00+00:00", + }), + outputFile({ + id: "b", + size: 2000, + storage_path: "runs/1/report-v2.md", + uploaded_at: "2026-07-27T09:00:00+00:00", + }), + ], + }), + ]; + + render(); + + expect(screen.getAllByText("report.md")).toHaveLength(1); + expect(screen.getByText("File · 2 KB")).toBeTruthy(); + }); + + it.each([ + { name: "a plan", type: "plan" as const }, + { name: "a user attachment", type: "user_attachment" as const }, + { name: "a skill bundle", type: "skill_bundle" as const }, + ])("shows the empty state for a run with only $name", ({ type }) => { + mocks.runs = [run("run-1", { artifacts: [outputFile({ id: "a", type })] })]; + + render(); + + expect(screen.getByText("No artifacts yet")).toBeTruthy(); + }); }); diff --git a/packages/ui/src/features/canvas/components/TaskArtifactsList.tsx b/packages/ui/src/features/canvas/components/TaskArtifactsList.tsx index 0f50a76fbe..6140d42c2b 100644 --- a/packages/ui/src/features/canvas/components/TaskArtifactsList.tsx +++ b/packages/ui/src/features/canvas/components/TaskArtifactsList.tsx @@ -1,11 +1,11 @@ import { ArrowSquareOutIcon, - ClipboardTextIcon, PackageIcon, SlackLogoIcon, } from "@phosphor-icons/react"; import { - parseRunPlans, + OUTPUT_ARTIFACT_TYPES, + parseRunArtifacts, type RunArtifact, } from "@posthog/core/canvas/runArtifactSchemas"; import type { ThreadTimelineRow } from "@posthog/core/canvas/threadTimeline"; @@ -28,8 +28,10 @@ import { useReviewNavigationStore } from "@posthog/ui/features/code-review/revie import { usePrArtifact } from "@posthog/ui/features/git-interaction/usePrArtifact"; import { usePrComments } from "@posthog/ui/features/pr-review/usePrComments"; import { usePrReviewThreads } from "@posthog/ui/features/pr-review/usePrReviewThreads"; +import { FileIcon } from "@posthog/ui/primitives/FileIcon"; import { toast } from "@posthog/ui/primitives/toast"; import { openExternalUrl } from "@posthog/ui/shell/openExternal"; +import { formatFileSize } from "@posthog/ui/utils/formatFileSize"; import { parseHttpsUrl, parseShareLink } from "@posthog/ui/utils/posthogLinks"; import { navigateToShareTarget } from "@posthog/ui/utils/shareLinks"; import { getPostHogUrl } from "@posthog/ui/utils/urls"; @@ -39,23 +41,20 @@ type ArtifactRow = | { kind: "pr"; key: string; url: string } | { kind: "canvas"; key: string; name: string; url: string | null } | { - kind: "plan"; + kind: "file"; key: string; name: string; storagePath: string | null; runId: string | null; + size: number | undefined; } | { kind: "slack"; key: string; url: string }; -function readRunPlans(run: TaskRun): RunArtifact[] { - return parseRunPlans((run as { artifacts?: unknown }).artifacts); -} - -const UUID_RE = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i; -function planTitle(name: string | undefined): string { - if (!name || UUID_RE.test(name)) return "Plan"; - return name; +function readRunOutputs(run: TaskRun): RunArtifact[] { + return parseRunArtifacts( + (run as { artifacts?: unknown }).artifacts, + OUTPUT_ARTIFACT_TYPES, + ); } function buildRows( @@ -65,7 +64,6 @@ function buildRows( ): ArtifactRow[] { const rows: ArtifactRow[] = []; const seenPrUrls = new Set(); - const seenPlanKeys = new Set(); const addPr = (url: string, key: string) => { if (seenPrUrls.has(url)) return; @@ -89,24 +87,35 @@ function buildRows( const allRuns = runs.length > 0 ? runs : task.latest_run ? [task.latest_run] : []; + + // Re-uploading a file replaces it rather than adding a second one: agents + // revise a deliverable and upload it again under the same name, so keeping + // every copy would bury the current one under its own drafts. + const newestByName = new Map(); for (const run of allRuns) { const outputPr = run.output?.pr_url; if (typeof outputPr === "string" && outputPr) { addPr(outputPr, `output-pr:${outputPr}`); } - for (const plan of readRunPlans(run)) { - const key = `plan:${plan.id ?? plan.storage_path ?? plan.name}`; - if (seenPlanKeys.has(key)) continue; - seenPlanKeys.add(key); - rows.push({ - kind: "plan", - key, - name: planTitle(plan.name), - storagePath: plan.storage_path ?? null, - runId: run.id, - }); + for (const file of readRunOutputs(run)) { + if (!file.name) continue; + const previous = newestByName.get(file.name); + const isNewer = + !previous || + (file.uploaded_at ?? "") >= (previous.file.uploaded_at ?? ""); + if (isNewer) newestByName.set(file.name, { file, runId: run.id }); } } + for (const [name, { file, runId }] of newestByName) { + rows.push({ + kind: "file", + key: `file:${file.id ?? file.storage_path ?? name}`, + name, + storagePath: file.storage_path ?? null, + runId, + size: file.size, + }); + } const slackUrl = task.latest_run?.state?.slack_thread_url; if (typeof slackUrl === "string" && slackUrl) { @@ -226,16 +235,18 @@ function CanvasRow({ name, url }: { name: string; url: string | null }) { ); } -function PlanRow({ +function FileRow({ taskId, runId, name, storagePath, + size, }: { taskId: string; runId: string | null; name: string; storagePath: string | null; + size: number | undefined; }) { const client = useOptionalAuthenticatedClient(); const canOpen = !!client && !!runId && !!storagePath; @@ -249,7 +260,7 @@ function PlanRow({ ) .then((url) => openExternalUrl(url)) .catch((error: unknown) => { - toast.error("Couldn't open plan", { + toast.error("Couldn't open file", { description: error instanceof Error ? error.message : String(error), }); @@ -258,9 +269,9 @@ function PlanRow({ : undefined; return ( } + icon={} title={name} - detail="Plan" + detail={["File", formatFileSize(size)].filter(Boolean).join(" · ")} external={canOpen} onOpen={onOpen} /> @@ -289,8 +300,8 @@ export function TaskArtifactsList({ No artifacts yet - Pull requests and canvases produced while working on this task show - up here. + Pull requests, canvases, and files produced while working on this + task show up here. @@ -304,13 +315,14 @@ export function TaskArtifactsList({ ) : row.kind === "canvas" ? ( - ) : row.kind === "plan" ? ( - ) : ( Date: Tue, 28 Jul 2026 12:39:15 +0200 Subject: [PATCH 5/5] test(channels): pin artifact rows to per-file-type icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rows already use the same FileIcon the chat's file list does, so markdown resolves to the markdown icon and HTML to the HTML one. Lock that in — the row it replaced hardcoded a single clipboard glyph, and nothing else would catch a regression back to a generic icon. Generated-By: PostHog Code Task-Id: 8d187d00-0633-4706-8443-f79130b65f9f --- .../components/TaskArtifactsList.test.tsx | 38 ++++++++++++------- .../canvas/components/TaskArtifactsList.tsx | 36 +++++++----------- 2 files changed, 38 insertions(+), 36 deletions(-) diff --git a/packages/ui/src/features/canvas/components/TaskArtifactsList.test.tsx b/packages/ui/src/features/canvas/components/TaskArtifactsList.test.tsx index 88f9eb3d65..878222dd48 100644 --- a/packages/ui/src/features/canvas/components/TaskArtifactsList.test.tsx +++ b/packages/ui/src/features/canvas/components/TaskArtifactsList.test.tsx @@ -4,16 +4,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ runs: [] as TaskRun[], - presignTaskRunArtifact: vi.fn(), + openArtifactTab: vi.fn(), })); vi.mock("@posthog/ui/features/canvas/hooks/useTaskRuns", () => ({ useTaskRuns: () => ({ runs: mocks.runs, isLoading: false }), })); -vi.mock("@posthog/ui/features/auth/authClient", () => ({ - useOptionalAuthenticatedClient: () => ({ - presignTaskRunArtifact: mocks.presignTaskRunArtifact, - }), +vi.mock("@posthog/ui/features/panels/panelLayoutStore", () => ({ + usePanelLayoutStore: () => mocks.openArtifactTab, })); vi.mock("@posthog/ui/features/git-interaction/usePrArtifact", () => ({ usePrArtifact: (url: string) => ({ @@ -66,8 +64,7 @@ function outputFile( describe("TaskArtifactsList", () => { beforeEach(() => { mocks.runs = [run("run-1", { prNumber: 1 }), run("run-2", { prNumber: 2 })]; - mocks.presignTaskRunArtifact.mockReset(); - mocks.presignTaskRunArtifact.mockResolvedValue("https://signed.example/x"); + mocks.openArtifactTab.mockReset(); useReviewNavigationStore.setState({ reviewModes: {}, selectedPrUrls: {}, @@ -97,7 +94,22 @@ describe("TaskArtifactsList", () => { expect(screen.getByText("File · 17 KB")).toBeTruthy(); }); - it("presigns the artifact of the run that produced it", () => { + // The row should read like the chat's file list: markdown looks like + // markdown, HTML looks like HTML. + it.each([ + { name: "notes.md", icon: "markdown" }, + { name: "demo.html", icon: "html" }, + ])("gives $name an icon for its file type", ({ name, icon }) => { + mocks.runs = [run("run-1", { artifacts: [outputFile({ id: "a", name })] })]; + + const { container } = render( + , + ); + + expect(container.querySelector("img")?.getAttribute("src")).toContain(icon); + }); + + it("opens the artifact of the run that produced it beside the chat", () => { mocks.runs = [ run("run-1", { artifacts: [outputFile({ id: "a", name: "old.md" })] }), run("run-2", { @@ -114,11 +126,11 @@ describe("TaskArtifactsList", () => { render(); fireEvent.click(screen.getByText("new.md")); - expect(mocks.presignTaskRunArtifact).toHaveBeenCalledWith( - "task-1", - "run-2", - "runs/2/new.md", - ); + expect(mocks.openArtifactTab).toHaveBeenCalledWith("task-1", { + runId: "run-2", + artifactId: "b", + name: "new.md", + }); }); // Agents revise a deliverable and upload it again under the same name. diff --git a/packages/ui/src/features/canvas/components/TaskArtifactsList.tsx b/packages/ui/src/features/canvas/components/TaskArtifactsList.tsx index 6140d42c2b..4ac3e526f4 100644 --- a/packages/ui/src/features/canvas/components/TaskArtifactsList.tsx +++ b/packages/ui/src/features/canvas/components/TaskArtifactsList.tsx @@ -21,15 +21,14 @@ import type { TaskRun, TaskThreadMessage, } from "@posthog/shared/domain-types"; -import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; import { iconForTemplate } from "@posthog/ui/features/canvas/components/canvasTemplateIcon"; import { useTaskRuns } from "@posthog/ui/features/canvas/hooks/useTaskRuns"; import { useReviewNavigationStore } from "@posthog/ui/features/code-review/reviewNavigationStore"; import { usePrArtifact } from "@posthog/ui/features/git-interaction/usePrArtifact"; +import { usePanelLayoutStore } from "@posthog/ui/features/panels/panelLayoutStore"; import { usePrComments } from "@posthog/ui/features/pr-review/usePrComments"; import { usePrReviewThreads } from "@posthog/ui/features/pr-review/usePrReviewThreads"; import { FileIcon } from "@posthog/ui/primitives/FileIcon"; -import { toast } from "@posthog/ui/primitives/toast"; import { openExternalUrl } from "@posthog/ui/shell/openExternal"; import { formatFileSize } from "@posthog/ui/utils/formatFileSize"; import { parseHttpsUrl, parseShareLink } from "@posthog/ui/utils/posthogLinks"; @@ -43,8 +42,8 @@ type ArtifactRow = | { kind: "file"; key: string; + artifactId: string | null; name: string; - storagePath: string | null; runId: string | null; size: number | undefined; } @@ -110,8 +109,8 @@ function buildRows( rows.push({ kind: "file", key: `file:${file.id ?? file.storage_path ?? name}`, + artifactId: file.id ?? null, name, - storagePath: file.storage_path ?? null, runId, size: file.size, }); @@ -238,33 +237,25 @@ function CanvasRow({ name, url }: { name: string; url: string | null }) { function FileRow({ taskId, runId, + artifactId, name, - storagePath, size, }: { taskId: string; runId: string | null; + artifactId: string | null; name: string; - storagePath: string | null; size: number | undefined; }) { - const client = useOptionalAuthenticatedClient(); - const canOpen = !!client && !!runId && !!storagePath; + const openArtifactTab = usePanelLayoutStore((state) => state.openArtifactTab); + const canOpen = !!runId && !!artifactId; const onOpen = canOpen ? () => { - client - .presignTaskRunArtifact( - taskId, - runId as string, - storagePath as string, - ) - .then((url) => openExternalUrl(url)) - .catch((error: unknown) => { - toast.error("Couldn't open file", { - description: - error instanceof Error ? error.message : String(error), - }); - }); + openArtifactTab(taskId, { + runId: runId as string, + artifactId: artifactId as string, + name, + }); } : undefined; return ( @@ -272,7 +263,6 @@ function FileRow({ icon={} title={name} detail={["File", formatFileSize(size)].filter(Boolean).join(" · ")} - external={canOpen} onOpen={onOpen} /> ); @@ -320,8 +310,8 @@ export function TaskArtifactsList({ key={row.key} taskId={task.id} runId={row.runId} + artifactId={row.artifactId} name={row.name} - storagePath={row.storagePath} size={row.size} /> ) : (