Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions FORK.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions SYNC-LEDGER.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 8 additions & 2 deletions crates/agent_threads/src/panel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
}
Expand Down
36 changes: 36 additions & 0 deletions crates/agent_threads/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
_ => {}
},
);
Expand Down Expand Up @@ -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<Self>) {
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,
Expand Down
5 changes: 5 additions & 0 deletions crates/agent_threads/src/terminal_control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions crates/dez_sidebar/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
18 changes: 16 additions & 2 deletions crates/dez_sidebar/src/sidebar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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(
Expand Down
Loading