diff --git a/packages/core/src/canvas/runArtifactSchemas.test.ts b/packages/core/src/canvas/runArtifactSchemas.test.ts new file mode 100644 index 0000000000..71d61af599 --- /dev/null +++ b/packages/core/src/canvas/runArtifactSchemas.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { OUTPUT_ARTIFACT_TYPES, parseRunArtifacts } from "./runArtifactSchemas"; + +describe("parseRunArtifacts", () => { + it.each([ + { name: "undefined", raw: undefined }, + { name: "null", raw: null }, + { name: "an object", raw: { artifacts: [] } }, + { name: "a string", raw: "output" }, + ])("reads $name as no artifacts", ({ raw }) => { + expect(parseRunArtifacts(raw, OUTPUT_ARTIFACT_TYPES)).toEqual([]); + }); + + // 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, + ]); + }); + + 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 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" }, + ]); + }); + + it("ignores an artifact with no type", () => { + 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 new file mode 100644 index 0000000000..878075a68e --- /dev/null +++ b/packages/core/src/canvas/runArtifactSchemas.ts @@ -0,0 +1,33 @@ +import { z } from "zod"; + +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; + +/** 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); + if (!parsed.success) return []; + const { type } = parsed.data; + return type && types.includes(type) ? [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..67216759df --- /dev/null +++ b/packages/ui/src/features/canvas/components/ActivityPanel.tsx @@ -0,0 +1,320 @@ +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"; +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, + 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"; + +/** 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 && ( + + )} +
+
+ ); +} + +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 ( +
+ {/* Same 32px band as the expanded header, so the button doesn't jump. */} +
+ +
+
+ ); + } + + 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/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 && }
); 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..878222dd48 --- /dev/null +++ b/packages/ui/src/features/canvas/components/TaskArtifactsList.test.tsx @@ -0,0 +1,173 @@ +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[], + openArtifactTab: vi.fn(), +})); + +vi.mock("@posthog/ui/features/canvas/hooks/useTaskRuns", () => ({ + useTaskRuns: () => ({ runs: mocks.runs, isLoading: false }), +})); +vi.mock("@posthog/ui/features/panels/panelLayoutStore", () => ({ + usePanelLayoutStore: () => mocks.openArtifactTab, +})); +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, + options: { prNumber?: number; artifacts?: Partial[] } = {}, +): TaskRun { + return { + id, + 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", { prNumber: 1 }), run("run-2", { prNumber: 2 })]; + mocks.openArtifactTab.mockReset(); + 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"); + }); + + 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(); + }); + + // 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", { + artifacts: [ + outputFile({ + id: "b", + name: "new.md", + storage_path: "runs/2/new.md", + }), + ], + }), + ]; + + render(); + fireEvent.click(screen.getByText("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. + 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 new file mode 100644 index 0000000000..4ac3e526f4 --- /dev/null +++ b/packages/ui/src/features/canvas/components/TaskArtifactsList.tsx @@ -0,0 +1,330 @@ +import { + ArrowSquareOutIcon, + PackageIcon, + SlackLogoIcon, +} from "@phosphor-icons/react"; +import { + OUTPUT_ARTIFACT_TYPES, + parseRunArtifacts, + 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 { 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 { 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"; +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: "file"; + key: string; + artifactId: string | null; + name: string; + runId: string | null; + size: number | undefined; + } + | { kind: "slack"; key: string; url: string }; + +function readRunOutputs(run: TaskRun): RunArtifact[] { + return parseRunArtifacts( + (run as { artifacts?: unknown }).artifacts, + OUTPUT_ARTIFACT_TYPES, + ); +} + +function buildRows( + task: Task, + timeline: ThreadTimelineRow[], + runs: TaskRun[], +): ArtifactRow[] { + const rows: ArtifactRow[] = []; + const seenPrUrls = 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] : []; + + // 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 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}`, + artifactId: file.id ?? null, + name, + runId, + size: file.size, + }); + } + + 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 FileRow({ + taskId, + runId, + artifactId, + name, + size, +}: { + taskId: string; + runId: string | null; + artifactId: string | null; + name: string; + size: number | undefined; +}) { + const openArtifactTab = usePanelLayoutStore((state) => state.openArtifactTab); + const canOpen = !!runId && !!artifactId; + const onOpen = canOpen + ? () => { + openArtifactTab(taskId, { + runId: runId as string, + artifactId: artifactId as string, + name, + }); + } + : undefined; + return ( + } + title={name} + detail={["File", formatFileSize(size)].filter(Boolean).join(" · ")} + 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, canvases, and files produced while working on this + task show up here. + + + + ); + } + + return ( +
+ {rows.map((row) => + row.kind === "pr" ? ( + + ) : row.kind === "canvas" ? ( + + ) : row.kind === "file" ? ( + + ) : ( + } + title="Slack thread" + detail="External" + external + onOpen={() => openExternalUrl(row.url)} + /> + ), + )} +
+ ); +} 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, 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/sessions/components/CloudArtifactDownloads.tsx b/packages/ui/src/features/sessions/components/CloudArtifactDownloads.tsx index 1a445344e1..75f599c362 100644 --- a/packages/ui/src/features/sessions/components/CloudArtifactDownloads.tsx +++ b/packages/ui/src/features/sessions/components/CloudArtifactDownloads.tsx @@ -15,17 +15,11 @@ import { usePanelLayoutStore } from "@posthog/ui/features/panels/panelLayoutStor import { useSessionSelector } from "@posthog/ui/features/sessions/sessionStore"; import { FileIcon } from "@posthog/ui/primitives/FileIcon"; import { toast } from "@posthog/ui/primitives/toast"; +import { formatFileSize } from "@posthog/ui/utils/formatFileSize"; import { Box, Flex, Text } from "@radix-ui/themes"; import { useQuery } from "@tanstack/react-query"; import { useCallback, useMemo, useState } from "react"; -function formatFileSize(size: number | undefined): string | null { - if (size === undefined) return null; - if (size < 1_000) return `${size} B`; - if (size < 1_000_000) return `${Math.round(size / 1_000)} KB`; - return `${(size / 1_000_000).toFixed(1)} MB`; -} - export function CloudArtifactDownloads({ taskId, task, 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, diff --git a/packages/ui/src/utils/formatFileSize.ts b/packages/ui/src/utils/formatFileSize.ts new file mode 100644 index 0000000000..3305a6088c --- /dev/null +++ b/packages/ui/src/utils/formatFileSize.ts @@ -0,0 +1,7 @@ +/** Byte count as a short label for artifact and attachment rows. */ +export function formatFileSize(size: number | undefined): string | null { + if (size === undefined) return null; + if (size < 1_000) return `${size} B`; + if (size < 1_000_000) return `${Math.round(size / 1_000)} KB`; + return `${(size / 1_000_000).toFixed(1)} MB`; +}