fix(tui): work-bar rows are persistent, labeled, clickable objects in every panel

Owner-reported 0.9.4 release blockers (HANDOFF-ALL-ISSUES-2026-08-04 A1+A2):
the to-do list lost its status words and click-to-select, and sub-agents had
no permanent home after spawn. Root causes were three separate cuts, two of
which predate the 08-04 rebuild:

- has_live_item (5db74a09d) emptied the whole Top projection the moment no
  row was live, killing settled to-dos, finished workers, and the goal title
  with them; 7ca247aad's 4s recent-only TTL + user-turn force-hide evicted
  the same rows on side placements.
- 2011b9b11 dropped the status word from pending to-do rows (conflating the
  state label with the redundant 'plan step' kind label), and 7b20ef513
  stopped painting the agent status word entirely.
- 2baf1627b's panel unification wiped every hitbox in non-Tasks panels, so a
  user whose rail_panel migrated to pinned/agents (any classic sidebar_focus
  of pinned/work/plan/todos) could not click a single work-bar row.

The fix makes the work bar a standing register of the session's work:

- Persistence: plan-step and worker rows are durable in ordered_rows —
  exempt from the recent-only TTL, the user-turn force-hide, and the
  all-settled collapse. Transient operation/activity receipts keep their
  #4688/#4690 lifetimes. has_live_item is gone; the goal title survives
  settled work. Quiet completion (FINISH entry 36), not eviction.
- Labels: to-do rows always carry their state in the /task digest vocabulary
  (pending / in progress / completed / cancelled); agent rows regain a
  status-word column (new AgentRowFacts::status), degraded only when the row
  is down to the objective alone.
- Clicks: Agents and Pinned panels now route through the same WorkRow/hitbox
  machinery as Tasks (visible_rows_for_panel), so click and Enter open the
  row's world in every panel and placement, including finished agents.
  Context stays a fact list. A click after the pager closed itself reopens
  the detail instead of being swallowed by the stale opened owner.
- The four doc-comments that would have re-derived the regression are
  corrected in place (project_visible contract, graph_node_row label note,
  render_panel scope, panels.rs auto-fit note), plus a row-lifetime section
  in the module header.

