Skip to content

Commit eccca1e

Browse files
committed
feat(orchestrator): make the dock's start-up state configurable
The dock always opened in card density with "all worktrees" off and "show empty" on, and never opened by itself. Those defaults were module constants only a source-reader could find, and the in-dock controls only remembered a change for the current session. Declare four fields through the plugin config API, so they render in the Settings UI under "Plugin: orchestrator" and layer/persist like any other setting: autoOpenDock (false) open the dock on `ready`, unfocused defaultView ("card") density the dock opens at showAllWorktrees (false) initial "all worktrees" checkbox state showEmptyWorkspaces (true) initial "show empty" checkbox state Plugin config rather than core config: these mean nothing without the plugin loaded, and the plugin API already supplies schema validation, the User/Project/Session merge, and the rendered widgets. Each is only where the dock *starts*. The "view" button and the two Filters checkboxes still win for the rest of the session — the session state they write is now `null` until touched, and the settings fill in underneath. Settings are re-read on every dock open, so an edit lands on the next toggle without a plugin reload or restart. `showDockForPending` becomes `showDockUnfocused`: the auto-open path wants exactly its behaviour (mount the dock, leave the keyboard with the editor), and the name no longer fits only the placeholder case. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CstcifC5JjgznkUK8L8dm2
1 parent af123a3 commit eccca1e

5 files changed

Lines changed: 307 additions & 21 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Keyboard and mouse input is now parsed by our own `fresh-input-parser` crate ins
1818
* **Orchestrator: agents & workspaces**
1919
* **`Run Agent…` command** launches a terminal or a coding agent (`claude`, `codex`, `opencode`, `aider`) in your current workspace or a new one, without the full New Workspace dialog.
2020
* **Non-blocking workspace creation** - creating a workspace no longer freezes the editor; choose **Create & Visit** or **Create in Background** and keep working while it comes up.
21+
* **The dock's start-up state is now configurable** - Settings → **Plugin: orchestrator** has four new options: open the dock automatically on startup (off by default, and it opens unfocused so typing still goes to the editor), the layout it opens with (**card** or **compact**), and the starting state of the **all worktrees** and **show empty** checkboxes. They're defaults, not locks: the dock's own view button and checkboxes still win for the rest of the session, and an edit applies the next time you open the dock — no restart.
2122
* **Restart an exited terminal in place** - when a terminal's process quits, the status bar offers a clickable `⟳ Restart terminal` (or `⟳ Resume claude`, named after whatever was running, with the exit code when it's non-zero). Click it, run **Restart Terminal Process** from the command palette, or use **View → Terminal → Restart Terminal Process**, and the process comes back in the *same* buffer below the existing scrollback — no new terminal, no lost place. A coding agent with a resume spec rejoins its conversation rather than starting over, exactly as it does when you reopen the editor. Works for plain shells and commands too; a *running* terminal is never restarted. The offer survives quitting the editor — the pane comes back with its transcript and the indicator, deliberately still stopped rather than silently re-running.
2223
* A terminal's exit is **no longer written into the output** as a `[Terminal process exited]` line, which cost a row and scrolled the top of the final screen out of view — often the first, most important line of an agent's last answer. The tab reads `claude (exited)` instead, and the dead terminal is left exactly on its last frame.
2324
* **Terminal tab names now survive an editor restart** — a restored agent tab stays named `claude` instead of falling back to `bash` / `*Terminal 1*`.

crates/fresh-editor/plugins/orchestrator.ts

