148635e3 | feat(word-addin): align chat UI with web app | willchen96 | 2026-08-07 | ↗ GitHub |
8b4980ac | docs: document Word add-in beta | willchen96 | 2026-08-07 | ↗ GitHub |
8e76906f | fix(word-addin): stabilize dropdown hover state | willchen96 | 2026-08-07 | ↗ GitHub |
9dbe9d59 | feat(backend): add document-scoped Word chats | willchen96 | 2026-08-10 | ↗ GitHub |
169ec3e4 | feat(word-addin): complete document chat experience | willchen96 | 2026-08-10 | ↗ GitHub |
4a099ba2 | refactor(word-addin): stabilize document chat runtime | willchen96 | 2026-08-10 | ↗ GitHub |
8b3cd9ab | fix(word-addin): improve layout calculations for assistant chat and header components | willchen96 | 2026-08-11 | ↗ GitHub |
078a5d5b | fix(word-addin): settle the submitted turn on one 80px pin line and defend it against pane resizes | Amal | 2026-08-11 | ↗ GitHub |
commit body WHY THIS MATTERS
When you send a message in the assistant pane, the transcript scrolls so your
turn "pins" near the top while the answer streams in below it. Users reported
the pinned turn drifting to different heights per turn and sliding when the
response's activity strip changed size.
WHAT IS A PIN LINE (and what was wrong)
The pin is built from two cooperating pieces:
1. a scroll target - where the turn should sit (`element.offsetTop - N`), and
2. a reserved spacer under the turn (min-height on the assistant row) that
guarantees the scroll range can actually reach that position.
The old code targeted N=24px but sized the spacer against the container's
80px top padding, so the maximum scroll only reached `offsetTop - 80`. The
turn's resting position became a function of the answer's height:
`max(24, 80 - (answerHeight - spacer))` - short answers rested at 80, long
ones climbed to 24, and any shrink slid the turn back down. The web assistant
avoids this by keeping the target and the container padding on the same line;
the add-in had copied the constant but not the invariant.
HOW IT WORKS NOW
- `PIN_TOP_OFFSET = 80` - one constant, documented as "must equal the
container's pt-20" - is used by the live pin scroll, the restored-history
scroll, and `measureSpacerPx`. With both pieces on the same line, the
minimum scroll range lands exactly on the pin position for every answer
height, so post-completion shrinking can never clamp the turn away.
- Two ResizeObserver watchdogs (container box + active assistant row)
re-assert the anchored position when the pane or the streaming row resizes,
and restore the user's own position (clamped to the new range) once the
user has scrolled away. Scroll "ownership" is tracked in refs
(`anchorActiveRef`, `desiredScrollTopRef`): the app owns the anchor until
wheel/touch/pointer/keyboard input hands it to the user.
- `UserMessage` clamps long prompts from the first painted frame so the
spacer measurement and the painted layout can never disagree.
VERIFICATION TOOLING
- e2e/chat-layout.spec.ts gains completion-transition and bottom-arrow specs.
- playwright.webkit.temp.config.ts runs the same suite under WebKit - the
engine the Office task pane actually uses (WKWebView) - where Chromium-only
runs cannot observe engine scroll adjustments.
- docs/word-addin-chat-scroll-report.md records the full investigation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
00aac4d0 | fix(word-addin): stop WKWebView from stealing the transcript's scroll position, and cut streaming render cost | Amal | 2026-08-11 | ↗ GitHub |
commit body WHY THIS MATTERS
In real Word (not in our Chromium e2e), the moment a response finished - the
activity strip flipping from "Working" to "Completed in N steps" - the whole
transcript jumped away from the pinned turn. Streaming also felt sluggish.
Both had the same underlying theme: work and behavior that only shows up in
the Office WebView under real streaming load.
WHAT IS SCROLL ANCHORING (and why the pane fought it)
Browsers try to keep what you're reading still when content above or around
it changes size, by silently adjusting the scroller's scrollTop - "scroll
anchoring". The pane opts out with `overflow-anchor: none` because it manages
its own pin geometry. Chromium honors the opt-out; WebKit (the engine inside
Word's task pane) never implemented the property. When a response completes,
the strip's streamed rows unmount in the same React commit that flips its
label - and WebKit, which had latched onto one of those DOM nodes as its
anchor, resets scrollTop to 0. Instrumentation showed the reset arrives with
no JavaScript write and zero geometry change (the min-height spacer holds the
row's size), so no ResizeObserver watchdog can see it. Under a WebKit
Playwright run the pinned turn measured 80px -> 2228px at completion; under
Chromium the bug is unobservable, which is why the suite was green while
users saw the jump.
HOW THE FIX WORKS - explicit scroll ownership (ChatView.tsx)
Scroll position now always has an owner, and scroll events are audited
against that owner:
1. Every position the app writes (pin animation frames included, via
animateScrollTo's onFrame callback) is mirrored into desiredScrollTopRef
BEFORE the write, so the app's own scroll events match the record.
2. User input opens an ownership window before its scroll events land:
wheel/keyboard grant a short grace, pointer/touch hold ownership while
pressed, and touch release keeps it through momentum. During the window,
desiredScrollTopRef simply follows the user.
3. Any other scroll event matches neither owner - it can only be
engine-initiated - and is snapped back to the owned position.
This kills the completion reset, and also a second WebKit habit the tests
exposed: a scroller resting exactly at the bottom gets dragged along as
streamed content grows ("bottom-follow"), which made the view creep after
pressing the scroll-to-bottom arrow.
STABLE EVENT IDENTITIES (wordChatEvents.ts, AssistantMessage.tsx)
React keys for streamed rows were derived from event-array indices. At
completion, completeAssistantEvents() filters out transient rows (trailing
"thinking", stuck "reading"), shifting every later index - so surviving rows
remounted, destroying exactly the DOM nodes WebKit anchored to and flashing
the reasoning block open for one frame. Events are now stamped with a
creation-time `key` (a module counter) that survives streaming mutations, and
render keys prefer it: `event.key ?? index`. The field is inert in storage;
unit specs assert it with expect.any(String).
STREAMING PERFORMANCE - why it was quadratic
1. projectRedlineStream() re-parsed the FULL accumulated answer on every
chunk, and ran >=3x per chunk (edit controller + renderer + per-event
map): O(n^2) over a stream. A single-entry memo (same text + same flag
returns the cached projection) collapses those to one parse per change,
with zero call-site churn (redline.ts).
2. Every SSE event committed React state, re-rendering the whole transcript
far more often than the screen paints. Publishes now coalesce onto one
requestAnimationFrame, flushed synchronously at stream end/error so
terminal UI state never lags (useWordAssistantChat.ts).
3. Nothing was memoized: every settled message re-rendered - and
react-markdown re-parsed its full text - on every chunk. AssistantMessage,
UserMessage, and Markdown are now React.memo boundaries; Markdown's
plugins/components props are hoisted to module scope (an inline object
defeats react-markdown's own memoization); ChatView passes stable
useCallback handlers; and handleChat reads messages via a render-synced
ref instead of depending on them, so its identity stops churning per
chunk (which was re-rendering the composer).
4. Edit cards/sections painted a backdrop-blur behind a fully opaque
bg-white - invisible, but a compositing layer per card in the Office
WebView. Removed (messageStyles.ts).
VERIFICATION
- New WebKit regression spec (chat-layout.spec.ts) streams a doc-read plus a
multi-step reasoning strip and asserts the pinned turn holds through
completion: fails on the parent commit (80 -> 2228px, scrollTop 2148 -> 0),
passes here. The bottom-arrow spec now asserts the settled position, since
the corrector is deliberately eventually-consistent within a frame.
- Full Chromium suite: 107/107. WebKit chat-layout suite: 4/4. Typecheck clean.
- Deferred (tracked in docs/word-addin-chat-scroll-report.md): batching the
~9 serialized context.sync round trips Office needs per tracked edit - the
dominant remaining wall-clock on edit turns - and the header/glass blur
stack, which is a deliberate design choice asserted by e2e.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
80ac1dca | fix(backend): keep streaming reservations out of last-assistant-message lookups | Amal | 2026-08-12 | ↗ GitHub |
commit body WHY THIS MATTERS
The chat routes reserve the assistant row with `content: null` BEFORE
the LLM stream runs, so the row id can be sent to the client early. If
the stream dies before its save path (process crash, deploy restart) --
or while a concurrent POST to the same chat is still streaming -- that
empty reservation is the newest assistant row in the table. Any query
for "the last assistant message" then finds a husk instead of the real
last turn.
WHAT BREAKS
Two context builders used order-by-created_at-desc + limit(1):
- enrichWithPriorEvents bails when content is not an array, so an
orphaned reservation silently hides the prior turn's doc_created /
doc_edited events -- the model loses its references to documents it
just generated.
- appendAssistantEventsToLastAssistantMessage could append
ask_inputs_response events onto the empty reservation, where the
eventual stream save would overwrite them.
HOW IT WORKS
Both queries now add `.not("content", "is", null)`, so they resolve to
the most recent COMPLETED assistant message; reservations are invisible
to reads while the reservation design itself stays unchanged. The only
other assistant-row query (buildDocContext) selects all rows and
already skips non-array content per row, so it needed no change.
Tests pin the behavior at two levels: unit tests drive a fake
chat_messages table through the real filter chain (prior turn's events
still surface past a newer null row; ask-inputs append targets the real
message), and a route-level test proves a POST /chat ask-inputs
continuation never updates the reservation row. Removing either filter
fails three tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015U1iPxsADQ2yuksNmDuvvW
|
8c0b4922 | test(word-addin): run the e2e suite under WebKit and gate it in CI | Amal | 2026-08-12 | ↗ GitHub |
commit body WHY THIS MATTERS
The task pane's headline fix defends the transcript's scroll position
against WKWebView, but the assertions that prove it only fail under
WebKit. WebKit's scroll anchoring ignores `overflow-anchor: none` and
rewrites scrollTop whenever a descendant (e.g. the collapsing
"Working -> Completed in N steps" strip) resizes; Chromium honours the
opt-out. A chromium-only suite therefore stays green even with the fix
fully reverted -- the tests pass vacuously.
WHAT IS A VACUOUS TEST
A test that asserts a property the environment can never violate. It
looks like coverage but can't fail, so regressions ship silently. The
cure is to run the assertion in the environment where the property is
actually at risk -- here, a WebKit browser, the same engine Word on
macOS embeds.
HOW IT WORKS
- playwright.config.ts gains a `webkit` project (Desktop Safari), so
`npm run test:e2e` runs every spec in both engines by default; a
`test:e2e:webkit` script exists for targeted debugging.
- The orphaned playwright.webkit.temp.config.ts (referenced by no
script, doc, or CI, and invisible to every tsconfig) is deleted --
its only non-duplicated content was the webkit device entry.
- A new path-filtered .github/workflows/word-addin.yml gates
typecheck + the two-browser suite on every change under word-addin/.
The webServer timeout rises 180s->300s because in CI that command
performs a cold typecheck + production webpack build, and a webServer
timeout aborts the run un-retried.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015U1iPxsADQ2yuksNmDuvvW
|
2f9d007f | fix(word-addin): unstick chat-history pagination and stop cross-document flashes | Amal | 2026-08-12 | ↗ GitHub |
commit body WHY THIS MATTERS
loadMore talked to the fetch effect solely through setOffset(chats.length).
Offset pagination over a list ordered by updated_at shifts whenever a
chat is bumped to the top, so a whole fetched page can be deduplicated
away -- leaving chats.length equal to the current offset. The
offset-keyed effect then never re-fires, and the requestPending guard
plus the loadingMore spinner stay stuck forever: pagination is dead
until the pane reloads.
WHAT IS AN OFFSET-PAGINATION SHIFT
Page N is defined as "rows N*size..N*size+size at query time". If a row
moves ahead of the cursor between requests (updated_at reordering),
page N+1 re-serves rows you already have; dedupe correctly drops them,
but any signal derived from list length no longer moves.
HOW IT WORKS
A monotonic requestId now accompanies the offset: loadMore bumps both,
and the fetch effect keys on the requestId, so every loadMore performs
exactly one fetch even when the numeric offset is unchanged. Dedupe
semantics and the history-changed subscription are untouched.
Also fixed here: switching document or storage scope while offset != 0
used to early-return after setOffset(0) without clearing state, so the
previous document's chats stayed on screen until the new fetch
resolved. The list and hasMore now reset before that early return.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015U1iPxsADQ2yuksNmDuvvW
|
72254ef3 | perf(word-addin): cut streaming and chat-open hot-path costs in the tracked-edits runtime | Amal | 2026-08-12 | ↗ GitHub |
commit body Three fixes to the same runtime, all about respecting WKWebView's
single main thread: work done per SSE chunk or per Word round-trip
competes directly with paint, scroll handling, and typing.
1. PARSE STREAMED REDLINES ONCE PER FRAME, NOT PER CHUNK
WHY: projectRedlineStream re-parses the accumulated answer from index
zero, so invoking it synchronously on every SSE chunk makes a stream
O(n^2) -- the longer the answer, the more each keystroke-sized chunk
costs, exactly while the transcript is animating.
HOW: the chunk handler now only flags redlineParsePending; the
projection runs inside the same requestAnimationFrame callback that
already coalesces the transcript publish, i.e. once per painted frame
with the latest snapshot. Terminal paths stay exact: success flushes
synchronously and still runs the streamComplete pass, and the
catch/abort path flushes before markIncompleteRedlines, so a sealed
edit in a trailing un-flushed chunk still applies on cancel. A
sendIsCurrent() predicate (currency minus the abort bit) guards the
deferred parse so a rAF firing after a session switch cannot schedule
Word edits under the new generation -- deliberately not the stricter
requestIsCurrent, because an aborted-but-current stream must keep the
edits it already received.
2. STOP THE EDIT CONTROLLER FROM RECREATING handleChat MID-STREAM
WHY: the hook returned a fresh object literal carrying editStateByKey,
and handleChat listed that controller in its deps -- so every
receiving->applying->pending transition recreated handleChat and
re-rendered everything holding it, defeating the message-ref mirroring
built precisely to keep it stable.
HOW: the controller now exposes a useMemo'd streamController
(processLiveRedlines / markIncompleteRedlines / waitForMessageEdits,
all useCallback-stable) separate from editStateByKey. The chat hook
receives only the stable streamController, so handleChat's identity
survives the whole stream; components that render edit state still
consume editStateByKey and re-render on real state changes.
3. RESTORE A CHAT'S TRACKED EDITS IN ONE Word.run BATCH
WHY: opening a chat ran one serialized Word.run (~4 context.sync()
host round-trips) per stored edit behind the global mutation queue --
pane readiness was linear in chat history, and every user action
queued behind the backlog. Each sync is a WKWebView<->host hop.
HOW: restoreTrackedEdits(descriptors) performs one Word.run for the
whole set: all getBookmarkRangeOrNullObject lookups load before one
sync, then items, verification, stale-bookmark deletes, and tracking --
a constant ~4 syncs total. Missing bookmarks are null objects, never
batch failures; per-edit classification (not-found / resolved /
view-only / restored) is preserved verbatim; if Word fails the shared
batch outright, each edit retries sequentially via restoreTrackedEditNow
so one bad object cannot sink the rest.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015U1iPxsADQ2yuksNmDuvvW
|
48d18fe4 | fix(word-addin): stop the workflow editor from losing edits or resurrecting stale views | Amal | 2026-08-12 | ↗ GitHub |
commit body WHY THIS MATTERS
The prompt editor auto-saves on an 800ms debounce. Three lifecycle bugs
hid in that convenience:
1. Navigating away inside the debounce window cleared the timer without
firing it -- the user's typed instructions silently vanished.
2. If an updateWorkflow call was in flight when the user deselected,
its .then called onSelectedWorkflowChange, which the App wires
straight into page navigation -- re-opening the detail view for a
workflow the user had just left.
3. The "Saved -> idle" status timer was never tracked, so it could
stamp "idle" over a newer "Saving..." and fire after unmount.
WHAT IS A DEBOUNCE FLUSH
A debounce trades latency for batching, which is only safe while the
component lives. At any teardown boundary (unmount, deselect, switch)
the pending work must either flush or be knowingly discarded; silently
dropping it turns an optimization into data loss.
HOW IT WORKS
- pendingSaveRef holds the latest unsaved edit; flushPendingSave()
fires it (fire-and-forget) from the effect cleanup, which runs on
deselect, workflow switch, and unmount.
- selectedIdRef is nulled in cleanup and re-set synchronously by the
next effect run; the save's .then/.catch check it, so a late
resolution for a departed workflow can no longer navigate or write
status.
- The status-reset timer lives in statusResetTimerRef and is cleared
on every new edit and in cleanup.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015U1iPxsADQ2yuksNmDuvvW
|
4dcdead5 | perf(word-addin): collapse the header's four backdrop blurs into one masked layer | Amal | 2026-08-12 | ↗ GitHub |
commit body WHY THIS MATTERS
The floating header's progressive-blur scrim stacked four full-width
backdrop-blur layers (1/2/4/8px). backdrop-filter cannot be cached: the
compositor must re-sample whatever is behind the layer every time it
changes -- and behind this scrim is the transcript, which moves on
every scroll frame and repaints on every streamed token. Four stacked
layers meant four full-width re-samples per frame over the hottest
region of the pane, in WKWebView where main-thread headroom is already
the constraint (the same cost class messageStyles.ts documents for
text blurs).
WHAT IS A MASKED PROGRESSIVE BLUR
The standard single-layer approximation: one blur at the maximum
strength whose mask-image alpha ramps from opaque to transparent, so
the blurred copy cross-fades into the sharp content underneath. The
eye reads the fade-out of an 8px blur as the blur itself easing off --
visually equivalent to stacked increasing blurs at a quarter of the
sampling cost.
HOW IT WORKS
One backdrop-blur-[8px] layer with a multi-stop mask ramp
(black 0-16%, 0.55 @46%, 0.2 @72%, transparent 100%) replaces the four
layers; both mask-image and -webkit-mask-image are set (WKWebView needs
the prefix), and the gradient overlay above it is unchanged. The layout
spec now asserts exactly one masked blur layer instead of the old
stack, and a leftover WEBKIT_COMPLETION_DIAGNOSTIC debug console.log in
the same spec was removed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015U1iPxsADQ2yuksNmDuvvW
|
daf680f3 | fix(word-addin): give copied documents their own chat identity | Amal | 2026-08-12 | ↗ GitHub |
commit body WHY THIS MATTERS
The add-in identifies a document by a UUID stored in
Office.context.document.settings -- and Office embeds those settings in
the .docx itself. "Save As" or a file copy therefore carries the UUID
into the copy, which silently inherits the original's ENTIRE chat
history (cloud and local storage are both keyed by this ID) and its
tracked-edit anchor registry. Chats about one contract surface inside a
different file; anchors point at revisions that don't exist there.
WHAT IS THE FAILURE MODE
Document settings are the right place for identity (they survive
renames and moves), but they conflate "same document" with "same file
lineage". A second identity signal is needed to tell a moved original
apart from a spawned copy.
HOW IT WORKS
A companion setting stores the normalized document URL next to the
UUID. On load:
- both stored and current URL known and different -> this is a copy:
mint a fresh UUID (createSecureUuid), persist it with the new URL,
and clear the stale anchor registry -- all batched into one saveAsync;
- stored URL missing (first open after upgrade) -> keep identity,
adopt the current URL;
- current URL empty or unavailable (unsaved doc) -> keep identity,
store nothing.
Normalization is trim + trailing-slash strip + lowercase, deliberately
without SharePoint URL canonicalization: an over-eager "copy" verdict
would orphan real chat history, so ambiguity always resolves to
keeping the existing identity.
The e2e Office mock gains a seedable document.url; new specs cover the
Save As path (fresh document_id, updated URL setting, anchor registry
cleared) and both keep-identity paths.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015U1iPxsADQ2yuksNmDuvvW
|
f102d300 | fix(word-addin): scope Escape to the open dropdown inside modals | Amal | 2026-08-12 | ↗ GitHub |
commit body WHY THIS MATTERS
Pressing Escape with a select dropdown open inside the workflow modals
closed BOTH the dropdown and the modal, discarding everything typed
into the form. Escape should peel one layer at a time: first the
dropdown, then (on a second press) the modal.
WHAT IS THE EVENT-ORDER MECHANISM
Radix's dismissable layer registers a capture-phase keydown listener on
document; the Modal listens bubble-phase on window. Capture on document
runs before the event bubbles back out to window, so stopping
propagation inside the Radix handler kills the event before the Modal
ever sees it -- no timing hacks, just DOM event phases.
HOW IT WORKS
ModalSelect passes onEscapeKeyDown={(e) => e.stopPropagation()} to its
DropdownContent. The dropdown still closes (no preventDefault), and a
second Escape -- with the Radix layer unmounted -- bubbles to window
and closes the modal as before. The behavior is opt-in at the
ModalSelect call site rather than baked into the shared Dropdown
primitive, because that primitive also serves non-modal surfaces
(header menu, history, document source, model toggle) where swallowing
Escape could collide with the prompt editor's document-level handler.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015U1iPxsADQ2yuksNmDuvvW
|
149e79af | fix(word-addin): harden local loading and document IDs | willchen96 | 2026-08-12 | ↗ GitHub |
6dd49083 | fix(word-addin): address PR review feedback | willchen96 | 2026-08-12 | ↗ GitHub |
28acdbd5 | test(chat): align prior-event query mock | willchen96 | 2026-08-13 | ↗ GitHub |
c7d58746 | fix(word-addin): stabilize WebKit composer interactions | willchen96 | 2026-08-13 | ↗ GitHub |
3bcae4fb | test(word-addin): keep composer overlap protected | willchen96 | 2026-08-13 | ↗ GitHub |