Skip to content

fix(chat): keep open artifacts in sync with disk, with calm stale-state handling - #179

Merged
badideasforsale merged 6 commits into
mainfrom
update-artifacts
Aug 26, 2026
Merged

fix(chat): keep open artifacts in sync with disk, with calm stale-state handling#179
badideasforsale merged 6 commits into
mainfrom
update-artifacts

Conversation

@matt2e

@matt2e matt2e commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Category: fix
User Impact: Documents and images open in the artifact viewer now track what's actually on disk — external edits, shell writes, and subagent output appear automatically — and when the view genuinely can't match the file, the viewer says exactly why (changed-but-unreadable vs deleted), dims the stale content, and recovers on its own when the file settles.

Problem: The artifact viewer rendered a one-shot snapshot. Files rewritten by shell commands, external editors, or delegated subagents drifted silently out of date, images stayed cached at their old bytes, and there was no signal that the preview no longer matched disk. Early iterations of polling then overcorrected: a single mid-rewrite race or transient I/O error instantly branded the view stale, one message covered every failure mode, and deleted files offered a Reload button that could never succeed.

Solution: The viewer polls the open file's metadata fingerprint (size + mtime + ctime, including the Windows change time) every 1.5s in the foreground and 10s in the background, checking immediately on focus and pausing while hidden. Content swaps use a stat → read → stat-confirm cycle so torn writes are never rendered or judged — a mismatched confirm is treated as "no verdict" and the next cycle retries. Images preload the cache-busted URL off-screen and swap only after decode, so the rendered image never flashes broken. Real failures get a two-strike grace period before the warning shows; a user-initiated Reload bypasses it for an immediate answer, and any successful cycle clears the warning and resets the streak. stat_file now returns a structured error distinguishing a missing file from other failures: deletion shows "File deleted from disk." with no Reload button (polling self-heals if it reappears), other failures show "File changed but can't be read." with Reload, and diverged content dims to 60% opacity beneath the strip.

Product behavior

  • Externally changed text and images refresh in place, preserving scroll and without spinner flashes.
  • One transient failed check changes nothing; two consecutive failures surface the warning strip and dim the content.
  • Mid-rewrite (torn-write) races never flag the view; the next cycle retries the settled file.
  • Deleted files get deletion-specific copy and no Reload; unreadable files keep the Reload action.
  • Reload answers immediately, even on failure; recovery clears the strip and dim automatically.
  • Polling slows to 10s while Berd is unfocused and stops while hidden.

Warning-strip copy is easy to change — happy to bikeshed the exact wording in review.

Verification

  • just check, just test (6,900 passed, 1 skipped), just tauri-check, just tauri-test, just clippy, just tauri-fmt-check
  • Manual verification on macOS (screenshots in comments)

Context: reported and root-caused in BOT-1675.

File changes

src-tauri/src/commands/system.rs
Add the stat_file command returning the file's metadata fingerprint (size, mtime, ctime — via FILE_BASIC_INFO on Windows) off the async command thread, with a structured FileStatError { kind: missing | other, message } so deletion survives the IPC boundary.

src-tauri/Cargo.toml
Adds the Windows filesystem API feature needed to read file change-time metadata.

src-tauri/src/lib.rs
Registers the new stat_file command with the Tauri invoke handler.

src/shared/api/system.ts
Add statFile(), the FileStatPayload/FileStatError types, and the fileStatErrorKind() rejection-narrowing helper.

src/features/chat/ui/ArtifactViewer.tsx
Poll the open artifact with visibility/focus-aware intervals; detect changes by fingerprint with stat → read → stat-confirm torn-write protection; preload-swap images; apply the two-strike grace period with immediate user-reload bypass; dim diverged content; show deleted vs unreadable warning strips.

src/features/chat/ui/tests/ArtifactViewer.test.tsx
Cover polling swap, background/focus timing, ctime-only rewrites, image preload/decode races, the grace period, torn-write healing, deleted vs unreadable copy and Reload visibility, immediate user-reload flagging, and recovery.

src/shared/i18n/locales/en/chat.json / src/shared/i18n/locales/es/chat.json
Add artifactViewer.fileDeleted, fileUnreadable, and reload strings in both locales.

matt2e and others added 5 commits August 24, 2026 16:34
Poll open artifact fingerprints while visible, reload stable text and image changes without flicker, and retain last-good content behind an explicit divergence warning when disk reads fail.

Adapted from Brandon Sherman's format-patch attached to BOT-1675.

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Track cross-platform change times and signed pre-epoch mtimes, serialize forced refreshes against polling, and only accept image fingerprints after the rendered cache-busted source decodes. Cover metadata-preserving rewrites, refresh races, image URL validation, and empty-file recovery.

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Recheck image fingerprints after asynchronous decode before accepting cache-busted content, and run polled metadata inspection on Tokio's blocking pool.

Add focused coverage for decode-time file changes and async metadata execution.

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Poll visible artifacts every ten seconds while Berd is unfocused, check immediately when focus returns, and restore the foreground interval. Cover background timing and focus recovery.

