Skip to content

feat(desktop): version, dismiss, and timestamp uploaded run files - #78644

Merged
trunk-io[bot] merged 6 commits into
masterfrom
posthog-code/task-file-versions-and-dismissal
Aug 7, 2026
Merged

feat(desktop): version, dismiss, and timestamp uploaded run files#78644
trunk-io[bot] merged 6 commits into
masterfrom
posthog-code/task-file-versions-and-dismissal

Conversation

@adboio

@adboio adboio commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Problem

A cloud agent that revises a deliverable uploads the file again, and the chat's Files box listed every copy. A task showed report.pdf three times, with nothing marking which one was current, no upload times, and no way to remove one.

Changes

  • Re-uploading a name replaces the file. The box groups entries by name, shows the newest as the row, and puts earlier ones behind a "Latest / Version 2 / …" picker. Picking a version retargets open and download.
  • A file can be dismissed. The row's × hides every version at once, so dismissing the current one cannot resurface the copy it replaced. Dismissed files collapse behind a "Show N dismissed" toggle with Restore.
  • Rows show when the file arrived, with the exact time on hover.
  • A file delivered near the end of a run appears without a refresh. The box rereads the manifest when an upload_artifact tool call completes, instead of fetching once when the run turns terminal.
  • The canvas artifacts pane hides a file once every version of it is dismissed. It still shows only the newest upload and has no picker.
  • The upload_artifact description tells agents that re-uploading a name revises a delivered file.

Note

The artifact query now runs for the whole life of a run, not only after it finishes. A 30s poll backs up the tool-call trigger while a run is live.

No backend change here. #78654 added the dismiss endpoint and is merged.

How did you test this code?

I (actually Claude) wrote this. Adam ran it from the desktop app against production, which is how the fourth and fifth bullets above were found and fixed.

No screenshot in this body: I have no display in the sandbox, and the controls only render on a run that uploaded files. Unverified by eye: the dismissed-files toggle and the Restore button.

  • Core tests cover the grouping rule: newest-first per name, and a file counting as dismissed only when every version of it is.
  • Component tests cover a repeated name rendering one row on the newest upload, dismissing sending every version id, a dismissed file staying hidden until the toggle, and later uploads still appearing after a mid-run dismissal.
  • A tracker test covers counting completed upload_artifact calls, ignoring failures and repeats.

Automatic notifications

  • Publish to changelog?

Docs update

products/desktop/docs/cloud-task-artifacts.md gains a section on versions and dismissal.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Built with PostHog Code (Claude). Skills invoked: /improving-drf-endpoints, /implementing-mcp-tools, /writing-tests, /stacking-prs, /writing-pr-descriptions.

Dismissal lives on the run manifest rather than in local UI state. A persisted zustand store like reviewViewedStore needs no migration or codegen, but would not follow a user across devices.

Three PRs landed on these files while this one was open. #78681 made the Files box collapsible, #78757 added view and download actions, and #78690 moved buildRows into taskArtifactRows.ts and anchored comments to artifact ids. This PR takes the canvas pane as those left it and adds only the dismissal filter, so the version picker lives in the chat's Files box alone.

ReviewHog found two real bugs here. A version pick tracked by array position could shift onto a different file when a concurrent upload reordered the group. A dismissal wrote the whole manifest into a query cache that then outranked the live session store, hiding every file uploaded afterwards. Both are fixed with regression tests.


Created with PostHog Code

@adboio adboio self-assigned this Aug 6, 2026
@trunk-io

trunk-io Bot commented Aug 6, 2026

Copy link
Copy Markdown

😎 Stack merged successfully - details.

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Hey @adboio! 👋

It looks like your git author email on this PR isn't your @posthog.com address (adambowker98@gmail.com). Since you're on the PostHog team, it's worth pointing your local git author email at your @posthog.com address. Why it matters:

  • Consistent work identity in git history — internal tooling that attributes commits to team members keys off your @posthog.com address.
  • Keeps team contributions easy to tell apart from external community ones when scanning history.

You can fix it for this repo with:

git config user.email "you@posthog.com"

Or set it globally with git config --global user.email "you@posthog.com". No need to redo this PR — just a nudge for next time. 🙂

@github-actions github-actions Bot added the feature/desktop Feature Tag: Desktop label Aug 6, 2026
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

React Doctor found 3 issues in 1 file · 3 warnings.

3 warnings

packages/ui/src/features/sessions/components/CloudArtifactDownloads.tsx

Reviewed by React Doctor for commit 2229b03.

@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

🤖 CI report

⚠️ Bundle size — 🔺 +3.5 KiB (+0.0%)

Uncompressed size of every built .js bundle, compared against the base branch.