Verified: cargo test -p codewhale-tui --bin codewhale-tui (9719 passed, 0
failed; the one engine-test failure in a prior run was a parallelism flake
that passes in isolation and in the rerun), clippy --all-targets -Dwarnings
clean, cargo fmt applied. New coverage: persistence across TTL+user turns,
status-word projection+paint, per-panel hitbox dispatch (finished agent
detail opens; Pinned to-do opens inspector), stale-opened reopen, and three
real-PTY probes (goal-title click, mid-stream click, and a pinned-panel
click that fails on the pre-fix binary). Authored with agent assistance
(Claude); regression brief from codewhale-ops reports 2026-08-04.
This commit is contained in:
Hmbown
2026-08-04 07:54:38 -07:00
parent 08fcefcc0a
commit c16946c106
11 changed files with 873 additions and 83 deletions
+14 -1
View File
@@ -24,7 +24,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
An empty panel now collapses like the Tasks panel always has, and the settings
migration no longer folds the default `sidebar_focus = "auto"` into a pinned
always-on strip, which had silently handed that panel to every user who had a
settings file at all.
settings file at all. (An *empty* panel collapses; a panel holding settled
to-dos or finished workers is not empty — see the standing-register entry
below.)
- The work bar is a standing register again: settled to-dos and finished
sub-agents keep their rows for the rest of the session instead of being
evicted four seconds after the work settles (or on the next user turn), the
active goal title stays up with them, to-do rows say their state in words
(pending / in progress / completed / cancelled), and sub-agent rows carry a
status-word column next to the type. Every work row is a door in every rail
panel and placement: to-dos and sub-agents in the Agents and Pinned panels
now route through the same row machinery as Tasks, so click and Enter open
the row's world (work inspector / agent details — finished agents included)
instead of doing nothing. A click after the detail pager closed itself
reopens the detail rather than being swallowed by a stale toggle.
- The rail strip yields its rows to the transcript when the terminal cannot
seat both, so the idle ocean survives at 24 rows instead of being evicted.
- `code_execution` and `js_execution` no longer describe themselves to the model
+9
View File
@@ -19967,6 +19967,15 @@ mod work_surface {
);
agent.nickname = Some(AGENT_MARK.to_string());
app.subagent_cache.push(agent);
// A fan-out, not a single worker: the yield tests need the panel's
// natural height (heading + rows + divider) to sit above the
// TOP_HEIGHT_MIN clamp, or "the strip yielded rows" becomes
// unobservable now that the Agents panel row projection is more
// compact than the old line list.
app.subagent_cache.push(make_subagent(
"agent_rail_probe_b",
crate::tools::subagent::SubAgentStatus::Running,
));
assert!(
should_render_empty_state(&app),
"the Agents fixture must leave the session idle — the ocean it is \
+4 -2
View File
@@ -5,7 +5,7 @@ use crate::tui::app::{App, SidebarRowAction};
use super::interaction::{activate_primary, claim_focus, close_opened, release_focus};
use super::model::{
SIDE_WIDTH_MAX, SIDE_WIDTH_MIN, TOP_HEIGHT_MAX, TOP_HEIGHT_MIN, WorkRow, WorkRowId,
WorkSurfacePlacement, project_visible,
WorkSurfacePlacement, visible_rows_for_panel,
};
#[derive(Debug, Default)]
@@ -19,7 +19,9 @@ pub struct MouseOutcome {
/// a local stop arm / open detail first). Plain printable input always returns
/// ownership to the composer instead of becoming a hidden panel shortcut.
pub fn handle_key(app: &mut App, key: KeyEvent) -> Option<Option<SidebarRowAction>> {
let rows = project_visible(app);
// Keyboard and mouse share one row source per panel: Enter on the
// selected row must open the same world a click would.
let rows = visible_rows_for_panel(app);
if rows.is_empty() {
return None;
}
+22 -2
View File
@@ -40,8 +40,15 @@ pub fn activate_primary(
primary: Option<SidebarRowAction>,
) -> Option<SidebarRowAction> {
if app.work_surface.opened.as_ref() == Some(row_id) {
// Toggle-close only while the detail is actually on screen. When the
// pager closed itself (q/Esc inside it), `opened` is a stale owner —
// swallowing the click here would make the row look dead, so fall
// through and reopen instead.
let detail_on_screen = app.view_stack.top_kind() == Some(ModalKind::Pager);
close_opened(app);
return None;
if detail_on_screen {
return None;
}
}
app.work_surface.selected = Some(row_id.clone());
let action = primary?;
@@ -95,8 +102,21 @@ mod tests {
};
assert!(activate_primary(&mut app, &row, Some(open.clone())).is_some());
assert_eq!(app.work_surface.opened.as_ref(), Some(&row));
assert!(activate_primary(&mut app, &row, Some(open)).is_none());
// With the detail pager on screen, the second activation toggles it
// closed; with no pager on screen (it closed itself), the activation
// reopens instead of going dead.
app.view_stack.push(crate::tui::pager::PagerView::from_text(
"Agent".to_string(),
"body",
40,
));
assert!(activate_primary(&mut app, &row, Some(open.clone())).is_none());
assert!(app.work_surface.opened.is_none());
assert!(activate_primary(&mut app, &row, Some(open.clone())).is_some());
assert_eq!(app.work_surface.opened.as_ref(), Some(&row));
// Pager already gone (closed from inside): reopen, don't swallow.
assert!(activate_primary(&mut app, &row, Some(open)).is_some());
assert_eq!(app.work_surface.opened.as_ref(), Some(&row));
}
#[test]
+259 -13
View File
@@ -30,10 +30,30 @@
//!
//! Shared rules: content drives size; the setting is a ceiling, never padding;
//! empty work is not a rail. Top never paints a chrome panel title (a checklist
//! reads as a checklist). Side rails keep a muted title because a full-height
//! column among other chrome needs naming. Narrow hosts that cannot fit a side
//! reads as a checklist); side rails are named by their content's own heading
//! row (`Work · …`, `▾ Subagents N`, `Goal: …`) except Context, which keeps a
//! muted panel title over its fact list. Narrow hosts that cannot fit a side
//! column fall back to Top, where height auto-fit takes over.
//!
//! ## Row lifetime
//!
//! The strip is a standing register of this session's work, not a live-only
//! view. A to-do or sub-agent row appears when the work exists and stays for
//! the rest of the session after it settles — completion is quiet (glyph,
//! tone, frozen receipt), never an eviction, and the active goal title
//! outlives the work under it. Only transient receipts (aggregated file
//! activity, settled operations) expire on the #4688/#4690 lifetimes.
//! Auto-fit and the row budget decide how many rows are *visible* at once;
//! they never decide membership.
//!
//! ## Rows are objects — in every panel
//!
//! Tasks, Agents, and Pinned all render through one row/hitbox pipeline:
//! every visible work row is selectable, hoverable, and clickable, and its
//! primary action opens the row's world (agent details / work inspector).
//! Keyboard Enter and mouse click dispatch identically. Context is the one
//! line-list panel; it holds facts, not rows.
//!
//! Height is decided once per frame by [`render::height`]; the row budget it is
//! given comes from `crate::tui::ui::rail_row_budget`, which is its only
//! production caller.
@@ -1232,7 +1252,7 @@ mod tests {
assert_eq!(
fleet_row(&rows),
" ▸ general-purpose Streaming dead-code removal \
" ▸ general-purpose running Streaming dead-code removal \
12m 33s · ↓ 111.9k tokens"
);
// The group header the strip already had stays put.
@@ -1335,21 +1355,27 @@ mod tests {
#[test]
fn fleet_row_drops_tokens_then_elapsed_then_type_as_the_surface_narrows() {
// Settled degradation order. The objective is the last thing to go and
// every column truncates rather than wrapping.
// Settled degradation order: tokens first, then elapsed, then the
// type and status columns together. The objective is the last thing
// to go and every column truncates rather than wrapping. The status
// word outlives the whole receipt — a fleet row that cannot say its
// state in words has lost the fact the strip exists to show.
let mut app = fleet_app(Some(111_900));
let medium = fleet_row(&render_rows(&mut app, 62, 4));
let medium = fleet_row(&render_rows(&mut app, 72, 4));
assert!(medium.contains("12m 33s"), "{medium}");
assert!(!medium.contains("tokens"), "{medium}");
assert!(medium.contains("general-purpose"), "{medium}");
assert!(medium.contains("running"), "{medium}");
let narrow = fleet_row(&render_rows(&mut app, 44, 4));
let narrow = fleet_row(&render_rows(&mut app, 56, 4));
assert!(!narrow.contains("tokens"), "{narrow}");
assert!(!narrow.contains("12m 33s"), "{narrow}");
assert!(narrow.contains("general-purpose"), "{narrow}");
assert!(narrow.contains("running"), "{narrow}");
let tight = fleet_row(&render_rows(&mut app, 28, 4));
assert!(!tight.contains("general-purpose"), "{tight}");
assert!(!tight.contains("running"), "{tight}");
assert!(tight.contains("Streaming"), "{tight}");
for line in [&medium, &narrow, &tight] {
@@ -1699,8 +1725,10 @@ mod tests {
/// Render-level smoke coverage for the ported rail panels — reinstates
/// the sidebar render smoke tests removed with the classic shell
/// (739616787). Placement decides chrome: side rails keep a muted panel
/// title; Top never spends a row on one (content is self-evident).
/// (739616787). Top never spends a row on panel chrome (content is
/// self-evident). Side rails are named by their content's own heading
/// row (`▾ Subagents N`, `Goal: …`); Context is the one line-list panel
/// and keeps its muted panel title.
#[test]
fn rail_panels_render_in_all_placements() {
for panel in [
@@ -1784,10 +1812,36 @@ mod tests {
rail.is_some() || strip > 0,
"{panel:?} in {placement:?} should reserve a rail"
);
assert!(
text.contains(panel.title()),
"{panel:?} in {placement:?} should render its muted title; got: {text}"
);
// Work-row panels are named by their content heading;
// only the Context fact list keeps a panel title.
match panel {
super::RailPanel::Agents => {
assert!(
text.contains("Subagents 1"),
"{panel:?} in {placement:?} should render its \
Subagents heading; got: {text}"
);
assert!(
!app.work_surface.hitboxes.is_empty(),
"{panel:?} in {placement:?} must record hitboxes — \
every work row is a door"
);
}
super::RailPanel::Pinned => {
assert!(
text.contains("Goal: ship the release"),
"{panel:?} in {placement:?} should render the goal \
heading; got: {text}"
);
}
_ => {
assert!(
text.contains(panel.title()),
"{panel:?} in {placement:?} should render its muted \
title; got: {text}"
);
}
}
}
super::WorkSurfacePlacement::Off => {}
}
@@ -2557,8 +2611,200 @@ mod tests {
let open = row.primary_action.clone();
assert!(super::interaction::activate_primary(&mut app, &row.id, open.clone()).is_some());
// The action's pager is on screen, so the second activation is a
// toggle-close.
app.view_stack.push(crate::tui::pager::PagerView::from_text(
"Work · test".to_string(),
"body",
40,
));
assert!(super::interaction::activate_primary(&mut app, &row.id, open).is_none());
assert!(app.work_surface.opened.is_none());
assert_eq!(app.work_surface.selected.as_ref(), Some(&row.id));
}
#[test]
fn a_click_after_the_pager_closed_itself_reopens_instead_of_going_dead() {
// q/Esc inside the pager pops it without clearing `opened`. The next
// click on that row must reopen its world, not be swallowed by a
// stale toggle (owner regression report, 2026-08-04).
let mut app = app();
add_todos(&mut app, 1);
let row = super::model::project(&mut app)
.into_iter()
.find(|row| row.selectable)
.expect("work row");
let open = row.primary_action.clone();
assert!(super::interaction::activate_primary(&mut app, &row.id, open.clone()).is_some());
// The pager was closed from inside itself; `opened` is now stale.
assert_eq!(app.work_surface.opened.as_ref(), Some(&row.id));
assert!(app.view_stack.is_empty());
let reopened = super::interaction::activate_primary(&mut app, &row.id, open);
assert!(
reopened.is_some(),
"a stale opened owner must not swallow the next activation"
);
assert_eq!(app.work_surface.opened.as_ref(), Some(&row.id));
}
/// The 2026-08-04 owner regression: the strip is a standing register of
/// the session's work. Settled to-dos and finished sub-agents keep their
/// rows across the recent-only TTL and across new user turns — quiet
/// completion, not eviction — and the strip keeps its height.
#[test]
fn settled_todos_and_workers_stay_after_ttl_and_user_turns() {
let mut app = app();
app.current_session_id = Some(SESSION.to_string());
{
let mut todos = app.todos.try_lock().expect("todos");
todos.add("ship the fix".to_string(), TodoStatus::Completed);
todos.add("verify the fix".to_string(), TodoStatus::Completed);
}
app.subagent_cache.push(cached_worker(
"agent-settled",
"builder",
None,
None,
SubAgentStatus::Completed,
));
app.work_surface.set_presentation_now_ms(0);
let first = super::model::project_visible(&mut app);
assert!(
first.iter().any(|row| row.id.0.starts_with("graph:")),
"settled to-dos must be listed: {first:?}"
);
assert!(
first.iter().any(|row| row.id.0.starts_with("worker:")),
"finished workers must be listed: {first:?}"
);
app.work_surface
.set_presentation_now_ms(super::model::RECENT_ONLY_TTL_MS + 1);
app.work_surface.note_user_turn_or_new_operation();
let later = super::model::project_visible(&mut app);
assert!(
later.iter().any(|row| row.id.0.starts_with("graph:")),
"a settled to-do must survive the TTL and the next user turn: {later:?}"
);
assert!(
later.iter().any(|row| row.id.0.starts_with("worker:")),
"a finished worker must survive the TTL and the next user turn: {later:?}"
);
assert!(
super::height(&mut app, 100, 40, AMPLE_BUDGET) > 0,
"the strip must keep its height while it holds settled work"
);
}
/// A to-do row says its state in words, in the `/task digest` vocabulary.
/// Dropping the words (2011b9b11 conflated them with the redundant kind
/// label) was half of owner regression A1.
#[test]
fn todo_rows_carry_their_status_words() {
let mut app = app();
add_todos(&mut app, 3);
let rows = super::model::project(&mut app);
let todo_details: Vec<&str> = rows
.iter()
.filter(|row| row.id.0.starts_with("graph:"))
.map(|row| row.detail.as_str())
.collect();
assert!(
todo_details.contains(&"in progress"),
"the active step says so in words: {todo_details:?}"
);
assert!(
todo_details.contains(&"pending"),
"a pending step is labeled, not blank: {todo_details:?}"
);
// And the words are painted, not just projected.
let text = render_text(&mut app, 100, 6);
assert!(text.contains("in progress"), "{text}");
assert!(text.contains("pending"), "{text}");
}
/// Acceptance for owner regression A2: an agent row is a door in the
/// Agents panel too, and a FINISHED agent's world still opens — the
/// panel is a standing register, not a live-only view.
#[test]
fn agents_panel_click_opens_details_even_for_finished_agents() {
let mut app = app();
app.work_surface.panel = super::RailPanel::Agents;
app.current_session_id = Some(SESSION.to_string());
app.subagent_cache.push(cached_worker(
"agent-finished",
"builder",
None,
None,
SubAgentStatus::Completed,
));
let _ = render_text(&mut app, 100, 6);
let row_y = app
.work_surface
.hitboxes
.iter()
.find(|hit| hit.id.0 == "worker:agent-finished")
.expect("finished agent row must keep a hitbox in the Agents panel")
.row_y;
let action = super::handle_mouse(
&mut app,
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 2,
row: row_y,
modifiers: KeyModifiers::NONE,
},
)
.action
.expect("click on a finished agent row must dispatch its primary action");
assert_eq!(
action,
SidebarRowAction::OpenAgentDetail {
agent_id: "agent-finished".to_string()
}
);
crate::tui::mouse_ui::apply_sidebar_row_action(&mut app, action);
assert!(
!app.view_stack.is_empty(),
"the finished agent's details must actually open"
);
}
/// Acceptance for owner regression A1: to-do rows are doors in the
/// Pinned panel too — clicking one opens the work inspector.
#[test]
fn pinned_panel_todo_rows_stay_clickable() {
let mut app = app();
app.work_surface.panel = super::RailPanel::Pinned;
add_todos(&mut app, 2);
let _ = render_text(&mut app, 100, 6);
let hit = app
.work_surface
.hitboxes
.iter()
.find(|hit| hit.id.0.starts_with("graph:"))
.expect("Pinned panel to-do rows must keep hitboxes")
.clone();
let action = super::handle_mouse(
&mut app,
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: 2,
row: hit.row_y,
modifiers: KeyModifiers::NONE,
},
)
.action
.expect("click on a Pinned to-do row must dispatch its primary action");
assert!(
matches!(action, SidebarRowAction::InspectWork { .. }),
"a to-do row opens the work inspector: {action:?}"
);
}
}
+129 -34
View File
@@ -140,6 +140,11 @@ pub(super) struct AgentRowFacts {
/// renderer falls back to this when a nickname is too wide for the
/// identity column — a name is shown whole or not at all.
pub role_label: String,
/// The status word (`running`, `completed`, `failed`, …) painted as its
/// own column. The glyph carries the same fact for scanning; the word is
/// what makes the row legible without memorizing glyph vocabulary
/// (owner regression report, 2026-08-04).
pub status: String,
/// What the agent was sent to do.
pub objective: String,
/// Wall-clock seconds, frozen once the agent is observed terminal so a
@@ -529,6 +534,13 @@ pub(super) fn project(app: &mut App) -> Vec<WorkRow> {
/// to-dos first, then current sub-agents. Tool operations, coordination
/// receipts, file activity, and generic graph headings never enter this list.
///
/// "Persistent" is a lifetime promise, not decoration: to-do and sub-agent
/// rows survive their own completion and stay on the surface for the rest of
/// the session — a completed-only list is still a list, and the strip is a
/// standing register of the session's work (GrokBuild's tasks pane), not a
/// live-only view. Completion is quiet (glyph, tone, frozen receipt), never
/// an eviction. `ordered_rows` enforces the same rule for side placements.
///
/// On Top, the list is GrokBuild-shaped without losing CodeWhale identity:
/// selectable to-dos, then a `▾ Subagents N` group header (when any workers
/// are present), then the workers. To-do density still uses the pinned
@@ -539,19 +551,7 @@ pub(super) fn project_visible(app: &mut App) -> Vec<WorkRow> {
return rows;
}
let todo_ids = app
.work_surface
.cached_graph
.as_ref()
.map(|snapshot| {
snapshot
.nodes
.iter()
.filter(|node| node.kind == NodeKind::PlanStep)
.map(|node| format!("graph:{}", node.id.as_str()))
.collect::<HashSet<_>>()
})
.unwrap_or_default();
let todo_ids = plan_step_row_ids(app);
let mut todos = Vec::new();
let mut agents = Vec::new();
for row in rows {
@@ -562,15 +562,6 @@ pub(super) fn project_visible(app: &mut App) -> Vec<WorkRow> {
}
}
let has_live_item = todos
.iter()
.chain(agents.iter())
.any(|row| !matches!(row.tone, WorkTone::Success));
if !has_live_item {
app.work_surface.latest_rows.clear();
return Vec::new();
}
let mut out = Vec::with_capacity(todos.len() + agents.len() + 1);
out.extend(todos);
if !agents.is_empty() {
@@ -586,6 +577,86 @@ pub(super) fn project_visible(app: &mut App) -> Vec<WorkRow> {
out
}
/// Row ids of the plan-step (to-do) nodes in the cached graph.
fn plan_step_row_ids(app: &App) -> HashSet<String> {
app.work_surface
.cached_graph
.as_ref()
.map(|snapshot| {
snapshot
.nodes
.iter()
.filter(|node| node.kind == NodeKind::PlanStep)
.map(|node| format!("graph:{}", node.id.as_str()))
.collect::<HashSet<_>>()
})
.unwrap_or_default()
}
/// Rows for the selected rail panel, routed through the same row/hitbox
/// machinery regardless of panel: every work row a user can see is a door
/// (`crates/tui/AGENTS.md`, "rows are objects"), whichever panel it appears
/// in.
///
/// - `Tasks` — the full live projection ([`project_visible`]).
/// - `Agents` — the sub-agent rows only, under the `▾ Subagents N` header.
/// - `Pinned` — the goal plus the plan-step to-dos, without the workers.
/// - `Context` — empty: session facts are a line list, not work rows, and
/// render outside the row machinery.
pub(super) fn visible_rows_for_panel(app: &mut App) -> Vec<WorkRow> {
match app.work_surface.panel {
RailPanel::Tasks => project_visible(app),
RailPanel::Agents => {
let rows = project(app);
let agents: Vec<WorkRow> = rows
.into_iter()
.filter(|row| row.id.0.starts_with("worker:"))
.collect();
let mut out = Vec::with_capacity(agents.len() + 1);
if !agents.is_empty() {
out.push(section_heading(
"agents",
&format!("Subagents {}", agents.len()),
"",
));
out.extend(agents);
}
app.work_surface.latest_rows = out.clone();
out
}
RailPanel::Pinned => {
let rows = project(app);
let todo_ids = plan_step_row_ids(app);
let todos: Vec<WorkRow> = rows
.into_iter()
.filter(|row| todo_ids.contains(&row.id.0))
.collect();
let mut out = Vec::with_capacity(todos.len() + 1);
// On Top the goal is already the strip title; a side column
// repeats it as its first row so the durable goal home survives
// in every placement.
if app.work_surface.effective_placement != WorkSurfacePlacement::Top
&& let Some((objective, paused)) =
crate::tui::footer_ui::active_goal_chip_state(app)
{
let flat = objective.trim().replace(['\n', '\r'], " ");
if !flat.is_empty() {
let label = if paused {
format!("Goal (paused): {flat}")
} else {
format!("Goal: {flat}")
};
out.push(section_heading("goal", &label, ""));
}
}
out.extend(todos);
app.work_surface.latest_rows = out.clone();
out
}
RailPanel::Context => Vec::new(),
}
}
/// Classify the current session against this process's session-instance
/// boot id (#4416), mirroring the `SubAgentManager` prior-session pattern
/// (#405). The probe runs once per session id. Persisted row identity is
@@ -881,8 +952,14 @@ fn ordered_rows(
// Live chrome policy:
// - actionable: heading + (optional) single activity receipt
// - recent-only: heading briefly, then empty
// - recent-only: transient receipts collapse after the TTL / next user
// turn (#4688), but settled to-dos and sub-agents are DURABLE — they
// keep their rows for the session. Quiet completion, not eviction
// (owner regression report, 2026-08-04).
// - empty: no heading
let is_durable =
|item: &RankedWorkRow| item.is_plan_step || item.row.id.0.starts_with("worker:");
let has_durable = ranked.iter().any(is_durable);
if ranked.is_empty() && source_state.is_none() {
return Vec::new();
}
@@ -894,7 +971,8 @@ fn ordered_rows(
Vec::new()
};
}
if actionable == 0 && surface.recent_only_suppressed {
let suppress_transient_recent = actionable == 0 && surface.recent_only_suppressed;
if suppress_transient_recent && !has_durable {
return Vec::new();
}
@@ -906,6 +984,9 @@ fn ordered_rows(
if item.row.id.0 == "activity:aggregate" && !show_activity {
continue;
}
if suppress_transient_recent && !is_durable(&item) {
continue;
}
live.push(item.row);
}
live
@@ -1240,6 +1321,7 @@ fn agent_rows(app: &App) -> Vec<RankedWorkRow> {
// Stamped by `order_agent_seeds`, which is where
// the indent and child count become known.
role_label: String::new(),
status: status.to_string(),
objective,
elapsed_secs: Some(
agent_elapsed_ms(app, &agent.agent_id, agent.duration_ms) / 1_000,
@@ -1315,10 +1397,13 @@ fn agent_rows(app: &App) -> Vec<RankedWorkRow> {
}),
agent: Some(AgentRowFacts {
role_label: String::new(),
status: status.to_string(),
// No manager snapshot yet, so there is no
// assignment to quote: the live activity line
// is the honest answer to "what is it doing".
objective: facts.join(" · "),
// The status word itself is the status
// column's job, so it is not repeated here.
objective: facts[1..].join(" · "),
// Neither a duration nor a usage envelope has
// been seen for this id. Both render as
// nothing rather than as `0s` / `0 tokens`.
@@ -1794,16 +1879,15 @@ fn graph_node_row(snapshot: &WorkGraphSnapshot, node: &WorkNode) -> WorkRow {
};
let state = state_label(node);
let kind = kind_label(node.kind);
// To-do rows are self-evidently plan steps — the strip's checkbox marks
// already say so, so the "plan step" kind label is pure noise there. A
// ready step shows no detail at all; other states show just the state.
// Non-step nodes keep the state · kind pair.
// A to-do row always carries its status word in the detail column, using
// the same vocabulary as `/task digest` (pending / in progress /
// completed / cancelled). Only the redundant `· plan step` KIND suffix is
// dropped — the strip's checkbox marks already say the row is a plan
// step, but they do not say its state in words, and dropping the state
// itself was the 0.9.4 regression (a pending to-do rendered no label at
// all). Non-step nodes keep the state · kind pair.
let detail = if node.kind == NodeKind::PlanStep {
if node.state == NodeState::Ready {
String::new()
} else {
state.to_string()
}
todo_state_label(node).to_string()
} else {
format!("{state} · {kind}")
};
@@ -1828,6 +1912,17 @@ fn graph_node_row(snapshot: &WorkGraphSnapshot, node: &WorkNode) -> WorkRow {
}
}
/// Status word for a plan-step (to-do) row, aligned with the four-state
/// To-do vocabulary the `/task digest` text surface uses. Graph-only states
/// keep their graph names.
fn todo_state_label(node: &WorkNode) -> &'static str {
match node.state {
NodeState::Ready => "pending",
NodeState::Initializing | NodeState::Active => "in progress",
_ => state_label(node),
}
}
fn state_label(node: &WorkNode) -> &'static str {
match node.state {
NodeState::Ready => "ready",
+14 -12
View File
@@ -1,20 +1,22 @@
//! Non-Tasks rail panels, ported from the legacy classic-shell sidebar
//! during the 0.9.4 rail unification (spec step 2). Agents, Context, and
//! Pinned render as line lists inside the one work-surface rail, in
//! whatever placement the user picked; panel selection is orthogonal to
//! placement. The Tasks panel is *not* here — it renders through the
//! row/hitbox machinery in `render.rs`.
//! Line-list rail panels, ported from the legacy classic-shell sidebar
//! during the 0.9.4 rail unification (spec step 2). Only **Context** still
//! renders this way: its lines are session facts, not work rows, so there
//! is nothing to click. Tasks, Agents, and Pinned all render through the
//! row/hitbox machinery in `render.rs` — a work row is a selectable,
//! clickable object in every panel, and a panel is just a subset of the one
//! work list.
//!
//! On Top placement the strip auto-fits its content the way Tasks always
//! did (and the way GrokBuild's tasks pane does): a two-agent fan-out is
//! two rows, not a fixed four-row band with a chrome title. The only Top
//! title is an active **goal** (not panel names like "Pinned"). Side rails
//! keep a muted panel label because a column among other chrome needs
//! naming.
//! two rows, not a fixed four-row band with a chrome title. Auto-fit
//! governs HEIGHT only, never membership — a settled to-do or finished
//! sub-agent still occupies a row (quiet completion, not eviction). The
//! only Top title is an active **goal** (not panel names like "Pinned").
//!
//! The line builders themselves still live in `tui::sidebar` (they are
//! `pub(crate)` there) while the sidebar module is wound down; the rail is
//! their only production caller now.
//! `pub(crate)` there) while the sidebar module is wound down. The Agents
//! and Pinned arms below are retained for that wind-down but are no longer
//! reachable from the rail, which routes those panels through rows.
use ratatui::text::Line;
@@ -4,7 +4,9 @@
use ratatui::layout::Rect;
use crate::tui::app::App;
use crate::tui::work_surface::model::{self, RailPanel, WorkSurfacePlacement, project_visible};
use crate::tui::work_surface::model::{
self, RailPanel, WorkSurfacePlacement, visible_rows_for_panel,
};
use crate::tui::work_surface::panels;
use super::{progress_shares_goal_row, top_goal_title, top_todo_progress};
@@ -43,10 +45,12 @@ pub fn height(app: &mut App, width: u16, terminal_height: u16, rail_budget: u16)
collapse_strip(app);
return 0;
}
// Non-Tasks panels on Top auto-fit like Tasks. Empty projections collapse
// to zero — an empty panel is not a panel. Side placements reserve via
// `split_chat` and take no top strip.
if app.work_surface.panel != RailPanel::Tasks {
// The Context fact list on Top auto-fits like the row surface. Empty
// projections collapse to zero — an empty panel is not a panel. (Auto-fit
// governs HEIGHT only; membership is the model's business, and a settled
// to-do or finished sub-agent still occupies a row.) Side placements
// reserve via `split_chat` and take no top strip.
if app.work_surface.panel == RailPanel::Context {
if app.work_surface.effective_placement != WorkSurfacePlacement::Top {
return 0;
}
@@ -80,7 +84,7 @@ pub fn height(app: &mut App, width: u16, terminal_height: u16, rail_budget: u16)
return desired.clamp(model::TOP_HEIGHT_MIN, cap);
}
let rows = project_visible(app);
let rows = visible_rows_for_panel(app);
let goal_rows = u16::from(
app.work_surface.effective_placement == WorkSurfacePlacement::Top
&& top_goal_title(app).is_some(),
@@ -249,7 +253,7 @@ pub fn split_chat(app: &mut App, area: Rect, min_chat_width: u16) -> (Rect, Opti
/// Whether a Left/Right rail should reserve columns this frame.
fn side_rail_has_content(app: &mut App) -> bool {
match app.work_surface.panel {
RailPanel::Tasks => !project_visible(app).is_empty(),
panel => panels::panel_has_useful_content(app, panel),
RailPanel::Context => panels::panel_has_useful_content(app, RailPanel::Context),
_ => !visible_rows_for_panel(app).is_empty(),
}
}
+30 -11
View File
@@ -26,7 +26,7 @@ use crate::tui::app::{App, SidebarHoverRow, SidebarHoverSection};
use crate::tui::ui_text::truncate_line_to_width;
use super::model::{
RailPanel, WorkHitbox, WorkRow, WorkSurfacePlacement, WorkTone, project_visible,
RailPanel, WorkHitbox, WorkRow, WorkSurfacePlacement, WorkTone, visible_rows_for_panel,
};
mod layout;
@@ -36,7 +36,7 @@ pub use layout::{height, split_chat};
use rows::{
AGENT_ROLE_GUTTER, AgentRowTier, agent_identity, agent_identity_cap, agent_identity_column,
agent_receipt, agent_row_styles, layout_agent_row, row_style,
agent_receipt, agent_row_styles, agent_status_column, layout_agent_row, row_style,
};
pub fn render(frame: &mut Frame, area: Rect, app: &mut App) {
@@ -74,14 +74,17 @@ pub fn render(frame: &mut Frame, area: Rect, app: &mut App) {
WorkSurfacePlacement::Off => unreachable!("off placement returned above"),
};
// Non-Tasks panels render as a titled line list and skip the row
// machinery (hitboxes, selection, todo ordinals) entirely.
if app.work_surface.panel != RailPanel::Tasks {
// Context is the one panel that is not a work-row surface: session facts
// render as a titled line list with nothing to click. Every other panel
// (Tasks, Agents, Pinned) routes through the row machinery below, so its
// rows keep hitboxes, selection, and primary actions — a work row is a
// door in every panel, not only in Tasks.
if app.work_surface.panel == RailPanel::Context {
render_panel(frame, area, body_area, app);
return;
}
let mut rows = project_visible(app);
let mut rows = visible_rows_for_panel(app);
if placement == WorkSurfacePlacement::Top {
// Literal work list only: selectable to-dos/agents plus the
// GrokBuild-style `▾ Subagents N` group header. Generic graph chrome
@@ -206,6 +209,7 @@ pub fn render(frame: &mut Frame, area: Rect, app: &mut App) {
let visible = rows.iter().skip(start).take(list_rows).collect::<Vec<_>>();
let identity_cap = agent_identity_cap(usize::from(content_area.width));
let identity_column = agent_identity_column(&visible, identity_cap);
let status_column = agent_status_column(&visible);
let mut lines = Vec::with_capacity(visible.len().saturating_add(1));
let mut hover_rows = Vec::new();
let mut hitboxes = Vec::new();
@@ -245,17 +249,24 @@ pub fn render(frame: &mut Frame, area: Rect, app: &mut App) {
UnicodeWidthStr::width(prefix.as_str()),
agent_identity(row, identity_cap),
identity_column,
status_column,
facts,
);
let (normal, muted) = agent_row_styles(app, selected, hovered, opened);
let display = format!(
"{prefix}{}{}{}{}{}",
"{prefix}{}{}{}{}{}{}{}",
laid_out.role,
if laid_out.role.is_empty() {
String::new()
} else {
" ".repeat(AGENT_ROLE_GUTTER)
},
laid_out.status,
if laid_out.status.is_empty() {
String::new()
} else {
" ".repeat(AGENT_ROLE_GUTTER)
},
laid_out.objective,
" ".repeat(laid_out.gap),
laid_out.receipt,
@@ -267,6 +278,12 @@ pub fn render(frame: &mut Frame, area: Rect, app: &mut App) {
muted,
));
}
if !laid_out.status.is_empty() {
spans.push(Span::styled(
format!("{}{}", laid_out.status, " ".repeat(AGENT_ROLE_GUTTER)),
muted,
));
}
spans.push(Span::styled(laid_out.objective.clone(), normal));
spans.push(Span::styled(
format!("{}{}", " ".repeat(laid_out.gap), laid_out.receipt),
@@ -386,10 +403,12 @@ pub fn render(frame: &mut Frame, area: Rect, app: &mut App) {
});
}
/// Render a non-Tasks rail panel (Agents / Context / Pinned) as a line list
/// in the same body area and with the same divider and scrollbar the Tasks
/// list would use. Row interactivity (hitboxes, selection, click actions)
/// is Tasks-only for now; panels scroll via the shared `scroll_offset`.
/// Render the Context panel as a titled line list in the same body area and
/// with the same divider and scrollbar the row surface would use. Context is
/// the only panel that renders here: its lines are session facts, not work
/// rows, so there is nothing to click and no hitboxes to record. Every panel
/// that shows work rows (Tasks, Agents, Pinned) goes through the row/hitbox
/// machinery in [`render`] instead.
fn render_panel(frame: &mut Frame, area: Rect, body_area: Rect, app: &mut App) {
let panel = app.work_surface.panel;
let placement = app.work_surface.effective_placement;
@@ -47,6 +47,11 @@ const AGENT_ROW_TIERS: [AgentRowTier; 4] = [
pub(super) struct AgentRowText {
/// Agent-type column, padded to the shared width. Empty once dropped.
pub(super) role: String,
/// Status word column (`running`, `completed`, …), padded to the shared
/// width. Dropped only with the identity column: a fleet row that cannot
/// say its state in words has lost the fact the owner asked for back
/// (2026-08-04 regression report).
pub(super) status: String,
pub(super) objective: String,
/// `12m 33s · ↓ 111.9k tokens`. Empty once dropped.
pub(super) receipt: String,
@@ -122,6 +127,16 @@ pub(super) fn agent_identity_column(rows: &[&WorkRow], cap: usize) -> usize {
.unwrap_or(0)
}
/// Shared width of the status-word column across the rows painted this frame.
/// Statuses come from a fixed vocabulary, so no cap is needed.
pub(super) fn agent_status_column(rows: &[&WorkRow]) -> usize {
rows.iter()
.filter_map(|row| row.agent.as_ref())
.map(|facts| UnicodeWidthStr::width(facts.status.as_str()))
.max()
.unwrap_or(0)
}
/// Fit one sub-agent row into `width`, dropping optional columns in
/// [`AGENT_ROW_TIERS`] order until the objective has room to say something.
/// Every column truncates; nothing ever wraps.
@@ -130,6 +145,7 @@ pub(super) fn layout_agent_row(
prefix_width: usize,
identity: &str,
identity_column: usize,
status_column: usize,
facts: &AgentRowFacts,
) -> AgentRowText {
for tier in AGENT_ROW_TIERS {
@@ -142,11 +158,25 @@ pub(super) fn layout_agent_row(
let pad = identity_column.saturating_sub(UnicodeWidthStr::width(identity));
format!("{identity}{}", " ".repeat(pad))
};
// The status word degrades with the identity: it survives the loss of
// tokens and elapsed, and yields only when the row is down to the
// objective alone.
let status = if tier == AgentRowTier::ObjectiveOnly || status_column == 0 {
String::new()
} else {
let pad = status_column.saturating_sub(UnicodeWidthStr::width(facts.status.as_str()));
format!("{}{}", facts.status, " ".repeat(pad))
};
let role_cost = if role.is_empty() {
0
} else {
UnicodeWidthStr::width(role.as_str()).saturating_add(AGENT_ROLE_GUTTER)
};
let status_cost = if status.is_empty() {
0
} else {
UnicodeWidthStr::width(status.as_str()).saturating_add(AGENT_ROLE_GUTTER)
};
let receipt_cost = if receipt.is_empty() {
0
} else {
@@ -155,6 +185,7 @@ pub(super) fn layout_agent_row(
let budget = width
.saturating_sub(prefix_width)
.saturating_sub(role_cost)
.saturating_sub(status_cost)
.saturating_sub(receipt_cost);
if budget < AGENT_OBJECTIVE_MIN && tier != AgentRowTier::ObjectiveOnly {
continue;
@@ -163,10 +194,12 @@ pub(super) fn layout_agent_row(
let gap = width
.saturating_sub(prefix_width)
.saturating_sub(role_cost)
.saturating_sub(status_cost)
.saturating_sub(UnicodeWidthStr::width(objective.as_str()))
.saturating_sub(UnicodeWidthStr::width(receipt.as_str()));
return AgentRowText {
role,
status,
objective,
receipt,
gap,
+347
View File
@@ -1166,6 +1166,353 @@ fn work_surface_real_rows_own_click_wheel_and_resize() -> anyhow::Result<()> {
Ok(())
}
/// Owner-reported (2026-08-04): "you cannot click a work-bar row AT ALL".
/// The bare-session click test above passes, so this probe reproduces the
/// dogfood shape it does not cover: an active goal title occupying the
/// strip's header row, then a real SGR click on a to-do row. The click must
/// open the row's world (the Work inspector pager), not just move focus.
#[test]
fn work_surface_rows_stay_clickable_under_a_goal_title() -> anyhow::Result<()> {
let _guard = qa_pty_test_lock();
let ws = make_sealed_workspace()?;
std::fs::write(
ws.home().join(".deepseek").join("config.toml"),
"[retry]\nenabled = false\n\n[notifications]\nmethod = \"off\"\ncompletion_sound = \"off\"\n",
)?;
let session_path = ws.workspace().join("goal-click-session.json");
let todos = (0..6)
.map(|index| {
serde_json::json!({
"id": index + 1,
"content": format!("todo-goal-{index:02}"),
"status": if index == 0 { "in_progress" } else { "pending" }
})
})
.collect::<Vec<_>>();
std::fs::write(
&session_path,
serde_json::to_vec_pretty(&serde_json::json!({
"schema_version": 1,
"metadata": {
"id": "pty-goal-click",
"title": "Goal-title click probe",
"created_at": "2026-08-04T00:00:00Z",
"updated_at": "2026-08-04T00:00:00Z",
"message_count": 0,
"total_tokens": 0,
"model": "deepseek-v4-pro",
"model_provider": "deepseek",
"workspace": ws.workspace(),
"mode": "agent",
"cost": {},
"cumulative_turn_secs": 0
},
"messages": [],
"system_prompt": null,
"work_state": {
"todos": {"items": todos, "completion_pct": 0, "in_progress_id": 1},
"plan": {"objective": "", "items": []}
}
}))?,
)?;
let mut h = Harness::builder(Harness::cargo_bin("codewhale-tui"))
.cwd(ws.workspace())
.clear_env()
.seal_home(ws.home())
.env("DEEPSEEK_API_KEY", "ci-test-key-not-real")
.env("DEEPSEEK_BASE_URL", "http://127.0.0.1:1")
.env("NO_ANIMATIONS", "1")
.env("RUST_LOG", "warn")
.args([
"--workspace",
ws.workspace().to_str().expect("utf-8 workspace path"),
"--no-project-config",
"--skip-onboarding",
"--mouse-capture",
"--yolo",
])
.size(40, 140)
.spawn()?;
enter_launch_session(&mut h)?;
h.send(keys::key::text(&format!(
"/load {}",
session_path.to_string_lossy()
)))?;
h.wait_for_idle(Duration::from_millis(150), Duration::from_secs(2))?;
h.send(keys::key::enter())?;
h.wait_for_text("todo-goal-00", KEY_TIMEOUT)?;
// Declare a goal: the strip now pins `Goal: …` above the rows, which is
// the header-offset condition the bare click test never exercises. The
// /goal command also fires a turn; the refused base URL fails it fast.
h.send(keys::key::text("/goal click-probe objective"))?;
h.wait_for_idle(Duration::from_millis(150), Duration::from_secs(2))?;
h.send(keys::key::enter())?;
h.wait_for_text("Goal:", KEY_TIMEOUT)?;
h.wait_for_idle(Duration::from_millis(300), Duration::from_secs(5))?;
let target = "todo-goal-03";
let (row, col) = h.frame().find_text(target).expect("rendered to-do row");
h.send(keys::mouse::click(row, col))?;
h.wait_for_text("q/Esc close", KEY_TIMEOUT)?;
h.wait_for_text(target, KEY_TIMEOUT)?;
let _ = h.shutdown();
Ok(())
}
/// A loopback SSE fixture that streams one content chunk, then deliberately
/// holds the connection open before finishing, so a PTY scenario can interact
/// with the TUI while a turn is genuinely live (`is_loading == true`).
fn spawn_slow_stream_fixture(
hold: Duration,
) -> anyhow::Result<(String, std::thread::JoinHandle<()>)> {
let listener = TcpListener::bind("127.0.0.1:0")?;
listener.set_nonblocking(true)?;
let address = listener.local_addr()?;
let handle = std::thread::spawn(move || {
let deadline = Instant::now() + Duration::from_secs(30);
let mut served = 0usize;
while served < 3 && Instant::now() < deadline {
let Ok((mut stream, _)) = listener.accept() else {
std::thread::sleep(Duration::from_millis(10));
continue;
};
let _ = stream.set_read_timeout(Some(Duration::from_secs(2)));
let mut request = [0u8; 64 * 1024];
let _ = stream.read(&mut request);
let first = format!(
"data: {}\n\n",
serde_json::json!({
"id":"chatcmpl-slow",
"object":"chat.completion.chunk",
"model":"deepseek-v4-flash",
"choices":[{"index":0,"delta":{"content":"SLOW-STREAM-HOLD"},"finish_reason":null}]
})
);
let rest = [
format!(
"data: {}\n\n",
serde_json::json!({
"id":"chatcmpl-slow",
"object":"chat.completion.chunk",
"model":"deepseek-v4-flash",
"choices":[{"index":0,"delta":{},"finish_reason":"stop"}],
"usage":{"prompt_tokens":10,"completion_tokens":4,"total_tokens":14}
})
),
"data: [DONE]\n\n".to_string(),
]
.join("");
// No Content-Length: the reader must see the first chunk while
// the turn is still open, then the tail after the hold.
let _ = stream.write_all(
format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\n{first}"
)
.as_bytes(),
);
let _ = stream.flush();
if served == 0 {
std::thread::sleep(hold);
}
let _ = stream.write_all(rest.as_bytes());
let _ = stream.flush();
served += 1;
}
});
Ok((format!("http://{address}"), handle))
}
/// Second owner-repro probe: click a to-do row while a turn is actively
/// streaming. The bare and goal-title probes both pass idle; dogfood clicks
/// happen mid-run, so pin the live-turn path too.
#[test]
fn work_surface_rows_stay_clickable_during_a_live_turn() -> anyhow::Result<()> {
let _guard = qa_pty_test_lock();
let ws = make_sealed_workspace()?;
std::fs::write(
ws.home().join(".deepseek").join("config.toml"),
"[retry]\nenabled = false\n\n[notifications]\nmethod = \"off\"\ncompletion_sound = \"off\"\n",
)?;
let session_path = ws.workspace().join("live-click-session.json");
let todos = (0..6)
.map(|index| {
serde_json::json!({
"id": index + 1,
"content": format!("todo-live-{index:02}"),
"status": if index == 0 { "in_progress" } else { "pending" }
})
})
.collect::<Vec<_>>();
std::fs::write(
&session_path,
serde_json::to_vec_pretty(&serde_json::json!({
"schema_version": 1,
"metadata": {
"id": "pty-live-click",
"title": "Live-turn click probe",
"created_at": "2026-08-04T00:00:00Z",
"updated_at": "2026-08-04T00:00:00Z",
"message_count": 0,
"total_tokens": 0,
"model": "deepseek-v4-pro",
"model_provider": "deepseek",
"workspace": ws.workspace(),
"mode": "agent",
"cost": {},
"cumulative_turn_secs": 0
},
"messages": [],
"system_prompt": null,
"work_state": {
"todos": {"items": todos, "completion_pct": 0, "in_progress_id": 1},
"plan": {"objective": "", "items": []}
}
}))?,
)?;
let (base_url, server) = spawn_slow_stream_fixture(Duration::from_secs(6))?;
let mut h = Harness::builder(Harness::cargo_bin("codewhale-tui"))
.cwd(ws.workspace())
.clear_env()
.seal_home(ws.home())
.env("DEEPSEEK_API_KEY", "ci-test-key-not-real")
.env("DEEPSEEK_BASE_URL", &base_url)
.env("NO_ANIMATIONS", "1")
.env("RUST_LOG", "warn")
.args([
"--workspace",
ws.workspace().to_str().expect("utf-8 workspace path"),
"--no-project-config",
"--skip-onboarding",
"--mouse-capture",
"--yolo",
])
.size(40, 140)
.spawn()?;
enter_launch_session(&mut h)?;
h.send(keys::key::text(&format!(
"/load {}",
session_path.to_string_lossy()
)))?;
h.wait_for_idle(Duration::from_millis(150), Duration::from_secs(2))?;
h.send(keys::key::enter())?;
h.wait_for_text("todo-live-00", KEY_TIMEOUT)?;
// Start a turn; the fixture streams one chunk then holds ~6s.
h.send(keys::key::text("hold the stream open"))?;
h.wait_for_idle(Duration::from_millis(150), Duration::from_secs(2))?;
h.send(keys::key::enter())?;
h.wait_for_text("SLOW-STREAM-HOLD", Duration::from_secs(10))?;
// Mid-stream: the turn is live. Click a to-do row and require its world
// to open, exactly as it must when idle.
let target = "todo-live-03";
let (row, col) = h.frame().find_text(target).expect("rendered to-do row");
h.send(keys::mouse::click(row, col))?;
h.wait_for_text("q/Esc close", KEY_TIMEOUT)?;
let _ = h.shutdown();
drop(server);
Ok(())
}
/// Third owner-repro probe: a user whose settings select a non-Tasks rail
/// panel (the classic sidebar_focus migration lands many upgraders on
/// Pinned) must still be able to click a to-do row and have its world open.
/// Before the 2026-08-04 fix, non-Tasks panels wiped every hitbox — clicks
/// did nothing at all, which is exactly what the owner reported.
#[test]
fn pinned_panel_rows_stay_clickable_in_a_real_pty() -> anyhow::Result<()> {
let _guard = qa_pty_test_lock();
let ws = make_sealed_workspace()?;
std::fs::write(
ws.home().join(".deepseek").join("config.toml"),
"[retry]\nenabled = false\n\n[notifications]\nmethod = \"off\"\ncompletion_sound = \"off\"\n",
)?;
let settings_dir = ws.home().join(".codewhale");
std::fs::create_dir_all(&settings_dir)?;
std::fs::write(
settings_dir.join("settings.toml"),
"rail_panel = \"pinned\"\n",
)?;
let session_path = ws.workspace().join("pinned-click-session.json");
let todos = (0..5)
.map(|index| {
serde_json::json!({
"id": index + 1,
"content": format!("todo-pinned-{index:02}"),
"status": if index == 0 { "in_progress" } else { "pending" }
})
})
.collect::<Vec<_>>();
std::fs::write(
&session_path,
serde_json::to_vec_pretty(&serde_json::json!({
"schema_version": 1,
"metadata": {
"id": "pty-pinned-click",
"title": "Pinned-panel click probe",
"created_at": "2026-08-04T00:00:00Z",
"updated_at": "2026-08-04T00:00:00Z",
"message_count": 0,
"total_tokens": 0,
"model": "deepseek-v4-pro",
"model_provider": "deepseek",
"workspace": ws.workspace(),
"mode": "agent",
"cost": {},
"cumulative_turn_secs": 0
},
"messages": [],
"system_prompt": null,
"work_state": {
"todos": {"items": todos, "completion_pct": 0, "in_progress_id": 1},
"plan": {"objective": "", "items": []}
}
}))?,
)?;
let mut h = Harness::builder(Harness::cargo_bin("codewhale-tui"))
.cwd(ws.workspace())
.clear_env()
.seal_home(ws.home())
.env("DEEPSEEK_API_KEY", "ci-test-key-not-real")
.env("DEEPSEEK_BASE_URL", "http://127.0.0.1:1")
.env("NO_ANIMATIONS", "1")
.env("RUST_LOG", "warn")
.args([
"--workspace",
ws.workspace().to_str().expect("utf-8 workspace path"),
"--no-project-config",
"--skip-onboarding",
"--mouse-capture",
"--yolo",
])
.size(40, 140)
.spawn()?;
enter_launch_session(&mut h)?;
h.send(keys::key::text(&format!(
"/load {}",
session_path.to_string_lossy()
)))?;
h.wait_for_idle(Duration::from_millis(150), Duration::from_secs(2))?;
h.send(keys::key::enter())?;
h.wait_for_text("todo-pinned-00", KEY_TIMEOUT)?;
let target = "todo-pinned-02";
let (row, col) = h.frame().find_text(target).expect("rendered to-do row");
h.send(keys::mouse::click(row, col))?;
h.wait_for_text("q/Esc close", KEY_TIMEOUT)?;
h.wait_for_text(target, KEY_TIMEOUT)?;
let _ = h.shutdown();
Ok(())
}
#[test]
fn real_coordination_details_use_typed_persisted_receipts_in_a_unix_pty() -> anyhow::Result<()> {
let _guard = qa_pty_test_lock();