Signed-off-by: Matt Toohey <contact@matttoohey.com>
…dling

**Category:** fix
**User Impact:** The artifact viewer no longer flashes a stale warning
for routine one-off file races, visibly dims content that really is
stale, and tells users when the file was deleted instead of offering a
reload that cannot succeed.

**Problem:** The viewer flagged the view as diverged on the very first
failed poll cycle, including torn writes where the file was simply
mid-rewrite, so agents rewriting files tripped the warning constantly.
The single warning message also conflated "the file changed but can't
be read" with "the file is gone", and offered a pointless Reload button
for deleted files.

**Solution:** Polling failures now get a two-strike grace period: one
failed cycle changes nothing, two consecutive failures flag the view.
Torn-write fingerprint mismatches neither flag nor strike — the next
cycle retries against the settled file. User-initiated reloads bypass
the grace period so the user gets an immediate answer, and any
successful cycle resets the streak. The Rust `stat_file` command now
returns a structured error distinguishing missing files from other
failures, the warning strip shows deletion-specific copy without a
Reload button when the file is gone, and diverged content dims to 60%
opacity so the strip clearly describes it.

## Verification

- `just check`
- `just test` (6,900 passed, 1 skipped)
- `just tauri-check`
- `just tauri-test`
- `just clippy`
- `just tauri-fmt-check`

<details>
<summary>File changes</summary>

**src-tauri/src/commands/system.rs**
Return a structured `FileStatError { kind, message }` from `stat_file`
with a `missing`/`other` kind so deletion survives the IPC boundary;
update the stat tests and pin the serialized discriminant.

**src/shared/api/system.ts**
Add the `FileStatErrorKind`/`FileStatError` types and the
`fileStatErrorKind()` narrowing helper for command rejections.

**src/features/chat/ui/ArtifactViewer.tsx**
Add the two-strike divergence grace period, treat torn-write
fingerprint mismatches as no verdict, flag failed user reloads
immediately, dim diverged content, and split the warning strip into
deleted (no Reload) and unreadable (with Reload) cases.

**src/features/chat/ui/__tests__/ArtifactViewer.test.tsx**
Cover the grace period, torn-write healing, deleted vs unreadable
copy and Reload visibility, immediate user-reload flagging, dimming,
and strike reset on recovery.

**src/shared/i18n/locales/en/chat.json** /
**src/shared/i18n/locales/es/chat.json**
Replace `artifactViewer.diskDiverged` with `fileDeleted` and
`fileUnreadable` in both locales.

</details>

Co-authored-by: goose <goose@block.xyz>
@badideasforsale badideasforsale changed the title fix(chat): refresh artifact previews from disk fix(chat): keep open artifacts in sync with disk, with calm stale-state handling Aug 25, 2026
@badideasforsale

Copy link
Copy Markdown
Contributor

🤖 (posted by Brandon's agent; verification run by the agent, reviewed by Brandon)

Manual verification of this branch (599ec74) on macOS via just dev, exercising every stale-state path against a live artifact:

# Scenario Result
1 Raw shell write to the open file ✅ auto-refreshed within one poll cycle (~1.5s), no flicker, scroll preserved
2 File deleted ✅ "File deleted from disk." strip, no Reload button, last-good content kept + dimmed
3 File restored after deletion ✅ self-healed: strip cleared, fresh content rendered
4 File made unreadable (chmod 000) ✅ "File changed but can't be read." strip with Reload, content dimmed
5 Permissions restored ✅ strip + dim cleared automatically

Screenshots of each state are on the Linear ticket (BOT-1675, latest comment) — Linear-hosted images don't render cross-origin here.

One observation for review: the opacity-60 dim is subtle on the dark theme — visible side-by-side, but not loud. The strip carries the message, so we left it as-is; happy to tune the value (or the strip copy — see PR description) per taste.

@badideasforsale
badideasforsale marked this pull request as ready for review August 25, 2026 21:20
@badideasforsale
badideasforsale requested a review from a team August 25, 2026 21:20

@morgmart morgmart left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Automated code review

REQUEST_CHANGES: one blocking lifecycle issue remains. The new refresh/poll pipeline has no presentation timeout, so a metadata or content request that never settles can leave an initial viewer loading forever or permanently stop an already-open viewer from polling. Supplied GitHub evidence was inspected: all eight reported check runs completed successfully; required checks still independently govern merge readiness.

Deterministic publication result: 1 blocking and 0 non-blocking finding(s) publishable; 0 duplicate(s) suppressed.

Comment thread src/features/chat/ui/ArtifactViewer.tsx Outdated
void contentReadRevision;
void (async () => {
try {
const before = await statFile(artifact.resolvedPath);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 P1 · Bound filesystem waits (blocking)

The forced refresh awaits statFile/readTextFile without any presentation timeout. The native implementation explicitly moves metadata work off-thread because remote or removable filesystems may wait, but spawn_blocking does not bound that wait. If any invoke never settles, forcedRefreshInFlightRef remains true and suppresses every poll; similarly, a never-settling poll never reaches its finally/scheduleNextPoll chain. This creates a permanent stuck state rather than the promised degraded stale/error path.

User effect: Opening an artifact on a stalled filesystem can show the loading spinner forever, while an already-open artifact can silently stop checking for changes forever with no warning or recovery action.

Recommended fix: Add a presentation timeout around each stat/read/decode cycle. On initial load, leave the blocking state and show the existing load/error fallback; with last-good content, count the timeout as a divergence failure while allowing later poll cycles or a manual reload to proceed. Invalidate late results by generation so timed-out work cannot overwrite newer state.

Test: Add fake-timer tests where statFile and readTextFile each return a never-resolving promise; assert initial loading degrades to an actionable error, last-good content becomes stale after the intended grace period, subsequent polling continues, and a late completion cannot replace newer content.

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.

🤖 Fixed in d97bde3. Every stat/read/decode step in both the forced-refresh and poll effects is now bounded by withPresentationTimeout (PRESENTATION_TIMEOUT_MS = 10s), which rejects with a PresentationTimeoutError that flows through the existing catch/finally paths — so a hang gets the same treatment as a read failure (error state on initial load, divergence strikes against last-good content), and the finally blocks clear forcedRefreshInFlightRef/checkInFlight and reach scheduleNextPoll, so polling can no longer wedge.

Late settlement of a timed-out invoke is inert by construction: it merely re-settles an already-rejected wrapper (a no-op under settle-once semantics), so the abandoned cycle's continuation never runs and stale bytes can't overwrite newer state — the existing generation guards stay in place as a second line of defense.

Covered by four new fake-timer tests, including one that resolves the hung promise after a newer successful cycle and asserts the newer content stands.

**Category:** fix
**User Impact:** A hung filesystem operation (stalled network mount,
yanked removable media) can no longer wedge the artifact viewer into a
permanent spinner or silently kill freshness polling. After ten seconds
the hang surfaces through the same error state and stale-warning strip
as any other read failure, and polling keeps running so the view heals
on its own once the filesystem recovers.

**Problem:** Every stat/read/image-decode await in the freshness
machinery was unbounded. The Tauri filesystem commands run under
spawn_blocking precisely because these syscalls can hang indefinitely,
and a never-settling invoke had three failure modes: an initial load
spun forever with no error; a hung forced refresh left
`forcedRefreshInFlightRef` stuck true, suppressing every future poll;
and a hung poll never cleared `checkInFlight` or reached
`scheduleNextPoll`, silently ending polling. No warning, no recovery.

**Solution:** A `withPresentationTimeout()` helper bounds each
stat/read/decode step at `PRESENTATION_TIMEOUT_MS` (10s — comfortably
beyond any healthy local operation), rejecting with a distinguishable
`PresentationTimeoutError`. The rejection flows into the existing
catch/finally blocks, so timeouts get exactly the established failure
semantics: initial loads show the error state and flag immediately,
polls against last-good content consume divergence strikes, and
user-initiated reloads flag without grace. Crucially the finally blocks
now run on timeout, clearing `forcedRefreshInFlightRef`/`checkInFlight`
and rescheduling the next poll. A late settlement of the timed-out
promise only re-settles the already-rejected wrapper — a spec-level
no-op — so stale bytes can never overwrite newer state; the existing
generation guards remain as an independent second line of defense.

## Verification

- `just check`
- `just test` (6,904 passed, 1 skipped)

<details>
<summary>File changes</summary>

**src/features/chat/ui/ArtifactViewer.tsx**
Add `PRESENTATION_TIMEOUT_MS`, `PresentationTimeoutError`, and the
`withPresentationTimeout()` wrapper; apply it to every stat, text read,
and image preload in both the forced-refresh effect and the poll
effect.

**src/features/chat/ui/__tests__/ArtifactViewer.test.tsx**
Cover a hung initial load timing out into the error state with polling
proceeding, hung reads consuming the two-strike grace period, polling
continuing after a timed-out cycle, and a late settlement of a
timed-out read staying inert after newer content lands.

</details>

Co-authored-by: goose <goose@block.xyz>

@morgmart morgmart left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Automated code review

APPROVE: the prior presentation-stall defect is addressed with bounded renderer waits, recovery scheduling, late-result isolation, and discriminating tests. No new publishable findings remain after full review. One residual concern about repeated native operations after renderer timeouts is suppressed because it is the same underlying issue as the existing unresolved automation thread, which has a substantive human-account reply. Supplied GitHub evidence was inspected: six checks succeeded and two checks (Frontend build smoke and Transcript virtualization) were still in progress; required checks independently govern merge readiness.

Deterministic publication result: 0 blocking and 0 non-blocking finding(s) publishable; 1 duplicate(s) suppressed.

Pending checks: 1 check(s) are not complete.

This approval reflects the completed code review only; merge readiness remains governed by the repository's required checks.

@badideasforsale
badideasforsale merged commit 6e07c3c into main Aug 26, 2026
8 checks passed
@badideasforsale
badideasforsale deleted the update-artifacts branch August 26, 2026 04:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants