[pull] main from Open-Legal-Products:main

✅ merged · #27 · admariner/mike ← Open-Legal-Products/mike · opened 26d ago by pull[bot] · merged 26d ago by pull[bot] · +44,428-13,338 across 286 files · ↗ on GitHub

From the PR description

See Commits and Changes for more details.


Created by pull[bot] (v2.0.0-alpha.4)

Can you help keep this open source service alive? 💖 Please sponsor : )

Our analysis

Merge automated fork update — read the full analysis →

Think the analysis missed something the PR description covers?

Commits in this PR (79)

SHA Subject Author Date
317a8f05 feat: refactor settings and enable local MFA willchen96 2026-08-13 ↗ GitHub
fe52a7cb style: use black pill buttons in settings willchen96 2026-08-13 ↗ GitHub
4e13deb5 fix: align fresh schema with pagination migration willchen96 2026-08-13 ↗ GitHub
93c72a16 style: refine settings actions willchen96 2026-08-13 ↗ GitHub
7ec5a865 Merge pull request #317 from Open-Legal-Products/settings-refactor Will Chen 2026-08-13 ↗ GitHub
Refactor settings and enable local MFA
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>
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
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
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
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
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
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
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
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
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
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
7d5c0653 Merge pull request #299 from Open-Legal-Products/word-addin-fixes Will Chen 2026-08-13 ↗ GitHub
feat(word-addin): align chat UI with web app
c5c0de9d feat(workflows): restructure workflows and quick actions willchen96 2026-08-11 ↗ GitHub
03583037 refactor(workflows): align reference assets with document tables willchen96 2026-08-11 ↗ GitHub
8f673226 style(tables): restore reference document surface willchen96 2026-08-11 ↗ GitHub
78db2e4f style(tables): tighten checkbox spacing willchen96 2026-08-11 ↗ GitHub
c6d9d52e refactor(tabular): unify document directory pickers willchen96 2026-08-11 ↗ GitHub
9f99e47c feat(workflows): support asset drag and drop willchen96 2026-08-11 ↗ GitHub
887c24ef Enable assistant replication of workflow assets willchen96 2026-08-11 ↗ GitHub
38e8fe14 Condense workflow instruction policy willchen96 2026-08-11 ↗ GitHub
88ad6c05 Separate workflow and template prompt guidance willchen96 2026-08-11 ↗ GitHub
ed29287c Require replicated template editing willchen96 2026-08-11 ↗ GitHub
8c326074 Clarify natural-language response rule willchen96 2026-08-11 ↗ GitHub
ff927f8f Match edit wrapper download card background willchen96 2026-08-11 ↗ GitHub
8df658b7 Update edit card button tones willchen96 2026-08-11 ↗ GitHub
3277e3e5 Use black reject buttons on edit cards willchen96 2026-08-11 ↗ GitHub
a7984b7f Match side panel edit button tones willchen96 2026-08-11 ↗ GitHub
68ac0f50 Restore white edit reject buttons willchen96 2026-08-11 ↗ GitHub
b04540f4 Soften white pill button shadow willchen96 2026-08-11 ↗ GitHub
029eccef Match white pills to toolbar shadows willchen96 2026-08-11 ↗ GitHub
d2e89756 Increase white pill shadow strength willchen96 2026-08-11 ↗ GitHub
978d460d Use small shadow on white pills willchen96 2026-08-11 ↗ GitHub
04531293 Use translucent edit card backgrounds willchen96 2026-08-11 ↗ GitHub
386b6331 Soften individual edit card shadows willchen96 2026-08-11 ↗ GitHub
bf720701 Refine assistant prompt and edit card surface willchen96 2026-08-11 ↗ GitHub
92f65a10 Remove colored pill button borders willchen96 2026-08-11 ↗ GitHub
d35754b6 Remove pill border width for blue and black tones willchen96 2026-08-11 ↗ GitHub
a2492c34 Remove pill border regression test willchen96 2026-08-11 ↗ GitHub
c0ff9a00 Use black edit card view buttons willchen96 2026-08-11 ↗ GitHub
99bfa236 Add table sorting controls willchen96 2026-08-11 ↗ GitHub
5e01614b Use workflow metadata filters willchen96 2026-08-11 ↗ GitHub
161f8540 fix(workflows): reconcile main after rebase willchen96 2026-08-13 ↗ GitHub
575ba7d2 chore(workflows): normalize quick action route formatting willchen96 2026-08-13 ↗ GitHub
94f2536a fix(backend): keep chat alive when default-workflow installation fails Amal 2026-08-12 ↗ GitHub
commit body
WHY THIS MATTERS
buildWorkflowStore() awaited ensureDefaultWorkflows(), which throws on any
RPC error, on every chat message - and the chat routes call it OUTSIDE
their try/catch blocks. Reproduced live: dropping the RPC (equivalent to
deploying code before the migration, or one database hiccup) and sending a
single chat message printed UnhandledPromiseRejection and the Node process
exited. The entire backend went down for every user, and the next message
after a restart killed it again.