Total: 65.60 MiB · 🔺 +3.5 KiB (+0.0%)

No file changed by more than 1000 B.

Posted automatically by build-bundle-size-report · uncompressed bytes from dist-report

Eager graph — within budget

How much code each root ships on the eager path — downloaded and parsed before the surface is interactive. Measured from the esbuild output chunks (post-tree-shake, static imports only); lazy import() / React.lazy chunks are not counted.

Root Eager (shipped) Δ vs base Budget
entry (logged-out pages, app bootstrap)
src/index.tsx
1.26 MiB · 22 files no change ███░░░░░░░ 27.9% of 4.51 MiB
authenticated shell (every logged-in page)
src/scenes/AuthenticatedShell.tsx
8.18 MiB · 3,042 files 🔺 +610 B (+0.0%) ████████░░ 84.2% of 9.71 MiB

🟢 node_modules/monaco-editor/ stays out of src/index.tsx
🟢 src/lib/components/ActivityLog/describers stays out of src/index.tsx
🟢 [object Object] stays out of src/index.tsx
🟢 [object Object] stays out of src/index.tsx
🟢 node_modules/monaco-editor/ stays out of src/scenes/AuthenticatedShell.tsx
🟢 src/lib/components/ActivityLog/describers stays out of src/scenes/AuthenticatedShell.tsx
🟢 [object Object] stays out of src/scenes/AuthenticatedShell.tsx
🟢 [object Object] stays out of src/scenes/AuthenticatedShell.tsx

Largest files eagerly shipped from src/index.tsx
Size File
126.8 KiB ../node_modules/.pnpm/react-dom@18.3.1_react@18.3.1/node_modules/react-dom/cjs/react-dom.production.min.js
24.6 KiB ../node_modules/.pnpm/buffer@6.0.3/node_modules/buffer/index.js
6.3 KiB ../node_modules/.pnpm/react@18.3.1/node_modules/react/cjs/react.production.min.js
4.5 KiB ../node_modules/.pnpm/@jspm+core@2.1.0/node_modules/@jspm/core/nodelibs/browser/process.js
3.9 KiB ../node_modules/.pnpm/scheduler@0.23.2/node_modules/scheduler/cjs/scheduler.production.min.js
1.4 KiB ../node_modules/.pnpm/base64-js@1.5.1/node_modules/base64-js/index.js
1.3 KiB src/RootErrorBoundary.tsx
912 B ../node_modules/.pnpm/ieee754@1.2.1/node_modules/ieee754/index.js
789 B src/scenes/ChunkLoadErrorBoundary.tsx
762 B src/index.tsx
Largest files eagerly shipped from src/scenes/AuthenticatedShell.tsx
Size File
285.5 KiB ../node_modules/.pnpm/posthog-js@1.410.1/node_modules/posthog-js/dist/rrweb.js
267.7 KiB ../node_modules/.pnpm/@posthog+icons@0.38.0_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@posthog/icons/dist/posthog-icons.es.js
237.5 KiB src/taxonomy/core-filter-definitions-by-group.json
231.5 KiB ../node_modules/.pnpm/posthog-js@1.410.1/node_modules/posthog-js/dist/module.js
154.3 KiB ../node_modules/.pnpm/re2js@0.4.1/node_modules/re2js/build/index.esm.js
126.8 KiB ../node_modules/.pnpm/react-dom@18.3.1_react@18.3.1/node_modules/react-dom/cjs/react-dom.production.min.js
105.0 KiB src/lib/api.ts
94.7 KiB ../packages/quill/packages/quill/dist/index.js
93.3 KiB ../node_modules/.pnpm/prosemirror-view@1.40.1/node_modules/prosemirror-view/dist/index.js
90.6 KiB ../node_modules/.pnpm/@tiptap+core@3.20.6_@tiptap+pm@3.20.6/node_modules/@tiptap/core/dist/index.js

Posted automatically by check-eager-graph · sizes are eager output bytes (shipped, post-tree-shake) from the esbuild metafile · part of #32479

Toolbar bundle — eager 2.20 MiB within budget

What the toolbar ships to customer pages, measured from the esbuild output (minified, post-tree-shake). The eager set is the entry plus everything statically imported from it — fetched before any feature runs; deferred chunks load lazily. The eager guardrail is 5.72 MiB. Each output file must also stay below 10 MB, where CloudFront stops compressing it. The module boundary is enforced separately by check-toolbar-graph.

