From 87e361869a86fc37d97c4c09cbbeac5d94f491ce Mon Sep 17 00:00:00 2001 From: Edwin Date: Fri, 7 Aug 2026 08:04:43 -0700 Subject: [PATCH] fix(webui): fetch harness roster on connect, not first New Session open The Playbook clip picker builds its harness rows and the 'harness >' category from state.harnesses, but that list was only populated inside openNewSessionDialog(). On a fresh page load the picker offered sessions only, and @{harness:...} clips were uninsertable from the web UI even though the empty-state help advertises them. harness.list is fleet state: fetch it in the websocket open handler via a shared refreshHarnesses() helper, so it is populated on first connect and refreshed on every reconnect (the roster can change across a daemon restart, which is exactly when reconnects happen). The New Session dialog now uses the same helper, keeping its open-time availability refresh and error surface. Regression coverage in web_smoke: a freshly loaded page must offer the harness category and @{harness:...} clips in playbookClipRootRows with no prior New Session visit, and the roster must repopulate after a forced websocket reconnect. Closes #1098 --- crates/daemon/assets/index.html | 21 ++++++-- crates/e2e/tests/web_smoke.rs | 91 +++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 4 deletions(-) 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 = clip_probe["rowKinds"] + .as_array() + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + assert!( + row_kinds.iter().any(|k| k == "category:harness"), + "fresh load must offer the harness category in the clip picker root, got {clip_probe:?}" + ); + let clips: Vec = clip_probe["clips"] + .as_array() + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + assert!( + clips.iter().any(|c| c.starts_with("@{harness:")), + "fresh load must offer @{{harness:…}} clips in the relevance section, got {clip_probe:?}" + ); + + // …and the roster must survive a websocket reconnect: clear it, drop the + // socket, and expect the reconnect's open handler to refetch it. + page.evaluate( + r#" + (() => { + state.harnesses = []; + state.reconnectDelay = 1000; + state.ws.close(); + })() + "#, + ) + .await + .expect("force ws reconnect"); + let after_reconnect = wait_for_harness_roster(&page).await; + assert!( + after_reconnect["harnessCount"].as_u64().unwrap_or(0) > 0, + "harness roster must be refetched after reconnect, got {after_reconnect:?}" + ); + // Creating a session while viewing a session inside a project should // inherit that project, matching the TUI's new-session semantics. let inherited_project: serde_json::Value = page @@ -4410,6 +4460,47 @@ impl Drop for ScreencastRecording { /// screencast in JPEG mode, and spawn a task that writes each /// frame to `/_frames/frame_NNNN.jpg` /// (zero-padded so ffmpeg's image2 demuxer can sequence them). +/// Poll until `state.harnesses` is populated (the connect flow fetches it +/// asynchronously after the socket opens), then return a probe of the +/// Playbook clip picker's root rows built from it (#1098). +async fn wait_for_harness_roster(page: &Page) -> serde_json::Value { + let deadline = Instant::now() + Duration::from_secs(15); + loop { + let probe: serde_json::Value = page + .evaluate( + r#" + (() => { + const rows = playbookClipRootRows(""); + return { + harnessCount: state.harnesses.length, + rowKinds: rows.map((r) => + r.type === "clip" + ? `clip:${r.item.kind}` + : r.type === "category" + ? `category:${r.group}` + : r.type + ), + clips: rows + .filter((r) => r.type === "clip") + .map((r) => r.item.clip), + }; + })() + "#, + ) + .await + .expect("evaluate clip picker probe") + .into_value() + .expect("json value"); + if probe["harnessCount"].as_u64().unwrap_or(0) > 0 { + return probe; + } + if Instant::now() > deadline { + panic!("state.harnesses never populated from the connect flow: {probe:?}"); + } + tokio::time::sleep(Duration::from_millis(150)).await; + } +} + async fn start_screencast(page: &Page, name: &str) -> anyhow::Result { let frames_dir = artifact_dir()?.join(format!("{name}_frames")); let _ = std::fs::remove_dir_all(&frames_dir);