`useProgressToasts` and `useTaskToasts` both track their live toast ids in a
`Set` held in a ref, and every toast's `onClose` closes over that Set object.
`ref.current` is one object for the component's lifetime, so the teardown's
`liveToastIds.clear()` emptied the very container the *next* effect run would
refill.
`notifications.hide` does not fire `onClose` synchronously — it plays the exit
transition first. By then the client swap has completed and the new session may
already have `show`n the same id (both hooks derive ids from data — the progress
token, the task id — so a reconnect replaying the same call reproduces them
exactly). The stale `onClose` then deleted the *new* session's entry, so the
next tick took the `show` branch instead of `update`, Mantine refused the
duplicate id, and the toast froze at its last progress before auto-closing.
Assign a fresh `Set` in the cleanup instead, so the outgoing session's callbacks
mutate only the outgoing session's Set. The teardown comments now say why the
swap is a swap and not a clear, since the distinction is invisible otherwise.
Each hook gains a regression test that fires the captured `onClose` by hand
after the new session has re-shown the id — no timers needed, since that call
*is* the late fire. Both fail against `clear()` and pass against the swap.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RFGzWfBjghUsUVGzyY43ne
Signed-off-by: cliffhall <cliff@futurescale.com>
Copilot's second pass accepted every code fix and re-raised one item:
useProgressToasts is the only extracted hook that returns nothing, which
contradicts #2128's "each hook returns one object" criterion, and the
disagreement was recorded nowhere a reader would find it.
Its ask was either a result object or an explicit exception. Taking the
second: the hook subscribes, shows toasts and unsubscribes, and the only
state it owns is toast-id bookkeeping the caller must not see, so `{}`
would advertise an API that is not there. The criterion exists to stop a
hook handing back a long list of loose values — useTabUiState's
twenty-two is the case it was written against, and that one does return
a bundled object.
Noted in the hook's own doc block (so it reads as a decision rather than
an oversight) and on #2128 itself, so the acceptance criteria and the
implementation agree.
Signed-off-by: cliffhall <cliff@futurescale.com>
Seven of the eight comments, plus the three suppressed ones (the same
double-cast class):
- Narrow the toast hooks to `InspectorClientEventSource`, the client's
typed event-listener surface. A bare `EventTarget` cannot satisfy it
(its listener takes an `Event`, the typed one a `TypedEventGeneric`
with `detail`), so both tests now construct a real
`InspectorClientEventTarget` and dispatch through `dispatchTypedEvent`
rather than casting a stand-in. That failed to compile immediately and
caught two bad fixtures: `task()` was a `Pick<Task, "status">` where
the event map wants a full `Task`, and `toolCallTaskUpdated` was
dispatched with no `task` at all.
- Narrow `useExportActions`'s three store parameters to the single
clearing method each handler calls, so the test doubles are
structurally checked instead of cast away.
- Build real `MessageEntry` fixtures (`timestamp` is a `Date`, not `0`),
removing four more double casts.
- Fix a tautological assertion in useTabUiState.test: it compared a
callback to itself, so it held however the identity changed. Every
callback is now captured before the update and compared to those
references.
- Reword `resetTabUiState`'s doc: `exhaustive-deps` cannot infer
stability across a hook boundary, so the dependency IS named — the
stability buys no churn, not omission.
- Justify the one remaining double cast in place, per the type-safety
rule.
`useProgressToasts` still returns `void` rather than an object,
deliberately: it has nothing to return, and `{}` would imply an API that
is not there. Noted on the PR as the one deviation from #2128's
"returns one object" criterion.
Signed-off-by: cliffhall <cliff@futurescale.com>
Phase 1 of #2126. Six hooks move out of `clients/web/src/App.tsx`, taking
its component body from 4,891 to 4,267 lines with no behavior change:
- useThemeToggle — the light/dark toggle
- useTabUiState — the nine per-screen `ui` objects, the active tab,
the pinned-Protocol ids, and the session reset
- useInspectorStores — the twelve state managers as one create/destroy
lifecycle, their `core/react` hooks, and the
pagination-mode selection
- useExportActions — the eleven Clear / Export / Replay handlers
- useProgressToasts — `notifications/progress` toasts
- useTaskToasts — task-status toasts, the cancel confirmation, the
taskId -> progress map, and the in-flight
tool-call taskId ref
Holding the stores in one slot collapses `setupClientForServer`'s and
`onConfirmRemove`'s dependency arrays from twelve entries to two stable
callbacks; `destroyStores` reads the live set through a ref mirror rather
than the state it also writes.
All six hooks are at 100% on all four coverage dimensions, no `v8 ignore`
needed. Also refreshes the now-stale "~4.5k-line" description of App.tsx
in AGENTS.md and the coverage `include` comment.
Signed-off-by: cliffhall <cliff@futurescale.com>