Lines changed: 114 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -845,25 +845,102 @@ let pickerFocusKey: string = "sessions";
845845
// updates this and the next open honours it.
846846
let lastOpenScope: "current" | "all" = "all";
847847
// Remembered across opens, like `lastOpenScope`: whether the
848-
// discovered on-disk worktree rows are shown. Defaults to false
849-
// (worktrees hidden) — surfacing them is opt-in via "Show all
850-
// worktrees" (Alt+T).
851-
let lastShowWorktrees = false;
848+
// discovered on-disk worktree rows are shown. `null` means "not touched
849+
// this session" — the dock then seeds it from the `showAllWorktrees`
850+
// plugin setting (default false: worktrees hidden, surfacing them is
851+
// opt-in via "Show all worktrees" / Alt+T). Flipping the checkbox pins a
852+
// concrete value that wins for the rest of the session.
853+
let lastShowWorktrees: boolean | null = null;
852854
// Remembered across opens: whether "trivial" sessions are hidden.
853-
// Defaults to false — showing every workspace. A freshly created
854-
// workspace starts empty, so hiding trivial sessions made it vanish the
855-
// instant it lost the "active" exemption (issue: "new workspace
856-
// immediately disappears"). Showing all by default keeps new workspaces
857-
// visible; the "show empty" checkbox (Alt+I) opts back into hiding the
858-
// throwaway single-file / restored-shell rows.
859-
let lastHideTrivial = false;
860-
// Dock card density, remembered across opens. "card" (default) shows
861-
// the three-line rounded pill; "compact" shows one line per session.
862-
// Toggled by the dock toolbar's "view" button.
855+
// `null` ⇒ seeded from the `showEmptyWorkspaces` setting, which defaults
856+
// to true (nothing hidden). A freshly created workspace starts empty, so
857+
// hiding trivial sessions made it vanish the instant it lost the
858+
// "active" exemption (issue: "new workspace immediately disappears").
859+
// Showing all by default keeps new workspaces visible; the "show empty"
860+
// checkbox (Alt+I) opts back into hiding the throwaway single-file /
861+
// restored-shell rows.
862+
let lastHideTrivial: boolean | null = null;
863+
// Dock card density. "card" (default) shows the three-line rounded pill;
864+
// "compact" shows one line per session. Read all over the render path,
865+
// so it stays a plain value rather than a config lookup; the dock
866+
// re-seeds it from the `defaultView` setting on open unless the user has
867+
// hit the toolbar's "view" button this session (`dockViewOverride`).
863868
let dockView: "card" | "compact" = "card";
869+
let dockViewOverride: "card" | "compact" | null = null;
864870
// Remembered dock project filter (see `OpenDialogState.projectFilter`).
865871
let lastDockProjectFilter: string | null = null;
866872

