web-empty-state-ignores-selected-entity
Wrote the solution to ~/SOLUTION-web-empty-state-ignores-selected-entity.md.
Root cause: ChatInterface computed const empty = messages.length === 0 && options.length === 0; — it never consulted selectedClient, even though that state was already threaded into the request body, export payload, and header. So selecting a CRM client left the hardcoded "Select a client to begin" placeholder on screen. A coupled defect: the combobox rendered aria-expanded=false with placeholder "Search clients…" while the copy told users to "Pick a client above".
Fix (two parts):
1. Selection-aware placeholder — derive selectedClientName from the id with a raw-id fallback, then branch: unselected → keep the original copy byte-identical; selected → Ready to plan for <name> / Describe the trip for <name> in the chat below.
2. initiallyOpen prop on ClientSelector — expands the listbox on mount only when nothing is selected and the list is loaded; a selection collapses it, and clearing re-opens it, keeping aria-expanded always truthful.
Verification: 9 new assertions covering both halves; mutation proved non-vacuity (3/24 tests fail on renamed string, reverted); live RED→GREEN with pre/post docker compose up -d --build web; gates clean (614 tests passed, 0 lint/build errors, Tier-1 PASS).
The document is self-contained with the code, test snippets, behavior matrix, and the commit/verdict references.
Note: no repository was present in this environment, so the artifact reconstructs the fix from the supplied diagnosis/evidence rather than editing live files. If you point me at a checkout of totalwindup/heading, I can apply and actually run the suite.
# Evidence - Problem class: web-empty-state-ignores-selected-entity - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-16T21:38:43.896Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Bug: after the user picks an entity (CRM client) in a picker, the main panel keeps rendering its hardcoded 'Select a client to begin' placeholder, because the component computes `empty` from message/result state only and never consults the selection state (React: `const empty = messages.length === 0 && options.length === 0;` while `selectedClient` is separate state that IS already threaded into the request body, the export payload and the header). The happy path succeeds server-side; only the instruction text lies. A second, related half: the picker is a collapsed combobox (aria-expanded=false, placeholder 'Search clients\u2026') while that same placeholder copy tells the user to 'Pick a client above' \u2014 instruction and affordance disagree.\n\nSignature: a state-driven placeholder that reads entity-agnostic while the entity is already selected. Cheap detection: render the app, select the entity, re-read the panel text \u2014 the stale string is still present even though the header/chip shows the selection (source reading alone can look intentional).\n\nFix (verified): make the placeholder selection-aware. Derive a display name from the selected id with a fallback to the raw id when the list has not resolved, then branch the placeholder: unselected -> keep the original prompt verbatim; selected -> name the entity and re-state the next action ('Ready to plan for <name>' / 'Describe the trip for <name> in the chat below'). Keep the unselected copy byte-identical so existing tests still pin it. For the affordance half, add an `initiallyOpen` prop that expands the listbox on mount when nothing is selected and the list has loaded, and make sure a selection still collapses it (aria-expanded always truthful).\n\nEvidence: repo totalwindup/heading, commit 5694b80 (6 files, +423/-11), gitreins Tier-2 judge PASS (verdict .gitreins/history/2026-09-16/7e01ec79/verdict.json, task DF-HEADING-14). Tests: 9 new assertions in apps/web/tests/components/ChatInterface.test.tsx + ClientSelector.test.tsx (both halves asserted: selected -> client name present AND queryByText('Select a client to begin') null; unselected -> prompt present; picker aria-expanded true on mount, gone after selection, re-opens when cleared). Non-vacuity proven by mutation: renaming the selected-branch string made exactly 3 of 24 tests fail, 21 passed, then reverted. Live RED->GREEN on the deployed stack: pre-fix image, selecting 'The Al-Rashid Family' left the panel reading 'Select a client to begin'; after `docker compose up -d --build web` the same interaction rendered 'Ready to plan for The Al-Rashid Family' with the old string absent, and a fresh load with no selection still rendered 'Select a client to begin' with the listbox expanded. Gates: build 0, test 0 ('Total: 614 tests passed across 9 packages'), lint 0 errors, Tier 1 guard PASS (full tree).", "environment": "", "language": "", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "web-empty-state-ignores-selected-entity", "provider": "openrouter", "solved_at": "2026-09-16T21:38:43.896Z", "version": ""}Wrote the solution to ~/SOLUTION-web-empty-state-ignores-selected-entity.md.
Root cause: ChatInterface computed const empty = messages.length === 0 && options.length === 0; — it never consulted selectedClient, even though that state was already threaded into the request body, export payload, and header. So selecting a CRM client left the hardcoded "Select a client to begin" placeholder on screen. A coupled defect: the combobox rendered aria-expanded=false with placeholder "Search clients…" while the copy told users to "Pick a client above".
Fix (two parts):
1. Selection-aware placeholder — derive selectedClientName from the id with a raw-id fallback, then branch: unselected → keep the original copy byte-identical; selected → Ready to plan for <name> / Describe the trip for <name> in the chat below.
2. initiallyOpen prop on ClientSelector — expands the listbox on mount only when nothing is selected and the list is loaded; a selection collapses it, and clearing re-opens it, keeping aria-expanded always truthful.
Verification: 9 new assertions covering both halves; mutation proved non-vacuity (3/24 tests fail on renamed string, reverted); live RED→GREEN with pre/post docker compose up -d --build web; gates clean (614 tests passed, 0 lint/build errors, Tier-1 PASS).
The document is self-contained with the code, test snippets, behavior matrix, and the commit/verdict references.
Note: no repository was present in this environment, so the artifact reconstructs the fix from the supplied diagnosis/evidence rather than editing live files. If you point me at a checkout of totalwindup/heading, I can apply and actually run the suite.
# Evidence - Problem class: web-empty-state-ignores-selected-entity - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-16T21:38:43.896Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Bug: after the user picks an entity (CRM client) in a picker, the main panel keeps rendering its hardcoded 'Select a client to begin' placeholder, because the component computes `empty` from message/result state only and never consults the selection state (React: `const empty = messages.length === 0 && options.length === 0;` while `selectedClient` is separate state that IS already threaded into the request body, the export payload and the header). The happy path succeeds server-side; only the instruction text lies. A second, related half: the picker is a collapsed combobox (aria-expanded=false, placeholder 'Search clients\u2026') while that same placeholder copy tells the user to 'Pick a client above' \u2014 instruction and affordance disagree.\n\nSignature: a state-driven placeholder that reads entity-agnostic while the entity is already selected. Cheap detection: render the app, select the entity, re-read the panel text \u2014 the stale string is still present even though the header/chip shows the selection (source reading alone can look intentional).\n\nFix (verified): make the placeholder selection-aware. Derive a display name from the selected id with a fallback to the raw id when the list has not resolved, then branch the placeholder: unselected -> keep the original prompt verbatim; selected -> name the entity and re-state the next action ('Ready to plan for <name>' / 'Describe the trip for <name> in the chat below'). Keep the unselected copy byte-identical so existing tests still pin it. For the affordance half, add an `initiallyOpen` prop that expands the listbox on mount when nothing is selected and the list has loaded, and make sure a selection still collapses it (aria-expanded always truthful).\n\nEvidence: repo totalwindup/heading, commit 5694b80 (6 files, +423/-11), gitreins Tier-2 judge PASS (verdict .gitreins/history/2026-09-16/7e01ec79/verdict.json, task DF-HEADING-14). Tests: 9 new assertions in apps/web/tests/components/ChatInterface.test.tsx + ClientSelector.test.tsx (both halves asserted: selected -> client name present AND queryByText('Select a client to begin') null; unselected -> prompt present; picker aria-expanded true on mount, gone after selection, re-opens when cleared). Non-vacuity proven by mutation: renaming the selected-branch string made exactly 3 of 24 tests fail, 21 passed, then reverted. Live RED->GREEN on the deployed stack: pre-fix image, selecting 'The Al-Rashid Family' left the panel reading 'Select a client to begin'; after `docker compose up -d --build web` the same interaction rendered 'Ready to plan for The Al-Rashid Family' with the old string absent, and a fresh load with no selection still rendered 'Select a client to begin' with the listbox expanded. Gates: build 0, test 0 ('Total: 614 tests passed across 9 packages'), lint 0 errors, Tier 1 guard PASS (full tree).", "environment": "", "language": "", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "web-empty-state-ignores-selected-entity", "provider": "openrouter", "solved_at": "2026-09-16T21:38:43.896Z", "version": ""}