feat(desktop): version, dismiss, and timestamp uploaded run files - #78644
Conversation
|
😎 Stack merged successfully - details. |
|
Hey @adboio! 👋 It looks like your git author email on this PR isn't your
You can fix it for this repo with: git config user.email "you@posthog.com"Or set it globally with |
|
React Doctor found 3 issues in 1 file · 3 warnings. 3 warnings
Reviewed by React Doctor for commit |
🤖 CI report
|
| 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 |
d5f878f to
43d0562
Compare
43d0562 to
b89df74
Compare
b89df74 to
727b27b
Compare
🦔 ReviewHog reviewed this pull requestFound 0 must fix, 2 should fix, 0 consider. Published 2 findings (view the review). |
|
ReviewHog Alpha 🦔 If you find any issues helpful - please reply "valid", "invalid", etc., for evaluation purposes 🙏 |
There was a problem hiding this comment.
ReviewHog Report
Frontend
Issues: 2 issues
Files (2)
products/desktop/packages/ui/src/features/canvas/components/TaskArtifactsList.tsxproducts/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
| const [selectedIndex, setSelectedIndex] = useState(0); | ||
| const selected = group.versions[selectedIndex] ?? group.latest; | ||
| const canOpen = !!selected.id; |
There was a problem hiding this comment.
Version picker selection is tracked by array index, so it silently points at a different artifact once the poll reorders versions
Why we think it's a valid issue
- Checked: The new
FileRowfrom the PR diff (useState(0),selected = group.versions[selectedIndex] ?? group.latest, dropdownsetSelectedIndex(index)), the render keyfile:${group.name}, thegroupRunArtifactVersionssort,useTaskRunspolling, and the siblingCloudArtifactDownloads.tsxselection logic. - Found:
useTaskRunssetsrefetchInterval: 30_000unconditionally (useTaskRuns.ts:4,16).groupRunArtifactVersionssortsversionsnewest-first (localeComparedescending onuploaded_at), so a fresh upload is unshifted to index 0 and every older version shifts one slot right. TheFileRowinstance is keyed byfile:${group.name}, stable across the reorder, so the persistedselectedIndexnow resolves to a different artifact —onOpen/download act ongroup.versions[selectedIndex].idfor the wrong version. - Found (oversight confirmed):
CloudArtifactDownloads.tsxtracks selection by identity (selectedVersionByName, re-derived viafindIndex(v => v.id === ...)), so a reorder there is harmless; onlyTaskArtifactsListregressed to positional state within the same PR/helper. - Found (severity bounds): react-query structural sharing keeps
runsreference 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>
There was a problem hiding this comment.
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.
| 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, |
There was a problem hiding this comment.
Dismissing/restoring a file mid-run permanently freezes the artifact list, hiding files uploaded afterward
Why we think it's a valid issue
- Checked: The
useQueryconfig (enabledgated onisTerminalStatus,staleTime: Infinity), thegroups??chain order (fetchedArtifacts ?? sessionArtifacts ?? task?.latest_run?.artifacts ?? []), thedismissalonSuccess: setQueryData(artifactsQueryKey, manifest), the button gating (disabled={dismissal.isPending}only), the render guard (if (!runId || groups.length === 0) return null), and howsessionArtifacts(session.cloudArtifacts) stays live. - Found (live store drives mid-run):
refreshCloudRunStatuspolls the run and writescloudArtifacts: run.artifacts ?? []into the session store (sessionService.ts:4812); mid-run the query isenabled:falsesofetchedArtifactsisundefinedandsessionArtifactsdrives the box, reflecting new uploads. - Found (freeze is real): react-query cache reads are independent of
enabled, sosetQueryDataimmediately makesfetchedArtifactsdefined. WithstaleTime: Infinityand 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 sharedqueryClientit also survives a remount on the sametaskId/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.pdfv1 mid-run then having the agent upload v2 ornotes.mdleaves those only in the now-maskedsessionArtifacts, 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
useTaskRunspath, 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>
There was a problem hiding this comment.
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.
fac72d4 to
31c7924
Compare
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 |
| export type TaskRunArtifactDTO = Schemas.TaskRunArtifactResponse & { | ||
| metadata?: unknown; | ||
| dismissed_at?: string | null; | ||
| }; |
There was a problem hiding this 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)
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!
There was a problem hiding this comment.
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.
| onClick={() => | ||
| setSelectedVersionByName((current) => ({ | ||
| ...current, | ||
| [group.name]: version.id ?? "", | ||
| })) |
There was a problem hiding this comment.
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]
)| onClick={() => | |
| setSelectedVersionByName((current) => ({ | |
| ...current, | |
| [group.name]: version.id ?? "", | |
| })) | |
| onClick={() => | |
| setSelectedVersionByName((current) => ({ | |
| ...current, | |
| [group.name]: runArtifactVersionKey(version), | |
| })) | |
Spotted by Graphite
Is this helpful? React 👍 or 👎 to let us know.
There was a problem hiding this comment.
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.
4616aec to
6a5a284
Compare
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
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
6a5a284 to
f3d40f5
Compare
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
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.pdfthree times, with nothing marking which one was current, no upload times, and no way to remove one.Changes
upload_artifacttool call completes, instead of fetching once when the run turns terminal.upload_artifactdescription 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.
upload_artifactcalls, ignoring failures and repeats.Automatic notifications
Docs update
products/desktop/docs/cloud-task-artifacts.mdgains 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
reviewViewedStoreneeds 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
buildRowsintotaskArtifactRows.tsand 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