873+
// =============================================================================
874+
// Dock settings (Settings → Plugin: orchestrator)
875+
//
876+
// The dock's start-up defaults are *plugin* config, not core editor
877+
// config: they only mean anything while this plugin is loaded, and the
878+
// plugin-config API already gives us schema validation, the layered
879+
// User/Project/Session merge, and a rendered Settings sub-category for
880+
// free. Declaring them here is what makes them discoverable — before
881+
// this, the dock's defaults were module constants only a source-reader
882+
// could find, and the checkbox/density state was session-only.
883+
//
884+
// Each field is the *default* the dock opens with. The in-dock controls
885+
// (the "view" button, the two Filters checkboxes) still win for the rest
886+
// of the session — they set the `*Override` / `last*` values above —
887+
// they just no longer decide where the dock starts.
888+
editor.defineConfigBoolean("autoOpenDock", {
889+
default: false,
890+
description:
891+
"Open the workspace dock automatically when Fresh starts. The dock opens unfocused, so typing still goes to the editor.",
892+
});
893+
editor.defineConfigEnum("defaultView", {
894+
values: ["card", "compact"] as const,
895+
default: "card",
896+
description:
897+
"Dock layout the panel opens with: 'card' (multi-line pill per workspace) or 'compact' (one line per workspace). The dock's 'view' button flips it for the current session.",
898+
});
899+
editor.defineConfigBoolean("showAllWorktrees", {
900+
default: false,
901+
description:
902+
"Start the dock (and the Open picker) with 'all worktrees' checked, listing on-disk worktrees that have no open workspace.",
903+
});
904+
editor.defineConfigBoolean("showEmptyWorkspaces", {
905+
default: true,
906+
description:
907+
"Start the dock (and the Open picker) with 'show empty' checked, listing workspaces with no edited files (freshly created ones included).",
908+
});
909+
910+
interface DockSettings {
911+
autoOpenDock?: boolean;
912+
defaultView?: "card" | "compact";
913+
showAllWorktrees?: boolean;
914+
showEmptyWorkspaces?: boolean;
915+
}
916+
917+
// Re-read on demand rather than caching the values `defineConfigX`
918+
// returned at load time: a Settings-UI edit lands in the config snapshot
919+
// straight away, so the next dock open honours it without a plugin
920+
// reload or an editor restart.
921+
function dockSettings(): DockSettings {
922+
return (editor.getPluginConfig() ?? {}) as DockSettings;
923+
}
924+
925+
// Density the dock should open at: the session's own "view" toggle if
926+
// the user has hit it, otherwise the configured default.
927+
function configuredDockView(): "card" | "compact" {
928+
if (dockViewOverride !== null) return dockViewOverride;
929+
return dockSettings().defaultView === "compact" ? "compact" : "card";
930+
}
931+
932+
// The two Filters checkboxes, same precedence. `hideTrivial` is the
933+
// inverse of the user-facing "show empty" checkbox.
934+
function configuredShowWorktrees(): boolean {
935+
if (lastShowWorktrees !== null) return lastShowWorktrees;
936+
return dockSettings().showAllWorktrees === true;
937+
}
938+
939+
function configuredHideTrivial(): boolean {
940+
if (lastHideTrivial !== null) return lastHideTrivial;
941+
return dockSettings().showEmptyWorkspaces === false;
942+
}
943+
867944
// =============================================================================
868945
// Dock folder tree
869946
//
@@ -3819,8 +3896,8 @@ function openControlRoom(opts: { dock?: boolean } = {}): void {
38193896
// control / Alt+P updates it for next time.
38203897
scope: lastOpenScope,
38213898
selectedIds: new Set<number>(),
3822-
showWorktrees: lastShowWorktrees,
3823-
hideTrivial: lastHideTrivial,
3899+
showWorktrees: configuredShowWorktrees(),
3900+
hideTrivial: configuredHideTrivial(),
38243901
bulkInFlight: null,
38253902
projectFilter: asDock ? lastDockProjectFilter : null,
38263903
projectMenuOpen: false,
@@ -3844,6 +3921,10 @@ function openControlRoom(opts: { dock?: boolean } = {}): void {
38443921
if (asDock) {
38453922
dockMode = true;
38463923
dockBlurred = false;
3924+
// Adopt the configured density on every open (unless the "view"
3925+
// button already picked one this session), so editing the setting
3926+
// and reopening the dock is enough to see the change.
3927+
dockView = configuredDockView();
38473928
} else {
38483929
dockMode = false;
38493930
}
@@ -8424,15 +8505,16 @@ function startPendingWorkspace(
84248505
savePendingSpecs();
84258506
if (!restored) {
84268507
closeForm();
8427-
showDockForPending();
8508+
showDockUnfocused();
84288509
launchPendingCreate(id);
84298510
}
84308511
return id;
84318512
}
84328513

8433-
// Ensure the dock is visible so a just-created placeholder is seen, without
8434-
// stealing keyboard focus — the user asked to keep working ("stay put").
8435-
function showDockForPending(): void {
8514+
// Ensure the dock is visible without stealing keyboard focus — the user
8515+
// asked to keep working ("stay put"). Used for a just-created placeholder
8516+
// (so it's seen), and for the `autoOpenDock` setting at startup.
8517+
function showDockUnfocused(): void {
84368518
// Open the dock if nothing is up yet. (Structured so the module-level
84378519
// `openPanel`'s narrowing survives the `openControlRoom` reassignment —
84388520
// mirrors `openMoveToFolderForCurrent`.)
@@ -8977,7 +9059,7 @@ function recoverPendingWorkspaces(): void {
89779059
startPendingWorkspace(spec, { restored: true, label });
89789060
restored++;
89799061
}
8980-
if (restored > 0) showDockForPending();
9062+
if (restored > 0) showDockUnfocused();
89819063
}
89829064

89839065
// `visit`: "Create & Visit" — focus follows into the workspace once it's real.
@@ -10322,6 +10404,9 @@ editor.on("widget_event", (e) => {
1032210404
// Dock "view" button → flip card ⇄ compact density and re-render.
1032310405
if (e.event_type === "activate" && e.widget_key === "view-toggle") {
1032410406
dockView = dockView === "card" ? "compact" : "card";
10407+
// Pin it for the rest of the session — the configured default only
10408+
// decides where the dock *starts*.
10409+
dockViewOverride = dockView;
1032510410
if (openPanel) openPanel.update(buildDockSpec());
1032610411
return;
1032710412
}
@@ -10501,6 +10586,14 @@ editor.on("window_closed", (e) => {
1050110586
// resumes (Enter) or dismisses — nothing auto-runs (§ recoverPendingWorkspaces).
1050210587
editor.on("ready", () => {
1050310588
recoverPendingWorkspaces();
10589+
// Auto-open the dock when the user asked for it in Settings (Plugin:
10590+
// orchestrator → autoOpenDock). Runs after the recovery pass, which
10591+
// may already have shown the dock for a restored placeholder —
10592+
// `showDockUnfocused` is a no-op on an open panel, so the two can't
10593+
// fight. Like the pending-workspace case, the dock comes up *blurred*:
10594+
// it's a switcher, not something to type into, so the keyboard stays
10595+
// with whatever the editor restored.
10596+
if (dockSettings().autoOpenDock === true) showDockUnfocused();
1050410597
});
1050510598

1050610599
// Grace window after a session becomes active during which terminal

crates/fresh-editor/tests/e2e/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ pub mod on_save_actions;
170170
pub mod open_folder;
171171
pub mod orchestrator_dialog_trust_repro;
172172
pub mod orchestrator_dock;
173+
pub mod orchestrator_dock_settings;
173174
pub mod orchestrator_window_lsp;
174175
pub mod overlay_extend_to_line_end;
175176
pub mod pane_navigation;
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
//! E2E coverage for the orchestrator dock's user-facing settings
2+
//! (`plugins.orchestrator.settings.*`, rendered by the Settings UI under
3+
//! "Plugin: orchestrator"):
4+
//!
5+
//! * `autoOpenDock` — open the dock on the `ready` hook, unfocused;
6+
//! * `defaultView` — the density (`card` / `compact`) the dock opens at;
7+
//! * `showAllWorktrees` / `showEmptyWorkspaces` — the initial state of the
8+
//! two Filters checkboxes.
9+
//!
10+
//! Each is only a *default*: the dock's own controls still win once the
11+
//! user touches them. These tests pin the "where does it start" half,
12+
//! which is what the settings buy; `orchestrator_dock.rs` already covers
13+
//! the toggles themselves.
14+
15+
use crate::common::harness::{copy_plugin, copy_plugin_lib, EditorTestHarness};
16+
use crate::common::tracing::init_tracing_from_env;
17+
use crossterm::event::{KeyCode, KeyModifiers};
18+
use fresh::config::{Config, PluginConfig};
19+
use std::fs;
20+
use std::path::PathBuf;
21+
22+
/// A git project with the orchestrator plugin (+ shared lib) installed,
23+
/// and `settings` preset in the plugin's config slot so the plugin's
24+
/// `defineConfigX` calls see the user values the first time it runs.
25+
fn setup(settings: serde_json::Value) -> (tempfile::TempDir, PathBuf, Config) {
26+
init_tracing_from_env();
27+
let temp_dir = tempfile::TempDir::new().unwrap();
28+
let root = temp_dir.path().join("alphaproj");
29+
fs::create_dir(&root).unwrap();
30+
let plugins_dir = root.join("plugins");
31+
fs::create_dir(&plugins_dir).unwrap();
32+
copy_plugin_lib(&plugins_dir);
33+
copy_plugin(&plugins_dir, "orchestrator");
34+
fs::write(root.join("readme.txt"), "hello\n").unwrap();
35+
let ok = std::process::Command::new("git")
36+
.args(["init", "-q"])
37+
.current_dir(&root)
38+
.status()
39+
.unwrap()
40+
.success();
41+
assert!(ok);
42+
43+
let mut config = Config::default();
44+
config.plugins.insert(
45+
"orchestrator".to_string(),
46+
PluginConfig {
47+
enabled: true,
48+
path: None,
49+
settings,
50+
},
51+
);
52+
(temp_dir, root, config)
53+
}
54+
55+
/// Toggle the dock open via the command palette and wait for it to render
56+
/// *and* take keyboard focus (mirrors `orchestrator_dock::open_dock`).
57+
fn open_dock(h: &mut EditorTestHarness) {
58+
h.send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
59+
.unwrap();
60+
h.wait_for_prompt().unwrap();
61+
h.type_text("Orchestrator: Toggle Dock").unwrap();
62+
h.wait_until(|h| h.screen_to_string().contains("Toggle Dock"))
63+
.unwrap();
64+
h.send_key(KeyCode::Enter, KeyModifiers::NONE).unwrap();
65+
h.wait_until(|h| h.screen_to_string().contains("Orchestrator") && h.editor().is_dock_focused())
66+
.unwrap();
67+
}
68+
69+
/// Expand the dock's collapsible "Filters" section, which holds the
70+
/// density button and the two checkboxes.
71+
fn expand_filters(h: &mut EditorTestHarness) {
72+
let screen = h.screen_to_string();
73+
let frow = screen
74+
.lines()
75+
.position(|l| l.contains("Filters"))
76+
.unwrap_or_else(|| panic!("screen missing 'Filters':\n{screen}")) as u16;
77+
h.mouse_click(3, frow).unwrap();
78+
h.wait_until(|h| h.screen_to_string().contains("Manage"))
79+
.unwrap();
80+
}
81+
82+
/// `defaultView: "compact"` opens the dock in list density — without it
83+
/// the dock always started at "card" and the user had to click "view"
84+
/// on every launch.
85+
#[test]
86+
fn default_view_setting_opens_dock_compact() {
87+
let (_tmp, root, config) = setup(serde_json::json!({ "defaultView": "compact" }));
88+
let mut h = EditorTestHarness::with_config_and_working_dir(120, 32, config, root).unwrap();
89+
h.render().unwrap();
90+
open_dock(&mut h);
91+
expand_filters(&mut h);
92+
h.wait_until(|h| h.screen_to_string().contains("view: compact"))
93+
.unwrap();
94+
h.assert_screen_not_contains("view: card");
95+
}
96+
97+
/// The shipped default is unchanged: no setting ⇒ card density.
98+
#[test]
99+
fn default_view_setting_absent_opens_dock_card() {
100+
let (_tmp, root, config) = setup(serde_json::json!({}));
101+
let mut h = EditorTestHarness::with_config_and_working_dir(120, 32, config, root).unwrap();
102+
h.render().unwrap();
103+
open_dock(&mut h);
104+
expand_filters(&mut h);
105+
h.wait_until(|h| h.screen_to_string().contains("view: card"))
106+
.unwrap();
107+
}
108+
109+
/// The two Filters checkboxes start where the settings say: "all
110+
/// worktrees" checked, "show empty" unchecked — the inverse of both
111+
/// shipped defaults, so a stuck default would fail this.
112+
#[test]
113+
fn filter_checkbox_settings_seed_the_dock() {
114+
let (_tmp, root, config) = setup(serde_json::json!({
115+
"showAllWorktrees": true,
116+
"showEmptyWorkspaces": false,
117+
}));
118+
let mut h = EditorTestHarness::with_config_and_working_dir(120, 32, config, root).unwrap();
119+
h.render().unwrap();
120+
open_dock(&mut h);
121+
expand_filters(&mut h);
122+
h.wait_until(|h| {
123+
let s = h.screen_to_string();
124+
s.contains("[v] all worktrees") && s.contains("[ ] show empty")
125+
})
126+
.unwrap();
127+
}
128+
129+
/// `autoOpenDock: true` brings the dock up on the `ready` hook, and
130+
/// leaves the keyboard with the editor — it's a switcher, not something
131+
/// to type into.
132+
#[test]
133+
fn auto_open_setting_shows_dock_unfocused_at_startup() {
134+
let (_tmp, root, config) = setup(serde_json::json!({ "autoOpenDock": true }));
135+
let mut h = EditorTestHarness::with_config_and_working_dir(120, 32, config, root).unwrap();
136+
h.render().unwrap();
137+
h.editor_mut().fire_ready_hook();
138+
h.wait_until(|h| h.screen_to_string().contains("Filters"))
139+
.unwrap();
140+
assert!(
141+
!h.editor().is_dock_focused(),
142+
"auto-opened dock must not steal keyboard focus"
143+
);
144+
}
145+
146+
/// Auto-open is opt-in: the ready hook alone leaves the dock closed.
147+
#[test]
148+
fn auto_open_defaults_off() {
149+
let (_tmp, root, config) = setup(serde_json::json!({}));
150+
let mut h = EditorTestHarness::with_config_and_working_dir(120, 32, config, root).unwrap();
151+
h.render().unwrap();
152+
h.editor_mut().fire_ready_hook();
153+
// Let the ready hook round-trip through the plugin thread: open the
154+
// dock the normal way and close it again, which can only complete
155+
// after the plugin has processed everything queued before it.
156+
open_dock(&mut h);
157+
h.send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
158+
.unwrap();
159+
h.wait_for_prompt().unwrap();
160+
h.type_text("Orchestrator: Toggle Dock").unwrap();
161+
h.wait_until(|h| h.screen_to_string().contains("Toggle Dock"))
162+
.unwrap();
163+
h.send_key(KeyCode::Enter, KeyModifiers::NONE).unwrap();
164+
// A dock auto-opened at ready would have stayed mounted behind the
165+
// toggle; with auto-open off there is nothing left on screen.
166+
h.wait_until(|h| !h.screen_to_string().contains("Filters"))
167+
.unwrap();
168+
}

0 commit comments

Comments
 (0)