aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-05 00:40:18 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-05 00:40:18 +0300
commitb4b69b3539fc02d855c9b626cb205074e1541c78 (patch)
tree71f61897e2847f4627b8cdb38b80cd036de49deb
parent6b3f6462810e400fbbfbc21240b677732b0549ec (diff)
fix: open a default workspace when sessions are empty
refs #366
-rw-r--r--docs/next/CHANGELOG.md1
-rw-r--r--docs/next/README.md2
-rw-r--r--docs/next/website/src/content/docs/configuration.mdx2
-rw-r--r--docs/next/website/src/content/docs/quick-start.mdx2
-rw-r--r--src/app/creation.rs1
-rw-r--r--src/app/mod.rs43
-rw-r--r--src/app/runtime.rs10
-rw-r--r--src/server/headless.rs18
8 files changed, 73 insertions, 6 deletions
diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md
index 32b61d17..fb4e4cb2 100644
--- a/docs/next/CHANGELOG.md
+++ b/docs/next/CHANGELOG.md
@@ -3,6 +3,7 @@
## Unreleased
### Fixed
+- Starting Herdr with no restored workspaces, or closing the last workspace, now opens a default workspace instead of leaving the client on an empty screen where direct keybindings such as `cmd+n` were shown but ignored. (#366)
- Resizing restored panes no longer aborts the server when libghostty-vt reflows a terminal whose pre-resize cursor row is past the new height. (#465)
- Full-screen TUIs such as Neovim now receive resize-generated terminal responses after Herdr internal pane resizes, so grown panes redraw without waiting for extra input. (#471)
diff --git a/docs/next/README.md b/docs/next/README.md
index 031765d8..e15162ad 100644
--- a/docs/next/README.md
+++ b/docs/next/README.md
@@ -47,7 +47,7 @@ Start Herdr in the directory where the work lives:
herdr
```
-Herdr starts or attaches to one background session server. Press `ctrl+b`, then `shift+n` to create a workspace. Run an agent in the root pane. Press `ctrl+b`, then `v` or `minus` to split panes, `ctrl+b`, then `c` to create a tab, and `ctrl+b`, then `w` to switch workspaces.
+Herdr starts or attaches to one background session server. When a session has no workspaces, Herdr opens one automatically. Run an agent in the root pane. Press `ctrl+b`, then `shift+n` to create another workspace, `ctrl+b`, then `v` or `minus` to split panes, `ctrl+b`, then `c` to create a tab, and `ctrl+b`, then `w` to switch workspaces.
Press `ctrl+b q` to detach the client. The server and pane processes keep running. Open another terminal and run `herdr` again to reattach.
diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx
index 7dc5c6fc..f2159950 100644
--- a/docs/next/website/src/content/docs/configuration.mdx
+++ b/docs/next/website/src/content/docs/configuration.mdx
@@ -83,7 +83,7 @@ Set the working directory policy for new panes, tabs, and workspaces:
new_cwd = "follow"
```
-`new_cwd = "follow"` keeps the default behavior and inherits the source pane or workspace. Use `"home"` to always start in `$HOME`, `"current"` to use Herdr's process directory, or a fixed path such as `"~/Projects"`. Explicit `--cwd` values from the CLI or socket API still take precedence.
+`new_cwd = "follow"` keeps the default behavior and inherits the source pane or workspace. When there is no source workspace, Herdr starts in `$HOME`. Use `"home"` to always start in `$HOME`, `"current"` to use Herdr's process directory, or a fixed path such as `"~/Projects"`. Explicit `--cwd` values from the CLI or socket API still take precedence.
## Worktrees
diff --git a/docs/next/website/src/content/docs/quick-start.mdx b/docs/next/website/src/content/docs/quick-start.mdx
index 80563260..b6869add 100644
--- a/docs/next/website/src/content/docs/quick-start.mdx
+++ b/docs/next/website/src/content/docs/quick-start.mdx
@@ -15,7 +15,7 @@ For the local, SSH, and `herdr --remote` workflows, see [How to work with Herdr]
## Create a workspace
-Press `prefix+shift+n` to create a workspace. A workspace is a project-level container for tabs, panes, and agents.
+When a session has no workspaces, Herdr opens one automatically. A workspace is a project-level container for tabs, panes, and agents. Press `prefix+shift+n` to create another workspace.
Give each active project its own workspace. This keeps agent state readable in the sidebar.
diff --git a/src/app/creation.rs b/src/app/creation.rs
index f8edd676..3f643986 100644
--- a/src/app/creation.rs
+++ b/src/app/creation.rs
@@ -14,6 +14,7 @@ pub(crate) fn resolve_new_terminal_cwd(
) -> PathBuf {
match policy {
NewTerminalCwdConfig::Follow => follow_cwd
+ .or_else(|| std::env::var_os("HOME").map(PathBuf::from))
.or_else(|| std::env::current_dir().ok())
.unwrap_or_else(|| PathBuf::from("/")),
NewTerminalCwdConfig::Home => std::env::var_os("HOME")
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 0c2feb67..1914c895 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -806,6 +806,10 @@ impl App {
needs_render = true;
}
+ if self.ensure_default_workspace() {
+ needs_render = true;
+ }
+
let now = Instant::now();
self.sync_animation_timer(now);
self.sync_host_mouse_capture(&mut host_mouse_capture_active)?;
@@ -934,6 +938,33 @@ impl App {
Ok(())
}
+ pub(crate) fn ensure_default_workspace(&mut self) -> bool {
+ if !self.state.workspaces.is_empty() || self.state.mode == Mode::Onboarding {
+ return false;
+ }
+
+ let previous_mode = self.state.mode;
+ let preserve_mode = matches!(
+ previous_mode,
+ Mode::ReleaseNotes | Mode::ProductAnnouncement | Mode::Settings
+ );
+ let cwd = self.resolve_new_terminal_cwd(None);
+
+ match self.create_workspace_with_options(cwd, true) {
+ Ok(_) => {
+ if preserve_mode {
+ self.state.mode = previous_mode;
+ }
+ true
+ }
+ Err(err) => {
+ tracing::error!(err = %err, "failed to create default workspace");
+ self.state.mode = Mode::Navigate;
+ false
+ }
+ }
+ }
+
pub(crate) fn dismiss_release_notes(&mut self) {
let preview = self
.state
@@ -2644,6 +2675,18 @@ mod tests {
}
#[test]
+ fn new_terminal_cwd_follow_without_source_uses_home() {
+ let Some(home) = std::env::var_os("HOME").map(std::path::PathBuf::from) else {
+ return;
+ };
+
+ let cwd =
+ creation::resolve_new_terminal_cwd(&crate::config::NewTerminalCwdConfig::Follow, None);
+
+ assert_eq!(cwd, home);
+ }
+
+ #[test]
fn new_terminal_cwd_path_uses_configured_path() {
let cwd = creation::resolve_new_terminal_cwd(
&crate::config::NewTerminalCwdConfig::Path("/tmp/herdr-fixed".into()),
diff --git a/src/app/runtime.rs b/src/app/runtime.rs
index 5bfa9141..fe971bf5 100644
--- a/src/app/runtime.rs
+++ b/src/app/runtime.rs
@@ -62,8 +62,16 @@ impl App {
msg: crate::api::ApiRequestMessage,
) -> bool {
let previous_mode = self.state.mode;
- let changed = crate::api::request_changes_ui(&msg.request);
+ let mut changed = crate::api::request_changes_ui(&msg.request);
+ let skip_default_workspace = matches!(
+ &msg.request.method,
+ crate::api::schema::Method::ServerStop(_)
+ | crate::api::schema::Method::ServerLiveHandoff(_)
+ );
let response = self.handle_api_request(msg.request);
+ if !skip_default_workspace {
+ changed |= self.ensure_default_workspace();
+ }
let _ = msg.respond_to.send(response);
self.sync_prefix_input_source(previous_mode);
changed
diff --git a/src/server/headless.rs b/src/server/headless.rs
index 596c55df..11e5f6b8 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -499,6 +499,12 @@ impl HeadlessServer {
crate::render_prof::event("full_render_cause.config_reload");
}
+ if latest_app_client(&self.clients).is_some() && self.app.ensure_default_workspace() {
+ needs_render = true;
+ needs_full_render = true;
+ crate::render_prof::event("full_render_cause.default_workspace");
+ }
+
self.drain_client_config_reload_request();
self.stream_host_mouse_capture_mode();
@@ -2146,8 +2152,12 @@ impl HeadlessServer {
return true;
}
- let changed = api::request_changes_ui(&msg.request);
- let changed = self.drain_all_internal_events_with_forwarding() || changed;
+ let mut changed = api::request_changes_ui(&msg.request);
+ let skip_default_workspace = matches!(
+ &msg.request.method,
+ api::schema::Method::ServerStop(_) | api::schema::Method::ServerLiveHandoff(_)
+ );
+ changed |= self.drain_all_internal_events_with_forwarding();
// Capture toast and effective pane states before the API call so we can
// forward resulting client-local notifications. API requests like
@@ -2337,6 +2347,10 @@ impl HeadlessServer {
}
}
+ if !skip_default_workspace && latest_app_client(&self.clients).is_some() {
+ changed |= self.app.ensure_default_workspace();
+ }
+
changed
}