fix(word-addin): abort workflow runs on unmount and add a visible Stop control

↗ view on GitHub · Amal · 2026-08-05 · b87520fe

WHY THIS MATTERS
WorkflowPicker's handleRun started a server-sent-events (SSE) chat
stream with no AbortSignal and no mounted guard. Switching tabs mid-run
therefore (1) left the HTTP stream open, so the backend kept the LLM
call running and kept billing the user's tokens for an answer nobody
would ever see, and (2) let every later chunk call setState on an
unmounted component - wasted work and a classic React leak pattern.
ChatPanel and DocumentActions already implement the correct discipline;
this brings the remaining two panels in line, and gives the user a
Stop button so a run started by mistake can be cancelled at all.

WHAT IS THE ABORT/MOUNTED DISCIPLINE
Two refs plus one cleanup effect:

  const abortRef = useRef<AbortController | null>(null);
  const mountedRef = useRef(true);
  useEffect(() => {
    mountedRef.current = true;
    return () => {          // runs when the tab unmounts the panel
      mountedRef.current = false;
      abortRef.current?.abort();   // tears down the fetch + SSE reader
    };
  }, []);

`AbortController.signal` is threaded into fetch; aborting it closes the
connection, which is the backend's cue to cancel the upstream LLM call.
`mountedRef` guards every setState that can fire after an await, because
a promise resolving is not evidence the component still exists. In the
catch block, `controller.signal.aborted` distinguishes a deliberate
stop (keep the partial text, show no error) from a real failure.

HOW THE FIX WORKS
  - WorkflowPicker: handleRun now creates an AbortController per run,
    passes its signal to streamAssistant, guards all setState with
    mountedRef, and treats an aborted run as "keep the partial result".
    While a run is streaming, the Run button is swapped for a Stop
    button (same pattern as ChatPanel's composer Stop), wired to
    abortRef - the visible cancel the panel never had. The initial
    listWorkflows() load gets the same mounted guard.
  - ProjectPicker: the upload flow gets a mountedRef guard so an upload
    resolving after a tab switch no longer setStates on an unmounted
    component. The upload request itself cannot be aborted mid-flight -
    the shared uploadProjectDocument() accepts no AbortSignal (and the
    vendored client is deliberately untouched) - but unlike an LLM
    stream an upload is cheap, so discarding the response suffices.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Repository open-legal-products/mike
Author Amal <mamalanand3@gmail.com>
Authored
Committed
Parents 67a8d54e
Stats 2 files changed , +76 , -17
Part of Add a Word add-in for chat and tracked rewrites

Capture this commit into my fork

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

⬇ Download capture-commit-b87520fe.md