From c392aace5052dc7df5960816a0a09305baa99029 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Fri, 7 Aug 2026 11:12:59 -0400 Subject: [PATCH 1/6] feat(desktop): version, dismiss, and timestamp uploaded run files Files an agent uploads from a cloud run showed up as one row per upload, so a revised deliverable buried its current version under its own drafts, with no sign of which copy was current or when any of them arrived. Uploads that share a name are now one file with a history: the row shows the newest, and earlier ones sit behind a version picker that retargets open and download. Each row carries a relative upload time. A file can be dismissed, which hides every version of it behind a "Show N dismissed" toggle with a Restore button. The canvas artifacts pane still shows only the newest upload, and now hides a file once every version of it has been dismissed. The upload_artifact tool description now tells agents that re-uploading a name is how to revise a delivered file. Generated-By: PostHog Code Task-Id: 57deec6a-7831-4922-a4ef-6d0c9b86831b --- products/desktop/docs/cloud-task-artifacts.md | 6 + .../local-tools/tools/upload-artifact.ts | 2 + .../packages/api-client/src/posthog-client.ts | 31 +++ .../api-client/src/task-normalization.ts | 16 +- .../src/canvas/runArtifactSchemas.test.ts | 57 +++- .../core/src/canvas/runArtifactSchemas.ts | 65 +++++ .../core/src/sessions/sessionService.ts | 19 ++ .../packages/shared/src/domain-types.ts | 1 + .../components/TaskArtifactsList.test.tsx | 40 +++ .../canvas/components/taskArtifactRows.ts | 5 + .../CloudArtifactDownloads.test.tsx | 184 ++++++++++--- .../components/CloudArtifactDownloads.tsx | 251 ++++++++++++++---- 12 files changed, 575 insertions(+), 102 deletions(-) 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/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..a69406af6378 100644 --- a/products/desktop/packages/api-client/src/task-normalization.ts +++ b/products/desktop/packages/api-client/src/task-normalization.ts @@ -8,13 +8,16 @@ import type { } from "@posthog/shared/domain-types"; import type { Schemas } from "./generated"; +export type TaskRunArtifactDTO = Schemas.TaskRunArtifactResponse & { + metadata?: unknown; + dismissed_at?: string | null; +}; + 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 +104,8 @@ function normalizeArtifactMetadata( }; } -function normalizeTaskRunArtifact( - artifact: NonNullable[number], +export function normalizeTaskRunArtifact( + artifact: TaskRunArtifactDTO, ): TaskRunArtifact { const metadata = normalizeArtifactMetadata(artifact.metadata); @@ -126,6 +129,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..645d320556a8 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,29 +4,20 @@ 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 setQueryData = vi.fn(); +let fetchedArtifacts: unknown[] = []; 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", () => ({ @@ -40,6 +31,17 @@ vi.mock("@posthog/ui/features/auth/store", () => ({ vi.mock("@tanstack/react-query", () => ({ useQuery: () => ({ data: fetchedArtifacts }), + useQueryClient: () => ({ setQueryData }), + 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,10 +56,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", + }, + ]; getCloudAttachmentPreviewUrl.mockReset(); + setCloudRunArtifactsDismissed.mockReset(); openArtifactTab.mockReset(); + setQueryData.mockReset(); vi.restoreAllMocks(); }); @@ -78,11 +106,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 +128,7 @@ describe("CloudArtifactDownloads", () => { }); it("opens an artifact preview in a new tab", () => { - render( - - - , - ); + renderDownloads(); fireEvent.click(screen.getByText("report.pdf")); @@ -119,12 +139,102 @@ 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); + + fireEvent.click(screen.getByText("report.pdf")); + + expect(openArtifactTab).toHaveBeenCalledWith("task-1", { + runId: "run-1", + artifactId: "output-2", + name: "report.pdf", + }); + }); + + // 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, + ), ); + expect(setQueryData).toHaveBeenCalledWith( + expect.anything(), + dismissedManifest, + ); + }); + + 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(); + + fireEvent.click(screen.getByText("Show 1 dismissed")); + + expect(screen.getByText("report.pdf")).toBeInTheDocument(); + expect(screen.getByText("Restore")).toBeInTheDocument(); + }); + + // 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"); @@ -142,21 +252,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..488c6c3668f0 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 { formatRelativeTimeLong, type TaskRunArtifact } from "@posthog/shared"; import { isTerminalStatus, type Task } from "@posthog/shared/domain-types"; import { getAuthIdentity, @@ -19,11 +37,25 @@ 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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useMemo, useState } from "react"; +type ArtifactGroup = RunArtifactVersions; + +function versionMenuLabel( + artifact: TaskRunArtifact, + index: number, + total: number, +): string { + const label = runArtifactVersionLabel(index, total); + return artifact.uploaded_at + ? `${label} · ${formatRelativeTimeLong(artifact.uploaded_at)}` + : label; +} + export function CloudArtifactDownloads({ taskId, task, @@ -32,6 +64,7 @@ export function CloudArtifactDownloads({ task: Task | undefined; }) { const sessionService = useService(SESSION_SERVICE); + const queryClient = useQueryClient(); const openArtifactTab = usePanelLayoutStore((state) => state.openArtifactTab); const sessionArtifacts = useSessionSelector( taskId, @@ -45,9 +78,14 @@ export function CloudArtifactDownloads({ const { setArtifactFilesCollapsed } = useSessionViewActions(); const authIdentity = useAuthStateValue(getAuthIdentity); const [downloadingId, setDownloadingId] = useState(null); + const [selectedVersionByName, setSelectedVersionByName] = useState< + Record + >({}); + const [showDismissed, setShowDismissed] = useState(false); const runId = task?.latest_run?.id; + const artifactsQueryKey = ["cloudRunArtifacts", authIdentity, taskId, runId]; const { data: fetchedArtifacts } = useQuery({ - queryKey: ["cloudRunArtifacts", authIdentity, taskId, runId], + queryKey: artifactsQueryKey, queryFn: () => sessionService.getCloudRunArtifacts(taskId ?? "", runId ?? ""), enabled: @@ -58,16 +96,41 @@ export function CloudArtifactDownloads({ retry: false, staleTime: Infinity, }); - const artifacts = useMemo( + const groups = useMemo( () => - ( - fetchedArtifacts ?? - sessionArtifacts ?? - task?.latest_run?.artifacts ?? - [] - ).filter((artifact) => artifact.type === "output"), + groupRunArtifactVersions( + ( + fetchedArtifacts ?? + sessionArtifacts ?? + task?.latest_run?.artifacts ?? + [] + ).filter((artifact) => artifact.type === "output"), + ), [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, + ), + // The response carries the whole manifest, so the rows re-render from it + // even while the query itself is disabled for a run that is still going. + onSuccess: (manifest) => + queryClient.setQueryData(artifactsQueryKey, manifest), + onError: () => toast.error("Couldn't update this file"), + }); const downloadArtifact = useCallback( async (artifact: TaskRunArtifact): Promise => { @@ -100,7 +163,112 @@ 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) => version.id === 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]: version.id ?? "", + })) + } + > + {versionMenuLabel(version, index, group.versions.length)} + + ))} + + + )} + {group.dismissed ? ( + + ) : ( + <> + + + + )} +
+ ); + }; return ( // Base UI rather than quill's Collapsible: quill styles the root/trigger @@ -116,52 +284,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 && ( + + )}
); From 6534eeb285f45025487e97993c88e81f57b95f32 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Fri, 7 Aug 2026 11:13:00 -0400 Subject: [PATCH 2/6] chore(desktop): assert the files header counts files, not uploads The collapsible header's count now comes from the grouped, non-dismissed files, so pin that: two uploads of one name still read as one file, and an all-dismissed box reads as zero. Generated-By: PostHog Code Task-Id: 57deec6a-7831-4922-a4ef-6d0c9b86831b --- .../sessions/components/CloudArtifactDownloads.test.tsx | 6 ++++++ 1 file changed, 6 insertions(+) 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 645d320556a8..80179b2261ae 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 @@ -159,6 +159,9 @@ describe("CloudArtifactDownloads", () => { renderDownloads(); expect(screen.getAllByText("report.pdf")).toHaveLength(1); + expect( + screen.getByRole("button", { name: "Files (1)" }), + ).toBeInTheDocument(); fireEvent.click(screen.getByText("report.pdf")); @@ -224,6 +227,9 @@ describe("CloudArtifactDownloads", () => { renderDownloads(); expect(screen.queryByText("report.pdf")).not.toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Files (0)" }), + ).toBeInTheDocument(); fireEvent.click(screen.getByText("Show 1 dismissed")); From d1ff408df5a06d6d04ac77ac83a214c8e17eb89e Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Fri, 7 Aug 2026 11:13:02 -0400 Subject: [PATCH 3/6] fix(desktop): keep showing files uploaded after a dismissal Dismissing wrote the whole manifest into the artifact query cache. That query is disabled until the run is terminal, so the snapshot then outranked the live session store for the rest of the run and hid every file the agent uploaded afterward. The mutation now overlays only the dismissal stamps it was given. Generated-By: PostHog Code Task-Id: 57deec6a-7831-4922-a4ef-6d0c9b86831b --- .../CloudArtifactDownloads.test.tsx | 67 ++++++++++++++++--- .../components/CloudArtifactDownloads.tsx | 39 ++++++++--- 2 files changed, 89 insertions(+), 17 deletions(-) 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 80179b2261ae..164b2745fb31 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 @@ -6,8 +6,8 @@ import { CloudArtifactDownloads } from "./CloudArtifactDownloads"; const getCloudAttachmentPreviewUrl = vi.fn(); const setCloudRunArtifactsDismissed = vi.fn(); const openArtifactTab = vi.fn(); -const setQueryData = vi.fn(); -let fetchedArtifacts: unknown[] = []; +let fetchedArtifacts: unknown[] | undefined = []; +let session: { cloudArtifacts?: unknown[]; cloudStatus?: string } = {}; vi.mock("@posthog/core/sessions/sessionService", () => ({ SESSION_SERVICE: Symbol("SESSION_SERVICE"), @@ -21,7 +21,8 @@ vi.mock("@posthog/di/react", () => ({ })); 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", () => ({ @@ -31,7 +32,6 @@ vi.mock("@posthog/ui/features/auth/store", () => ({ vi.mock("@tanstack/react-query", () => ({ useQuery: () => ({ data: fetchedArtifacts }), - useQueryClient: () => ({ setQueryData }), useMutation: ({ mutationFn, onSuccess, @@ -82,10 +82,10 @@ describe("CloudArtifactDownloads", () => { storage_path: "tasks/run-1/handoff.pack", }, ]; + session = {}; getCloudAttachmentPreviewUrl.mockReset(); setCloudRunArtifactsDismissed.mockReset(); openArtifactTab.mockReset(); - setQueryData.mockReset(); vi.restoreAllMocks(); }); @@ -207,10 +207,61 @@ describe("CloudArtifactDownloads", () => { true, ), ); - expect(setQueryData).toHaveBeenCalledWith( - expect.anything(), - dismissedManifest, + await waitFor(() => + expect(screen.getByText("Show 1 dismissed")).toBeInTheDocument(), + ); + expect(screen.queryByText("report.pdf")).not.toBeInTheDocument(); + }); + + // The artifact query is disabled until the run is terminal, so a dismissal mid-run must not + // park a snapshot anywhere that outranks the live session store. + 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", () => { 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 488c6c3668f0..f14e85b3143e 100644 --- a/products/desktop/packages/ui/src/features/sessions/components/CloudArtifactDownloads.tsx +++ b/products/desktop/packages/ui/src/features/sessions/components/CloudArtifactDownloads.tsx @@ -40,7 +40,7 @@ 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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { useCallback, useMemo, useState } from "react"; type ArtifactGroup = RunArtifactVersions; @@ -64,7 +64,6 @@ export function CloudArtifactDownloads({ task: Task | undefined; }) { const sessionService = useService(SESSION_SERVICE); - const queryClient = useQueryClient(); const openArtifactTab = usePanelLayoutStore((state) => state.openArtifactTab); const sessionArtifacts = useSessionSelector( taskId, @@ -81,11 +80,13 @@ export function CloudArtifactDownloads({ const [selectedVersionByName, setSelectedVersionByName] = useState< Record >({}); + const [dismissalOverrides, setDismissalOverrides] = useState< + Record + >({}); const [showDismissed, setShowDismissed] = useState(false); const runId = task?.latest_run?.id; - const artifactsQueryKey = ["cloudRunArtifacts", authIdentity, taskId, runId]; const { data: fetchedArtifacts } = useQuery({ - queryKey: artifactsQueryKey, + queryKey: ["cloudRunArtifacts", authIdentity, taskId, runId], queryFn: () => sessionService.getCloudRunArtifacts(taskId ?? "", runId ?? ""), enabled: @@ -104,9 +105,20 @@ export function CloudArtifactDownloads({ sessionArtifacts ?? task?.latest_run?.artifacts ?? [] - ).filter((artifact) => artifact.type === "output"), + ) + .filter((artifact) => artifact.type === "output") + .map((artifact) => + artifact.id && artifact.id in dismissalOverrides + ? { ...artifact, dismissed_at: dismissalOverrides[artifact.id] } + : artifact, + ), ), - [fetchedArtifacts, sessionArtifacts, task?.latest_run?.artifacts], + [ + dismissalOverrides, + fetchedArtifacts, + sessionArtifacts, + task?.latest_run?.artifacts, + ], ); const visibleGroups = groups.filter((group) => !group.dismissed); const dismissedGroups = groups.filter((group) => group.dismissed); @@ -125,10 +137,19 @@ export function CloudArtifactDownloads({ group.versions.flatMap((version) => version.id ?? []), dismissed, ), - // The response carries the whole manifest, so the rows re-render from it - // even while the query itself is disabled for a run that is still going. + // Overlay just the dismissal stamps from the response. Writing the whole manifest into the + // query cache would define `fetchedArtifacts` mid-run, and since the query stays disabled + // until the run is terminal, that snapshot would then mask the live session store for good — + // hiding every file the agent uploads after a dismissal. onSuccess: (manifest) => - queryClient.setQueryData(artifactsQueryKey, 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"), }); From 352f2144f8bce5bc7853ba47e5016ef837441b70 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Fri, 7 Aug 2026 11:13:04 -0400 Subject: [PATCH 4/6] fix(desktop): show a delivered file without waiting for a refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session Files box read the run manifest through a query enabled only once the run was terminal, with staleTime Infinity. It fetched once, racing the agent's final uploads, and never refetched — so a file delivered near the end of a run stayed invisible until the view remounted. Its fallback did not cover the gap: cloudArtifacts is only written when a permission is answered, not on a timer. The query now runs for the whole life of a run. Rather than leaning on the poll, it rereads the manifest the moment an upload_artifact tool call completes in the session stream, which is the earliest this client learns a file exists and works the same for a sandboxed cloud run. The 30s poll stays as a backstop for an upload whose tool call never arrives. Generated-By: PostHog Code Task-Id: 57deec6a-7831-4922-a4ef-6d0c9b86831b --- .../CloudArtifactDownloads.test.tsx | 53 +++++++++++- .../components/CloudArtifactDownloads.tsx | 37 ++++++--- .../components/countArtifactUploads.test.ts | 83 +++++++++++++++++++ .../components/countArtifactUploads.ts | 62 ++++++++++++++ 4 files changed, 220 insertions(+), 15 deletions(-) create mode 100644 products/desktop/packages/ui/src/features/sessions/components/countArtifactUploads.test.ts create mode 100644 products/desktop/packages/ui/src/features/sessions/components/countArtifactUploads.ts 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 164b2745fb31..3e06d9ea9855 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 @@ -6,8 +6,23 @@ import { CloudArtifactDownloads } from "./CloudArtifactDownloads"; const getCloudAttachmentPreviewUrl = vi.fn(); const setCloudRunArtifactsDismissed = vi.fn(); const openArtifactTab = vi.fn(); +const refetch = vi.fn(); let fetchedArtifacts: unknown[] | undefined = []; -let session: { cloudArtifacts?: unknown[]; cloudStatus?: string } = {}; +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"), @@ -31,7 +46,7 @@ vi.mock("@posthog/ui/features/auth/store", () => ({ })); vi.mock("@tanstack/react-query", () => ({ - useQuery: () => ({ data: fetchedArtifacts }), + useQuery: () => ({ data: fetchedArtifacts, refetch }), useMutation: ({ mutationFn, onSuccess, @@ -83,6 +98,7 @@ describe("CloudArtifactDownloads", () => { }, ]; session = {}; + refetch.mockReset(); getCloudAttachmentPreviewUrl.mockReset(); setCloudRunArtifactsDismissed.mockReset(); openArtifactTab.mockReset(); @@ -213,8 +229,8 @@ describe("CloudArtifactDownloads", () => { expect(screen.queryByText("report.pdf")).not.toBeInTheDocument(); }); - // The artifact query is disabled until the run is terminal, so a dismissal mid-run must not - // park a snapshot anywhere that outranks the live session store. + // 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 = { @@ -288,6 +304,35 @@ describe("CloudArtifactDownloads", () => { 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", () => { 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 f14e85b3143e..0164187f49e1 100644 --- a/products/desktop/packages/ui/src/features/sessions/components/CloudArtifactDownloads.tsx +++ b/products/desktop/packages/ui/src/features/sessions/components/CloudArtifactDownloads.tsx @@ -41,7 +41,8 @@ import { RelativeTimestamp } from "@posthog/ui/primitives/RelativeTimestamp"; import { toast } from "@posthog/ui/primitives/toast"; import { formatFileSize } from "@posthog/ui/utils/formatFileSize"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { createArtifactUploadTracker } from "./countArtifactUploads"; type ArtifactGroup = RunArtifactVersions; @@ -85,18 +86,34 @@ export function CloudArtifactDownloads({ >({}); 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, }); + + // 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( () => groupRunArtifactVersions( @@ -137,10 +154,8 @@ export function CloudArtifactDownloads({ group.versions.flatMap((version) => version.id ?? []), dismissed, ), - // Overlay just the dismissal stamps from the response. Writing the whole manifest into the - // query cache would define `fetchedArtifacts` mid-run, and since the query stays disabled - // until the run is terminal, that snapshot would then mask the live session store for good — - // hiding every file the agent uploads after a dismissal. + // 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, 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, + }); +} From f3d40f5f705ec2b0835e234f4485a17b59a4db84 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Fri, 7 Aug 2026 11:13:06 -0400 Subject: [PATCH 5/6] fix(desktop): stop the version menu clipping a file's age MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The picker sits at the right edge of the thread, so its popup is capped at the space left there and hard-clips what overflows — "Version 1 · 49 minutes ago" lost its tail. The menu now uses the compact age ("49m"), which is what the app uses elsewhere in tight rows. The file row keeps the long form and its tooltip. Generated-By: PostHog Code Task-Id: 57deec6a-7831-4922-a4ef-6d0c9b86831b --- .../sessions/components/CloudArtifactDownloads.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 0164187f49e1..3506269924b4 100644 --- a/products/desktop/packages/ui/src/features/sessions/components/CloudArtifactDownloads.tsx +++ b/products/desktop/packages/ui/src/features/sessions/components/CloudArtifactDownloads.tsx @@ -24,7 +24,7 @@ import { DropdownMenuTrigger, Text, } from "@posthog/quill"; -import { formatRelativeTimeLong, type TaskRunArtifact } from "@posthog/shared"; +import { formatRelativeTimeShort, type TaskRunArtifact } from "@posthog/shared"; import { isTerminalStatus, type Task } from "@posthog/shared/domain-types"; import { getAuthIdentity, @@ -46,6 +46,10 @@ 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, @@ -53,7 +57,7 @@ function versionMenuLabel( ): string { const label = runArtifactVersionLabel(index, total); return artifact.uploaded_at - ? `${label} · ${formatRelativeTimeLong(artifact.uploaded_at)}` + ? `${label} · ${formatRelativeTimeShort(artifact.uploaded_at)}` : label; } From 2229b036adde2cf60247ba226c94ec5db0656a38 Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Fri, 7 Aug 2026 11:38:45 -0400 Subject: [PATCH 6/6] fix(desktop): let the picker reach a version with no id The picker stored the chosen version's id, falling back to an empty string, then looked the selection up by id. A manifest entry carries an id only once its upload is finalized, so for an entry without one the lookup compared undefined against that empty string, missed, and fell back to the newest version. Picking such a version did nothing. Selection now travels as the same version key the rows already render by, which every entry has. TaskRunArtifactResponse gains dismissed_at, so the artifact DTO no longer carries a parallel declaration of a field the response type should describe on its own. Generated-By: PostHog Code Task-Id: 57deec6a-7831-4922-a4ef-6d0c9b86831b --- .../packages/api-client/src/generated.ts | 1 + .../api-client/src/task-normalization.ts | 1 - .../CloudArtifactDownloads.test.tsx | 30 +++++++++++++++++++ .../components/CloudArtifactDownloads.tsx | 5 ++-- 4 files changed, 34 insertions(+), 3 deletions(-) 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/task-normalization.ts b/products/desktop/packages/api-client/src/task-normalization.ts index a69406af6378..b223b4c3cfde 100644 --- a/products/desktop/packages/api-client/src/task-normalization.ts +++ b/products/desktop/packages/api-client/src/task-normalization.ts @@ -10,7 +10,6 @@ import type { Schemas } from "./generated"; export type TaskRunArtifactDTO = Schemas.TaskRunArtifactResponse & { metadata?: unknown; - dismissed_at?: string | null; }; type TaskRunResponseDTO = Partial< 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 3e06d9ea9855..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 @@ -188,6 +188,36 @@ describe("CloudArtifactDownloads", () => { }); }); + // 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 () => { 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 3506269924b4..ca671edadb82 100644 --- a/products/desktop/packages/ui/src/features/sessions/components/CloudArtifactDownloads.tsx +++ b/products/desktop/packages/ui/src/features/sessions/components/CloudArtifactDownloads.tsx @@ -208,7 +208,8 @@ export function CloudArtifactDownloads({ const renderRow = (group: ArtifactGroup) => { const selectedIndex = Math.max( group.versions.findIndex( - (version) => version.id === selectedVersionByName[group.name], + (version) => + runArtifactVersionKey(version) === selectedVersionByName[group.name], ), 0, ); @@ -265,7 +266,7 @@ export function CloudArtifactDownloads({ onClick={() => setSelectedVersionByName((current) => ({ ...current, - [group.name]: version.id ?? "", + [group.name]: runArtifactVersionKey(version), })) } >