# Evidence matrix: PR #22874, head b9d6319d60 (pass 1, read-only)

The paths below are under `apps/web/src/components/`. "After" is head b9d6319d60 at `/home/ubuntu/wt/pr-22874`. "Before" is merge-base 8d3b061121 at `/tmp/ev-22874/before`. `ComposerPrimaryActions.tsx`, `ChatView.tsx` and `useComposerSend.ts` are byte-identical before and after. Only `ChatComposer.tsx` and `composerSubmission.ts` change behavior.

## Code facts the rows depend on

1. **Enter and click share one entry point.** Enter submits the form, and the pending Submit button is `type="submit"` (`chat/ComposerPrimaryActions.tsx:187`). Both reach `submitComposer` (`chat/ChatComposer.tsx:2111`).
2. **Before:** `submitComposer` returns early when `noProviderAvailable || isSendDisabled` (before `ChatComposer.tsx:~2096-2099`). Otherwise it calls `onSend`, which is `useComposerSend`'s handler. That handler bails out on `isSendBusy`, `isConnecting`, `isRevertingCheckpoint`, `threadDetailLoading`, `sendInFlightRef` and similar (`chat/useComposerSend.ts:420-434`). Only after that does it call `onAdvanceActivePendingUserInput()` (`useComposerSend.ts:465-471`).
3. **After:** the guard is `isComposerSubmissionBlocked` (`ChatComposer.tsx:2117-2124`, `chat/composerSubmission.ts:33-45`). For `pending-user-input` it blocks only on `attachmentBlockReason` or `environmentUnavailable`. The operate-scope check still applies (`ChatComposer.tsx:2125`). `onSend` is then skipped, and `onAdvanceActivePendingUserInput()` is called directly (`ChatComposer.tsx:2156-2161`).
4. **The pending Submit button's `disabled`** is `!canOperateThread || isEnvironmentUnavailable || isResponding || (isLastQuestion ? !isComplete : !canAdvance)` (`ComposerPrimaryActions.tsx:190-195`). The `isEnvironmentUnavailable` prop is passed as `environmentUnavailable !== null || noProviderAvailable || projectSelectionRequired`:
   - desktop footer: `ChatComposer.tsx:3950-3954`
   - mobile pending actions: `ChatComposer.tsx:3857-3861`
   - The collapsed-composer primary action also folds in `noProviderAvailable` and `isSendDisabled` (`ChatComposer.tsx:1307-1316`, used at `:3662`).
   - The pending button's `disabled` does **not** include `sendDisabledReason` or `isSendBusy`.
   - **Confirmed:** with `noProviderAvailable` the click path stays disabled on head. Both footer and mobile variants are affected.
5. **`isSendBusy` clears itself once a question is pending.** `useLocalDispatchState` treats `hasPendingUserInput` as a server acknowledgement (`ChatView.logic.ts:~1224`), so `isSendBusy` is false whenever a question is pending. The ChatView prop only adds `setupQueueSendThreadKey`, `queuedRunEdit.isSaving` and `isResuming` (`ChatView.tsx:3507-3513`). A running turn (`phase === "running"`) is in neither guard, before or after.
6. **Toggling a provider instance closes its live scope.** Toggling `enabled` changes the instance entry, and the registry closes that scope (`apps/server/src/provider/ProviderInstanceRegistry.ts:180-199`). That probably tears down the Claude session that holds the open AskUserQuestion. The recording must show whether the question card survives (see row A).

## Matrix