Metric Size Δ vs base Budget
Eager (shipped)
entry + static imports
2.20 MiB · 17 files no change ████░░░░░░ 38.4% of 5.72 MiB
Deferred (lazy) 2.08 MiB · 33 files no change n/a — loads on demand
Loader dist/toolbar.js 1.1 KiB no change █░░░░░░░░░ 5.8% of 19.5 KiB
Largest eagerly-shipped chunks
Size File
721.9 KiB dist/toolbar/toolbar-app-EFK53Z4U.css
552.0 KiB dist/toolbar/chunk-chunk-RKI77OFN.js
484.6 KiB dist/toolbar/chunk-chunk-BTPSXDW3.js
133.6 KiB dist/toolbar/chunk-chunk-VQYFWLUH.js
131.8 KiB dist/toolbar/chunk-chunk-T5KY5WYR.js
71.0 KiB dist/toolbar/toolbar-app-BKQSZS7N.js
69.0 KiB dist/toolbar/chunk-chunk-27JL52RE.js
35.6 KiB dist/toolbar/chunk-chunk-JP2XV73C.js
20.9 KiB dist/toolbar/chunk-chunk-IW6IKQIA.js
12.2 KiB dist/toolbar/chunk-chunk-PIK3PADE.js

Posted automatically by check-toolbar-size · sizes are toolbar output bytes (shipped, post-tree-shake) from the esbuild metafile

Dist folder size — 🔺 +30.1 KiB (+0.0%)

Total size of the built frontend/dist folder (all assets), compared against the base branch.

Total: 1394.02 MiB · 🔺 +30.1 KiB (+0.0%)

ℹ️ MCP UI apps size — 32 app(s), 17071.7 KB JS

Built size of each MCP UI app (main.js + styles.css).

App JS CSS
debug 599.6 KB 187.7 KB
action 457.8 KB 187.7 KB
action-list 564.4 KB 187.7 KB
cohort 456.8 KB 187.7 KB
cohort-list 563.4 KB 187.7 KB
email-template 456.6 KB 187.7 KB
error-details 472.4 KB 187.7 KB
error-issue 457.5 KB 187.7 KB
error-issue-list 564.3 KB 187.7 KB
experiment 561.5 KB 187.7 KB
experiment-list 565.2 KB 187.7 KB
experiment-results 563.2 KB 187.7 KB
feature-flag 567.2 KB 187.7 KB
feature-flag-list 570.9 KB 187.7 KB
feature-flag-testing 461.0 KB 187.7 KB
insight-actors 562.2 KB 187.7 KB
invite-email-preview 456.0 KB 187.7 KB
llm-costs 559.5 KB 187.7 KB
session-recording 458.6 KB 187.7 KB
session-summary 463.9 KB 187.7 KB
survey 458.4 KB 187.7 KB
survey-global-stats 562.2 KB 187.7 KB
survey-list 565.1 KB 187.7 KB
survey-stats 562.2 KB 187.7 KB
trace-span 457.2 KB 187.7 KB
trace-span-list 564.3 KB 187.7 KB
workflow 457.1 KB 187.7 KB
workflow-list 563.7 KB 187.7 KB
loops-review 461.4 KB 187.7 KB
query-results 748.0 KB 187.7 KB
render-ui 828.6 KB 187.7 KB
visual-review-snapshots 461.6 KB 187.7 KB

@trunk-io

trunk-io Bot commented Aug 6, 2026

Copy link
Copy Markdown

Static BadgeStatic BadgeStatic Badge

View Full Report ↗︎Docs

@adboio
adboio force-pushed the posthog-code/task-file-versions-and-dismissal branch from d5f878f to 43d0562 Compare August 6, 2026 01:31
@adboio adboio changed the title feat(tasks): version, dismiss, and timestamp uploaded run files feat(desktop): version, dismiss, and timestamp uploaded run files Aug 6, 2026
@adboio
adboio changed the base branch from master to posthog-code/task-artifact-dismissal-api August 6, 2026 01:32
@adboio
adboio force-pushed the posthog-code/task-file-versions-and-dismissal branch from 43d0562 to b89df74 Compare August 6, 2026 02:21
@adboio
adboio force-pushed the posthog-code/task-file-versions-and-dismissal branch from b89df74 to 727b27b Compare August 6, 2026 11:31
@adboio adboio added the reviewhog ($$$) Reviews pull requests before humans do label Aug 6, 2026
@posthog

posthog Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

🦔 ReviewHog reviewed this pull request

Found 0 must fix, 2 should fix, 0 consider.

Published 2 findings (view the review).

@posthog

posthog Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

ReviewHog Alpha 🦔 If you find any issues helpful - please reply "valid", "invalid", etc., for evaluation purposes 🙏

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ReviewHog Report

Frontend

Issues: 2 issues

Files (2)
  • products/desktop/packages/ui/src/features/canvas/components/TaskArtifactsList.tsx
  • products/desktop/packages/ui/src/features/sessions/components/CloudArtifactDownloads.tsx
