Skip to content

feat(files): preview .docx in a docked pane - #1121

Merged
philmerrell merged 3 commits into
developfrom
claude/docx-artifact-viewer-439f94
Sep 16, 2026
Merged

philmerrell merged 3 commits into
developfrom
claude/docx-artifact-viewer-439f94

Conversation

@philmerrell

@philmerrell philmerrell commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Adds a Preview button to the inline download card for .docx files, opening a right-docked pane that renders the document in the browser.

Why not the Office Online viewer

Our reference repo (aws-samples/sample-strands-agent-with-agentcore) does this by framing view.officeapps.live.com. That renders server-side at Microsoft, so the document has to be reachable by an unauthenticated URL — their implementation adds an open S3 proxy route for exactly that, and special-cases localhost because Office Online can't reach it.

That's not something to do with student, advising or HR documents. Here the bytes are fetched with the user's own session and rendered in their browser; nothing leaves the tenancy.

No backend change was needed for the viewer

/files/{id}/preview-url is already owner-scoped, READY-gated, and reports the stored MIME type, and the user-files bucket already allows CORS GET from the SPA origin.

The S3 leg uses plain fetch with credentials: 'omit' rather than HttpClient. S3 answers a CORS GET without Access-Control-Allow-Credentials, and routing it through the interceptor chain would hand S3's own 403s to the global error interceptor — which treats an auth failure as a reason to bounce the user to login. A presigned URL going stale is a retry, not a logout.

One structural change

The layout reserves exactly one right-side gutter, so two docked panes open at once would overlap. DockedPaneService now owns the rail — width, side-nav choreography, and which feature holds it — and enforces mutual exclusion. Eviction is a read: each owner gates its open-ref on owner(), so there's no callback to miss, no circular dependency, and no write-loop from an effect-based handoff. ArtifactStateService's public API is unchanged.

Renderer notes

  • docx-preview 0.4.0 (Apache-2.0, one dependency). Reproduces the document's own page layout and styles; mammoth deliberately flattens to "simple HTML" and discards styling — right for docx→Markdown, wrong for a viewer.
  • Dynamic import() — it and jszip are dead weight for the majority of sessions that never open a Word document (~284 kB, now its own lazy chunk), and it touches document at module scope, so a static import would run during SSR.
  • Detached render targets, swapped in on success. renderAsync captures the elements it's given and appends asynchronously, so two overlapping renders pointed at the live host interleave their output, and a sequence guard can't undo DOM the library wrote itself.
  • Fit via CSS zoom, not transform: scale() — zoom participates in layout, so the flow collapses to the scaled height instead of reserving the unscaled height and leaving dead space under every page. A ResizeObserver re-fits as the rail is dragged; capped at 1.

Two upstream bugs worked around

docx-preview drops conditional table formatting. It puts Word's tblLook flags on the <table> while its own emitted CSS targets rows and cells (tr.first-row td span for the bold header, tr.odd-row for the band fill), and never tags any <tr>/<td> — so its own rules can never match and tables render flat. applyTableConditionalClasses() restores only those hooks; it invents no formatting, and a style with no rule for a band changes nothing. Band numbering is 1-based over body rows, excluding a special header or total row — verified against Word.

It also doesn't paginate by content overflow, splitting only on explicit page-break runs or section props. That surfaced a real defect in our own tool rather than the viewer — see below.

create_word_document fix

python-docx does no layout, so a generated document contains no page boundaries at all unless the code adds them, and it never writes the lastRenderedPageBreak hints Word stamps on save. A "three page report" came out as one continuous run of text — wrong in Word itself, not just in a preview. The docstring never mentioned add_page_break(), so the model never emitted one. Same shape for header emphasis: table.style alone leaves it to conditional formatting, which not every viewer applies.

This docstring is in the cacheable toolConfig prefix, so it costs a one-time cache re-write per session on the next turn after deploy.

Testing

Verified end to end against dev (local SPA + local app-api, dev data):

  • Preview renders on persisted download cards; preview-url → 200; real S3 fetch succeeds
  • 3-page document splits into 3 page cards; short document fills a full page
  • Zoom: 0.783 at the default 672px rail (page 639×827, ratio 1.294 = 11/8.5); 1.0 and native 816×1056 when widened
  • Tables match Word: bold header, bold first column, #D3DFEE banding on rows 1/3/5
  • Images render at the requested Inches(6) = 576px, inlined as data URIs
  • Rail mutual exclusion verified both directions; resize, close and side-nav restore correct in light and dark

