fix(triage): exonerate reducer for stuck-Send + clear input on Start-over (PP-4846)#1318
Conversation
…n Start-over (PP-4846) PP-4846 (repro-first): after Discard → pick a different category → type, the Send arrow intermittently stayed disabled. It did not reproduce deterministically. Pinned the REDUCER side of that flow — the source of truth the Send button's `disabled` predicate reads (`!isFollowUpStep && inputText.trimmed.isEmpty`). `StuckSendReproTests` drives Discard → re-category → type and proves inputText is set and the step is a composition step, i.e. Send is enabled at the reducer level. So the intermittent field report is localized to the SwiftUI TextField binding (consistent with the "UIKit gesture-timeout" log + the non-determinism), NOT the state machine. Writing that test surfaced a real, separate latent bug: `.userTappedStartOver` returned to `.awaitingCategory` but did NOT clear `inputText`, so a half-typed composer survived "start fresh" — unlike Discard, which lands empty. In practice Start-over is reached with inputText already empty (post-submit), but the two reset paths should have identical composer semantics. Now both clear it. Verified: TriageBotCore reducer suites green (StuckSendReproTests + CategoryChipDebounce + StructuredEscalation, 13/13). **Scope:** the Start-over reset clears inputText; reducer-level tests for both reset paths. No SwiftUI change. **Not done:** the intermittent SwiftUI TextField(axis:.vertical) binding re-sync itself — reducer is exonerated; recommended fix is the local-@State mirror pattern already used by TicketPreviewCard, but it can't be verified without a deterministic repro, so it is not shipped blind here. **Deferred:** a simdrive repro harness for the SwiftUI race (needs a dedicated run; the field bug is intermittent).
🏗️ CodeAtlas Ledger Analysis✅ All Checks Passed♿ Accessibility (via AccessLint)
✅ No accessibility issues detected 🧪 Test Coverage (via QAAtlas)
🏛️ Architecture Analysis
🔍 Reachability Analysis
✅ No dead code detected 📊 0 files analyzed | 📦 Download Full Report Powered by CodeAtlas Ledger |
🧪 Unit Test Results📊 View Full Interactive Report ✅ ALL TESTS PASSED7964 tests | 7829 passed | 0 failed | 133 skipped | ⏱️ 8m 23s | 📊 98.3% | 📈 45.8% coverage Tests by Class — 909 classes (click to expand)
📊 Testing Coverage BreakdownUnit Test Line Coverage (testable surfaces): 45.8% Total coverage incl. UI/lifecycle: 44.8% (17 files excluded from testable denominator — see
🔗 Interactive HTML Report | CI Run Details 📦 Downloadable Artifacts
|
PP-4846 (repro-first): after Discard → pick a different category → type, the Send arrow intermittently stayed disabled. It did not reproduce deterministically in the regression pass.
What this PR establishes
The reducer is exonerated. The Send button's
disabledpredicate reads!isFollowUpStep && state.inputText.trimmed.isEmpty.StuckSendReproTestsdrives the exact flow (Discard → re-category → type) and proves the reducer leavesinputTextset and the step a composition step — i.e. Send is enabled at the state-machine level. Combined with the "UIKit gesture-timeout" log evidence and the non-determinism, that localizes the intermittent bug to the SwiftUITextField(axis: .vertical)binding, not the reducer.A separate latent bug, found + fixed. Writing the test surfaced that
.userTappedStartOverreturned to.awaitingCategorybut did not clearinputText— a half-typed composer survived "start fresh", unlike Discard which lands empty. In practice Start-over is reached with the composer already empty (post-submit), so it's latent, but the two reset paths should be identical. Now both clear it.Verification
TriageBotCore reducer suites green (StuckSendRepro + CategoryChipDebounce + StructuredEscalation, 13/13).
Not done (honest)
The SwiftUI binding race itself isn't fixed here — the recommended fix is the local-
@Statemirror pattern already used successfully byTicketPreviewCard(local text state +onChange→ reducer, decoupling live typing from reducer re-renders). It can't be verified without a deterministic repro, so it is not shipped blind. A simdrive repro harness is the right next step; see the PP-4846 ticket comment.