WHAT IS AN UNHANDLED REJECTION CRASH?
Express 4 route handlers are plain functions; if you pass an async function
and it throws before you reach your own try block, nothing catches the
rejected promise. Since Node 15, an unhandled promise rejection terminates
the process by default. That turns "one bad query" into "the whole API is
down". (Express 5 forwards async errors to the error middleware; this repo
is on Express 4, so every bare async handler carries this risk.)

HOW THE FIX WORKS
Installing defaults is a convenience, not a prerequisite for chatting, so
the call is now best-effort: failures are logged and the chat proceeds with
whatever workflows already exist. The user-visible defaults still install
on the next successful list/quick-action request, whose asyncRoute wrapper
already converts errors into a 500 JSON response instead of a crash.

Found by fault-injection review of PR #309.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E4PXCdenNH5Mqhm5Sre9Zs
bdb2b4b6 perf(backend): install defaults and sync the add-on catalog once per process Amal 2026-08-12 ↗ GitHub
commit body
WHY THIS MATTERS
Two hot-path costs shipped with the workflows restructure, measured live:

1. ensureDefaultWorkflows() ran on EVERY workflow list, quick-action list,
   and chat message. After first install the RPC is a pure no-op, yet each
   call shipped the full five-default payload (multi-KB of prompt text) to
   Postgres and took pg_advisory_xact_lock(user), serializing all of one
   user's concurrent requests through that transaction forever.

2. syncWorkflowAddonCatalog() ran on every GET /workflow-addons and upserted
   all ~129 catalog rows (142 KB response, full prompt bodies) with a fresh
   updated_at on each request. One browser refresh of the Workflows page
   rewrote every row - verified by watching min/max(updated_at) advance in
   the database on each reload. The content_hash column existed precisely to
   prevent this but was computed, stored, and never compared.

WHAT IS A PER-PROCESS LATCH?
The catalog is derived from a generated module that only changes when new
code deploys, and a deploy restarts the process. So "sync once per process
lifetime" is exactly as fresh as "sync on every request" - module-level
state (a cached promise) makes the first request do the work and every
later caller await the same result. Concurrent first requests share one
sync instead of racing duplicate upserts; a failed sync clears the latch so
the next request retries rather than caching the failure.

HOW THE FIX WORKS
- ensureDefaultWorkflows() remembers per process which users it has already
  ensured and skips the RPC afterwards. Failures are not cached.
- syncWorkflowAddonCatalog() runs behind the latch, reads the stored
  (addon_key, content_hash, active) rows first, and only upserts seeds whose
  hash differs - so updated_at now changes only when content actually
  changes, and the steady-state sync is a single SELECT.
- New unit tests pin all of this: RPC called once per user, failures retried,
  hash match skips the upsert, concurrent callers share one sync, and the
  latch clears after an error.

Found by review of PR #309 (catalog rewrite measured via psql before/after
page reloads).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E4PXCdenNH5Mqhm5Sre9Zs
ed735ea1 fix(frontend): make workflow-list failures visible and independent Amal 2026-08-12 ↗ GitHub
commit body
WHY THIS MATTERS
Four failure paths in the rewritten WorkflowList were invisible or worse:

1. The initial load used Promise.all over the user's workflows AND the new
   add-on catalog, so a failing /workflow-addons endpoint blanked the whole
   workflows page - data that loaded fine before this feature existed.
2. importAddon() had no error handling at all: a failed import flipped the
   button back from "Importing..." with zero feedback, leaving the user to
   wonder whether they now own a copy (an unhandled promise rejection in
   the console was the only trace).
3. Bulk delete/import wrote "Some selected ... could not be ..." into
   loadError, which only renders inside the table's EMPTY state - invisible
   while rows exist, then leaking into the empty state much later.
4. openAddon() set the preview modal from a fetch with no staleness check:
   close the modal while the request is in flight and it pops back open;
   open A then B quickly and the modal can swap back to A's content.

WHAT IS THE PATTERN HERE?
Every fetch needs an answer to "what does the user see when this fails or
resolves late?" Promise.allSettled loads independent datasets independently;
a ref that records which add-on is currently open lets late responses be
recognized as stale and dropped; action errors get a dedicated, dismissible
banner that renders regardless of table contents.

HOW THE FIX WORKS
- Load: allSettled over (workflows, add-ons); each dataset renders or
  reports its own error (loadError for workflows, addonsError shown in the
  Add-ons tab).
- importAddon: in-flight guard (second click is a no-op), try/catch, and a
  visible "Could not import ..." banner on failure.
- Bulk actions report into the new actionError banner above the table.
- openAddon/closeAddon track the open add-on id in a ref and ignore stale
  responses.

Found by review of PR #309.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E4PXCdenNH5Mqhm5Sre9Zs
6c3d0fd5 fix(frontend): retry quick-action migration on failure; reopen Templates tab Amal 2026-08-12 ↗ GitHub
commit body
WHY THIS MATTERS
Two regressions in the assistant home's quick actions:

1. The one-shot localStorage-to-database migration stamped its "done" marker
   even when the updateQuickAction() calls failed. One transient API error
   during a user's first load after upgrading would permanently discard the
   quick-action preferences they had set in the old localStorage system -
   silent, unrecoverable data loss of user configuration.
2. "Draft from Template" used to open the document picker on the Templates
   tab (initialDocumentTab: "templates" in the pre-database quick action).
   The database-backed rewrite dropped the option, so the picker opened on
   Files - replicated live: click the quick action, the Add Documents modal
   opens with Files pressed and the user's templates a tab away.

WHAT IS A ONE-SHOT MIGRATION MARKER?
A flag ("mike.quickActions.databaseMigrated") that says "the legacy state
has been carried over, never look at it again". Such a marker must only be
written after the migration actually succeeded; writing it on failure turns
a retryable error into permanent loss. The updates are idempotent, so
retrying a partial batch on the next load is safe.

HOW THE FIX WORKS
- The marker is now only written when no migration update was rejected; on
  partial failure the merged state still renders but the marker stays unset
  so the next load retries.
- handleQuickAction() passes initialDocumentTab: "templates" for the
  template-drafting default again. The workflow title is the only stable
  handle the quick-action row exposes today (the same handle the migration
  itself matches on); if the user renames their copy the picker simply
  falls back to Files.

Found by review of PR #309 (Templates-tab regression captured on video).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E4PXCdenNH5Mqhm5Sre9Zs
5ed658b4 fix(word-addin): open reference downloads in the system browser Amal 2026-08-12 ↗ GitHub
commit body
WHY THIS MATTERS
The new Assets download button called window.open() directly. In Office
task-pane webviews - notably desktop Word - window.open is blocked, so the
click did nothing, with no error shown. This add-in already documents the
problem: ApiKeyBanner.tsx says "window.open is blocked in some hosts" and
uses the sanctioned escape hatch. New code has to follow the same rule or
the feature silently works only in a browser tab.

WHAT IS Office.context.ui.openBrowserWindow?
Office.js's supported way to open a URL from inside the task-pane sandbox:
it asks the host application (Word) to launch the user's default browser.
window.open stays as the fallback for environments without Office.js (the
hermetic e2e bundle, plain-browser development).

HOW THE FIX WORKS
- Download now resolves the signed URL and hands it to a small
  openExternalUrl() helper mirroring the ApiKeyBanner pattern.
- Bonus hardening in the same component: the reference-file list effect now
  carries a cancelled flag (fast workflow switching can no longer render a
  previous workflow's files, matching every other fetch effect in this PR)
  and a failed list fetch surfaces its error instead of quietly rendering
  "No reference files."

Found by review of PR #309.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E4PXCdenNH5Mqhm5Sre9Zs
feb2e55f fix(backend): make document replication atomic, honest, and safely worded Amal 2026-08-12 ↗ GitHub
commit body
WHY THIS MATTERS
replicate_document inserted library rows with status "ready" BEFORE
uploading any bytes. A failed upload left user-visible broken documents
(rows with no version) in Library Files; a failed current_version_id update
was silently swallowed (Supabase builders return errors in-band - they do
not reject) while the tool still reported ok:true. The repo's own
persistGeneratedFile does it right: bytes first, rows last, so the worst
failure is an invisible orphaned blob, never a broken row the user can see.

WHAT IS UPLOAD-FIRST ORDERING?
When an operation spans storage and database, order the writes so each
partial-failure state is harmless: pre-generate the document UUIDs, upload
all bytes under those ids, then insert documents + versions, then link
current_version_id checking every in-band error. Copies whose linking fails
are rolled back (best-effort row delete) and reported in a new
failed_copies array; if every copy fails the result is ok:false.

ALSO IN THIS COMMIT (same pipeline, same files)
- Raw Postgres/S3 error text no longer reaches the model/user event stream:
  the three fail() sites now wrap errors with safeErrorMessage, matching
  the streaming path's convention (secrets/endpoints stay out of chat).
- Within-turn workflow reference handles use the full workflow id instead
  of id.slice(0,8) - every "builtin-*" id shared that prefix, so two system
  workflows' assets could silently rebind each other's doc handles.
- Dead DocStore.source_id field removed (written once, read nowhere).
- The prompt mandated replicate → edit_document for ALL templates, but
  edit_document only supports .docx; a PDF template boxed the model in with
  no permitted fallback. The instruction is now scoped to .docx copies,
  with explicit guidance for other types, and the per-file notices match
  the prompt's "will be edited or filled in" scope so purely-informational
  reference files no longer trigger spurious library copies.
- Tests now pin the contract: uploads are asserted to happen before any
  documents insert (call-order log), with the exact storage key and bytes,
  plus a failure path proving no "ready" row survives a failed upload.

Found by review of PR #309.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E4PXCdenNH5Mqhm5Sre9Zs
b2116375 fix(backend): harden the workflow and quick-action routes Amal 2026-08-12 ↗ GitHub
commit body
WHY THIS MATTERS
The new routers were correct on the happy path but leaked or mis-handled
several edge cases a public API will hit:

- Quick actions created against a shared workflow survived share
  revocation: the list path re-fetched workflow titles by bare id with no
  access re-check, so a revoked user kept a live row (title included) for a
  workflow they can no longer open. withWorkflowDetails now verifies the
  caller still owns or has a share for each workflow and drops
  inaccessible quick actions (PATCH returns 404 in that case).
- sort_order above 2^31-1 passed Number.isInteger and blew up as a 500 on
  the int4 column; it is now a 400 with a clear message. WHAT IS int4?
  Postgres integer is 32-bit - API validation must enforce the column's
  real range, not JavaScript's.
- Workflow DELETE removed R2 storage objects BEFORE the DB delete; a DB
  failure left rows pointing at deleted storage. Order flipped: DB rows
  first, storage cleanup only for rows actually removed (same
  partial-failure logic as the replication commit).
- Neither new router had the tail error middleware the workflows router
  has, so thrown errors rendered Express's HTML 500 instead of the API's
  {detail} JSON contract. Both routers now share the pattern.
- POST /workflow-addons/:addonId/import performs storage downloads and
  uploads but was missing from the uploadLimiter list; added.
- Add-on import inserted explicit nulls for language/practice/
  jurisdictions, bypassing the column defaults every other creation path
  gets; now coalesced to 'English' / 'General Transactions' / ['General'].
- Removed a provably-dead filter (SYSTEM_WORKFLOW_IDS can never match DB
  UUIDs) that implied system rows could appear in the database.

Found by review of PR #309.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E4PXCdenNH5Mqhm5Sre9Zs
dcb85a1a refactor(db): fold the no-op migration 03 into 01 while the branch is unmerged Amal 2026-08-12 ↗ GitHub
commit body
WHY THIS MATTERS
Migration 20260811_03 dropped a NOT NULL that 01 never shipped and re-added
an identical foreign key - a complete no-op, because 01 had been rewritten
after 03 was authored. Landing both would leave the open-source migration
trail asking "which one is authoritative?" forever. Since none of these
migrations have shipped anywhere (they are all new in this PR), the honest
history is one final migration.

WHAT IS THE RULE FOR EDITING MIGRATIONS?
Migrations are append-only once released, freely editable before. This
branch is unmerged, so 01 is still editable; after merge, semantics changes
would require a new dated migration.

HOW
- 20260811_03_deletable_default_workflows.sql deleted; 01 already contains
  its end state (nullable workflow_id, on delete set null).
- The redundant default_workflow_installations_user_idx dropped from 01 and
  schema.sql - the unique(user_id, default_key) constraint's index already
  serves user_id lookups. Postgres implements unique constraints as
  b-tree indexes; a separate single-column index on the same leading
  column is pure write overhead.

Found by review of PR #309.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E4PXCdenNH5Mqhm5Sre9Zs
946e4978 build(workflows): stamp source provenance and retire the dead availability flag Amal 2026-08-12 ↗ GitHub
commit body
WHY THIS MATTERS
backend/src/lib/systemWorkflows.ts is 2.2 MB of generated code whose source
of truth lives in a different repository (mike-workflows), with nothing
recording WHICH commit it was generated from and no way to detect a stale
or hand-edited regeneration. Separately, the mike-availability frontmatter
flag became dead data in this PR: 31 workflows are marked "system" but the
default/add-on split is really the hardcoded DEFAULT_WORKFLOW_IDS list -
two contradictory sources of truth for a contract contributors rely on.

WHAT IS PROVENANCE FOR GENERATED CODE?
A committed generated file is only reviewable if a machine can reproduce
it: record the exact source commit in the output, and let CI regenerate
from that commit and diff. The generator now runs `git rev-parse HEAD` in
the mike-workflows checkout, stamps it into the file header, and exports
SYSTEM_WORKFLOWS_SOURCE_COMMIT for tooling to read.

DECISION ENCODED HERE
The app owns which workflows install as defaults (a product decision, in
code, reviewed in this repo); repository metadata does not. Therefore the
generator stops emitting `availability` (the key is still accepted with a
deprecation warning so existing content builds) and the generated file
drops all 135 availability lines. A new provenance test pins the contract:
the stamped commit matches /^[0-9a-f]{40}$/ and every DEFAULT_WORKFLOW_IDS
entry exists in the generated catalog.

ALSO
- pack.yaml validation is now bidirectional: a workflow directory present
  under a pack but missing from pack.workflows fails the build (previously
  a WIP folder would silently ship as part of the pack).
- localeCompare calls pin the "en" locale so regeneration is byte-stable
  across machines with different ICU collations.
- Regeneration verified: before these changes the committed file reproduced
  byte-for-byte from mike-workflows @ 4b9c7cd; after, the diff is exactly
  the header/constant plus the removed availability lines.

Found by review of PR #309.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E4PXCdenNH5Mqhm5Sre9Zs
0ca6c460 ci: fail the build when the generated workflow catalog drifts from its source Amal 2026-08-12 ↗ GitHub
commit body
WHY THIS MATTERS
With 13k generated lines, no reviewer can eyeball whether a regeneration of
systemWorkflows.ts is faithful. This was flagged in PR #256 and deferred;
PR #309 tripling the file's size makes it overdue.

HOW IT WORKS
The new workflows-drift job checks out this repo and mike-workflows as
siblings (the layout the generator expects), pins mike-workflows to the
commit stamped inside the generated file (grepped from
SYSTEM_WORKFLOWS_SOURCE_COMMIT), reruns the zero-dependency generator, and
fails on `git diff --exit-code` over the generated outputs. Any hand-edit,
stale regeneration, or locale-dependent ordering difference turns the PR
red instead of shipping silently.

Found by review of PR #309.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E4PXCdenNH5Mqhm5Sre9Zs
3a4480f2 docs: describe the real defaults/add-ons/packs model in CONTRIBUTING Amal 2026-08-12 ↗ GitHub
commit body
WHY THIS MATTERS
CONTRIBUTING.md still told workflow contributors to set
metadata.mike-availability to "system" to publish a system workflow. That
contract died in the workflows restructure: a contributor following the
docs would see their workflow silently ship as an add-on instead, with no
error anywhere. Stale contributor docs are how an open-source project
burns its first-time contributors.

WHAT THE DOCS NOW SAY
Five defaults are hardcoded in DEFAULT_WORKFLOW_IDS
(backend/src/lib/workflowCatalog.ts) and install once per user as
editable, deletable copies; every other repository workflow ships in the
Add-ons catalog and imports as an independent copy; packs come from
pack.yaml, which must list exactly the workflow directories it contains;
mike-availability is deprecated and ignored; CI regenerates the catalog
from the stamped source commit and fails on drift.

Found by review of PR #309.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E4PXCdenNH5Mqhm5Sre9Zs
cd6caeed fix(frontend): reconcile quick-action updates per action, server wins Amal 2026-08-12 ↗ GitHub
commit body
WHY THIS MATTERS
Two optimistic-update bugs could show the user state the server does not
have:

1. InitialView's edit merge spread the stale local row OVER the server
   response ({ ...updated, ...item, ...changes }), so any server-side
   normalization of prompt/document_upload never reached the UI until a
   full reload.
2. The account Features page rolled back ALL toggles when ANY of its N
   PATCHes failed - if 3 of 4 succeeded, the UI showed the opposite of
   what the server now stores.

WHAT IS THE RECONCILIATION RULE?
An optimistic update may paint local state first, but when the server
answers, the response is the truth for the fields it covers; on partial
batch failure, keep the fulfilled updates and roll back only the rejected
ones, and say so ("Some quick actions could not be updated. Try again.").

Found by review of PR #309.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E4PXCdenNH5Mqhm5Sre9Zs
2c1b6214 fix(frontend): sort indicators reflect the effective sort, not just the explicit one Amal 2026-08-12 ↗ GitHub
commit body
WHY THIS MATTERS
DocTable's new defaultSort participated in row ordering
(sort ?? defaultSort) but the header indicators only read `sort`. In the
Library (defaultSort updated/desc) every column claimed "Default Order"
while rows were actually date-sorted, and choosing "Default Order" could
never restore insertion order because null falls straight back to the
default - the menu lied twice.

HOW IT WORKS
A single hoisted effectiveSort feeds ordering, header indicators, and the
folder-name comparator, so they cannot disagree again. Where a defaultSort
exists the reset option is labeled "Default (Updated)" - truthful about
what it restores - and surfaces the effective direction as checked.
Consumers without defaultSort are byte-identical (effectiveSort === sort).

Found by review of PR #309.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E4PXCdenNH5Mqhm5Sre9Zs
baba1840 fix(frontend): confirm asset deletion and serialize reference uploads Amal 2026-08-12 ↗ GitHub
commit body
WHY THIS MATTERS
Reference-file Delete acted immediately from a row menu - one misclick
permanently destroyed a file, while every other destructive action in this
feature (workflow delete, column delete) goes through ConfirmPopup.
Uploads had a matching gap: the toolbar button disabled during an upload
but the drag-drop path forwarded drops unconditionally, interleaving
batches and clobbering the shared warning text.

HOW IT WORKS
Delete now stages the file behind the same ConfirmPopup pattern as
workflow deletion. Uploads take a synchronous in-flight guard (a ref, so a
second drop in the same render frame is still caught), the detail page
stops accepting drops while a batch runs, and warnings append instead of
overwrite so an unsupported-file notice survives a later failure.

Found by review of PR #309.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E4PXCdenNH5Mqhm5Sre9Zs
fc0f76eb chore(frontend): delete the orphaned SelectAssistantProjectModal Amal 2026-08-12 ↗ GitHub
commit body
WHY THIS MATTERS
The workflows restructure removed the three non-workflow quick actions
(start chat in project / new project / new tabular review) that were this
modal's only consumers - repo-wide grep confirms zero imports remain. Dead
components in an open-source tree actively mislead: the next contributor
assumes something renders it and reads it as living code.

THE PRODUCT DECISION (stated, not hidden)
Dropping those three quick actions is treated as intentional: they were
navigation shortcuts duplicating the sidebar, and the DB-backed quick
actions model is deliberately workflow-only. If that call is reversed,
this component should be rebuilt against the new model rather than
resurrected - its data flow predates the restructure.

Found by review of PR #309.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E4PXCdenNH5Mqhm5Sre9Zs
5dfd2650 fix(word-addin): give add-ons real load/error states; stop silent quick-action saves Amal 2026-08-12 ↗ GitHub
commit body
WHY THIS MATTERS
Three silent-failure paths in the add-in's new surfaces:

1. Add-on import errors were written into the SHARED fetchError state,
   which WorkflowList renders on the other tab - the user saw a workflow
   list error after failing to import an add-on. Imports now have their
   own error state rendered where the action happened.
2. A failed listWorkflowAddons collapsed to an empty array: a blank tab
   with no message. The tab now has loading, error, and "No add-ons
   available." states - and requests ?type=assistant server-side (the
   backend supports it) instead of over-fetching and filtering.
3. Quick-action "Done" failures were fully silent: the modal just refused
   to close. Saves now show a Saving state and a role="alert" error.
   Toggling Active also no longer writes the modal's UNSAVED draft into
   list state - the list takes the server's row; only the modal keeps the
   draft.

WHAT IS THE PARITY RULE?
The add-in mirrors the web app's UX (project convention): same
error-surfacing expectations, same server-side filtering, same "the server
response is the truth for list state" reconciliation.

Found by review of PR #309.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E4PXCdenNH5Mqhm5Sre9Zs
e9568c12 test(workflows): reset default install cache between route tests willchen96 2026-08-13 ↗ GitHub
b03d6ec6 Merge pull request #329 from Open-Legal-Products/workflows-refactor-review-fixes Will Chen 2026-08-13 ↗ GitHub
fix(workflows): review fixes - chat-path crash, catalog rewrite, silent UI failures
aac82a36 fix workflow refactor CI regressions willchen96 2026-08-13 ↗ GitHub
3382734d Merge pull request #309 from Open-Legal-Products/workflows-refactor Will Chen 2026-08-13 ↗ GitHub
feat(workflows): restructure workflows and quick actions

Capture this PR into my fork

Download a Markdown prompt that tells Claude how to port every commit in this PR into your working tree. Run it via claude -p < capture-pull-27.md from inside the repo you want the changes in.

⬇ Download capture-pull-27.md