diff --git a/FORK.md b/FORK.md index 00f3b7c93c..ce75c4922d 100644 --- a/FORK.md +++ b/FORK.md @@ -107,6 +107,7 @@ older API surface. | `RegularTerminalSummary::title`, `has_registry`, `observe_activity` | `crates/agent_threads/src/terminal_control.rs` | The terminal's display title (OSC title, or the foreground process the terminal itself derives when none was set) is what makes a shell row legible; the model hands over a never-empty title and the rail owns nothing but rendering. Additive, and the summary is no longer `Copy` (it now holds a `SharedString`) | | `dez_sidebar` settings section (`starts_open`) | `crates/settings_content/src/dez_sidebar.rs`, `assets/settings/default.json` | Configurable default-open behavior | | Sidebar registration + default-open on window creation | `crates/dez/src/dez.rs` | Wires the shell into the app; deferred open avoids acting mid-construction | +| The rail subscribes to `AgentThreadStoreEvent` and to `terminal::Event::ProcessExited`, not just observing the store | `crates/dez_sidebar/src/sidebar.rs`, `crates/agent_threads/src/{store.rs,terminal_control.rs}` | `observe` only fires on `notify()`; opening and closing a session is an *emit*. An observer-only rail keeps a dead session's row, and an agent terminal exits without any output event while its tab survives (`HideStrategy::Never`), so the rail must subscribe to the exit explicitly or a `Running` dot outlives the process | | Rail status is a marker (dot/glyph) plus a label only for `Needs input` / `Finished`; titles use the default text color | `crates/dez_sidebar/src/sidebar.rs` | A busy workspace should read as a list, not a traffic light; `Running`/`Idle` are the calm default | | Rail search field filtering workspace names, session titles, agent kinds, and working directories, with in-place match highlighting | `crates/dez_sidebar/src/{filter.rs,sidebar.rs}` | A supervision surface with more than a handful of sessions is unsearchable without it. The matcher is a synchronous subsequence scan rather than the async `fuzzy` crate because the rail rebuilds every row on every render, where an async pass would race its own keystrokes; it widens v0.6's contiguous-substring match to a subsequence, preferring a continued run then a word start so the highlight reads as the word the user typed | | `Sidebar::focus_search` + `MultiWorkspace::focus_sidebar_search`, bound to `cmd-f` / `ctrl-f` in the `Workspace` key context | `crates/workspace/src/multi_workspace.rs`, `assets/keymaps/default-*.json` | Reaching the rail's search should not require first clicking the rail, but it must not steal `cmd-f` from an open editor — hence a context-scoped binding and a route that opens a closed sidebar rather than a global one | diff --git a/SYNC-LEDGER.md b/SYNC-LEDGER.md index db1d9014f9..9838fa8a24 100644 --- a/SYNC-LEDGER.md +++ b/SYNC-LEDGER.md @@ -18,4 +18,5 @@ One line per upstream adaptation. Format: - 2026-09-28 | dez v0 sidebar (rail polish) | Rail presentation pass: status is a colored marker plus a label only for `Needs input` / `Finished`, titles use the default text color, rows share one height and one hover/selection treatment. Group headers aggregate every root (branch, summed changed count, worst indicator) and are clickable; a single-root group no longer repeats its header as a per-root row. Plain terminals that match no agent manifest classify as `Idle` rather than being dropped, so an ordinary prompt is listed. README's release section now points at the nightly artifacts while no stable tag exists. | crates/dez_sidebar/src/sidebar.rs, crates/agent_threads/src/terminal_control.rs, docs/dez-workspace-shell.md, FORK.md, README.md - 2026-09-28 | dez v0 sidebar (collapsible groups) | Rail group headers gain a disclosure chevron that folds the group's rows, driven by `MultiWorkspace`'s existing persisted group state via a new small `toggle_project_group_expanded` method; a collapsed group still shows its attention rollup. | crates/dez_sidebar/src/sidebar.rs, crates/workspace/src/multi_workspace.rs, FORK.md - 2026-09-28 | dez v0 sidebar (focus follows workspace) | Focusing a session from the rail now brings its workspace forward before focusing the pane: `AgentThreadStore::focus_thread` and `terminal_control::focus_terminal` activate the owning workspace, so clicking a session that lives in a background workspace actually shows it instead of looking like a no-op. Matches the Agent Threads panel's cross-project focus. | crates/agent_threads/src/{store.rs,terminal_control.rs}, FORK.md +- 2026-10-04 | dez v0 sidebar (truthful terminal threads) | The rail now hears sessions open and close (`AgentThreadStoreEvent` is an emit, not a notify, so the rail's `observe` alone left rows behind after a session ended) and repaints when a terminal's process exits, so a dead agent session stops showing the status dot it had while alive (settling to finished, without a second notification). `panel::tests::wakeups_unclassifiable_content_leaves_attention_unchanged` now asserts `Blocked` specifically rather than `None`, because the echo agent it spawns exits inside its own 500ms debounce window and a dead thread is legitimately settled to `Idle`. Rail clicks log `focus_thread` / `focus_priority_terminal` failures instead of discarding them, since a stale `terminal_item_id` made a row look inert with no diagnostic. | crates/dez_sidebar/src/sidebar.rs, crates/agent_threads/src/{store.rs,terminal_control.rs} - 2026-10-04 | dez v0 sidebar (rail search) | A search field under the rail header filters the rail by workspace name, session title, agent kind, and working directory, with the matched characters highlighted in place and attention-first ordering kept. Matching is a synchronous subsequence scan (ported from v0.6's `fuzzy_match_positions`, widened from substring to subsequence) because the rail rebuilds its rows on every render; a group whose own name matched keeps all of its rows. `cmd-f` / `ctrl-f` focuses the field through a new `MultiWorkspace` route, which opens a closed rail first. | crates/dez_sidebar/src/{filter.rs,sidebar.rs}, crates/workspace/src/{multi_workspace.rs,workspace.rs}, crates/dez/src/dez.rs, assets/keymaps/default-{macos,linux,windows}.json, docs/dez-workspace-shell.md, FORK.md diff --git a/crates/agent_threads/src/panel.rs b/crates/agent_threads/src/panel.rs index e71e0466a1..839861bbba 100644 --- a/crates/agent_threads/src/panel.rs +++ b/crates/agent_threads/src/panel.rs @@ -4444,11 +4444,17 @@ mod tests { cx.executor().timer(Duration::from_millis(500)).await; cx.run_until_parked(); - assert_eq!( + // `Blocked` specifically, not `None`: the echo agent this test spawns + // exits during the 500ms debounce window, and a thread whose process is + // gone is legitimately settled to `Idle` (see `mark_exited`) -- that is + // a separate transition from the one under test here. What this test + // exists to catch is Wakeup raising the thread the way a Bell does, and + // only `Blocked` is the flagging it guards against. + assert_ne!( cx.update(|cx| AgentThreadStore::global(cx) .read(cx) .thread_attention(terminal_item_id)), - None, + Some(ThreadAttention::Blocked), "unclassifiable content reached via Wakeup (not Bell) should not flag the thread" ); } diff --git a/crates/agent_threads/src/store.rs b/crates/agent_threads/src/store.rs index 25b89f5595..563279dd1f 100644 --- a/crates/agent_threads/src/store.rs +++ b/crates/agent_threads/src/store.rs @@ -1372,6 +1372,15 @@ impl AgentThreadStore { terminal::Event::Wakeup => { store.schedule_wakeup_reclassify(terminal_item_id, cx); } + // Not a wakeup: there is no more output coming, so reclassify + // now rather than behind the output debounce. Agent terminals + // are spawned with `HideStrategy::Never`, so an exiting agent + // leaves its tab -- and this store entry -- alive, and without + // this the rail would keep showing the classification the + // session had while it was still alive. + terminal::Event::ProcessExited { .. } => { + store.mark_exited(terminal_item_id, cx); + } _ => {} }, ); @@ -1490,6 +1499,33 @@ impl AgentThreadStore { ); } + /// Settles a thread whose process has exited, so a session that is no longer + /// running stops presenting the classification it had while it was alive -- + /// typically a green "Running" dot that outlives the process, because + /// agent terminals are spawned with `HideStrategy::Never` and so leave their + /// tab, their `TerminalView`, and this store entry behind. + /// + /// Deliberately *not* `reclassify_attention`: a desktop notification is + /// correct when an agent rings the bell mid-turn, but the process exiting is + /// not a fresh "look at me" signal, and notifying here would double-notify + /// a thread that had already been flagged as blocked. The rail and the + /// rollup pick the change up through the emitted event instead. + fn mark_exited(&mut self, terminal_item_id: EntityId, cx: &mut Context) { + let Some(entry) = self.threads.get_mut(&terminal_item_id) else { + return; + }; + if entry.attention == Some(ThreadAttention::Idle) { + return; + } + entry.attention = Some(ThreadAttention::Idle); + // A fresh completion, so the rail reports it as finished-and-unseen + // rather than already-looked-at. See `ThreadEntry::finished_seen`. + entry.finished_seen = false; + let kind_id = entry.metadata.kind_id; + cx.emit(AgentThreadStoreEvent::ThreadUpdated { kind_id }); + cx.notify(); + } + fn begin_shutdown( &mut self, terminal_item_id: EntityId, diff --git a/crates/agent_threads/src/terminal_control.rs b/crates/agent_threads/src/terminal_control.rs index 4b8272d560..216e91e346 100644 --- a/crates/agent_threads/src/terminal_control.rs +++ b/crates/agent_threads/src/terminal_control.rs @@ -225,10 +225,15 @@ pub(crate) fn init(cx: &mut App) { ); if let Some(terminal) = terminal.upgrade() { let subscription = cx.subscribe(&terminal, |_registry, _, event, cx| { + // `ProcessExited` matters as much as output: a shell's + // status is re-derived from its screen tail on every + // read, so a process exiting changes the answer without + // necessarily producing any further output. if matches!( event, terminal::Event::Wakeup | terminal::Event::Bell + | terminal::Event::ProcessExited { .. } | terminal::Event::CloseTerminal ) { cx.notify(); diff --git a/crates/dez_sidebar/Cargo.toml b/crates/dez_sidebar/Cargo.toml index c72134395f..e684314e50 100644 --- a/crates/dez_sidebar/Cargo.toml +++ b/crates/dez_sidebar/Cargo.toml @@ -21,6 +21,7 @@ serde.workspace = true serde_json.workspace = true settings.workspace = true ui.workspace = true +util.workspace = true workspace.workspace = true [dev-dependencies] diff --git a/crates/dez_sidebar/src/sidebar.rs b/crates/dez_sidebar/src/sidebar.rs index 9656db023b..969c88a0d4 100644 --- a/crates/dez_sidebar/src/sidebar.rs +++ b/crates/dez_sidebar/src/sidebar.rs @@ -11,6 +11,7 @@ use serde::{Deserialize, Serialize}; use ui::{ Disclosure, HighlightedLabel, IconButton, IconButtonShape, Indicator, Tooltip, prelude::*, }; +use util::ResultExt as _; use workspace::{ FocusWorkspaceSidebarSearch, MultiWorkspace, MultiWorkspaceEvent, ProjectGroup, ProjectGroupKey, Sidebar, SidebarEvent, SidebarSide, @@ -319,6 +320,15 @@ impl DezSidebar { // changes that aren't emitted events, and the rail wants all of // them rather than just the ones the panel happens to care about. subscriptions.push(cx.observe(&store, |_this, _store, cx| cx.notify())); + // `observe` covers `notify()` only. Opening and closing a session + // are *emits* (`ThreadOpened` / `ThreadClosed`), and the store does + // not also notify, so without this the rail keeps showing a row for + // a session that has been closed -- and a row the user can click + // into nothing -- until some unrelated change repaints it. + subscriptions.push(cx.subscribe( + &store, + |_this, _store, _event: &agent_threads::AgentThreadStoreEvent, cx| cx.notify(), + )); } // Plain shells are classified live from their own screen tail, so the // rail has to repaint on terminal activity too, not only on store @@ -628,8 +638,12 @@ impl DezSidebar { let Some(store) = agent_threads::AgentThreadStore::try_global(cx) else { return; }; + // Not discarded: these are the failures that make a rail row look + // inert. A stale `terminal_item_id` (the session's pane was closed and + // the row has not repainted yet) surfaces here, and swallowing it makes + // a click indistinguishable from a click that was ignored. store.update(cx, |store, cx| { - let _ = store.focus_thread(terminal_item_id, window, cx); + store.focus_thread(terminal_item_id, window, cx).log_err(); }); cx.notify(); } @@ -1067,7 +1081,7 @@ impl DezSidebar { let terminal_item_id = terminal.terminal_item_id; let tooltip: SharedString = format!("Terminal · {}", status.state_label()).into(); let on_click = cx.listener(move |_this, _, window, cx| { - let _ = agent_threads::focus_priority_terminal(terminal_item_id, window, cx); + agent_threads::focus_priority_terminal(terminal_item_id, window, cx).log_err(); }); self.render_session_row(