diff --git a/crates/daemon/assets/index.html b/crates/daemon/assets/index.html index 32984d81..ffefbd8d 100644 --- a/crates/daemon/assets/index.html +++ b/crates/daemon/assets/index.html @@ -6418,6 +6418,10 @@
// disconnected (playbook/state pushes were missed); drop them so the next // widget render refetches. state.widgetPlaybookById.clear(); + // Harness roster for the Playbook clip picker (and the New Session + // dialog). Refetched on every (re)connect: the set can change across a + // daemon restart, which is exactly when reconnects happen. + refreshHarnesses().catch(() => {}); // Fetch the shared layout on every (re)connect, not just the first: a // client that was disconnected missed the broadcasts, so its tree is // stale by definition. Read it before the session-list selection, but do @@ -6535,6 +6539,16 @@ // --- Sessions list ------------------------------------------------------- +// The harness roster is fleet state, not New-Session-dialog state: the +// Playbook clip picker builds its `@{harness:…}` rows from it, so it must be +// populated on every (re)connect, not only after the New Session sheet has +// been opened once (#1098). +async function refreshHarnesses() { + const harnesses = await rpc("harness.list", null); + state.harnesses = Array.isArray(harnesses) ? harnesses : []; + return state.harnesses; +} + async function refreshSessions(startingLayoutTree = null) { try { // Fetch sessions + projects in parallel. Projects arrive as @@ -17872,13 +17886,12 @@ async function openNewSessionDialog() { // Capture before anything in this dialog takes the caret (issue #1074). const invoker = captureOverlayInvoker(); - // Populate the harness dropdown from `harness.list`. Filtered to + // Populate the harness dropdown from the shared roster. Re-fetched on + // open (not just read from state) so availability is current, filtered to // available harnesses so the user can't pick one that the daemon // would reject. - let harnesses = []; try { - harnesses = await rpc("harness.list", null); - state.harnesses = Array.isArray(harnesses) ? harnesses : []; + await refreshHarnesses(); } catch (e) { newSessionErrorEl.textContent = `harness.list failed: ${e.message}`; newSessionErrorEl.hidden = false; diff --git a/crates/e2e/tests/web_smoke.rs b/crates/e2e/tests/web_smoke.rs index 40167094..a5418c25 100644 --- a/crates/e2e/tests/web_smoke.rs +++ b/crates/e2e/tests/web_smoke.rs @@ -130,6 +130,56 @@ async fn web_client_loads_and_websocket_connects() { "expected 'session(s)' in rendered body, got:\n{body}" ); + // Regression #1098: the Playbook clip picker builds its harness rows from + // `state.harnesses`, which used to be populated only by opening the New + // Session dialog. On a fresh page load — no dialog visit — typing `@` + // must already offer harness clips and the `harness ▸` category, so the + // roster has to arrive with the connect flow itself. + let clip_probe = wait_for_harness_roster(&page).await; + let row_kinds: Vec