diff --git a/products/desktop/docs/cloud-task-artifacts.md b/products/desktop/docs/cloud-task-artifacts.md index 20f9eb125e99..0af8971d7df8 100644 --- a/products/desktop/docs/cloud-task-artifacts.md +++ b/products/desktop/docs/cloud-task-artifacts.md @@ -9,3 +9,9 @@ On success the tool returns a presigned download URL for the uploaded file (mint Repository changes should continue to be delivered through git rather than duplicated as task artifacts. A single uploaded artifact is limited to 30 MB. The desktop app runs scripts embedded in HTML artifacts inside an isolated preview process. The preview cannot access Node.js, Electron, PostHog credentials, remote resources, downloads, or device permissions. Use **Stop preview** if a script becomes unresponsive, then use **Restart preview** to load it in a fresh process. + +## Versions and dismissal + +Uploading a file under a name the run already has does not add a second file. Every upload stays on the manifest as its own entry, and clients group entries by name into one file with a version history: the newest upload is what the app shows, and the earlier ones sit behind a version picker on the row. That is how an agent revises a deliverable — upload it again under the same name. + +A user can dismiss a file they don't want to see. `POST .../runs//artifacts/dismiss/` takes `artifact_ids` and a `dismissed` boolean, and stamps `dismissed_at` on each named manifest entry. Nothing is deleted from object storage, and clients only hide a file once every version of it is dismissed, so dismissing the current version cannot resurface the one it replaced. Passing `dismissed: false` restores the file. diff --git a/products/desktop/packages/agent/src/adapters/local-tools/tools/upload-artifact.ts b/products/desktop/packages/agent/src/adapters/local-tools/tools/upload-artifact.ts index 4c1c776f991a..10cb40d47116 100644 --- a/products/desktop/packages/agent/src/adapters/local-tools/tools/upload-artifact.ts +++ b/products/desktop/packages/agent/src/adapters/local-tools/tools/upload-artifact.ts @@ -12,6 +12,8 @@ export const uploadArtifactTool = defineLocalTool({ "Deliver a file you created to the user as a downloadable task artifact. " + "Call this for every non-code deliverable (reports, images, archives, data files, and similar output) " + "before your final response. The file must be inside the session workspace. Repository changes belong in git and should not be uploaded. " + + "To revise a file you already delivered, upload it again under the same name: the app shows the newest version " + + "and keeps the earlier ones available. " + "On success the result includes a download URL for the uploaded file, which you can reference in your final response.", schema: { path: z diff --git a/products/desktop/packages/api-client/src/generated.ts b/products/desktop/packages/api-client/src/generated.ts index 05490a7e78fb..036544056d32 100644 --- a/products/desktop/packages/api-client/src/generated.ts +++ b/products/desktop/packages/api-client/src/generated.ts @@ -11061,6 +11061,7 @@ export namespace Schemas { content_type?: string | undefined; storage_path: string; uploaded_at: string; + dismissed_at?: string | undefined; }; export type TaskRunDetail = { id: string; diff --git a/products/desktop/packages/api-client/src/posthog-client.ts b/products/desktop/packages/api-client/src/posthog-client.ts index ebe06ca4a6b2..49533c1b60d2 100644 --- a/products/desktop/packages/api-client/src/posthog-client.ts +++ b/products/desktop/packages/api-client/src/posthog-client.ts @@ -105,6 +105,7 @@ import type { TaskMention, TaskRun, TaskRunArtefact, + TaskRunArtifact, TaskThreadMessage, UserBasic, } from "@posthog/shared/domain-types"; @@ -141,7 +142,9 @@ import type { import type { SpendAnalysisResponse } from "./spend-analysis"; import { normalizeTaskResponse, + normalizeTaskRunArtifact, normalizeTaskRunResponse, + type TaskRunArtifactDTO, } from "./task-normalization"; export type * from "./mcp-gateway"; @@ -3296,6 +3299,34 @@ export class PostHogAPIClient { }); } + /** Hide or restore every version of a file on the run, returning the updated manifest. */ + async setTaskRunArtifactsDismissed( + taskId: string, + runId: string, + artifactIds: string[], + dismissed: boolean, + ): Promise { + const teamId = await this.getTeamId(); + const path = `/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/artifacts/dismiss/`; + const response = await this.api.fetcher.fetch({ + method: "post", + url: new URL(`${this.api.baseUrl}${path}`), + path, + overrides: { + body: JSON.stringify({ artifact_ids: artifactIds, dismissed }), + }, + }); + + if (!response.ok) { + throw new Error(`Failed to update artifact: ${response.statusText}`); + } + + const data = (await response.json()) as { + artifacts?: TaskRunArtifactDTO[]; + }; + return (data.artifacts ?? []).map(normalizeTaskRunArtifact); + } + async getTaskSessionStorageAccess( taskId: string, runId: string, diff --git a/products/desktop/packages/api-client/src/task-normalization.ts b/products/desktop/packages/api-client/src/task-normalization.ts index 6d1f887f02bf..b223b4c3cfde 100644 --- a/products/desktop/packages/api-client/src/task-normalization.ts +++ b/products/desktop/packages/api-client/src/task-normalization.ts @@ -8,13 +8,15 @@ import type { } from "@posthog/shared/domain-types"; import type { Schemas } from "./generated"; +export type TaskRunArtifactDTO = Schemas.TaskRunArtifactResponse & { + metadata?: unknown; +}; + type TaskRunResponseDTO = Partial< Omit > & { id: string; - artifacts?: Array< - Schemas.TaskRunArtifactResponse & { metadata?: unknown } - > | null; + artifacts?: Array | null; status?: Schemas.StatusA35Enum | "started" | null; team?: number | null; }; @@ -101,8 +103,8 @@ function normalizeArtifactMetadata( }; } -function normalizeTaskRunArtifact( - artifact: NonNullable[number], +export function normalizeTaskRunArtifact( + artifact: TaskRunArtifactDTO, ): TaskRunArtifact { const metadata = normalizeArtifactMetadata(artifact.metadata); @@ -126,6 +128,9 @@ function normalizeTaskRunArtifact( ...(artifact.uploaded_at === undefined ? {} : { uploaded_at: artifact.uploaded_at }), + ...(artifact.dismissed_at === undefined + ? {} + : { dismissed_at: artifact.dismissed_at }), }; } diff --git a/products/desktop/packages/core/src/canvas/runArtifactSchemas.test.ts b/products/desktop/packages/core/src/canvas/runArtifactSchemas.test.ts index 71d61af599ef..0441e2b80eb5 100644 --- a/products/desktop/packages/core/src/canvas/runArtifactSchemas.test.ts +++ b/products/desktop/packages/core/src/canvas/runArtifactSchemas.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { OUTPUT_ARTIFACT_TYPES, parseRunArtifacts } from "./runArtifactSchemas"; +import { + groupRunArtifactVersions, + OUTPUT_ARTIFACT_TYPES, + parseRunArtifacts, +} from "./runArtifactSchemas"; describe("parseRunArtifacts", () => { it.each([ @@ -66,3 +70,54 @@ describe("parseRunArtifacts", () => { ).toEqual([]); }); }); + +describe("groupRunArtifactVersions", () => { + it("collapses re-uploads of a name into one newest-first group", () => { + const groups = groupRunArtifactVersions([ + { id: "a", name: "report.md", uploaded_at: "2026-07-27T08:00:00Z" }, + { id: "b", name: "chart.png", uploaded_at: "2026-07-27T08:30:00Z" }, + { id: "c", name: "report.md", uploaded_at: "2026-07-27T09:00:00Z" }, + ]); + + expect(groups.map((group) => group.name)).toEqual([ + "report.md", + "chart.png", + ]); + expect(groups[0]?.versions.map((version) => version.id)).toEqual([ + "c", + "a", + ]); + expect(groups[0]?.latest.id).toBe("c"); + }); + + // A file is only gone once every upload of it is dismissed — otherwise + // dismissing the current version would resurrect the one it replaced. + it.each([ + { name: "no version", dismissedIds: [] as string[], dismissed: false }, + { name: "only the newest version", dismissedIds: ["b"], dismissed: false }, + { name: "every version", dismissedIds: ["a", "b"], dismissed: true }, + ])( + "reports dismissed as $dismissed when $name is", + ({ dismissedIds, dismissed }) => { + const groups = groupRunArtifactVersions( + [ + { id: "a", name: "report.md", uploaded_at: "2026-07-27T08:00:00Z" }, + { id: "b", name: "report.md", uploaded_at: "2026-07-27T09:00:00Z" }, + ].map((artifact) => ({ + ...artifact, + dismissed_at: dismissedIds.includes(artifact.id) + ? "2026-07-27T10:00:00Z" + : null, + })), + ); + + expect(groups[0]?.dismissed).toBe(dismissed); + }, + ); + + it("skips artifacts with no name", () => { + expect( + groupRunArtifactVersions([{ uploaded_at: "2026-07-27T08:00:00Z" }]), + ).toEqual([]); + }); +}); diff --git a/products/desktop/packages/core/src/canvas/runArtifactSchemas.ts b/products/desktop/packages/core/src/canvas/runArtifactSchemas.ts index 878075a68ec1..37230862b2a4 100644 --- a/products/desktop/packages/core/src/canvas/runArtifactSchemas.ts +++ b/products/desktop/packages/core/src/canvas/runArtifactSchemas.ts @@ -8,6 +8,7 @@ export const runArtifactSchema = z.object({ content_type: z.string().optional(), storage_path: z.string().optional(), uploaded_at: z.string().optional(), + dismissed_at: z.string().nullish(), }); export type RunArtifact = z.infer; @@ -31,3 +32,67 @@ export function parseRunArtifacts( return type && types.includes(type) ? [parsed.data] : []; }); } + +/** Names a version by its position in a newest-first group. */ +export function runArtifactVersionLabel(index: number, total: number): string { + return index === 0 ? "Latest" : `Version ${total - index}`; +} + +/** + * A render key for one version of a file. Every identifying field goes in + * because a manifest entry is only guaranteed to carry its name — two versions + * collide only when they are indistinguishable, and then their order is moot. + */ +export function runArtifactVersionKey(artifact: { + id?: string; + storage_path?: string; + uploaded_at?: string; +}): string { + return [artifact.id, artifact.storage_path, artifact.uploaded_at].join(":"); +} + +interface VersionedArtifact { + name?: string; + uploaded_at?: string; + dismissed_at?: string | null; +} + +export interface RunArtifactVersions { + name: string; + /** Newest upload first. Always holds at least one entry. */ + versions: T[]; + latest: T; + /** Every version is dismissed, so the file as a whole is hidden. */ + dismissed: boolean; +} + +/** + * Group a run's artifacts into one entry per file name, newest upload first. + * + * Re-uploading a file is how an agent revises a deliverable, so the copies share + * a name and only the newest is the current file. Earlier ones stay in the group + * rather than being dropped, so a version the agent replaced is still reachable. + */ +export function groupRunArtifactVersions( + artifacts: T[], +): RunArtifactVersions[] { + const byName = new Map(); + for (const artifact of artifacts) { + if (!artifact.name) continue; + const group = byName.get(artifact.name); + if (group) group.push(artifact); + else byName.set(artifact.name, [artifact]); + } + + return [...byName].map(([name, group]) => { + const versions = [...group].sort((a, b) => + (b.uploaded_at ?? "").localeCompare(a.uploaded_at ?? ""), + ); + return { + name, + versions, + latest: versions[0] as T, + dismissed: versions.every((version) => Boolean(version.dismissed_at)), + }; + }); +} diff --git a/products/desktop/packages/core/src/sessions/sessionService.ts b/products/desktop/packages/core/src/sessions/sessionService.ts index 60cfe3c1bcdc..b79333c7e49d 100644 --- a/products/desktop/packages/core/src/sessions/sessionService.ts +++ b/products/desktop/packages/core/src/sessions/sessionService.ts @@ -7578,6 +7578,25 @@ export class SessionService { ); } + async setCloudRunArtifactsDismissed( + taskId: string, + runId: string, + artifactIds: string[], + dismissed: boolean, + ): Promise { + const authStatus = await this.getAuthCredentialsStatus(); + if (authStatus.kind !== "ready") { + throw new Error("Not signed in to PostHog"); + } + + return authStatus.auth.client.setTaskRunArtifactsDismissed( + taskId, + runId, + artifactIds, + dismissed, + ); + } + private getCloudAttachmentManifest( client: AuthClient, authIdentity: string, diff --git a/products/desktop/packages/shared/src/domain-types.ts b/products/desktop/packages/shared/src/domain-types.ts index 8e5d8c19269e..8d7d09f6bbe4 100644 --- a/products/desktop/packages/shared/src/domain-types.ts +++ b/products/desktop/packages/shared/src/domain-types.ts @@ -259,6 +259,7 @@ export interface TaskRunArtifact { metadata?: TaskRunArtifactMetadata; storage_path?: string; uploaded_at?: string; + dismissed_at?: string | null; } export const TERMINAL_STATUSES = ["completed", "failed", "cancelled"] as const; diff --git a/products/desktop/packages/ui/src/features/canvas/components/TaskArtifactsList.test.tsx b/products/desktop/packages/ui/src/features/canvas/components/TaskArtifactsList.test.tsx index e1e4c4151e0c..fb6fdaf92691 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/TaskArtifactsList.test.tsx +++ b/products/desktop/packages/ui/src/features/canvas/components/TaskArtifactsList.test.tsx @@ -327,6 +327,46 @@ describe("TaskArtifactsList", () => { expect(screen.getByText("File · 2 KB")).toBeInTheDocument(); }); + // A file dismissed in the chat's Files box has to go from this pane too, but + // only once every version of it is dismissed. + it.each([ + { + name: "keeps a file whose newest upload alone was dismissed", + dismissedNewest: true, + dismissedOldest: false, + visible: true, + }, + { + name: "leaves out a file whose every version was dismissed", + dismissedNewest: true, + dismissedOldest: true, + visible: false, + }, + ])("$name", ({ dismissedNewest, dismissedOldest, visible }) => { + const dismissedAt = "2026-07-27T10:00:00+00:00"; + mocks.runs = [ + run("run-1", { + artifacts: [ + outputFile({ + id: "a", + uploaded_at: "2026-07-27T08:00:00+00:00", + ...(dismissedOldest ? { dismissed_at: dismissedAt } : {}), + }), + outputFile({ + id: "b", + storage_path: "runs/1/report-v2.md", + uploaded_at: "2026-07-27T09:00:00+00:00", + ...(dismissedNewest ? { dismissed_at: dismissedAt } : {}), + }), + ], + }), + ]; + + render(); + + expect(screen.queryByText("report.md") !== null).toBe(visible); + }); + it.each([ { name: "a plan", type: "plan" as const }, { name: "a user attachment", type: "user_attachment" as const }, diff --git a/products/desktop/packages/ui/src/features/canvas/components/taskArtifactRows.ts b/products/desktop/packages/ui/src/features/canvas/components/taskArtifactRows.ts index dddef1dd5c27..3b02240eb972 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/taskArtifactRows.ts +++ b/products/desktop/packages/ui/src/features/canvas/components/taskArtifactRows.ts @@ -168,12 +168,14 @@ export function buildRows( // 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(); + const undismissedNames = new Set(); for (const run of allRuns) { for (const outputPr of readPrUrls(run.output)) { addPr(outputPr, `output-pr:${outputPr}`); } for (const file of readRunOutputs(run)) { if (!file.name) continue; + if (!file.dismissed_at) undismissedNames.add(file.name); const previous = newestByName.get(file.name); const isNewer = !previous || @@ -182,6 +184,9 @@ export function buildRows( } } for (const [name, { file, runId }] of newestByName) { + // A file goes only when every version of it is dismissed, so dismissing the + // one on show cannot resurface the copy it replaced. + if (!undismissedNames.has(name)) continue; rows.push({ kind: "file", key: `file:${file.id ?? file.storage_path ?? name}`, diff --git a/products/desktop/packages/ui/src/features/sessions/components/CloudArtifactDownloads.test.tsx b/products/desktop/packages/ui/src/features/sessions/components/CloudArtifactDownloads.test.tsx index dbb8282e9ee3..062b397a314d 100644 --- a/products/desktop/packages/ui/src/features/sessions/components/CloudArtifactDownloads.test.tsx +++ b/products/desktop/packages/ui/src/features/sessions/components/CloudArtifactDownloads.test.tsx @@ -4,33 +4,40 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { CloudArtifactDownloads } from "./CloudArtifactDownloads"; const getCloudAttachmentPreviewUrl = vi.fn(); +const setCloudRunArtifactsDismissed = vi.fn(); const openArtifactTab = vi.fn(); -const fetchedArtifacts = [ - { - id: "output-1", - name: "report.pdf", - type: "output", - size: 12_000, - storage_path: "tasks/run-1/report.pdf", - }, - { - id: "internal-1", - name: "handoff.pack", - type: "artifact", - storage_path: "tasks/run-1/handoff.pack", - }, -]; +const refetch = vi.fn(); +let fetchedArtifacts: unknown[] | undefined = []; +let session: { + cloudArtifacts?: unknown[]; + cloudStatus?: string; + events?: unknown[]; +} = {}; + +const UPLOAD_TOOL = "mcp__posthog-code-tools__upload_artifact"; + +function uploadEvent(update: Record) { + return { + type: "acp_message", + ts: 0, + message: { jsonrpc: "2.0", method: "session/update", params: { update } }, + }; +} vi.mock("@posthog/core/sessions/sessionService", () => ({ SESSION_SERVICE: Symbol("SESSION_SERVICE"), })); vi.mock("@posthog/di/react", () => ({ - useService: () => ({ getCloudAttachmentPreviewUrl }), + useService: () => ({ + getCloudAttachmentPreviewUrl, + setCloudRunArtifactsDismissed, + }), })); vi.mock("@posthog/ui/features/sessions/sessionStore", () => ({ - useSessionSelector: () => undefined, + useSessionSelector: (_taskId: string, select: (s: unknown) => unknown) => + select(session), })); vi.mock("@posthog/ui/features/auth/store", () => ({ @@ -39,7 +46,17 @@ vi.mock("@posthog/ui/features/auth/store", () => ({ })); vi.mock("@tanstack/react-query", () => ({ - useQuery: () => ({ data: fetchedArtifacts }), + useQuery: () => ({ data: fetchedArtifacts, refetch }), + useMutation: ({ + mutationFn, + onSuccess, + }: { + mutationFn: (variables: unknown) => Promise; + onSuccess: (result: unknown) => void; + }) => ({ + isPending: false, + mutate: (variables: unknown) => void mutationFn(variables).then(onSuccess), + }), })); vi.mock("@posthog/ui/features/panels/panelLayoutStore", () => ({ @@ -54,9 +71,36 @@ const task = { }, } as never; +function renderDownloads() { + return render( + + + , + ); +} + describe("CloudArtifactDownloads", () => { beforeEach(() => { + fetchedArtifacts = [ + { + id: "output-1", + name: "report.pdf", + type: "output", + size: 12_000, + storage_path: "tasks/run-1/report.pdf", + uploaded_at: "2026-07-27T08:00:00+00:00", + }, + { + id: "internal-1", + name: "handoff.pack", + type: "artifact", + storage_path: "tasks/run-1/handoff.pack", + }, + ]; + session = {}; + refetch.mockReset(); getCloudAttachmentPreviewUrl.mockReset(); + setCloudRunArtifactsDismissed.mockReset(); openArtifactTab.mockReset(); vi.restoreAllMocks(); }); @@ -78,11 +122,7 @@ describe("CloudArtifactDownloads", () => { .spyOn(HTMLAnchorElement.prototype, "click") .mockImplementation(() => undefined); - render( - - - , - ); + renderDownloads(); expect(screen.getByText("report.pdf")).toBeInTheDocument(); expect(screen.getByText("12 KB")).toBeInTheDocument(); @@ -104,11 +144,7 @@ describe("CloudArtifactDownloads", () => { }); it("opens an artifact preview in a new tab", () => { - render( - - - , - ); + renderDownloads(); fireEvent.click(screen.getByText("report.pdf")); @@ -119,13 +155,219 @@ describe("CloudArtifactDownloads", () => { }); }); - it("starts expanded and collapses when the header is clicked", () => { - render( + // A re-upload replaces the file rather than adding a second row for it. + it("opens the newest upload of a repeated name", () => { + fetchedArtifacts = [ + { + id: "output-1", + name: "report.pdf", + type: "output", + uploaded_at: "2026-07-27T08:00:00+00:00", + }, + { + id: "output-2", + name: "report.pdf", + type: "output", + uploaded_at: "2026-07-27T09:00:00+00:00", + }, + ]; + + renderDownloads(); + + expect(screen.getAllByText("report.pdf")).toHaveLength(1); + expect( + screen.getByRole("button", { name: "Files (1)" }), + ).toBeInTheDocument(); + + fireEvent.click(screen.getByText("report.pdf")); + + expect(openArtifactTab).toHaveBeenCalledWith("task-1", { + runId: "run-1", + artifactId: "output-2", + name: "report.pdf", + }); + }); + + // A manifest entry carries an id only once the upload is finalized, and a + // version the picker cannot switch to is worse than no picker at all. + it("switches to a version that has no id", () => { + fetchedArtifacts = [ + { + name: "report.pdf", + type: "output", + size: 1_000, + storage_path: "tasks/run-1/report-v1.pdf", + uploaded_at: "2026-07-27T08:00:00+00:00", + }, + { + name: "report.pdf", + type: "output", + size: 2_000, + storage_path: "tasks/run-1/report-v2.pdf", + uploaded_at: "2026-07-27T09:00:00+00:00", + }, + ]; + + renderDownloads(); + + expect(screen.getByText("2 KB")).toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText("Choose a version of report.pdf")); + fireEvent.click(screen.getByText(/^Version 1/)); + + expect(screen.getByText("1 KB")).toBeInTheDocument(); + }); + + // Dismissing the row a user sees has to take the versions behind it too, + // otherwise the file reappears as its own older upload. + it("dismisses every version of a file", async () => { + fetchedArtifacts = [ + { + id: "output-1", + name: "report.pdf", + type: "output", + uploaded_at: "2026-07-27T08:00:00+00:00", + }, + { + id: "output-2", + name: "report.pdf", + type: "output", + uploaded_at: "2026-07-27T09:00:00+00:00", + }, + ]; + const dismissedManifest = fetchedArtifacts.map((artifact) => ({ + ...(artifact as object), + dismissed_at: "2026-07-27T10:00:00+00:00", + })); + setCloudRunArtifactsDismissed.mockResolvedValue(dismissedManifest); + + renderDownloads(); + + fireEvent.click(screen.getByLabelText("Dismiss report.pdf")); + + await waitFor(() => + expect(setCloudRunArtifactsDismissed).toHaveBeenCalledWith( + "task-1", + "run-1", + ["output-2", "output-1"], + true, + ), + ); + await waitFor(() => + expect(screen.getByText("Show 1 dismissed")).toBeInTheDocument(), + ); + expect(screen.queryByText("report.pdf")).not.toBeInTheDocument(); + }); + + // A dismissal must not park a snapshot anywhere that outranks the live session store, which is + // what the box renders from before the first fetch resolves. + it("still shows files uploaded after a mid-run dismissal", async () => { + fetchedArtifacts = undefined; + session = { + cloudStatus: "in_progress", + cloudArtifacts: [ + { + id: "output-1", + name: "report.pdf", + type: "output", + uploaded_at: "2026-07-27T08:00:00+00:00", + }, + ], + }; + setCloudRunArtifactsDismissed.mockResolvedValue([ + { + id: "output-1", + name: "report.pdf", + type: "output", + uploaded_at: "2026-07-27T08:00:00+00:00", + dismissed_at: "2026-07-27T09:00:00+00:00", + }, + ]); + + const { rerender } = renderDownloads(); + + fireEvent.click(screen.getByLabelText("Dismiss report.pdf")); + await waitFor(() => + expect(screen.queryByText("report.pdf")).not.toBeInTheDocument(), + ); + + session.cloudArtifacts = [ + ...(session.cloudArtifacts as unknown[]), + { + id: "output-2", + name: "notes.md", + type: "output", + uploaded_at: "2026-07-27T10:00:00+00:00", + }, + ]; + rerender( + + + , + ); + + expect(screen.getByText("notes.md")).toBeInTheDocument(); + expect(screen.queryByText("report.pdf")).not.toBeInTheDocument(); + }); + + it("hides a dismissed file until the toggle brings it back", () => { + fetchedArtifacts = [ + { + id: "output-1", + name: "report.pdf", + type: "output", + uploaded_at: "2026-07-27T08:00:00+00:00", + dismissed_at: "2026-07-27T10:00:00+00:00", + }, + ]; + + renderDownloads(); + + expect(screen.queryByText("report.pdf")).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Files (0)" }), + ).toBeInTheDocument(); + + fireEvent.click(screen.getByText("Show 1 dismissed")); + + expect(screen.getByText("report.pdf")).toBeInTheDocument(); + expect(screen.getByText("Restore")).toBeInTheDocument(); + }); + + // Nothing pushes the run's manifest to this client, so without the tool call as a trigger a + // freshly delivered file waits for the backstop poll. + it("rereads the manifest as soon as an upload finishes", () => { + session = { cloudStatus: "in_progress", events: [] }; + + const { rerender } = renderDownloads(); + refetch.mockClear(); + + session.events = [ + uploadEvent({ + sessionUpdate: "tool_call", + toolCallId: "call-1", + _meta: { posthog: { toolName: UPLOAD_TOOL } }, + }), + uploadEvent({ + sessionUpdate: "tool_call_update", + toolCallId: "call-1", + status: "completed", + }), + ]; + rerender( , ); + expect(refetch).toHaveBeenCalled(); + }); + + // Collapse state lives in a module-scoped store that nothing here resets, so + // the case that leaves the box collapsed has to run last. + it("starts expanded and collapses when the header is clicked", () => { + renderDownloads(); + const trigger = screen.getByRole("button", { name: "Files (1)" }); expect(trigger).toHaveAttribute("aria-expanded", "true"); expect(screen.getByText("report.pdf")).toBeVisible(); @@ -142,21 +384,13 @@ describe("CloudArtifactDownloads", () => { }); it("remembers collapse state per task", () => { - const { unmount } = render( - - - , - ); + const { unmount } = renderDownloads(); fireEvent.click(screen.getByRole("button", { name: "Files (1)" })); expect(screen.queryByText("report.pdf")).not.toBeInTheDocument(); unmount(); - render( - - - , - ); + renderDownloads(); expect(screen.getByRole("button", { name: "Files (1)" })).toHaveAttribute( "aria-expanded", diff --git a/products/desktop/packages/ui/src/features/sessions/components/CloudArtifactDownloads.tsx b/products/desktop/packages/ui/src/features/sessions/components/CloudArtifactDownloads.tsx index 7c47ef4e3948..ca671edadb82 100644 --- a/products/desktop/packages/ui/src/features/sessions/components/CloudArtifactDownloads.tsx +++ b/products/desktop/packages/ui/src/features/sessions/components/CloudArtifactDownloads.tsx @@ -1,12 +1,30 @@ import { Collapsible } from "@base-ui/react/collapsible"; -import { CaretDown, CaretRight, DownloadSimple } from "@phosphor-icons/react"; +import { + CaretDown, + CaretRight, + DownloadSimple, + X, +} from "@phosphor-icons/react"; +import { + groupRunArtifactVersions, + type RunArtifactVersions, + runArtifactVersionKey, + runArtifactVersionLabel, +} from "@posthog/core/canvas/runArtifactSchemas"; import { SESSION_SERVICE, type SessionService, } from "@posthog/core/sessions/sessionService"; import { useService } from "@posthog/di/react"; -import { Button, Text } from "@posthog/quill"; -import type { TaskRunArtifact } from "@posthog/shared"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Text, +} from "@posthog/quill"; +import { formatRelativeTimeShort, type TaskRunArtifact } from "@posthog/shared"; import { isTerminalStatus, type Task } from "@posthog/shared/domain-types"; import { getAuthIdentity, @@ -19,10 +37,29 @@ import { useSessionViewActions, } from "@posthog/ui/features/sessions/sessionViewStore"; import { FileIcon } from "@posthog/ui/primitives/FileIcon"; +import { RelativeTimestamp } from "@posthog/ui/primitives/RelativeTimestamp"; import { toast } from "@posthog/ui/primitives/toast"; import { formatFileSize } from "@posthog/ui/utils/formatFileSize"; -import { useQuery } from "@tanstack/react-query"; -import { useCallback, useMemo, useState } from "react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { createArtifactUploadTracker } from "./countArtifactUploads"; + +type ArtifactGroup = RunArtifactVersions; + +/** + * The menu sits at the right edge of the thread, and its popup is capped at the space left + * there and clips what does not fit, so the age is the compact form rather than the row's. + */ +function versionMenuLabel( + artifact: TaskRunArtifact, + index: number, + total: number, +): string { + const label = runArtifactVersionLabel(index, total); + return artifact.uploaded_at + ? `${label} · ${formatRelativeTimeShort(artifact.uploaded_at)}` + : label; +} export function CloudArtifactDownloads({ taskId, @@ -45,29 +82,95 @@ export function CloudArtifactDownloads({ const { setArtifactFilesCollapsed } = useSessionViewActions(); const authIdentity = useAuthStateValue(getAuthIdentity); const [downloadingId, setDownloadingId] = useState(null); + const [selectedVersionByName, setSelectedVersionByName] = useState< + Record + >({}); + const [dismissalOverrides, setDismissalOverrides] = useState< + Record + >({}); + const [showDismissed, setShowDismissed] = useState(false); const runId = task?.latest_run?.id; - const { data: fetchedArtifacts } = useQuery({ + const isTerminal = isTerminalStatus(cloudStatus ?? task?.latest_run?.status); + const { data: fetchedArtifacts, refetch } = useQuery({ queryKey: ["cloudRunArtifacts", authIdentity, taskId, runId], queryFn: () => sessionService.getCloudRunArtifacts(taskId ?? "", runId ?? ""), enabled: - authIdentity !== null && - taskId !== undefined && - runId !== undefined && - isTerminalStatus(cloudStatus ?? task?.latest_run?.status), + authIdentity !== null && taskId !== undefined && runId !== undefined, retry: false, - staleTime: Infinity, + staleTime: 15_000, + // Backstop only, for an upload whose tool call never reached this client. + refetchInterval: isTerminal ? false : 30_000, }); - const artifacts = useMemo( + + // The agent's own upload_artifact call is the earliest signal a new file exists, + // so read the manifest the moment one finishes rather than on the next tick. + const events = useSessionSelector(taskId, (session) => session?.events); + const uploadTracker = useRef | null>(null); + uploadTracker.current ??= createArtifactUploadTracker(); + const tracker = uploadTracker.current; + const completedUploads = useMemo( + () => tracker.update(events ?? []), + [events, tracker], + ); + useEffect(() => { + if (completedUploads > 0) void refetch(); + }, [completedUploads, refetch]); + const groups = useMemo( () => - ( - fetchedArtifacts ?? - sessionArtifacts ?? - task?.latest_run?.artifacts ?? - [] - ).filter((artifact) => artifact.type === "output"), - [fetchedArtifacts, sessionArtifacts, task?.latest_run?.artifacts], + groupRunArtifactVersions( + ( + fetchedArtifacts ?? + sessionArtifacts ?? + task?.latest_run?.artifacts ?? + [] + ) + .filter((artifact) => artifact.type === "output") + .map((artifact) => + artifact.id && artifact.id in dismissalOverrides + ? { ...artifact, dismissed_at: dismissalOverrides[artifact.id] } + : artifact, + ), + ), + [ + dismissalOverrides, + fetchedArtifacts, + sessionArtifacts, + task?.latest_run?.artifacts, + ], ); + const visibleGroups = groups.filter((group) => !group.dismissed); + const dismissedGroups = groups.filter((group) => group.dismissed); + + const dismissal = useMutation({ + mutationFn: ({ + group, + dismissed, + }: { + group: ArtifactGroup; + dismissed: boolean; + }) => + sessionService.setCloudRunArtifactsDismissed( + taskId ?? "", + runId ?? "", + group.versions.flatMap((version) => version.id ?? []), + dismissed, + ), + // Overlay just the dismissal stamps from the response, so the row updates at once without + // parking a whole-manifest snapshot over a source that keeps refreshing behind it. + onSuccess: (manifest) => + setDismissalOverrides((current) => ({ + ...current, + ...Object.fromEntries( + manifest.flatMap((entry) => + entry.id ? [[entry.id, entry.dismissed_at ?? null]] : [], + ), + ), + })), + onError: () => toast.error("Couldn't update this file"), + }); const downloadArtifact = useCallback( async (artifact: TaskRunArtifact): Promise => { @@ -100,7 +203,113 @@ export function CloudArtifactDownloads({ [runId, sessionService, taskId], ); - if (!runId || artifacts.length === 0) return null; + if (!runId || groups.length === 0) return null; + + const renderRow = (group: ArtifactGroup) => { + const selectedIndex = Math.max( + group.versions.findIndex( + (version) => + runArtifactVersionKey(version) === selectedVersionByName[group.name], + ), + 0, + ); + const selected = group.versions[selectedIndex] as TaskRunArtifact; + const size = formatFileSize(selected.size); + const canDownload = Boolean(selected.id); + + return ( +
+ + {group.versions.length > 1 && ( + + + {runArtifactVersionLabel( + selectedIndex, + group.versions.length, + )} + + + } + /> + + {group.versions.map((version, index) => ( + + setSelectedVersionByName((current) => ({ + ...current, + [group.name]: runArtifactVersionKey(version), + })) + } + > + {versionMenuLabel(version, index, group.versions.length)} + + ))} + + + )} + {group.dismissed ? ( + + ) : ( + <> + + + + )} +
+ ); + }; return ( // Base UI rather than quill's Collapsible: quill styles the root/trigger @@ -116,52 +325,25 @@ export function CloudArtifactDownloads({ > {collapsed ? : } - Files ({artifacts.length}) + Files ({visibleGroups.length})
- {artifacts.map((artifact) => { - const size = formatFileSize(artifact.size); - const canDownload = Boolean(artifact.id); - return ( -
- - -
- ); - })} + {visibleGroups.map(renderRow)} + {showDismissed && dismissedGroups.map(renderRow)}
+ {dismissedGroups.length > 0 && ( + + )}
); diff --git a/products/desktop/packages/ui/src/features/sessions/components/countArtifactUploads.test.ts b/products/desktop/packages/ui/src/features/sessions/components/countArtifactUploads.test.ts new file mode 100644 index 000000000000..c051a38a9302 --- /dev/null +++ b/products/desktop/packages/ui/src/features/sessions/components/countArtifactUploads.test.ts @@ -0,0 +1,83 @@ +import type { AcpMessage } from "@posthog/shared"; +import { describe, expect, it } from "vitest"; +import { createArtifactUploadTracker } from "./countArtifactUploads"; + +function toolCallEvent( + sessionUpdate: "tool_call" | "tool_call_update", + update: { toolCallId: string; status?: string; toolName?: string }, +): AcpMessage { + return { + type: "acp_message", + ts: 0, + message: { + jsonrpc: "2.0", + method: "session/update", + params: { + update: { + sessionUpdate, + toolCallId: update.toolCallId, + ...(update.status ? { status: update.status } : {}), + ...(update.toolName + ? { _meta: { posthog: { toolName: update.toolName } } } + : {}), + }, + }, + }, + } as unknown as AcpMessage; +} + +const UPLOAD = "mcp__posthog-code-tools__upload_artifact"; + +describe("createArtifactUploadTracker", () => { + it("counts an upload once it completes", () => { + const tracker = createArtifactUploadTracker(); + const events = [ + toolCallEvent("tool_call", { toolCallId: "c1", toolName: UPLOAD }), + ]; + + expect(tracker.update(events)).toBe(0); + + events.push( + toolCallEvent("tool_call_update", { + toolCallId: "c1", + status: "completed", + }), + ); + + expect(tracker.update(events)).toBe(1); + }); + + it.each([ + ["a repeated completion for one call", "completed", UPLOAD, 1], + ["a failed upload", "failed", UPLOAD, 0], + ["another tool completing", "completed", "mcp__other__write_file", 0], + ])("ignores %s", (_case, status, toolName, expected) => { + const tracker = createArtifactUploadTracker(); + const events = [ + toolCallEvent("tool_call", { toolCallId: "c1", toolName }), + toolCallEvent("tool_call_update", { toolCallId: "c1", status }), + toolCallEvent("tool_call_update", { toolCallId: "c1", status }), + ]; + + expect(tracker.update(events)).toBe(expected); + }); + + it("counts each upload separately", () => { + const tracker = createArtifactUploadTracker(); + + expect( + tracker.update([ + toolCallEvent("tool_call", { toolCallId: "c1", toolName: UPLOAD }), + toolCallEvent("tool_call_update", { + toolCallId: "c1", + status: "completed", + }), + toolCallEvent("tool_call", { toolCallId: "c2", toolName: UPLOAD }), + toolCallEvent("tool_call_update", { + toolCallId: "c2", + status: "completed", + }), + ]), + ).toBe(2); + }); +}); diff --git a/products/desktop/packages/ui/src/features/sessions/components/countArtifactUploads.ts b/products/desktop/packages/ui/src/features/sessions/components/countArtifactUploads.ts new file mode 100644 index 000000000000..c22547886e83 --- /dev/null +++ b/products/desktop/packages/ui/src/features/sessions/components/countArtifactUploads.ts @@ -0,0 +1,62 @@ +import { createAppendOnlyTracker } from "@posthog/core/sessions/appendOnlyTracker"; +import { isJsonRpcNotification, readMcpToolDescriptor } from "@posthog/shared"; + +const UPLOAD_ARTIFACT_TOOL = "upload_artifact"; + +interface ArtifactUploadState { + /** Tool calls identified as an artifact upload, by tool call id. */ + uploadCallIds: Set; + completedCallIds: Set; +} + +/** + * Counts the artifact uploads that have finished this session, from the agent's + * own `upload_artifact` tool calls. + * + * The run's manifest is server state, and the endpoint that serves it is not + * pushed to, so this count is what tells a reader an upload landed — without it + * a freshly delivered file waits for the next poll. + */ +export function createArtifactUploadTracker() { + return createAppendOnlyTracker({ + init: () => ({ uploadCallIds: new Set(), completedCallIds: new Set() }), + processEvent: (state, event) => { + const msg = event.message; + if (!isJsonRpcNotification(msg)) return; + if (msg.method !== "session/update") return; + + const update = ( + msg.params as + | { + update?: { + sessionUpdate?: string; + toolCallId?: string; + status?: string; + _meta?: unknown; + }; + } + | undefined + )?.update; + if ( + !update?.toolCallId || + (update.sessionUpdate !== "tool_call" && + update.sessionUpdate !== "tool_call_update") + ) { + return; + } + + if (readMcpToolDescriptor(update._meta)?.tool === UPLOAD_ARTIFACT_TOOL) { + state.uploadCallIds.add(update.toolCallId); + } + // A completion only counts once the call is known to be an upload, and the + // id keeps a re-sent update from counting twice. + if ( + update.status === "completed" && + state.uploadCallIds.has(update.toolCallId) + ) { + state.completedCallIds.add(update.toolCallId); + } + }, + getResult: (state) => state.completedCallIds.size, + }); +}