What were the main changes
  • Canvas artifacts pane (TaskArtifactsList.tsx) now uses the shared groupRunArtifactVersions helper instead of manually picking the newest same-name upload, and adds a version-picker dropdown to retarget the row's open/download plus a relative upload timestamp in the detail line
  • Session Files box (CloudArtifactDownloads.tsx) groups repeated uploads into versioned rows with a version dropdown, adds dismiss/restore buttons wired to a new useMutation calling setCloudRunArtifactsDismissed for every version id at once, collapses dismissed files behind a 'Show N dismissed' toggle, shows a relative timestamp per row, and updates the header count to reflect grouped/undismissed files

Comment on lines +244 to +246
const [selectedIndex, setSelectedIndex] = useState(0);
const selected = group.versions[selectedIndex] ?? group.latest;
const canOpen = !!selected.id;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Version picker selection is tracked by array index, so it silently points at a different artifact once the poll reorders versions

should_fix bug

Why we think it's a valid issue
  • Checked: The new FileRow from the PR diff (useState(0), selected = group.versions[selectedIndex] ?? group.latest, dropdown setSelectedIndex(index)), the render key file:${group.name}, the groupRunArtifactVersions sort, useTaskRuns polling, and the sibling CloudArtifactDownloads.tsx selection logic.
  • Found: useTaskRuns sets refetchInterval: 30_000 unconditionally (useTaskRuns.ts:4,16). groupRunArtifactVersions sorts versions newest-first (localeCompare descending on uploaded_at), so a fresh upload is unshifted to index 0 and every older version shifts one slot right. The FileRow instance is keyed by file:${group.name}, stable across the reorder, so the persisted selectedIndex now resolves to a different artifact — onOpen/download act on group.versions[selectedIndex].id for the wrong version.
  • Found (oversight confirmed): CloudArtifactDownloads.tsx tracks selection by identity (selectedVersionByName, re-derived via findIndex(v => v.id === ...)), so a reorder there is harmless; only TaskArtifactsList regressed to positional state within the same PR/helper.
  • Found (severity bounds): react-query structural sharing keeps runs reference stable unless a genuine new upload arrives, so the reorder fires only on a real new version, not every poll. Trigger needs a >=2-version file, a user-picked non-latest version, and a new same-name upload landing (<=30s) while the pane is open. The label button re-renders ('Version 1' -> 'Version 2'), a partial cue, and the state is recoverable by re-picking; no data loss.
  • Impact: Real correctness bug — after a concurrent upload the row silently opens/downloads a different version than the user chose, reachable in normal active-task use (agents re-uploading revisions is the feature's premise). Clears the bar to keep.
  • Priority: Lowered must_fix -> should_fix: impact is bounded and recoverable (wrong version shown, not lost/corrupted), the trigger is a specific timing race, and the changing label gives a partial signal.
Issue description

FileRow keeps the chosen version as a raw position (useState(0) at line 244) and reads group.versions[selectedIndex] (line 245) each render. group is rebuilt every time buildRows recomputes (line 289's useMemo depends on runs, which useTaskRuns polls every 30s — see products/desktop/packages/ui/src/features/canvas/hooks/useTaskRuns.ts). groupRunArtifactVersions orders each group's versions newest-first, so when a same-named file is re-uploaded while the panel is open, the new version is unshifted to index 0 and every older version shifts one slot to the right. The FileRow instance persists across this re-render (its key at line 351 is file:${group.name}, unaffected by the reorder), so selectedIndex is not reset and now resolves to a different actual artifact than the one the user explicitly picked from the dropdown at lines 270-300 — e.g. a user viewing 'Version 1' can have the row silently start pointing at what was 'Version 2' as soon as a newer revision lands, with no indication anything changed. Opening or downloading from that row would then act on the wrong file version.
By contrast, CloudArtifactDownloads.tsx (same PR, same helper) tracks selection by artifact identity (selectedVersionByName), not position — this file is the one place in the chunk that regresses to index-based state.

Suggested fix

Track the selection by a stable identifier that survives reordering instead of a positional index — e.g. store the selected runArtifactVersionKey(version) (already imported and used for the item key) or the artifact id, and look it up in group.versions each render, falling back to group.latest/index 0 when the previously selected identifier is no longer present. This mirrors the approach already used in CloudArtifactDownloads.tsx's selectedVersionByName.

Prompt to fix with AI (copy-paste)
## Context
@products/desktop/packages/ui/src/features/canvas/components/TaskArtifactsList.tsx#L244-246
@products/desktop/packages/ui/src/features/canvas/components/TaskArtifactsList.tsx#L270-300

<issue_description>
`FileRow` keeps the chosen version as a raw position (`useState(0)` at line 244) and reads `group.versions[selectedIndex]` (line 245) each render. `group` is rebuilt every time `buildRows` recomputes (line 289's `useMemo` depends on `runs`, which `useTaskRuns` polls every 30s — see `products/desktop/packages/ui/src/features/canvas/hooks/useTaskRuns.ts`). `groupRunArtifactVersions` orders each group's `versions` newest-first, so when a same-named file is re-uploaded while the panel is open, the new version is unshifted to index 0 and every older version shifts one slot to the right. The `FileRow` instance persists across this re-render (its `key` at line 351 is `file:${group.name}`, unaffected by the reorder), so `selectedIndex` is not reset and now resolves to a *different* actual artifact than the one the user explicitly picked from the dropdown at lines 270-300 — e.g. a user viewing 'Version 1' can have the row silently start pointing at what was 'Version 2' as soon as a newer revision lands, with no indication anything changed. Opening or downloading from that row would then act on the wrong file version.
By contrast, `CloudArtifactDownloads.tsx` (same PR, same helper) tracks selection by artifact identity (`selectedVersionByName`), not position — this file is the one place in the chunk that regresses to index-based state.
</issue_description>

<issue_validation>
- **Checked:** The new `FileRow` from the PR diff (`useState(0)`, `selected = group.versions[selectedIndex] ?? group.latest`, dropdown `setSelectedIndex(index)`), the render key `file:${group.name}`, the `groupRunArtifactVersions` sort, `useTaskRuns` polling, and the sibling `CloudArtifactDownloads.tsx` selection logic.
- **Found:** `useTaskRuns` sets `refetchInterval: 30_000` unconditionally (`useTaskRuns.ts:4,16`). `groupRunArtifactVersions` sorts `versions` newest-first (`localeCompare` descending on `uploaded_at`), so a fresh upload is unshifted to index 0 and every older version shifts one slot right. The `FileRow` instance is keyed by `file:${group.name}`, stable across the reorder, so the persisted `selectedIndex` now resolves to a different artifact — `onOpen`/download act on `group.versions[selectedIndex].id` for the wrong version.
- **Found (oversight confirmed):** `CloudArtifactDownloads.tsx` tracks selection by identity (`selectedVersionByName`, re-derived via `findIndex(v => v.id === ...)`), so a reorder there is harmless; only `TaskArtifactsList` regressed to positional state within the same PR/helper.
- **Found (severity bounds):** react-query structural sharing keeps `runs` reference stable unless a genuine new upload arrives, so the reorder fires only on a real new version, not every poll. Trigger needs a >=2-version file, a user-picked non-latest version, and a new same-name upload landing (<=30s) while the pane is open. The label button re-renders ('Version 1' -> 'Version 2'), a partial cue, and the state is recoverable by re-picking; no data loss.
- **Impact:** Real correctness bug — after a concurrent upload the row silently opens/downloads a different version than the user chose, reachable in normal active-task use (agents re-uploading revisions is the feature's premise). Clears the bar to keep.
- **Priority:** Lowered must_fix -> should_fix: impact is bounded and recoverable (wrong version shown, not lost/corrupted), the trigger is a specific timing race, and the changing label gives a partial signal.
</issue_validation>

## Task
Investigate the issue and solve it

<potential_solution>
Track the selection by a stable identifier that survives reordering instead of a positional index — e.g. store the selected `runArtifactVersionKey(version)` (already imported and used for the item `key`) or the artifact id, and look it up in `group.versions` each render, falling back to `group.latest`/index 0 when the previously selected identifier is no longer present. This mirrors the approach already used in `CloudArtifactDownloads.tsx`'s `selectedVersionByName`.
</potential_solution>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

valid — fixed in 696e0a7. FileRow now holds the selection as runArtifactVersionKey(version) and resolves it against group.versions each render, falling back to index 0 when the key is gone, which is what CloudArtifactDownloads was already doing. You were right that this file was the one place in the change that regressed to positional state.

Added a test that picks a non-latest version, then rerenders with a newer same-name upload appended, and asserts the row still opens the originally picked artifact. It fails on the index-based version.

Comment on lines +49 to 60
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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dismissing/restoring a file mid-run permanently freezes the artifact list, hiding files uploaded afterward

should_fix bug

Why we think it's a valid issue
  • Checked: The useQuery config (enabled gated on isTerminalStatus, staleTime: Infinity), the groups ?? chain order (fetchedArtifacts ?? sessionArtifacts ?? task?.latest_run?.artifacts ?? []), the dismissal onSuccess: setQueryData(artifactsQueryKey, manifest), the button gating (disabled={dismissal.isPending} only), the render guard (if (!runId || groups.length === 0) return null), and how sessionArtifacts (session.cloudArtifacts) stays live.
  • Found (live store drives mid-run): refreshCloudRunStatus polls the run and writes cloudArtifacts: run.artifacts ?? [] into the session store (sessionService.ts:4812); mid-run the query is enabled:false so fetchedArtifacts is undefined and sessionArtifacts drives the box, reflecting new uploads.
  • Found (freeze is real): react-query cache reads are independent of enabled, so setQueryData immediately makes fetchedArtifacts defined. With staleTime: Infinity and the query disabled until terminal, nothing refetches to reconcile, so the dismiss-time snapshot wins the ?? chain for the rest of the run; being in the shared queryClient it also survives a remount on the same taskId/runId, and fresh-forever data means no auto-refetch even after the run turns terminal.
  • Found (reachable, conflicts with feature premise): Dismiss/restore render whenever a run id and >=1 output artifact exist, including mid-run. The PR's core use case is agents re-uploading revisions; dismissing report.pdf v1 mid-run then having the agent upload v2 or notes.md leaves those only in the now-masked sessionArtifacts, permanently invisible.
  • Impact: After a mid-run dismiss/restore, later uploads silently vanish from the Files box for the rest of the run and across remounts — a real reliability defect from the new mutation. Not data loss (files persist on backend, still shown by the canvas pane's separate useTaskRuns path, recover on fresh cache) and it needs the specific mid-run-dismiss-then-upload sequence, so should_fix stands.
Issue description

groups/artifacts are computed from fetchedArtifacts ?? sessionArtifacts ?? task?.latest_run?.artifacts ?? []. fetchedArtifacts comes from a useQuery that is only enabled once the run is terminal, with staleTime: Infinity, precisely so that while a run is still in progress the live sessionArtifacts store (updated as the agent uploads more files) is what drives the UI. The new dismissal mutation's onSuccess calls queryClient.setQueryData(artifactsQueryKey, manifest) unconditionally, even though the dismiss/restore buttons are rendered for every row regardless of run status (only dismissal.isPending gates them, not isTerminalStatus). The comment on lines 128-129 explains why the cache is written to (so the row updates immediately without waiting for the disabled query to run), but it doesn't address the side effect: once that setQueryData call runs, fetchedArtifacts becomes a defined array and the ?? chain will prefer it over sessionArtifacts for the rest of the component's life — and because staleTime: Infinity and the query stays enabled: false until terminal, nothing ever triggers a refetch to reconcile it. Since the cache lives in the shared queryClient, this even survives a remount of the component with the same taskId/runId.

Suggested fix

Concrete scenario: task is still running, agent has uploaded report.pdf v1; user dismisses it via the new (X) button. onSuccess writes that snapshot into the cloudRunArtifacts query cache. The agent then uploads report.pdf v2 and notes.md — both land in sessionArtifacts but are now permanently invisible in this box because fetchedArtifacts (frozen at dismiss time) wins the ?? chain and is never invalidated. Either gate the dismiss/restore actions behind isTerminalStatus (matching the query's own enabled condition), or merge the mutation's returned manifest into sessionArtifacts-driven state rather than writing straight into a staleTime: Infinity, conditionally-disabled query cache, or explicitly invalidate/refetch once the run becomes terminal.

Prompt to fix with AI (copy-paste)
## Context
@products/desktop/packages/ui/src/features/sessions/components/CloudArtifactDownloads.tsx#L49-60
@products/desktop/packages/ui/src/features/sessions/components/CloudArtifactDownloads.tsx#L114-133

<issue_description>
`groups`/`artifacts` are computed from `fetchedArtifacts ?? sessionArtifacts ?? task?.latest_run?.artifacts ?? []`. `fetchedArtifacts` comes from a `useQuery` that is only `enabled` once the run is terminal, with `staleTime: Infinity`, precisely so that while a run is still in progress the live `sessionArtifacts` store (updated as the agent uploads more files) is what drives the UI. The new `dismissal` mutation's `onSuccess` calls `queryClient.setQueryData(artifactsQueryKey, manifest)` unconditionally, even though the dismiss/restore buttons are rendered for every row regardless of run status (only `dismissal.isPending` gates them, not `isTerminalStatus`). The comment on lines 128-129 explains why the cache is written to (so the row updates immediately without waiting for the disabled query to run), but it doesn't address the side effect: once that `setQueryData` call runs, `fetchedArtifacts` becomes a defined array and the `??` chain will prefer it over `sessionArtifacts` for the rest of the component's life — and because `staleTime: Infinity` and the query stays `enabled: false` until terminal, nothing ever triggers a refetch to reconcile it. Since the cache lives in the shared `queryClient`, this even survives a remount of the component with the same `taskId`/`runId`.
</issue_description>

<issue_validation>
- **Checked:** The `useQuery` config (`enabled` gated on `isTerminalStatus`, `staleTime: Infinity`), the `groups` `??` chain order (`fetchedArtifacts ?? sessionArtifacts ?? task?.latest_run?.artifacts ?? []`), the `dismissal` `onSuccess: setQueryData(artifactsQueryKey, manifest)`, the button gating (`disabled={dismissal.isPending}` only), the render guard (`if (!runId || groups.length === 0) return null`), and how `sessionArtifacts` (`session.cloudArtifacts`) stays live.
- **Found (live store drives mid-run):** `refreshCloudRunStatus` polls the run and writes `cloudArtifacts: run.artifacts ?? []` into the session store (`sessionService.ts:4812`); mid-run the query is `enabled:false` so `fetchedArtifacts` is `undefined` and `sessionArtifacts` drives the box, reflecting new uploads.
- **Found (freeze is real):** react-query cache reads are independent of `enabled`, so `setQueryData` immediately makes `fetchedArtifacts` defined. With `staleTime: Infinity` and the query disabled until terminal, nothing refetches to reconcile, so the dismiss-time snapshot wins the `??` chain for the rest of the run; being in the shared `queryClient` it also survives a remount on the same `taskId`/`runId`, and fresh-forever data means no auto-refetch even after the run turns terminal.
- **Found (reachable, conflicts with feature premise):** Dismiss/restore render whenever a run id and >=1 output artifact exist, including mid-run. The PR's core use case is agents re-uploading revisions; dismissing `report.pdf` v1 mid-run then having the agent upload v2 or `notes.md` leaves those only in the now-masked `sessionArtifacts`, permanently invisible.
- **Impact:** After a mid-run dismiss/restore, later uploads silently vanish from the Files box for the rest of the run and across remounts — a real reliability defect from the new mutation. Not data loss (files persist on backend, still shown by the canvas pane's separate `useTaskRuns` path, recover on fresh cache) and it needs the specific mid-run-dismiss-then-upload sequence, so should_fix stands.
</issue_validation>

## Task
Investigate the issue and solve it

<potential_solution>
Concrete scenario: task is still running, agent has uploaded `report.pdf` v1; user dismisses it via the new (X) button. `onSuccess` writes that snapshot into the `cloudRunArtifacts` query cache. The agent then uploads `report.pdf` v2 and `notes.md` — both land in `sessionArtifacts` but are now permanently invisible in this box because `fetchedArtifacts` (frozen at dismiss time) wins the `??` chain and is never invalidated. Either gate the dismiss/restore actions behind `isTerminalStatus` (matching the query's own `enabled` condition), or merge the mutation's returned manifest into `sessionArtifacts`-driven state rather than writing straight into a `staleTime: Infinity`, conditionally-disabled query cache, or explicitly invalidate/refetch once the run becomes terminal.
</potential_solution>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

valid — fixed in 696e0a7. This was the more serious of the two and the analysis was exactly right, including that the shared queryClient made it survive a remount.

Took the third of your suggested options rather than gating on isTerminalStatus, since dismissing while an agent is still working seems worth keeping: onSuccess now overlays only the dismissed_at stamps from the returned manifest into local state, and the source chain is left alone, so sessionArtifacts keeps driving the box mid-run and later uploads still appear. useQueryClient is gone from the component.

Regression test drives the exact sequence — query disabled, live store supplies report.pdf, dismiss it, then a rerender adds notes.md — and asserts notes.md shows. It fails when a dismiss-time snapshot outranks the store.

@adboio
adboio force-pushed the posthog-code/task-file-versions-and-dismissal branch from fac72d4 to 31c7924 Compare August 6, 2026 13:40
@adboio
adboio marked this pull request as ready for review August 6, 2026 15:45
@greptile-apps

greptile-apps Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor
Prompt To Fix All With AI
### Issue 1
products/desktop/packages/api-client/src/task-normalization.ts:11-14
**Avoid duplicating generated response fields**

`TaskRunArtifactDTO` manually adds `metadata` and `dismissed_at` to the generated backend response type, allowing the handwritten contract to drift from the serializer-generated schema and requiring duplicate maintenance. Please update and consume the generated response type instead.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "fix(desktop): stop the version menu clip..." | Re-trigger Greptile

Comment on lines +11 to +14
export type TaskRunArtifactDTO = Schemas.TaskRunArtifactResponse & {
metadata?: unknown;
dismissed_at?: string | null;
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Avoid duplicating generated response fields

TaskRunArtifactDTO manually adds metadata and dismissed_at to the generated backend response type, allowing the handwritten contract to drift from the serializer-generated schema and requiring duplicate maintenance. Please update and consume the generated response type instead.

Context Used: docs/published/handbook/engineering/type-system.md (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: products/desktop/packages/api-client/src/task-normalization.ts
Line: 11-14

Comment:
**Avoid duplicating generated response fields**

`TaskRunArtifactDTO` manually adds `metadata` and `dismissed_at` to the generated backend response type, allowing the handwritten contract to drift from the serializer-generated schema and requiring duplicate maintenance. Please update and consume the generated response type instead.

**Context Used:** docs/published/handbook/engineering/type-system.md ([source](https://github.com/posthog/posthog/blob/master/docs/published/handbook/engineering/type-system.md))

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Took the intent in 2229b03, though the mechanism does not apply here.

packages/api-client/src/generated.ts is not generated despite its name. The desktop package keeps a hand-maintained mirror of the schemas it uses, which .agents/skills/posthog-desktop/SKILL.md lists explicitly as the desktop counterpart to hogli build:openapi. It is also deliberately partial: TaskRunArtifactResponse there omits url and metadata, which the serializer does define. So there is no regeneration step to run, and consuming a generated type is not available for this package.

The drift you point at was real all the same. dismissed_at now sits on TaskRunArtifactResponse in that mirror, matching the merged serializer (required=False, no allow_null, so optional and never null), and TaskRunArtifactDTO no longer redeclares it. The remaining metadata?: unknown predates this PR and stays, since its shape is polymorphic across artifact types.

Comment on lines +265 to +269
onClick={() =>
setSelectedVersionByName((current) => ({
...current,
[group.name]: version.id ?? "",
}))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Version selection breaks for artifacts without an ID. When version.id is undefined, this sets selectedVersionByName[group.name] to an empty string "". Later on line 211, the code checks version.id === selectedVersionByName[group.name], which compares undefined === "" and fails to match. This causes findIndex to return -1, defaulting selectedIndex to 0 (the latest version), making it impossible to select versions that lack an ID.

Fix by using a composite key like TaskArtifactsList.tsx does:

const [selectedVersionByName, setSelectedVersionByName] = useState<
  Record<string, string>
>({});

// When selecting:
setSelectedVersionByName((current) => ({
  ...current,
  [group.name]: runArtifactVersionKey(version),
}))

// When finding:
group.versions.findIndex(
  (version) => runArtifactVersionKey(version) === selectedVersionByName[group.name]
)
Suggested change
onClick={() =>
setSelectedVersionByName((current) => ({
...current,
[group.name]: version.id ?? "",
}))
onClick={() =>
setSelectedVersionByName((current) => ({
...current,
[group.name]: runArtifactVersionKey(version),
}))

Spotted by Graphite

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 2229b03, using the composite key you suggested.

Traced it as described: the setter stored version.id ?? "" while the lookup compared version.id, so an entry with no id stored an empty string and then failed its own comparison, findIndex returned -1, and Math.max(-1, 0) silently pinned the row to the newest version. id is z.string().optional() on the manifest schema, and an entry only gains one when its upload is finalized, so this is reachable rather than theoretical.

Both sides now use runArtifactVersionKey(version), which the dropdown items were already using as their React key. Added a regression test with two id-less versions that picks the older one and asserts the row switches to its size; it fails against the id-based comparison.

@puemos
puemos self-requested a review August 6, 2026 16:03
@adboio
adboio force-pushed the posthog-code/task-file-versions-and-dismissal branch from 4616aec to 6a5a284 Compare August 6, 2026 23:23
Base automatically changed from posthog-code/task-artifact-dismissal-api to master August 7, 2026 00:45
adboio added 2 commits August 7, 2026 11:12
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
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
adboio added 3 commits August 7, 2026 11:13
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
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
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
@adboio
adboio force-pushed the posthog-code/task-file-versions-and-dismissal branch from 6a5a284 to f3d40f5 Compare August 7, 2026 15:13
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
@trunk-io
trunk-io Bot merged commit 7f8958e into master Aug 7, 2026
206 checks passed
@trunk-io
trunk-io Bot deleted the posthog-code/task-file-versions-and-dismissal branch August 7, 2026 16:35
@deployment-status-posthog

deployment-status-posthog Bot commented Aug 7, 2026

Copy link
Copy Markdown

Deploy status

Environment Status Deployed At Workflow
dev ✅ Deployed 2026-08-07 17:04 UTC Run
prod-us ✅ Deployed 2026-08-07 17:19 UTC Run
prod-eu ✅ Deployed 2026-08-07 17:21 UTC Run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature/desktop Feature Tag: Desktop reviewhog ($$$) Reviews pull requests before humans do

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants