Skip to content

Preview workdir files in-chat with a swipeable kind-aware pager#262

Merged
91jaeminjo merged 13 commits into
mainfrom
fix/workdirectory-preview-files
May 21, 2026
Merged

Preview workdir files in-chat with a swipeable kind-aware pager#262
91jaeminjo merged 13 commits into
mainfrom
fix/workdirectory-preview-files

Conversation

@91jaeminjo
Copy link
Copy Markdown
Collaborator

Summary

  • Workdir files now render an in-chat preview pager that supports text, code, markdown, SVG, JSON, CSV, HTML, and image kinds. PDF + unknown extensions fall through to download as before.
  • PreviewKind (extension-based) drives kind detection, icon mapping, and language selection for the shared code-block renderer. Exhaustive switch on every getter keeps the dispatch table closed.
  • Full-screen preview with swipe + chevrons + dot pager + arrow-key navigation. Per-file Future<Uint8List> cache + retry token re-keys the FutureBuilder so swipe-back doesn't refetch and Retry can replace a failed future.
  • Failure modes get distinct fallbacks: 404 is permanent ("File no longer exists", no Retry); generic errors show Retry; binary content under a text-shaped extension shows "This file looks binary"; bytes >5 MB route to the too-large fallback; empty bytes show "File is empty"; corrupt SVG/image show dedicated messages.
  • DownloadFeedbackButton centralizes the idle → in-flight → success/error → 2s revert state machine so every download affordance (file row, fallback, too-large) behaves identically. A shared DownloadFeedbackAffordance extension maps state → (icon, label) so the three sites can't drift.
  • PagerDots and PreviewIconButton extracted to lib/src/shared/ and lib/src/modules/room/ui/. Citation PDF eye moved to the header.
  • WorkdirPreviewPage layout is derived from MediaQuery inside build() (dialog at ≥ tablet width, full-screen Scaffold below) so callers can't pass a stale flag.

Test plan

  • flutter analyze — zero warnings
  • flutter test — 1410 tests pass
  • Behavioral tests pin: cap boundary (>= vs >), NotFound vs generic error split, retry invalidates cache, swipe-back doesn't refetch, UTF-8 binary fallback, cancelled-download stays idle, 1024 B formats as 1 KB, content ending in 3 backticks gets a 4-backtick fence, raw exception text never leaks to chunk-viz UI, sub-tablet width opens as Scaffold not Dialog, arrow-key release doesn't double-advance.

🤖 Generated with Claude Code

91jaeminjo and others added 13 commits May 21, 2026 11:17
…kdir

Workdir file rows previewed only raster images before; everything else
fell through to download. This dispatches by extension to one of seven
in-app renderers and reuses the existing markdown + flutter_highlight
pipeline (no new deps): images stay on Image.memory, SVG renders inside
InteractiveViewer, markdown/text route through FlutterMarkdownPlusRenderer,
code/html/csv wrap into a fenced code block so CodeBlockBuilder's
syntax highlighter and copy button apply, and JSON is pretty-printed
with a graceful fallback for malformed input. Per-kind leading icons
replace the generic file icon on previewable rows. Files over 5 MB show
a "too large" placeholder with a download button so the chat scroller
never decodes a huge log or PDF. PDFs continue to fall through to
download — preview would need a client-side library this app
deliberately doesn't pull in, or a backend page-image endpoint
(follow-up). The shared markdown renderer now uses
ExtensionSet.gitHubFlavored so tables, strikethrough, task lists, and
autolinks render in both chat and preview.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
showDialog's default fade and MaterialPageRoute's slide both add a
visible flash before the preview content lands. Swap in
showGeneralDialog / PageRouteBuilder with zero-duration transitions so
the preview appears immediately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Opening any row's eye icon now lands on that file with the whole run's
files reachable via swipe, prev/next chevrons, keyboard arrows, and
tappable dots. PDFs and unknown extensions occupy slots in the
sequence but render the existing "Can't preview" state without
fetching bytes, so swiping never dead-ends and never wastes a request.
Per-index byte futures are cached for the page's lifetime, so swiping
back is instant; the cache is GC'd on close.

Title bar carries only filename + "N / M" + close X. Prev/next
chevrons sit at the edges of the bottom navigation strip flanking the
dots: `[<]   • ● •   [>]` (or `[<]   [>]` when the file count exceeds
the dot threshold). The strip hides entirely for single-file runs.
This keeps the close button well clear of the next chevron and lets
long filenames use the full title-bar width.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…mation

Both the workdir preview pager and the citation chunk visualization
rendered nearly identical dot indicators inline. Extract a shared
PagerDots widget — capped at 12 dots, tappable jump-to-index,
optional per-dot tooltip — and route both consumers through it. As a
side effect the citation dots become tap-to-jump, matching the
workdir pager.