Suites: backend 8626 passed / 3 skipped, SPA 3206 passed / 256 files (46 new tests).

Follow-ups not in this PR

  • Preview for uploaded files from the file browser
  • .docx in the Artifact Library — the big one, since artifacts are text-in-S3 keyed by content_type and a binary type ripples into versioning, the source view and sharing
  • Thumbnails still need the out-of-process LibreOffice service files/thumbnails.py already documents

🤖 Generated with Claude Code

philmerrell and others added 3 commits September 15, 2026 13:53
The layout reserves exactly one right-side gutter (`--artifact-pane-width`
plus the `artifact-pane-open` class, read in `app.ts` and seven places in
`chat-container.component.html`), so two docked panes open at once would
overlap and the chat column would be sized for whichever service answered
first. With a second pane arriving, the rail's width and the side-nav
choreography stop being artifact concerns.

`DockedPaneService` now owns the rail: its width, the collapse/restore of
the side nav, and which feature holds it. Mutual exclusion is enforced
there rather than trusted to callers — `claim()` hands the rail over and
implicitly evicts the current holder.

Evicted owners are not called back. Each gates its public open-ref on
`owner()` instead, so eviction is a read rather than a notification. That
keeps the dependency one-way and rules out the write-loop an effect-based
handoff would invite.

`ArtifactStateService`'s public API is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds a Preview button to the inline download card for `.docx` files,
opening a right-docked pane that renders the document in the browser.

Rendering is client-side via `docx-preview`, which walks the OOXML and
reproduces the document's own page layout, styles, tables and numbering.
The reference implementation we looked at frames
`view.officeapps.live.com` instead, which renders server-side at Microsoft
and therefore needs the document reachable by an unauthenticated URL — not
something to do with university documents. Nothing leaves the browser here.

No backend change was needed: `/files/{id}/preview-url` is already
owner-scoped and READY-gated and reports the stored MIME type, and the
user-files bucket already allows CORS GET from the SPA origin. The S3 leg
deliberately uses plain `fetch` with `credentials: 'omit'` — S3 answers a
CORS GET without `Access-Control-Allow-Credentials`, and routing it through
HttpClient would hand S3's own 403s to the global error interceptor, which
treats an auth failure as a reason to bounce the user to login. A presigned
URL going stale is a retry, not a logout.

Notes on the renderer:

- Loaded with a dynamic `import()`. It and its jszip dependency are dead
  weight in the initial bundle for the majority of sessions that never open
  a Word document (~284 kB, now its own lazy chunk), and it touches
  `document` at module scope, so a static import would run during SSR.
- Each render targets detached containers that are swapped in on success.
  `renderAsync` captures the elements it is handed and appends to them
  asynchronously, so pointing two overlapping renders at the live host
  interleaves their output, and a sequence guard cannot undo DOM the
  library wrote itself. Fresh containers also stop the injected stylesheet
  accumulating a copy per render.
- Pages are scaled to fit with CSS `zoom` rather than `transform: scale()`,
  because zoom participates in layout: the flow collapses to the scaled
  height instead of reserving the unscaled height and leaving dead space
  under every page. Natural page width is read from the inline `width:
  612pt` the library stamps, since measuring it back from a zoomed layout
  would feed the fit calculation its own output.
- `applyTableConditionalClasses()` re-tags rows and cells after render.
  docx-preview puts Word's `tblLook` flags on the `<table>` while its own
  emitted CSS targets rows and cells, so its rules for bold headers, first
  columns and row banding can never match and tables render flat. This
  restores only the hooks its per-style rules already target — it invents
  no formatting, and a style with no rule for a band changes nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
python-docx does no layout, so a generated document contains no page
boundaries at all unless the code adds them, and it never writes the
`lastRenderedPageBreak` hints Word stamps on save. A "three page report"
therefore came out as one continuous run of text — wrong in Word itself,
not just in a preview. The docstring never mentioned `add_page_break()`,
so the model never emitted one.

Header emphasis has the same shape. Setting `table.style` alone leaves the
header row to the style's *conditional* formatting, which not every viewer
applies; direct run formatting always renders. The bolding loop is folded
into the table example rather than added as a separate note, so it travels
with the code the model copies.

This docstring is part of the cacheable `toolConfig` prefix, so it costs a
one-time cache re-write per session on the next turn after deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@philmerrell
philmerrell merged commit 222e1d2 into develop Sep 16, 2026
6 checks passed
@philmerrell
philmerrell deleted the claude/docx-artifact-viewer-439f94 branch September 16, 2026 00:56
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