| Row | Precondition and how to reach it (synthetic data only) | Action | Expected BEFORE (8d3b061121) | Expected AFTER (b9d6319d60) | Code that predicts it | Recording |
|---|---|---|---|---|---|---|
| **A** | **Pending question, no provider available, answered by Enter.** Start an isolated dev server on a fresh `--home-dir` under `/tmp/ev-22874/home-{before,after}` with a throwaway demo repo (`/tmp/ev-22874/demo-repo`). Start a real Claude turn: "Use your AskUserQuestion tool to ask me to pick a colour (red/blue), then reply with my pick." Wait for the question card, pick an option or type a custom answer. Then get `noProviderAvailable`: Settings > Providers, turn Claude off. Check that the composer placeholder reads "Enable a provider in Settings to send a message" and that the question card is still shown. **Survival risk (fact 6):** disabling the instance closes its scope, which probably ends the Claude session. If the card disappears, the row cannot be reached this way. Fallback: keep the card visible and record it disappearing as the finding. Do not fake a request. | Focus the composer and press Enter. | Nothing happens: no request is sent, no toast, the card stays. `submitComposer` returns at `noProviderAvailable` and Enter is swallowed by `preventDefault`. | `onAdvanceActivePendingUserInput()` runs, sends a respond request (visible in the network tab or WS log) and the card shows "Submitting…". If the provider session is already gone, the server may reject it with an error toast. That still proves the client fix. | Before `ChatComposer.tsx:~2096-2101`. After `ChatComposer.tsx:2116-2128`, `:2156-2161`, `composerSubmission.ts:42-44`. | yes |
| **B** | **Same state as A, answered by clicking Submit.** | Hover, then click the pending Submit/Next pill. | The button renders **disabled** (opacity-64, pointer-events-none). The click does nothing. | **Unchanged: still disabled.** `isEnvironmentUnavailable` includes `noProviderAvailable` and the PR does not touch this prop. The click path for this trigger is covered only by the unit test in `composerSubmission.test.ts:212-236`. The PR body's claim that both Submit and Enter are fixed is false for this trigger. **Record as a row to show, not a blocker.** **B′ (optional):** the click path the fix does reach, where the button is enabled but `sendDisabledReason` is set (for example "Messages loading" or "Rewinding conversation"). Before: an enabled button that does nothing, which is the #9318 symptom. After: it submits. These states are short-lived (`threadDetailLoading`, `isRevertingCheckpoint`), so B′ is hard to hold on camera. Treat it as best-effort. | `ComposerPrimaryActions.tsx:190-195`. `ChatComposer.tsx:3950-3954` (footer), `:3857-3861` (mobile), `:1307-1316` and `:3662` (collapsed). B′: `ChatComposer.tsx:990-997` (an answer's `sendDisabledReason` is external ?? attachment) and before guard `isSendDisabled`. | yes |
| **C** | **Pending question while a turn is running, provider enabled.** Same Claude turn as A, but leave Claude enabled. The thread phase stays running/waiting while the tool call is open. | (1) Pick an option and press Enter. (2) Repeat on a new question by clicking Submit. | Both submit. The card resolves, Claude continues and replies with the pick. No guard blocks it: `isSendBusy` is cleared by the pending input (fact 5), `phase==="running"` is not a guard, and `useComposerSend.ts:465` routes to `onAdvanceActivePendingUserInput`. | Same: both submit and the turn continues. The answer now skips `useComposerSend` entirely. **Note:** a real "send busy" state alongside a pending question (`isSendBusy` true) is not reachable in a real app, because `useLocalDispatchState` clears it when a question appears. `isResuming`, `queuedRunEdit.isSaving` and `sendInFlightRef` are only brief windows. So C is a regression control (no change), not a before/after difference. | `chat/useLocalDispatchState.ts:26-36,63`, `ChatView.logic.ts:~1224`, `useComposerSend.ts:420-471`, `ComposerPrimaryActions.tsx:190-195`. | yes |
| **D1** | **Control: normal message with no provider.** No pending question. Claude disabled in Settings > Providers. Type "hello". | Press Enter, then try the send button. | Blocked: nothing is sent, and the send button is disabled. | Still blocked: `submissionTarget` is `provider-turn`, so `noProviderAvailable` blocks it. | `composerSubmission.ts:44`, `ChatComposer.tsx:2116`, `ComposerPrimaryActions.tsx:320-325`. | yes |
| **D2** | **Control: normal message while a turn is running.** Claude enabled. Start a long turn ("count slowly to 50 using bash sleep 1 between numbers"). | Type a follow-up, then press Enter and click. | Not sent as a new turn. It goes through `onSend` and is queued or steered by `resolveComposerDispatchMode` (`running: true`), and the turn keeps running. | Identical: the provider-turn path is unchanged apart from the guard refactor, which keeps `noProviderAvailable \|\| sendDisabledReason !== null`. Confirm the same queue/steer chip appears in both. | `ChatComposer.tsx:2162-2175`, `composerSubmission.ts:44`. | yes |
| **E** | **Question already answered, or withdrawn.** From C after answering. Or withdraw it: dismiss the question, or press Stop to interrupt the turn while the question is open. | Type "follow-up" and press Enter. | The composer is back in normal mode (normal placeholder and send button, no pending pill). The text is sent as a normal turn, not as an answer to the old request. | Same. When `activePendingProgress` is null, `submissionTarget` is `provider-turn`, so no stale `onAdvanceActivePendingUserInput` runs. A held request (`heldRequestIds`) also clears `activePendingProgress`. Check in the WS log that no respond request for the old `requestId` is sent. | `ChatComposer.tsx:658-661` (`holdingUserInput` and `activePendingProgress`), `:2116`. | yes |

## Rows that cannot be reached as written

- **A/B with a live session:** probably not reachable. Disabling Claude in Settings > Providers closes the instance scope (`ProviderInstanceRegistry.ts:180-199`), which likely ends the session that holds the AskUserQuestion. The recording must show whether the card survives. If it does not, `noProviderAvailable` together with a live answerable question cannot be produced from Settings alone. Report that rather than staging it.
- **C "send busy":** `isSendBusy` cannot be true together with a pending question, apart from brief resume or queued-edit windows. The running-turn variant is reachable, but it is a control with no before/after difference.
- **B′:** reachable only during brief states (messages loading, rewinding). Best-effort.

MATRIX_DONE