Also fix two bugs in chunk_visualization_page:

- The mobile Scaffold body had no SafeArea, so the system home
  indicator sat on top of the dots row and made them effectively
  untappable. Wrap body in SafeArea(top: false).
- showDialog/MaterialPageRoute added a fade/slide transition before
  the rendered pages appeared. Switch to showGeneralDialog +
  PageRouteBuilder with zero-duration transitions, matching the
  workdir preview's open behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…eader

Workdir preview rows and the citation rows both wanted an eye icon
that opens a preview. Workdir had an inline InkWell+Tooltip+Icon
block; citations had a TextButton.icon with the label "View in PDF"
tucked inside the expanded body. Extract a shared PreviewIconButton
mirroring CopyButton's shape (bare icon, hover tooltip, no chrome) and
route both consumers through it.

For the citation row, the affordance moves up to the header alongside
the copy button: visible without expanding the row, and consistent
with workdir preview's row chrome. The label "View in PDF" is gone;
the tooltip "View source PDF" carries the meaning on hover. Citation
tests follow accordingly — they assert the tooltip instead of the
button text and no longer need to expand a row to find the affordance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Type-design and consistency fixes from the multi-agent PR review.

PreviewKind now owns its own row icon, render-eligibility, and
highlight-language mappings as enum methods. The previous structure
kept three parallel lookup tables (a code-extension Set in
preview_kind, a language Map in code_extensions, and the local
_leadingIconFor / _canPreview helpers in workdir_files_section) that
could drift independently. The duplicate _extensionOf in
workdir_files_section — which silently skipped the toLowerCase that
preview_kind's version performs — is gone with it. code_extensions.dart
deleted; languageForExtension folded into
PreviewKind.highlightLanguageFor(filename).

DownloadOutcome moves to its own file under workdir_preview/ so
too_large_preview.dart no longer reaches back into the section file.

The byte cache (and retry-token map) in WorkdirPreviewPage are now
keyed by WorkdirFile (which has proper == / hashCode) rather than by
list index, so any future list reordering or mutation can't alias
bytes across files.

show() asserts files.isNotEmpty and clamps initialIndex into range so
an out-of-bounds caller can't crash the first build.

Other tidying:
- PreviewIconButton: BorderRadius.circular(4) → soliplexRadii.sm
  (design-system rule); Semantics reports enabled per onTap.
- Diagnostic logging at every previously-silent catch site and
  errorBuilder in the preview path — image and SVG decode failures,
  download contract violations, the section's listing FutureBuilder,
  and the JSON pretty-print fallback (info level).
- Tokenized the new title-bar EdgeInsets.
- Removed the "per Jaemin's call" attribution comment and the
  inaccurate [codeExtensions] doc reference flagged in review.

Tests for the renamed/refactored APIs updated in lockstep.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New coverage for behaviors that had real branches with no test:

- SvgPreview decode failure: garbage bytes should swap in the
  fallback widget (the post-frame setState path was untested).
- TooLargePreview state machine: success / failed / cancelled /
  throwing / in-flight reentry. The widget carries its own copy of
  the icon-swap pattern, so the section's tests don't cover it.
- WorkdirPreviewPage: retry-twice on the same slide must clear the
  cache and re-run fetch each time (a regression that reused the
  failed future would leave the count at 1 across all retries).
- WorkdirPreviewPage: bytes exactly at the 5 MB cap still preview
  (the cap is a strict `>`, easy to regress to `>=`).
- fitFilenameForWidth: pure-logic unit tests for the extension-
  preserving truncation, including the dotfile and long-extension
  fallback branches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UTF-8 decoding moves up into _PreviewBody so the leaf renderers
(TextPreview, CodePreview, SvgPreview, JsonPreview) now accept a
String content rather than Uint8List bytes. PreviewKind gains an
isText getter so the dispatch can decide once whether to decode.
_PreviewBody asserts kind.canRender in its constructor, expressing
the invariant the runtime guard in _slideFor already enforces.

_CannotPreview and TooLargePreview each take a filename so their
download-throw logs carry it as a structured attribute. Contract-
violation catches (the three "callback threw" sites) now log at
error level instead of warning, and include error.runtimeType so a
refactor-introduced TypeError is grep-distinguishable from a routine
IO failure.

PagerDots gains const-constructor asserts on itemCount, maxVisible,
and currentIndex bounds. _measure() in workdir_files_section now
disposes its TextPainter to avoid the Flutter 3.16+ leak warning.

Test coverage extends to the _CannotPreview download state machine
(success, failed, cancelled, throwing — driven through the
empty-bytes path) and to keyboard arrow nav at the first and last
slides (no wrap, no exception). The previously-over-specified
exhaustive rowIcon and canRender PreviewKind tests are removed.

