feat(files): preview .docx in a docked pane - #1121
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds a Preview button to the inline download card for
.docxfiles, 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 framingview.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-urlis 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
fetchwithcredentials: 'omit'rather thanHttpClient. S3 answers a CORS GET withoutAccess-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.
DockedPaneServicenow 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 onowner(), 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-preview0.4.0 (Apache-2.0, one dependency). Reproduces the document's own page layout and styles;mammothdeliberately flattens to "simple HTML" and discards styling — right for docx→Markdown, wrong for a viewer.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 touchesdocumentat module scope, so a static import would run during SSR.renderAsynccaptures 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.zoom, nottransform: 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. AResizeObserverre-fits as the rail is dragged; capped at 1.Two upstream bugs worked around
docx-preview drops conditional table formatting. It puts Word's
tblLookflags on the<table>while its own emitted CSS targets rows and cells (tr.first-row td spanfor the bold header,tr.odd-rowfor 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_documentfixpython-docx does no layout, so a generated document contains no page boundaries at all unless the code adds them, and it never writes the
lastRenderedPageBreakhints 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 mentionedadd_page_break(), so the model never emitted one. Same shape for header emphasis:table.stylealone leaves it to conditional formatting, which not every viewer applies.This docstring is in the cacheable
toolConfigprefix, 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-url→ 200; real S3 fetch succeeds#D3DFEEbanding on rows 1/3/5Inches(6)= 576px, inlined as data URIsSuites: backend 8626 passed / 3 skipped, SPA 3206 passed / 256 files (46 new tests).
Follow-ups not in this PR
.docxin the Artifact Library — the big one, since artifacts are text-in-S3 keyed bycontent_typeand a binary type ripples into versioning, the source view and sharingfiles/thumbnails.pyalready documents🤖 Generated with Claude Code