A few dartdoc fixes: PreviewKind.rowIcon no longer misdescribes its
contract, highlightLanguageFor no longer references a rot-prone
[CodeBlockBuilder] symbol, and a couple of class-level dartdocs trim
restated-WHAT prose.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extract DownloadFeedbackButton so the file-row, can't-preview, and
too-large callsites share one in-flight + 2 s revert state machine.
Distinguish decode-failure fallbacks (image/SVG corrupt, binary
content, empty file) so the user can tell a damaged artifact from an
unsupported kind. Strict UTF-8 decode on text-shaped extensions
replaces silent mojibake. JsonPreview renders a banner above raw
content when parsing fails. Preview-fetch logs at error with
stackTrace + errorType; chunk-viz drops raw error.toString() from the
user-facing body. WorkdirPreviewPage constructor asserts non-empty
files and in-range initialIndex.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- DownloadFeedbackButton: route _inFlight transitions through
  setState so the button rebuilds disabled while a download is in
  flight, and gate onTap on _inFlight in addition to state.
- PreviewKind: make canRender/isText exhaustive switch expressions
  so adding a new kind forces a deliberate decision instead of
  inheriting the boolean default.
- Logging: drop the routine "preview bytes empty" / file-listing
  failure warnings to debug (the UI already surfaces them); promote
  workdir download failures to error with errorType/filename
  attributes so unexpected throws are diagnosable.
- Tests: cover the image- and svg-specific corrupt fallbacks
  (distinct from the generic unsupported copy), the
  WorkdirPreviewPage.show out-of-range initialIndex clamp, and
  switch the PreviewIconButton null-onTap assertion to a semantics
  check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- workdir_controller: keep the download-failure log at warning;
  the catch swallows routine SocketException/HttpException/Timeout
  where error level would spam on normal network failures. Keep
  the errorType/filename attributes for triage. Restores severity
  parity with sibling fetchFiles/fetchBytes catches.
- DownloadFeedbackButton: move the "must not throw on routine IO"
  contract onto the onDownload field doc; the catch comment points
  at it. The contract belongs on the API, not buried in a catch.
- Tests: assert the download InkWell's onTap is null mid-flight —
  the existing "second tap is a no-op" test is also satisfied by
  the _handleTap early-return, so it doesn't prove build() wires
  the disabled state. Add an exhaustive
  PreviewKind.{canRender,isText} truth table so the point of the
  exhaustive switch (forcing a decision per variant) is defended;
  tighten the show()-clamp test comment to drop a hypothetical
  race scenario.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… flag

- WorkdirPreviewPage: drop the public useDialogLayout field and
  re-derive the layout from MediaQuery in build(). The flag was a
  derived fact (>= tablet breakpoint) that show() computed and the
  constructor exposed, letting a direct caller break the layout
  invariant silently. Also drop the explicit Colors.black54 barrier
  so the dialog inherits the theme's barrier color.
- DownloadFeedbackAffordance: new extension on DownloadFeedbackState
  returning (icon, label). The file-row tooltip, too-large-preview
  button, and _CannotPreview button all rendered the same three
  icon/label pairs via duplicated switches; one source of truth keeps
  the wording from drifting. The row keeps its own color switch since
  that's the only field that varies per site.
- workdir_controller: add errorType to the fetchFiles and fetchBytes
  catch attributes for grep parity with download. Same on the
  _PreviewBody UTF-8 decode warning.
- Doc accuracy: drop the cross-private [_handleTap] reference;
  rewrite previewSizeCapBytes wording (the cap is enforced post-fetch);
  rewrite highlightLanguageFor doc to say only code/html/csv route
  through it; flag the plaintext-markdown trade-off in TextPreview;
  remove the duplicate .bashrc rule from PreviewKind.from.
- Tests pinning real product decisions:
  - sub-tablet width opens the preview as a Scaffold, not a Dialog
  - arrow-key release does not advance the pager a second time
  - JsonPreview malformed payload renders banner AND raw CodePreview
  - fitFilenameForWidth keeps an 8-char extension, drops a 9-char one
  - wrapInCodeFence rounds the fence up when content ends with the
    default 3-backtick run (CommonMark §4.5)
  - TooLargePreview formats 1024 B as 1 KB and 1 MiB as 1.0 MB
  - chunk visualization failure does not leak the raw exception text

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Matches the same fix on WorkdirPreviewPage. The previous showDialog
inherited the theme's barrier color; the showGeneralDialog migration
explicitly set Colors.black54, bypassing the theme. Drop the override
so both dialogs honor whatever the app theme decides.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@91jaeminjo 91jaeminjo merged commit 98f65cb into main May 21, 2026
6 checks passed
@91jaeminjo 91jaeminjo deleted the fix/workdirectory-preview-files branch May 21, 2026 18:26
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.

1 participant