diff --git a/.github/workflows/release_configurations.json b/.github/workflows/release_configurations.json index 3467fdf75b7..d892ef70926 100644 --- a/.github/workflows/release_configurations.json +++ b/.github/workflows/release_configurations.json @@ -7,7 +7,7 @@ "is_prerelease": false, "is_autopush": false, "release_base_name": "Zaplex", - "release_body_text": "Zaplex 1.1.1 — signed and notarized macOS distribution", + "release_body_text": "Zaplex 1.2.0 — signed and notarized macOS distribution", "changelog_slack_channel": "", "gcs_cache_control_value": "", "web_gcs_bucket_prefix": "" diff --git a/.github/workflows/test-dmg.yml b/.github/workflows/test-dmg.yml index 2ab138f6a19..4b36e30bba4 100644 --- a/.github/workflows/test-dmg.yml +++ b/.github/workflows/test-dmg.yml @@ -28,7 +28,7 @@ on: dmg_tag: description: "GIT_RELEASE_TAG (bestimmt den remote-server-Pfad auf dem Ziel-Host)" type: string - default: v1.1.1 + default: v1.2.0 fast: description: "Debug-Profil für Mac-App und Linux-Daemon. Nur für schnelle Entwicklerprüfung; für Abnahme-DMGs ausgeschaltet lassen." type: boolean diff --git a/.github/workflows/zap_release.yml b/.github/workflows/zap_release.yml index d057801ca62..75356284b85 100644 --- a/.github/workflows/zap_release.yml +++ b/.github/workflows/zap_release.yml @@ -31,7 +31,7 @@ on: release_tag: description: "Release tag; must match VERSION" type: string - default: v1.1.1 + default: v1.2.0 required: true # Build and metadata jobs only need repository read access. The publisher job diff --git a/CHANGELOG.md b/CHANGELOG.md index 16f9ff9ebd4..495beabe293 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,9 @@ This document records key changes: the Zap/Warp release history inherited before the zaplex fork (translated from the original Chinese), followed by zaplex's own work on top of it. Only functional commits are listed; internal dev/stable rolling tags are omitted. -## [1.1.1] — 2026-09-23 +## [1.2.0] — 2026-10-06 + +- Tabs und Panes haben ein gemeinsames Startmenü: „+“ und alle vier Split-Richtungen zeigen Terminal, „Neuer Agent…“, die Favoriten und neu „Weitere Hosts“ mit allen übrigen registrierten Hosts. Aus einem Split heraus startet jeder Eintrag im neuen Pane, auch der Agent; Konto, Startabsicht und Prompt hängen an genau diesem Pane. Tab-spezifische Einträge (Tab-Konfigurationen, Docker-Sandbox, Worktree-Konfiguration, geschlossene Session) bleiben im „+“-Menü. - Die Integrationssuite (#472) initialisiert englische Texte vor dem Start und bedient beim Teilen eines Panes den echten Hostpicker. SSH-Prüfungen nutzen eigene kurzlebige Konten auf einem lokalen CI-SSH-Server; Passwortanmeldung, Shell-Bootstrap und ProxyCommand bleiben Teil der Prüfung. diff --git a/Cargo.lock b/Cargo.lock index 15568d43e00..12b1621820b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13791,7 +13791,7 @@ dependencies = [ [[package]] name = "warp" -version = "1.1.1" +version = "1.2.0" dependencies = [ "addr", "aho-corasick", diff --git a/README.md b/README.md index 33330e7cf77..14e0d64d51b 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ zaplex is a fork of [Zap](https://github.com/zerx-lab/zap) — the open-source, ## Status & roadmap -Zaplex 1.1.1 is the version prepared by this source tree. The table reflects +Zaplex 1.2.0 is the version prepared by this source tree. The table reflects the integrated source state; supported release artifacts are built, signed and published by the release workflow: @@ -101,7 +101,7 @@ Every designed item has a dated design doc in [`docs/superpowers/`](docs/superpo ## Install -Once published, download Zaplex 1.1.1 from +Once published, download Zaplex 1.2.0 from [Releases](https://github.com/byte5ai/zaplex/releases). The macOS DMG is built in GitHub Actions, signed with Developer ID, notarized by Apple, and shipped together with its matching Linux host daemon. Nothing needs to be installed diff --git a/VERSION b/VERSION index 524cb55242b..26aaba0e866 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.1 +1.2.0 diff --git a/app/Cargo.toml b/app/Cargo.toml index 80e448ef3c0..aa890f1d2d1 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -5,7 +5,7 @@ description = "The terminal cockpit for AI coding agents on remote machines" edition = "2021" autobins = false name = "warp" -version = "1.1.1" +version = "1.2.0" publish.workspace = true license.workspace = true diff --git a/app/DockTilePlugin/Info.plist b/app/DockTilePlugin/Info.plist index f4cb780e793..87b7407b3b7 100644 --- a/app/DockTilePlugin/Info.plist +++ b/app/DockTilePlugin/Info.plist @@ -11,9 +11,9 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.1.1 + 1.2.0 CFBundleVersion - 1.1.1 + 1.2.0 NSPrincipalClass ZapDockTilePlugin CFBundleInfoDictionaryVersion diff --git a/app/assets/resources/mac/CLI-Info.plist b/app/assets/resources/mac/CLI-Info.plist index 9468d18623d..b41382af196 100644 --- a/app/assets/resources/mac/CLI-Info.plist +++ b/app/assets/resources/mac/CLI-Info.plist @@ -16,9 +16,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.1.1 + 1.2.0 CFBundleVersion - 1.1.1 + 1.2.0 LSBackgroundOnly diff --git a/app/i18n/de/warp.ftl b/app/i18n/de/warp.ftl index 7b6d3bb3a93..bf388c69938 100644 --- a/app/i18n/de/warp.ftl +++ b/app/i18n/de/warp.ftl @@ -4076,6 +4076,8 @@ workspace-remote-pty-already-open = Dieses Remote-PTY ist bereits geöffnet, abe workspace-remote-fallback-split-target-changed = Der Remote-Fallback wurde abgebrochen, weil sich das ursprüngliche Split-Ziel geändert hat. workspace-split-target-changed = Der Split wurde nicht geöffnet, weil sich seine Pane verschoben hat oder geschlossen wurde. workspace-split-host-already-connecting = { $host } wird bereits verbunden. Öffne den Split erneut, sobald die Verbindung steht. +workspace-split-agent-single-launch = Ein Split nimmt genau einen Agenten auf. Wähle ein einzelnes Konto. +workspace-launch-more-hosts = Weitere Hosts workspace-remote-agent-route-validation-unavailable = Der Daemon kann diese Agent-zu-PTY-Route nicht validieren. workspace-remote-daemon-connection-unavailable = Die Daemon-Verbindung ist nicht verfügbar; aktualisiere die Agent-Sessions. workspace-remote-pty-agent-changed = Der Vordergrund-Agent dieses PTYs hat sich geändert. Aktualisiere die Agent-Sessions, bevor du es erneut anhängst. diff --git a/app/i18n/en/warp.ftl b/app/i18n/en/warp.ftl index 207cfd70bbd..52b0a0880e6 100644 --- a/app/i18n/en/warp.ftl +++ b/app/i18n/en/warp.ftl @@ -4421,6 +4421,8 @@ workspace-remote-pty-already-open = This remote PTY is already open, but its exi workspace-remote-fallback-split-target-changed = Remote fallback was cancelled because its original split target changed. workspace-split-target-changed = The split was not opened because its pane moved or closed. workspace-split-host-already-connecting = { $host } is already connecting. Open the split again once it is connected. +workspace-split-agent-single-launch = A split holds exactly one agent. Choose a single account. +workspace-launch-more-hosts = More hosts workspace-remote-agent-route-validation-unavailable = The daemon cannot validate this agent-to-PTY route. workspace-remote-daemon-connection-unavailable = The daemon connection is unavailable; refresh Agent Sessions. workspace-remote-pty-agent-changed = This PTY's foreground agent changed. Refresh Agent Sessions before attaching again. diff --git a/app/src/bin/zaplex.rs b/app/src/bin/zaplex.rs index c16f29619dc..69d84c93906 100644 --- a/app/src/bin/zaplex.rs +++ b/app/src/bin/zaplex.rs @@ -81,9 +81,9 @@ embed_plist::embed_info_plist_bytes!(r#" CFBundlePackageType APPL CFBundleShortVersionString - 1.1.1 + 1.2.0 CFBundleVersion - 1.1.1 + 1.2.0 LSApplicationCategoryType public.app-category.developer-tools NSHighResolutionCapable diff --git a/app/src/integration_testing/native_workspace.rs b/app/src/integration_testing/native_workspace.rs index 2300f47618a..0421f08e6a2 100644 --- a/app/src/integration_testing/native_workspace.rs +++ b/app/src/integration_testing/native_workspace.rs @@ -264,13 +264,7 @@ pub fn split_local_right() -> TestStep { TestStep::new("Split the real pane through its overflow menu and choose local") .with_click_on_saved_position_fn(|app, window_id| overflow_id(app, window_id, 0)) .with_click_on_saved_position_fn(|_, _| crate::t!("keybinding-desc-pane-group-split-right")) - .with_click_on_saved_position_fn(|_, _| { - format!( - "{} · {}", - crate::t!("cockpit-spawn-card-host-local"), - crate::t!("common-current") - ) - }) + .with_click_on_saved_position_fn(|_, _| crate::t!("workspace-new-session-terminal")) } #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/app/src/integration_testing/pane_group/step.rs b/app/src/integration_testing/pane_group/step.rs index 32a7621414a..5b81d1dbf79 100644 --- a/app/src/integration_testing/pane_group/step.rs +++ b/app/src/integration_testing/pane_group/step.rs @@ -54,19 +54,13 @@ pub fn move_pane_by_indices( }) } -/// Completes the host picker opened by a split shortcut using the real local row. +/// Completes the launch menu opened by a split shortcut with its local Terminal entry. pub trait LocalSplitTarget { fn with_local_split_target(self) -> Self; } impl LocalSplitTarget for TestStep { fn with_local_split_target(self) -> Self { - self.with_click_on_saved_position_fn(|_, _| { - format!( - "{} · {}", - crate::t!("cockpit-spawn-card-host-local"), - crate::t!("common-current") - ) - }) + self.with_click_on_saved_position_fn(|_, _| crate::t!("workspace-new-session-terminal")) } } diff --git a/app/src/pane_group/mod.rs b/app/src/pane_group/mod.rs index 3bacddf83aa..dc5806c2c4a 100644 --- a/app/src/pane_group/mod.rs +++ b/app/src/pane_group/mod.rs @@ -4743,13 +4743,13 @@ impl PaneGroup { PaneEvent::CloseAndFocus { pane_to_focus } => { self.close_pane_and_focus(pane_id, *pane_to_focus, ctx); } - // Pane-splitting events always create a new terminal pane, regardless of the original - // pane's type. This makes it easy to get a terminal session next to a non-terminal - // pane like a notebook. Once it's possible to open the same notebook more than once, - // we may revisit this so that splitting from a terminal pane starts a new session, but - // splitting from a notebook pane reopens the notebook side-by-side. + // Every split direction opens the workspace launch menu for the captured target; the + // chosen entry (local terminal, host, or agent) decides what the new pane runs. PaneEvent::SplitLeft(chosen_shell) => { - self.insert_terminal_pane(Direction::Left, pane_id, chosen_shell.clone(), ctx); + ctx.emit(Event::SplitLaunchRequested { + target: self.capture_split_target(pane_id, Direction::Left), + chosen_shell: chosen_shell.clone(), + }); } PaneEvent::SplitRight(chosen_shell) => { ctx.emit(Event::SplitLaunchRequested { @@ -4758,7 +4758,10 @@ impl PaneGroup { }); } PaneEvent::SplitUp(chosen_shell) => { - self.insert_terminal_pane(Direction::Up, pane_id, chosen_shell.clone(), ctx); + ctx.emit(Event::SplitLaunchRequested { + target: self.capture_split_target(pane_id, Direction::Up), + chosen_shell: chosen_shell.clone(), + }); } PaneEvent::SplitDown(chosen_shell) => { ctx.emit(Event::SplitLaunchRequested { @@ -7221,18 +7224,11 @@ impl TypedActionView for PaneGroup { None } }; - match direction { - Direction::Right | Direction::Down => { - let pane_id = self.focused_pane_id(ctx); - ctx.emit(Event::SplitLaunchRequested { - target: self.capture_split_target(pane_id, *direction), - chosen_shell, - }); - } - Direction::Left | Direction::Up => { - self.add_terminal_pane(*direction, chosen_shell, ctx); - } - } + let pane_id = self.focused_pane_id(ctx); + ctx.emit(Event::SplitLaunchRequested { + target: self.capture_split_target(pane_id, *direction), + chosen_shell, + }); } Remove(view_id) => self.close_pane_with_confirmation(*view_id, ctx), RemoveActive => self.close_active_pane_with_confirmation(ctx), diff --git a/app/src/workspace/action.rs b/app/src/workspace/action.rs index ab33da92961..6651de78205 100644 --- a/app/src/workspace/action.rs +++ b/app/src/workspace/action.rs @@ -159,6 +159,21 @@ pub enum WorkspaceAction { OpenSshTerminalByNode { node_id: String, }, + /// Launch-menu choice from a pane split: open a local terminal at the split + /// target captured when the split launch menu opened. + SplitLaunchLocal, + /// Launch-menu choice from a pane split: open a registered host at the + /// captured split target, resolving the connection by its `node_id`. + SplitLaunchHost { + node_id: String, + }, + /// Launch-menu choice from a pane split: open the Spawn-Karte whose single + /// launch lands at the captured split target instead of a new tab. + /// `registry_node_id`/`host` pre-scope the card like [`Self::OpenSpawnCard`]. + SplitLaunchSpawnCard { + registry_node_id: Option, + host: Option, + }, /// Open an SFTP file-manager pane for a registered SSH host, resolving the /// connection by its stable registry node id. Dispatched by the visible /// "Files" action on a Conductor host node. @@ -1057,6 +1072,8 @@ impl WorkspaceAction { | AddTerminalTab { .. } | OpenSshTerminal { .. } | OpenSshTerminalByNode { .. } + | SplitLaunchLocal + | SplitLaunchHost { .. } | OpenSftpPaneByNode { .. } | OpenLocalFileManager { .. } | ToggleSshManager @@ -1262,6 +1279,7 @@ impl WorkspaceAction { | CreateReviewPr { .. } | LaunchAgent { .. } | OpenSpawnCard { .. } + | SplitLaunchSpawnCard { .. } | RunCockpitPaletteTarget { .. } | OpenFileInEditor { .. } | OpenReadOnlyTextInEditor { .. } diff --git a/app/src/workspace/view.rs b/app/src/workspace/view.rs index 216066a8825..ad8c80c2f5d 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -62,8 +62,8 @@ use crate::app_state::{ bind_daemon_pty_claim_owner, claim_daemon_pty, daemon_pty_claim, release_daemon_pty_claim_for_connection, release_daemon_pty_claim_for_terminal_view, release_daemon_pty_claim_reservation, DaemonPtyClaimOutcome, DaemonPtyClaimOwner, - DaemonPtyIdentity, FileManagerPaneMode, LeafContents, LeafSnapshot, LeftPanelDisplayedTab, - LeftPanelSnapshot, NotebookPaneSnapshot, PaneNodeSnapshot, PaneUuid, PersistedDaemonRuntime, + DaemonPtyIdentity, LeafContents, LeafSnapshot, LeftPanelDisplayedTab, LeftPanelSnapshot, + NotebookPaneSnapshot, PaneNodeSnapshot, PaneUuid, PersistedDaemonRuntime, RemoteTerminalIdentity, RemoteTerminalTransport, RightPanelSnapshot, SettingsPaneSnapshot, TabSnapshot, WindowSnapshot, WorkflowPaneSnapshot, }; @@ -851,6 +851,13 @@ struct PendingSplitLaunch { inherited_remote_cwd: Option, } +/// A routed agent launch that opened its terminal: the token the Spawn-Karte +/// records, and the terminal the agent runs in when it could be resolved. +struct LaunchedAgent { + token: String, + terminal: Option>, +} + #[cfg(unix)] #[derive(Clone, Copy)] struct PendingDaemonSurface { @@ -2081,9 +2088,12 @@ pub struct Workspace { // Same applies to "show_new_session_dropdown_menu" new_session_dropdown_menu: ViewHandle>, show_new_session_dropdown_menu: Option, - split_launch_menu: ViewHandle>, + split_launch_menu: ViewHandle>, show_split_launch_menu: Option, pending_split_launch: Option, + /// Split target for a Spawn-Karte opened from the split launch menu; its + /// single launch lands there instead of a new tab. + spawn_card_split: Option, changelog_model: ModelHandle, palette: ViewHandle, ctrl_tab_palette: ViewHandle, @@ -2248,11 +2258,61 @@ fn host_lookup_failure_message(registry_read_failed: bool) -> String { } } +/// Where a launch-menu entry opens its session: a new tab from `+`, or the pane +/// split captured when the split launch menu opened. Both menus offer the same +/// entries; only the dispatched action differs. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum LaunchMenuTarget { + NewTab, + Split, +} + +impl LaunchMenuTarget { + fn local_terminal_action(self) -> WorkspaceAction { + match self { + Self::NewTab => WorkspaceAction::AddTerminalTab { + hide_homepage: false, + }, + Self::Split => WorkspaceAction::SplitLaunchLocal, + } + } + + fn host_terminal_action(self, node_id: &str) -> WorkspaceAction { + let node_id = node_id.to_string(); + match self { + Self::NewTab => WorkspaceAction::OpenSshTerminalByNode { node_id }, + Self::Split => WorkspaceAction::SplitLaunchHost { node_id }, + } + } + + fn new_agent_action( + self, + registry_node_id: Option<&str>, + host: Option<&str>, + ) -> WorkspaceAction { + let registry_node_id = registry_node_id.map(str::to_string); + let host = host.map(str::to_string); + match self { + Self::NewTab => WorkspaceAction::OpenSpawnCard { + registry_node_id, + host_id: None, + host, + project: None, + }, + Self::Split => WorkspaceAction::SplitLaunchSpawnCard { + registry_node_id, + host, + }, + } + } +} + fn favorite_host_menu_item( favorite: &zaplex_cockpit::Favorite, host_nodes: &[(String, String)], changes_disabled: bool, host_registry_unavailable: bool, + target: LaunchMenuTarget, ) -> MenuItem { let remove_item = if changes_disabled || host_registry_unavailable { MenuItemFields::new(crate::t!("cockpit-tt-favorite-remove")) @@ -2275,9 +2335,7 @@ fn favorite_host_menu_item( // `⋯` trigger and kept the label hovered while the submenu was open. return MenuItem::Submenu { fields: MenuItemFields::new_submenu(name.clone()) - .with_on_select_action(WorkspaceAction::OpenSshTerminalByNode { - node_id: node_id.clone(), - }) + .with_on_select_action(target.host_terminal_action(node_id)) .with_split_submenu_trigger(crate::t!( "workspace-favorite-more-actions", host = name.clone() @@ -2285,12 +2343,9 @@ fn favorite_host_menu_item( .with_icon(icons::Icon::StarFilled), menu: SubMenu::new(vec![ MenuItemFields::new(crate::t!("cockpit-spawn-card-new-agent")) - .with_on_select_action(WorkspaceAction::OpenSpawnCard { - registry_node_id: Some(node_id.clone()), - host_id: None, - host: Some(name.clone()), - project: None, - }) + .with_on_select_action( + target.new_agent_action(Some(node_id.as_str()), Some(name.as_str())), + ) .with_icon(icons::Icon::LayoutAlt01) .into_item(), MenuItemFields::new(crate::t!("workspace-left-panel-ssh-manager-menu-edit")) @@ -2341,6 +2396,7 @@ fn favorite_host_menu_items( host_nodes: &[(String, String)], changes_disabled: bool, host_registry_unavailable: bool, + target: LaunchMenuTarget, ) -> Vec> { favorites .iter() @@ -2350,15 +2406,41 @@ fn favorite_host_menu_items( host_nodes, changes_disabled, host_registry_unavailable, + target, ) }) .collect() } +/// The "More hosts" submenu: every registered host that is not a favorite, in +/// registry order. `None` when there is no such host. +fn more_hosts_menu_item( + host_nodes: &[(String, String)], + is_favorite: impl Fn(&str) -> bool, + target: LaunchMenuTarget, +) -> Option> { + let hosts = host_nodes + .iter() + .filter(|(node_id, _)| !is_favorite(node_id.as_str())) + .map(|(node_id, name)| { + MenuItemFields::new(name.clone()) + .with_on_select_action(target.host_terminal_action(node_id)) + .with_icon(icons::Icon::Terminal) + .into_item() + }) + .collect::>(); + (!hosts.is_empty()).then(|| MenuItem::Submenu { + fields: MenuItemFields::new_submenu(crate::t!("workspace-launch-more-hosts")) + .with_icon(icons::Icon::DotsHorizontal), + menu: SubMenu::new(hosts), + }) +} + fn favorites_menu_items_from_sources( favorites_store: &crate::cockpit::favorites::FavoritesStore, host_nodes: Vec<(String, String)>, host_registry_unavailable: bool, + target: LaunchMenuTarget, ) -> Vec> { let persistence_is_protected = favorites_store.persistence_is_protected(); let favorites = favorites_store @@ -2402,51 +2484,61 @@ fn favorites_menu_items_from_sources( &host_nodes, persistence_is_protected, host_registry_unavailable, + target, + )); + items.extend(more_hosts_menu_item( + &host_nodes, + |node_id| favorites_store.contains(zaplex_cockpit::FavoriteKind::Host, node_id), + target, )); items } -/// `None` means the registry could not be read, rather than an empty registry. +/// The split launch menu: the launch section of the `+` menu (local Terminal, +/// New agent…, favorite hosts, "More hosts"), with every entry targeting the +/// captured split instead of a new tab. Tab-only entries are not offered. fn split_launch_menu_items( - hosts: Option>, - current_host: Option<&SplitLaunchDestination>, -) -> Vec> { - let host_item = |name: String, destination: SplitLaunchDestination| { - let label = if current_host == Some(&destination) { - format!("{name} · {}", crate::t!("common-current")) - } else { - name - }; - MenuItemFields::new(label) - .with_on_select_action(destination) - .with_icon(icons::Icon::Terminal) - .into_item() - }; - let mut items = vec![host_item( - crate::t!("cockpit-spawn-card-host-local"), - SplitLaunchDestination::Local, - )]; - match hosts { - Some(hosts) if !hosts.is_empty() => { - items.push(MenuItem::Separator); - items.extend(hosts.into_iter().map(|(node_id, name)| { - host_item(name, SplitLaunchDestination::Remote { node_id }) - })); - } - Some(_) => {} - None => { - items.push(MenuItem::Separator); - items.push( - MenuItemFields::new(crate::t!("workspace-host-registry-unavailable")) - .with_disabled(true) - .with_icon(icons::Icon::AlertTriangle) - .into_item(), - ); - } + favorites_store: &crate::cockpit::favorites::FavoritesStore, + host_nodes: Vec<(String, String)>, + host_registry_unavailable: bool, + cockpit_enabled: bool, +) -> Vec> { + let target = LaunchMenuTarget::Split; + let mut items = vec![ + MenuItemFields::new(crate::t!("workspace-new-session-terminal")) + .with_on_select_action(target.local_terminal_action()) + .with_icon(icons::Icon::LayoutAlt01) + .into_item(), + ]; + if cockpit_enabled { + items.push( + MenuItemFields::new(crate::t!("cockpit-spawn-card-new-agent")) + .with_on_select_action(target.new_agent_action(None, None)) + .with_icon(icons::Icon::LayoutAlt01) + .into_item(), + ); } + items.extend(favorites_menu_items_from_sources( + favorites_store, + host_nodes, + host_registry_unavailable, + target, + )); items } +/// Registered SSH hosts as `(node_id, name)`, read once from the registry. +/// An `Err` is a failed read, which is not the same as an empty registry. +fn registered_host_nodes() -> anyhow::Result> { + warp_ssh_manager::with_conn(|conn| { + Ok(warp_ssh_manager::SshRepository::list_nodes(conn)? + .into_iter() + .filter(|node| matches!(node.kind, warp_ssh_manager::types::NodeKind::Server)) + .map(|node| (node.id, node.name)) + .collect::>()) + }) +} + fn primary_host_navigation_views(cockpit_enabled: bool) -> Vec { if cockpit_enabled { vec![ToolPanelView::Cockpit, ToolPanelView::SshManager] @@ -3930,33 +4022,25 @@ impl Workspace { let tab_bar_overflow_menu = Self::build_tab_bar_overflow_menu(ctx); let (tab_right_click_menu, new_session_dropdown_menu, new_session_sidecar_menu) = Self::build_menus(ctx); + // The split launch menu dispatches the same kind of actions as the `+` + // menu (split variants for launches), including from nested submenus. let split_launch_menu = ctx.add_typed_action_view(|_| { Menu::new() - .without_item_action_dispatch() .with_drop_shadow() + .with_safe_triangle() + .with_ignore_hover_when_covered() .prevent_interaction_with_other_elements() }); ctx.subscribe_to_view(&split_launch_menu, |me, menu, event, ctx| { - if let MenuEvent::Close { via_select_item } = event { - let destination = (*via_select_item).then(|| { - menu.as_ref(ctx) - .selected_item() - .and_then(|item| match item { - MenuItem::Item(fields) => fields.on_select_action().cloned(), - MenuItem::Separator - | MenuItem::ItemsRow { .. } - | MenuItem::Submenu { .. } - | MenuItem::Header { .. } => None, - }) - }); + if let MenuEvent::Close { .. } = event { + // A selected item's action is dispatched before the menu + // reports Close, so a launch has already consumed the pending + // target. Anything left over belongs to a cancelled menu or to + // a non-launch entry (edit/remove favorite). me.show_split_launch_menu = None; - if let Some(Some(destination)) = destination { - me.complete_split_launch(destination, ctx); - } else { - me.pending_split_launch = None; - } - // The picker took focus when it opened. A local split focuses - // its new pane; a cancelled picker or a remote split that is + me.pending_split_launch = None; + // The menu took focus when it opened. A local split focuses + // its new pane; a cancelled menu or a remote split that is // still connecting hands focus back to the active tab. if menu.is_focused(ctx) { me.focus_active_tab(ctx); @@ -4647,6 +4731,7 @@ impl Workspace { split_launch_menu, show_split_launch_menu: None, pending_split_launch: None, + spawn_card_split: None, changelog_model, welcome_tips_view_state, welcome_tips_view, @@ -5969,45 +6054,83 @@ impl Workspace { account_id: Option<&str>, ctx: &mut ViewContext, ) { + let Some(terminal_view) = self + .active_tab_pane_group() + .as_ref(ctx) + .active_session_view(ctx) + else { + return; + }; + Self::bind_terminal_account_with_id( + &terminal_view, + agent, + config_dir, + account_email, + account_id, + ctx, + ); + } + + /// Binds the launch account to exactly `terminal_view`, which is not + /// necessarily the focused one (a split launch does not focus its pane). + fn bind_terminal_account_with_id( + terminal_view: &ViewHandle, + agent: CLIAgent, + config_dir: Option<&Path>, + account_email: Option<&str>, + account_id: Option<&str>, + ctx: &mut ViewContext, + ) { + let terminal_view_id = terminal_view.as_ref(ctx).view_id(); let config_dir = config_dir.map(|dir| dir.to_string_lossy().into_owned()); let account_email = account_email.map(str::to_owned); let account_id = account_id.map(str::to_owned); - self.active_tab_pane_group().update(ctx, |pane_group, ctx| { - if let Some(terminal_view) = pane_group.active_session_view(ctx) { - let terminal_view_id = terminal_view.as_ref(ctx).view_id(); - CLIAgentSessionsModel::handle(ctx).update(ctx, |model, _| { - model.bind_account_identity_with_id( - terminal_view_id, - agent, - config_dir.clone(), - account_email.clone(), - account_id.clone(), - ); - }); - } + CLIAgentSessionsModel::handle(ctx).update(ctx, |model, _| { + model.bind_account_identity_with_id( + terminal_view_id, + agent, + config_dir, + account_email, + account_id, + ); }); } /// Attaches pre-recorded launch intent to the terminal that will execute it. /// The hook bridge may complete the binding before or after this call. - fn attach_active_terminal_launch_intent( - &self, + fn attach_terminal_launch_intent( launch_id: crate::cockpit::launch_registry::LaunchId, + terminal_view: &ViewHandle, ctx: &AppContext, ) { - let Some(terminal_view) = self - .active_tab_pane_group() - .as_ref(ctx) - .active_session_view(ctx) - else { - return; - }; crate::cockpit::launch_registry::attach_terminal( launch_id, terminal_view.as_ref(ctx).view_id(), ); } + /// The terminal a just-opened agent launch runs in: the active tab's + /// session for a new tab, or the one pane a split launch added to the + /// captured group (`split_probe` lists the panes it showed before). + fn launched_agent_terminal( + &self, + split_probe: Option<&(ViewHandle, Vec)>, + ctx: &AppContext, + ) -> Option> { + let Some((pane_group, panes_before)) = split_probe else { + return self + .active_tab_pane_group() + .as_ref(ctx) + .active_session_view(ctx); + }; + let group = pane_group.as_ref(ctx); + group + .visible_pane_ids() + .into_iter() + .find(|pane_id| !panes_before.contains(pane_id)) + .and_then(|pane_id| group.terminal_view_from_pane_id(pane_id, ctx)) + } + /// Fork an agent conversation into a NEW session (fork/worktree design §2): /// a new tab in the source session's cwd runs the provider's fork command — /// same history, divergent future; the original session stays untouched. @@ -6200,6 +6323,7 @@ impl Workspace { provider: provider.to_string(), account_id: account_id.to_string(), }, + None, ctx, ) } else { @@ -7070,6 +7194,27 @@ impl Workspace { }); } + /// Prefills a just-launched agent's input for the human to review and send + /// (never auto-sent). Falls back to the active tab when the launch's own + /// terminal is unknown. + fn prefill_launched_agent_input( + &mut self, + terminal: Option<&ViewHandle>, + text: &str, + ctx: &mut ViewContext, + ) { + let Some(terminal_view) = terminal else { + self.prefill_active_tab_input(text, ctx); + return; + }; + terminal_view.update(ctx, |terminal_view, ctx| { + terminal_view.input().update(ctx, |input, ctx| { + input.replace_buffer_content(text, ctx); + input.focus_input_box(ctx); + }); + }); + } + fn terminal_view_handle( terminal_view_id: EntityId, ctx: &AppContext, @@ -8407,8 +8552,10 @@ impl Workspace { effort, spawn_card::ManagedLaunchMode::Ordinary, None, + None, ctx, ) + .map(|launched| launched.token) } #[allow(clippy::too_many_arguments)] @@ -8424,8 +8571,24 @@ impl Workspace { effort: Option<&str>, managed_mode: spawn_card::ManagedLaunchMode, managed_launch_id: Option<&str>, + split: Option, ctx: &mut ViewContext, - ) -> Result { + ) -> Result { + let split = match split { + Some(split) => Some( + self.revalidated_split_launch(split, ctx) + .ok_or_else(|| crate::t!("workspace-split-target-changed").to_string())?, + ), + None => None, + }; + // A split launch adds exactly one pane to the captured group; the panes + // it shows now identify that terminal once the launch has opened it. + let split_probe = split.as_ref().map(|split| { + ( + split.pane_group.clone(), + split.pane_group.as_ref(ctx).visible_pane_ids(), + ) + }); if managed_mode != spawn_card::ManagedLaunchMode::Ordinary { if config_dir.is_some() || model.is_some() || effort.is_some() { return Err( @@ -8530,19 +8693,27 @@ impl Workspace { server, route.clone(), launch, + split.clone(), ctx, ) { return Err(crate::t!("workspace-managed-launch-daemon-route-failed").to_string()); } - self.bind_active_terminal_account_with_id( - agent, - None, - account_email, - Some(route.account_id.as_str()), - ctx, - ); - self.attach_active_terminal_launch_intent(launch_record, ctx); - return Ok(format!("managed:{launch_id}")); + let terminal = self.launched_agent_terminal(split_probe.as_ref(), ctx); + if let Some(terminal) = terminal.as_ref() { + Self::bind_terminal_account_with_id( + terminal, + agent, + None, + account_email, + Some(route.account_id.as_str()), + ctx, + ); + Self::attach_terminal_launch_intent(launch_record, terminal, ctx); + } + return Ok(LaunchedAgent { + token: format!("managed:{launch_id}"), + terminal, + }); } // Record the chosen (model, effort) against the new terminal so the // first native hook event can bind it to the exact provider session id. @@ -8622,21 +8793,27 @@ impl Workspace { node_id.to_string(), server, route.clone(), + split.clone(), ctx, ) } else { - self.open_ssh_terminal(node_id.to_string(), server, false, ctx) + self.open_ssh_terminal_at(node_id.to_string(), server, split.clone(), ctx) }; if opened { - self.bind_active_terminal_account_with_id( - agent, - None, - account_email, - agent_launch_route.map(|route| route.account_id.as_str()), - ctx, - ); - self.attach_active_terminal_launch_intent(launch_id, ctx); - return Ok(format!("remote:{}", launch_id.opaque_id())); + let token = format!("remote:{}", launch_id.opaque_id()); + let terminal = self.launched_agent_terminal(split_probe.as_ref(), ctx); + if let Some(terminal) = terminal.as_ref() { + Self::bind_terminal_account_with_id( + terminal, + agent, + None, + account_email, + agent_launch_route.map(|route| route.account_id.as_str()), + ctx, + ); + Self::attach_terminal_launch_intent(launch_id, terminal, ctx); + } + return Ok(LaunchedAgent { token, terminal }); } return Err("The remote account route could not be opened.".to_string()); } @@ -8663,27 +8840,43 @@ impl Workspace { if let Some(dir) = cwd { options = options.with_initial_directory(dir.to_path_buf()); } - self.add_tab_with_pane_layout( - PanesLayout::SingleTerminal(Box::new(options)), - Arc::new(HashMap::new()), - None, - ctx, - ); - self.bind_active_terminal_account(agent, config_dir, account_email, ctx); - self.attach_active_terminal_launch_intent(launch_id, ctx); - let launched = self.active_tab_pane_group().update(ctx, |pane_group, ctx| { - pane_group - .active_session_view(ctx) - .is_some_and(|terminal_view| { - terminal_view.update(ctx, |view, ctx| { - view.execute_routed_agent_launch_or_set_pending(&launch, ctx) - }) + let terminal = match split.as_ref() { + None => { + self.add_tab_with_pane_layout( + PanesLayout::SingleTerminal(Box::new(options)), + Arc::new(HashMap::new()), + None, + ctx, + ); + self.launched_agent_terminal(None, ctx) + } + Some(split) => { + options.shell = split.chosen_shell.clone(); + let target = split.target; + split.pane_group.update(ctx, |group, ctx| { + let (_, terminal_view, focus_guard) = + group.insert_terminal_for_split(target, options, ctx)?; + group.focus_split_result_if_current(focus_guard, ctx); + Some(terminal_view) }) + } + }; + let Some(terminal) = terminal else { + return Err("Could not determine the new terminal's shell.".to_string()); + }; + Self::bind_terminal_account_with_id(&terminal, agent, config_dir, account_email, None, ctx); + let token = format!("local:{}", launch_id.opaque_id()); + Self::attach_terminal_launch_intent(launch_id, &terminal, ctx); + let launched = terminal.update(ctx, |view, ctx| { + view.execute_routed_agent_launch_or_set_pending(&launch, ctx) }); if !launched { return Err("Could not determine the new terminal's shell.".to_string()); } - Ok(format!("local:{}", launch_id.opaque_id())) + Ok(LaunchedAgent { + token, + terminal: Some(terminal), + }) } fn show_agent_launch_error(&mut self, message: String, ctx: &mut ViewContext) { @@ -8702,6 +8895,13 @@ impl Workspace { validation_error: Option, ctx: &mut ViewContext, ) { + // A card opened from the split launch menu places exactly one session + // at the captured split; a multi-account selection is refused there. + let split = self.spawn_card_split.clone(); + let validation_error = validation_error.or_else(|| { + (split.is_some() && targets.len() > 1) + .then(|| crate::t!("workspace-split-agent-single-launch")) + }); let mut attempted = false; for (target_id, target) in targets { // A directory response may arrive after a selection change or Close. @@ -8714,7 +8914,7 @@ impl Workspace { continue; } attempted = true; - let mut result = if let Some(error) = validation_error.as_ref() { + let launched = if let Some(error) = validation_error.as_ref() { Err(error.clone()) } else { let local_account_email = if target.node_id.is_none() { @@ -8742,14 +8942,16 @@ impl Workspace { target.effort.as_deref(), target.managed_mode, target.managed_launch_id.as_deref(), + split.clone(), ctx, ) }; - if result.is_ok() { + if let Ok(launched) = launched.as_ref() { if let Some(prompt) = target.prompt.as_deref() { - self.prefill_active_tab_input(prompt, ctx); + self.prefill_launched_agent_input(launched.terminal.as_ref(), prompt, ctx); } } + let mut result = launched.map(|launched| launched.token); if target.managed_mode != spawn_card::ManagedLaunchMode::Ordinary && result.is_ok() { if let Some(launch_id) = target .managed_launch_id @@ -8790,6 +8992,7 @@ impl Workspace { return; } if self.spawn_card.as_ref(ctx).launch_batch_succeeded(plan_id) { + self.spawn_card_split = None; self.current_workspace_state.is_spawn_card_open = false; self.focus_active_tab(ctx); } else { @@ -11366,12 +11569,42 @@ impl Workspace { ); } + /// Opens a registered host like [`Self::open_ssh_terminal`], or at `split` + /// when given (a Spawn-Karte launch from the split launch menu). + fn open_ssh_terminal_at( + &mut self, + node_id: String, + server: warp_ssh_manager::SshServerInfo, + split: Option, + ctx: &mut ViewContext, + ) -> bool { + let Some(split) = split else { + return self.open_ssh_terminal(node_id, server, false, ctx); + }; + let Some(attempt) = self.begin_ssh_connect(node_id.clone(), server.host.clone(), ctx) + else { + return false; + }; + self.open_ssh_terminal_command( + node_id, + server, + false, + None, + None, + None, + Some(attempt), + Some(split), + ctx, + ) + } + #[cfg(all(unix, feature = "local_tty"))] fn open_ssh_terminal_for_agent_account( &mut self, node_id: String, server: warp_ssh_manager::SshServerInfo, route: remote_server::proto::AgentLaunchRoute, + split: Option, ctx: &mut ViewContext, ) -> bool { if !server.session_resilience.is_enabled() { @@ -11416,7 +11649,7 @@ impl Workspace { Some(route), None, Some(attempt), - None, + split, ctx, ) } @@ -11427,6 +11660,7 @@ impl Workspace { _node_id: String, _server: warp_ssh_manager::SshServerInfo, _route: remote_server::proto::AgentLaunchRoute, + _split: Option, ctx: &mut ViewContext, ) -> bool { self.toast_stack.update(ctx, |view, ctx| { @@ -11447,6 +11681,7 @@ impl Workspace { server: warp_ssh_manager::SshServerInfo, route: remote_server::proto::AgentLaunchRoute, launch: remote_server::proto::ManagedLaunch, + split: Option, ctx: &mut ViewContext, ) -> bool { let supported = RemoteServerManager::as_ref(ctx) @@ -11498,7 +11733,7 @@ impl Workspace { Some(route), Some(launch), Some(attempt), - None, + split, ctx, ) } @@ -11510,6 +11745,7 @@ impl Workspace { _server: warp_ssh_manager::SshServerInfo, _route: remote_server::proto::AgentLaunchRoute, _launch: remote_server::proto::ManagedLaunch, + _split: Option, ctx: &mut ViewContext, ) -> bool { self.show_agent_launch_error( @@ -14473,7 +14709,7 @@ impl Workspace { /// Builds the unified new-session menu items /// tab bar chevron and the vertical tab bar `+` button. /// - /// Order: Terminal → User tab configs → separator → Agent → Coding Agents → separator → Docker → Worktree config → New tab config → separator → Reopen closed session. + /// Order: Terminal → Agent / New agent → Coding Agents → Favorites → More hosts → separator → User tab configs → Docker → separator → Worktree config → New tab config → separator → Reopen closed session. /// The **Favorites** section of the "+" dropdown (design §10): only /// user-curated host favorites. A registered host label opens its terminal /// directly, while the fixed trailing action opens its management flyout. @@ -14483,50 +14719,18 @@ impl Workspace { fn favorites_menu_items(&self, ctx: &mut ViewContext) -> Vec> { // Registered hosts (node_id -> label), read once from the SSH registry // to resolve favorite references without duplicating connection data. - let host_nodes = warp_ssh_manager::with_conn(|c| { - let mut out = Vec::new(); - for node in warp_ssh_manager::SshRepository::list_nodes(c)? { - if matches!(node.kind, warp_ssh_manager::types::NodeKind::Server) { - out.push((node.id, node.name)); - } - } - Ok(out) - }); + let host_nodes = registered_host_nodes(); let host_registry_unavailable = host_nodes.is_err(); let host_nodes: Vec<(String, String)> = host_nodes.unwrap_or_default(); let favorites_store = crate::cockpit::favorites::FavoritesStore::handle(ctx); let favorites_store = favorites_store.as_ref(ctx); - favorites_menu_items_from_sources(favorites_store, host_nodes, host_registry_unavailable) - } - - fn split_launch_source_host( - &self, - pane_group: &ViewHandle, - pane_id: PaneId, - ctx: &AppContext, - ) -> Option { - let group = pane_group.as_ref(ctx); - if let Some(pane) = group.downcast_pane_by_id::(pane_id) { - // The visible file manager may show a different host than its covered terminal. - // Its own persisted identity takes precedence over terminal and legacy tab maps. - let LeafContents::Sftp { node_id, mode, .. } = pane.snapshot(ctx) else { - return None; - }; - return Some(match mode { - FileManagerPaneMode::Local => SplitLaunchDestination::Local, - FileManagerPaneMode::Remote | FileManagerPaneMode::RemotePicker => { - SplitLaunchDestination::Remote { node_id } - } - }); - } - let source_view = group.terminal_view_from_pane_id(pane_id, ctx)?; - self.node_for_pane(pane_group, pane_id, Some(&source_view), ctx) - .map(|node_id| SplitLaunchDestination::Remote { node_id }) - .or_else(|| { - (source_view.as_ref(ctx).active_session_is_local(ctx) == Some(true)) - .then_some(SplitLaunchDestination::Local) - }) + favorites_menu_items_from_sources( + favorites_store, + host_nodes, + host_registry_unavailable, + LaunchMenuTarget::NewTab, + ) } fn open_split_launch_menu( @@ -14540,17 +14744,21 @@ impl Workspace { return; } - let current_host = self.split_launch_source_host(&pane_group, target.pane_id(), ctx); - let hosts = warp_ssh_manager::with_conn(|conn| { - Ok(warp_ssh_manager::SshRepository::list_nodes(conn)? - .into_iter() - .filter(|node| matches!(node.kind, warp_ssh_manager::types::NodeKind::Server)) - .map(|node| (node.id, node.name)) - .collect::>()) + let host_nodes = registered_host_nodes(); + let host_registry_unavailable = host_nodes.is_err(); + let cockpit_enabled = *crate::cockpit::CockpitSettings::as_ref(ctx).enabled; + let favorites_store = crate::cockpit::favorites::FavoritesStore::handle(ctx); + let items = split_launch_menu_items( + favorites_store.as_ref(ctx), + host_nodes.unwrap_or_default(), + host_registry_unavailable, + cockpit_enabled, + ); + self.split_launch_menu.update(ctx, |menu, ctx| { + menu.set_width(MENU_DEFAULT_WIDTH); + menu.set_items(items, ctx); + menu.reset_selection(ctx); }); - let items = split_launch_menu_items(hosts.ok(), current_host.as_ref()); - self.split_launch_menu - .update(ctx, |menu, ctx| menu.set_items(items, ctx)); self.pending_split_launch = Some(PendingSplitLaunch { pane_group, target, @@ -14868,58 +15076,8 @@ impl Workspace { } } - // 2. User tab configs - if FeatureFlag::TabConfigs.is_enabled() { - let tab_configs = WarpConfig::as_ref(ctx).tab_configs().to_vec(); - - let mut name_totals: HashMap = HashMap::new(); - for config in &tab_configs { - *name_totals.entry(config.name.clone()).or_default() += 1; - } - let mut name_seen: HashMap = HashMap::new(); - - for tab_config in tab_configs { - let is_worktree = tab_config.is_worktree(); - let icon = if is_worktree { - icons::Icon::Dataflow02 - } else { - icons::Icon::LayoutAlt01 - }; - let is_default_config = effective_default == DefaultSessionMode::TabConfig - && tab_config - .source_path - .as_ref() - .is_some_and(|p| p.to_string_lossy() == default_tab_config_path); - - let display_name = if name_totals.get(&tab_config.name).copied().unwrap_or(0) > 1 { - let seen = name_seen.entry(tab_config.name.clone()).or_default(); - *seen += 1; - if *seen == 1 { - tab_config.name.clone() - } else { - format!("{} ({})", tab_config.name, *seen - 1) - } - } else { - tab_config.name.clone() - }; - - let mut item = MenuItemFields::new(display_name) - .with_on_select_action(WorkspaceAction::SelectTabConfig(tab_config)) - .with_icon(icon); - if is_default_config { - item = item.with_key_shortcut_label(shortcut_label.clone()); - } - menu_items.push(item.into_item()); - } - } - - // 3. Separator — only shown when an Agent or Coding Agent follows - if is_any_ai_enabled { - menu_items.push(MenuItem::Separator); - } - - // 4. Agent — the in-app (Warp) AI agent. With the cockpit on, the explicit - // spawn card (4b, "Neuer Agent…") is the single app-level launch grammar, + // 2. Agent — the in-app (Warp) AI agent. With the cockpit on, the explicit + // spawn card (2b, "Neuer Agent…") is the single app-level launch grammar, // so this legacy parallel entry is hidden in that (production) mode and // kept only as a fallback when the cockpit is off (Codex gate: no parallel // launch grammar next to the spawn card). @@ -14933,26 +15091,20 @@ impl Workspace { menu_items.push(agent_item.into_item()); } - // 4b. Spawn-Karte — the launch card that makes model + effort a visible + // 2b. Spawn-Karte — the launch card that makes model + effort a visible // launch attribute (unscoped: host/project default to the local context). // Shown once, above the per-agent quick launches, when the cockpit is on. if *crate::cockpit::CockpitSettings::as_ref(ctx).enabled { menu_items.push( MenuItemFields::new(crate::t!("cockpit-spawn-card-new-agent")) - .with_on_select_action(WorkspaceAction::OpenSpawnCard { - registry_node_id: None, - host_id: None, - host: None, - project: None, - }) + .with_on_select_action(LaunchMenuTarget::NewTab.new_agent_action(None, None)) .with_icon(icons::Icon::LayoutAlt01) .into_item(), ); } - // 5. Coding Agents — only those installed and with tab_menu enabled appear in the menu - let coding_agent_count = { - let start_len = menu_items.len(); + // 3. Coding Agents — only those installed and with tab_menu enabled appear in the menu + { let ai_settings = AISettings::as_ref(ctx); let install_model = CLIAgentInstallModel::as_ref(ctx); for agent in enum_iterator::all::() { @@ -14987,20 +15139,64 @@ impl Workspace { // repo/PR. The flows are now favoritable / command-palette actions // (design §10, #102); hosts are reached via favorites (below). } - menu_items.len() - start_len - }; + } - // 5b. Favorite hosts (design §10), replacing the old automatic host and - // launch wall. A favorite duplicates no connection data. + // 4. Favorite hosts (design §10), replacing the old automatic host and + // launch wall, followed by the "More hosts" submenu with the remaining + // registered hosts. A favorite duplicates no connection data. Entries 1–4 + // are the launch section the split launch menu shares. menu_items.extend(self.favorites_menu_items(ctx)); - // 6. Separator — only shown when there are coding agents and Docker is enabled - // The TabConfigs section adds its own separator in step 8, so no need to duplicate it here - if coding_agent_count > 0 && FeatureFlag::LocalDockerSandbox.is_enabled() { - menu_items.push(MenuItem::Separator); + // 5. Tab-only entries: a split cannot host a tab config, a Docker sandbox + // tab or a reopened tab, so these follow the shared launch section. + let mut tab_only_items = Vec::new(); + + // 5a. User tab configs + if FeatureFlag::TabConfigs.is_enabled() { + let tab_configs = WarpConfig::as_ref(ctx).tab_configs().to_vec(); + + let mut name_totals: HashMap = HashMap::new(); + for config in &tab_configs { + *name_totals.entry(config.name.clone()).or_default() += 1; + } + let mut name_seen: HashMap = HashMap::new(); + + for tab_config in tab_configs { + let is_worktree = tab_config.is_worktree(); + let icon = if is_worktree { + icons::Icon::Dataflow02 + } else { + icons::Icon::LayoutAlt01 + }; + let is_default_config = effective_default == DefaultSessionMode::TabConfig + && tab_config + .source_path + .as_ref() + .is_some_and(|p| p.to_string_lossy() == default_tab_config_path); + + let display_name = if name_totals.get(&tab_config.name).copied().unwrap_or(0) > 1 { + let seen = name_seen.entry(tab_config.name.clone()).or_default(); + *seen += 1; + if *seen == 1 { + tab_config.name.clone() + } else { + format!("{} ({})", tab_config.name, *seen - 1) + } + } else { + tab_config.name.clone() + }; + + let mut item = MenuItemFields::new(display_name) + .with_on_select_action(WorkspaceAction::SelectTabConfig(tab_config)) + .with_icon(icon); + if is_default_config { + item = item.with_key_shortcut_label(shortcut_label.clone()); + } + tab_only_items.push(item.into_item()); + } } - // 7. Local Docker Sandbox + // 5b. Local Docker Sandbox if FeatureFlag::LocalDockerSandbox.is_enabled() { let mut docker_item = MenuItemFields::new(crate::t!("workspace-new-session-local-docker-sandbox")) @@ -15009,10 +15205,15 @@ impl Workspace { if effective_default == DefaultSessionMode::DockerSandbox { docker_item = docker_item.with_key_shortcut_label(shortcut_label.clone()); } - menu_items.push(docker_item.into_item()); + tab_only_items.push(docker_item.into_item()); + } + + if !tab_only_items.is_empty() { + menu_items.push(MenuItem::Separator); + menu_items.extend(tab_only_items); } - // 8. Separator + worktree config entry + new tab config + // 5c. Separator + worktree config entry + new tab config if FeatureFlag::TabConfigs.is_enabled() { menu_items.push(MenuItem::Separator); menu_items.push( @@ -25018,6 +25219,9 @@ impl Workspace { default_agent: Option, ctx: &mut ViewContext, ) { + // Every open starts as a new-tab launch; only the split launch menu + // re-targets the card afterwards (`open_spawn_card_for_split`). + self.spawn_card_split = None; // S0 fix: install status is populated by an async startup scan cached in // CLIAgentInstallModel; reading that cache here raced (cache=None → false) // and the card never recovered, so it falsely claimed "No agent CLI @@ -25130,6 +25334,44 @@ impl Workspace { ctx.notify(); } + /// "New agent…" from the split launch menu: the Spawn-Karte opens as usual, + /// but its single launch lands at the split target captured by the menu. + fn open_spawn_card_for_split( + &mut self, + registry_node_id: Option, + host: Option, + ctx: &mut ViewContext, + ) { + let Some(split) = self.pending_split_launch.take() else { + return; + }; + self.open_spawn_card(registry_node_id, None, host, None, None, None, ctx); + self.spawn_card_split = Some(split); + } + + /// The captured split target re-read at launch time. The Spawn-Karte can + /// stay open while the layout changes (a remote directory pick opens a + /// pane), so the target is recaptured as long as its source pane is still + /// visible in a live tab; otherwise the launch must not happen. + fn revalidated_split_launch( + &self, + split: PendingSplitLaunch, + ctx: &AppContext, + ) -> Option { + let pane_group_is_live = self + .tabs + .iter() + .any(|tab| tab.pane_group.id() == split.pane_group.id()); + if !pane_group_is_live { + return None; + } + let target = split + .pane_group + .as_ref(ctx) + .recapture_split_target(split.target.pane_id(), split.target.direction())?; + Some(PendingSplitLaunch { target, ..split }) + } + /// Handle Spawn-Karte events: cancel closes it; confirm turns the chosen /// (agent, model, effort, account, host, project) into a routed launch. fn handle_spawn_card_event(&mut self, event: &SpawnCardEvent, ctx: &mut ViewContext) { @@ -25670,6 +25912,7 @@ impl Workspace { SpawnCardEvent::Close => { self.spawn_card .update(ctx, |card, _| card.cancel_pending_launches()); + self.spawn_card_split = None; self.current_workspace_state.is_spawn_card_open = false; self.focus_active_tab(ctx); ctx.notify(); @@ -25710,7 +25953,8 @@ impl Workspace { let account_email = remote_account_email .as_ref() .or(local_account_email.as_ref()); - let result = self.launch_routed_agent_with_mode( + let split = self.spawn_card_split.take(); + let launched = self.launch_routed_agent_with_mode( *agent, config_dir.as_deref(), account_email.map(String::as_str), @@ -25721,8 +25965,14 @@ impl Workspace { effort.as_deref(), *managed_mode, managed_launch_id.as_deref(), + split, ctx, ); + let launched_terminal = launched + .as_ref() + .ok() + .and_then(|launched| launched.terminal.clone()); + let result = launched.map(|launched| launched.token); if *managed_mode == spawn_card::ManagedLaunchMode::Ordinary { self.current_workspace_state.is_spawn_card_open = false; if let Err(error) = result { @@ -25769,17 +26019,7 @@ impl Workspace { // (never auto-sent) — the same in-the-loop behavior the old direct // ask_agent path had, now unified through the spawn card. if let Some(prompt) = prompt { - let prompt = prompt.clone(); - self.active_tab_pane_group().update(ctx, |pane_group, ctx| { - if let Some(terminal_view) = pane_group.focused_session_view(ctx) { - terminal_view.update(ctx, |terminal_view, ctx| { - terminal_view.input().update(ctx, |input, ctx| { - input.replace_buffer_content(&prompt, ctx); - input.focus_input_box(ctx); - }); - }); - } - }); + self.prefill_launched_agent_input(launched_terminal.as_ref(), prompt, ctx); } ctx.notify(); } @@ -29433,6 +29673,23 @@ impl TypedActionView for Workspace { } } } + SplitLaunchLocal => { + self.complete_split_launch(SplitLaunchDestination::Local, ctx); + } + SplitLaunchHost { node_id } => { + self.complete_split_launch( + SplitLaunchDestination::Remote { + node_id: node_id.clone(), + }, + ctx, + ); + } + SplitLaunchSpawnCard { + registry_node_id, + host, + } => { + self.open_spawn_card_for_split(registry_node_id.clone(), host.clone(), ctx); + } OpenSftpPaneByNode { node_id } => { self.open_sftp_pane(node_id.clone(), ctx); } diff --git a/app/src/workspace/view_test.rs b/app/src/workspace/view_test.rs index 4de07a536f0..08e3d8f0f8c 100644 --- a/app/src/workspace/view_test.rs +++ b/app/src/workspace/view_test.rs @@ -1453,6 +1453,7 @@ fn favorite_host_submenu() -> MenuItem { &[("node-dev".to_string(), "example-host".to_string())], false, false, + super::LaunchMenuTarget::NewTab, ) } @@ -1513,15 +1514,27 @@ fn connections_registry_drives_favorite_launch_menu() { }); let favorites_store = crate::cockpit::favorites::FavoritesStore::handle(&app); - let (menu_items, unreadable_registry_items) = favorites_store.read(&app, |store, _| { - assert_eq!(store.items().len(), 1); - assert_eq!(store.items()[0].label, "stale-display-name"); - assert!(store.contains(zaplex_cockpit::FavoriteKind::Host, &favorite_server.id)); - ( - super::favorites_menu_items_from_sources(store, registered_hosts, false), - super::favorites_menu_items_from_sources(store, Vec::new(), true), - ) - }); + let (menu_items, unreadable_registry_items, split_items) = + favorites_store.read(&app, |store, _| { + assert_eq!(store.items().len(), 1); + assert_eq!(store.items()[0].label, "stale-display-name"); + assert!(store.contains(zaplex_cockpit::FavoriteKind::Host, &favorite_server.id)); + ( + super::favorites_menu_items_from_sources( + store, + registered_hosts.clone(), + false, + super::LaunchMenuTarget::NewTab, + ), + super::favorites_menu_items_from_sources( + store, + Vec::new(), + true, + super::LaunchMenuTarget::NewTab, + ), + super::split_launch_menu_items(store, registered_hosts, false, true), + ) + }); assert!(unreadable_registry_items.iter().any(|item| matches!( item, @@ -1572,8 +1585,11 @@ fn connections_registry_drives_favorite_launch_menu() { let favorite_submenus = menu_items .iter() .filter_map(|item| match item { - MenuItem::Submenu { fields, menu } => Some((fields, menu)), - MenuItem::Item(_) + MenuItem::Submenu { fields, menu } if fields.has_split_submenu_trigger() => { + Some((fields, menu)) + } + MenuItem::Submenu { .. } + | MenuItem::Item(_) | MenuItem::Separator | MenuItem::ItemsRow { .. } | MenuItem::Header { .. } => None, @@ -1604,6 +1620,96 @@ fn connections_registry_drives_favorite_launch_menu() { project: None, }) if node_id == &favorite_server.id && host == "renamed-remote" )); + + // Registered hosts that are not favorites stay reachable under + // "More hosts", which never repeats a favorite. + let more_hosts = menu_items + .iter() + .find_map(|item| match item { + MenuItem::Submenu { fields, menu } + if fields.label() == crate::t!("workspace-launch-more-hosts") => + { + Some(menu) + } + MenuItem::Submenu { .. } + | MenuItem::Item(_) + | MenuItem::Separator + | MenuItem::ItemsRow { .. } + | MenuItem::Header { .. } => None, + }) + .expect("non-favorite hosts are offered under More hosts"); + assert_eq!(more_hosts.items().len(), 1); + let MenuItem::Item(other_host) = &more_hosts.items()[0] else { + panic!("a non-favorite host is a plain launch row"); + }; + assert_eq!(other_host.label(), "not-favorited"); + assert!(matches!( + other_host.on_select_action(), + Some(WorkspaceAction::OpenSshTerminalByNode { node_id }) + if node_id != &favorite_server.id + )); + assert!(!unreadable_registry_items.iter().any(|item| matches!( + item, + MenuItem::Submenu { fields, .. } + if fields.label() == crate::t!("workspace-launch-more-hosts") + ))); + + // The split launch menu is the same launch section, aimed at the split. + let split_labels = split_items + .iter() + .map(new_session_menu_label) + .collect::>(); + assert_eq!( + split_labels, + vec![ + crate::t!("workspace-new-session-terminal"), + crate::t!("cockpit-spawn-card-new-agent"), + "---".to_string(), + crate::t!("workspace-favorites-header"), + "renamed-remote".to_string(), + crate::t!("workspace-launch-more-hosts"), + ] + ); + assert!(matches!( + split_items[0].item_on_select_action(), + Some(WorkspaceAction::SplitLaunchLocal) + )); + assert!(matches!( + split_items[1].item_on_select_action(), + Some(WorkspaceAction::SplitLaunchSpawnCard { + registry_node_id: None, + host: None, + }) + )); + let MenuItem::Submenu { + fields: split_favorite, + menu: split_flyout, + } = &split_items[4] + else { + panic!("the split menu keeps the favorite flyout"); + }; + assert!(matches!( + split_favorite.on_select_action(), + Some(WorkspaceAction::SplitLaunchHost { node_id }) if node_id == &favorite_server.id + )); + assert!(matches!( + split_flyout.items()[0].item_on_select_action(), + Some(WorkspaceAction::SplitLaunchSpawnCard { + registry_node_id: Some(node_id), + host: Some(host), + }) if node_id == &favorite_server.id && host == "renamed-remote" + )); + let MenuItem::Submenu { + menu: split_more_hosts, + .. + } = &split_items[5] + else { + panic!("the split menu keeps More hosts"); + }; + assert!(matches!( + split_more_hosts.items()[0].item_on_select_action(), + Some(WorkspaceAction::SplitLaunchHost { node_id }) if node_id != &favorite_server.id + )); }); } @@ -1683,8 +1789,13 @@ fn missing_host_in_readable_registry_keeps_favorite_remove_available() { "deleted-node", "old-example-host", ); - let mut items = - super::favorite_host_menu_items(std::slice::from_ref(&favorite), &[], false, false); + let mut items = super::favorite_host_menu_items( + std::slice::from_ref(&favorite), + &[], + false, + false, + super::LaunchMenuTarget::NewTab, + ); assert_eq!(items.len(), 1); let MenuItem::Submenu { fields, menu } = items.pop().unwrap() else { panic!("a stale favorite must remain visible as a submenu"); @@ -1722,9 +1833,13 @@ fn protected_favorite_store_disables_stale_removal() { "deleted-node", "old-example-host", ); - let MenuItem::Submenu { menu, .. } = - super::favorite_host_menu_item(&favorite, &[], true, false) - else { + let MenuItem::Submenu { menu, .. } = super::favorite_host_menu_item( + &favorite, + &[], + true, + false, + super::LaunchMenuTarget::NewTab, + ) else { panic!("the protected stale favorite must remain visible"); }; let MenuItem::Item(remove) = &menu.items()[1] else { @@ -1741,9 +1856,13 @@ fn removed_favorite_host_is_disabled_and_never_routed() { "removed-node", "removed-host", ); - let MenuItem::Submenu { menu, .. } = - super::favorite_host_menu_item(&favorite, &[], false, false) - else { + let MenuItem::Submenu { menu, .. } = super::favorite_host_menu_item( + &favorite, + &[], + false, + false, + super::LaunchMenuTarget::NewTab, + ) else { panic!("a removed favorite must remain explicitly removable"); }; assert!(menu.items().iter().all(|item| !matches!( @@ -1755,151 +1874,58 @@ fn removed_favorite_host_is_disabled_and_never_routed() { #[test] fn automatic_host_registration_never_adds_menu_favorite() { let registered_hosts = vec![("node-dev".to_string(), "example-host".to_string())]; - assert!(super::favorite_host_menu_items(&[], ®istered_hosts, false, false).is_empty()); -} - -#[test] -fn split_picker_marks_only_the_current_host_and_preserves_routes() { - use super::SplitLaunchDestination; - - crate::i18n::init(Some("en")); - let current = SplitLaunchDestination::Remote { - node_id: "second".into(), - }; - let items = super::split_launch_menu_items( - Some(vec![ - ("first".into(), "same-name".into()), - ("second".into(), "same-name".into()), - ]), - Some(¤t), - ); - let choices = items - .iter() - .filter_map(|item| { - if let MenuItem::Item(fields) = item { - Some(fields) - } else { - None - } - }) - .collect::>(); - assert_eq!(choices.len(), 3); - assert_eq!( - choices[0].label(), - crate::t!("cockpit-spawn-card-host-local") - ); - assert_eq!( - choices[0].on_select_action(), - Some(&SplitLaunchDestination::Local) - ); - assert_eq!(choices[1].label(), "same-name"); - assert_eq!( - choices[1].on_select_action(), - Some(&SplitLaunchDestination::Remote { - node_id: "first".into() - }) - ); - assert_eq!( - choices[2].label(), - format!("same-name · {}", crate::t!("common-current")) - ); - assert_eq!(choices[2].on_select_action(), Some(¤t)); - - let local = - super::split_launch_menu_items(Some(Vec::new()), Some(&SplitLaunchDestination::Local)); - let MenuItem::Item(local) = &local[0] else { - panic!("local launch remains available"); - }; - assert_eq!( - local.label(), - format!( - "{} · {}", - crate::t!("cockpit-spawn-card-host-local"), - crate::t!("common-current") - ) - ); + assert!(super::favorite_host_menu_items( + &[], + ®istered_hosts, + false, + false, + super::LaunchMenuTarget::NewTab + ) + .is_empty()); } #[test] -fn split_picker_uses_the_visible_file_manager_host_instead_of_the_covered_terminal() { +fn split_launch_menu_without_cockpit_or_registry_keeps_the_local_terminal() { App::test((), |mut app| async move { initialize_app(&mut app); - app.add_singleton_model(|_| crate::sftp_manager::fm_registry::FileManagerRegistry::new()); - let directory = tempfile::tempdir().unwrap(); - let workspace = mock_workspace(&mut app); - workspace.update(&mut app, |workspace, ctx| { - for (target, expected) in [ - ( - pane_group::FileManagerTarget::Local { - start_path: directory.path().to_path_buf(), - }, - SplitLaunchDestination::Local, - ), - ( - pane_group::FileManagerTarget::Remote { - node_id: "visible-file-host".to_string(), - start_path: Some(PathBuf::from("/srv")), - }, - SplitLaunchDestination::Remote { - node_id: "visible-file-host".to_string(), - }, - ), - ] { - workspace.add_terminal_tab(false, ctx); - let group = workspace.active_tab_pane_group().clone(); - let terminal = group.as_ref(ctx).focused_pane_id(ctx); - workspace - .ssh_pane_nodes - .insert(terminal, "covered-host".to_string()); - workspace - .ssh_tab_nodes - .insert(group.id(), "legacy-tab-host".to_string()); - let visible = group.update(ctx, |group, ctx| { - group.open_file_manager_in_place(terminal, target, ctx); - group.focused_pane_id(ctx) - }); - assert_ne!(visible, terminal); - assert!(group - .as_ref(ctx) - .terminal_view_from_pane_id(visible, ctx) - .is_none()); - assert_eq!( - workspace.split_launch_source_host(&group, visible, ctx), - Some(expected), - ); - } + let favorites_store = crate::cockpit::favorites::FavoritesStore::handle(&app); + let (failed, empty) = favorites_store.read(&app, |store, _| { + ( + super::split_launch_menu_items(store, Vec::new(), true, false), + super::split_launch_menu_items(store, Vec::new(), false, false), + ) }); + for items in [&failed, &empty] { + assert!(matches!( + items[0].item_on_select_action(), + Some(WorkspaceAction::SplitLaunchLocal) + )); + assert!(!items.iter().any(|item| matches!( + item.item_on_select_action(), + Some(WorkspaceAction::SplitLaunchSpawnCard { .. }) + ))); + assert!(!items.iter().any(|item| matches!( + item, + MenuItem::Submenu { fields, .. } + if fields.label() == crate::t!("workspace-launch-more-hosts") + ))); + } + let registry_error = |items: &[MenuItem]| { + items.iter().any(|item| { + matches!( + item, + MenuItem::Item(fields) + if fields.label() == crate::t!("workspace-host-registry-unavailable") + && fields.is_disabled() + && fields.on_select_action().is_none() + ) + }) + }; + assert!(registry_error(&failed)); + assert!(!registry_error(&empty)); }); } -#[test] -fn split_picker_distinguishes_unreadable_registry_from_empty_registry() { - use super::SplitLaunchDestination; - - crate::i18n::init(Some("en")); - let failed = super::split_launch_menu_items(None, None); - let empty = super::split_launch_menu_items(Some(Vec::new()), None); - assert_eq!(empty.len(), 1); - assert_eq!(failed.len(), 3); - let MenuItem::Item(local) = &failed[0] else { - panic!("local launch remains available"); - }; - assert_eq!(local.label(), crate::t!("cockpit-spawn-card-host-local")); - assert_eq!( - local.on_select_action(), - Some(&SplitLaunchDestination::Local) - ); - let MenuItem::Item(error) = &failed[2] else { - panic!("registry failure must stay visible"); - }; - assert_eq!( - error.label(), - crate::t!("workspace-host-registry-unavailable") - ); - assert!(error.is_disabled()); - assert!(error.on_select_action().is_none()); -} - #[test] fn primary_sidebar_keeps_cockpit_and_connections_separate() { assert_eq!( @@ -3826,6 +3852,88 @@ fn cancelling_split_launch_menu_returns_focus_to_the_active_tab() { }); } +#[test] +fn every_split_direction_opens_the_launch_menu_without_adding_a_pane() { + App::test((), |mut app| async move { + initialize_app(&mut app); + let workspace = mock_workspace(&mut app); + let pane_group = workspace.read(&app, |workspace, _| { + workspace.active_tab_pane_group().clone() + }); + + for direction in [ + Direction::Left, + Direction::Up, + Direction::Right, + Direction::Down, + ] { + let panes_before = pane_group.read(&app, |group, _| group.visible_pane_ids()); + pane_group.update(&mut app, |group, ctx| { + ::handle_action( + group, + &crate::pane_group::PaneGroupAction::Add(direction), + ctx, + ); + }); + workspace.read(&app, |workspace, ctx| { + let pending = workspace + .pending_split_launch + .as_ref() + .expect("the split waits for a launch-menu choice"); + assert_eq!(pending.target.direction(), direction); + assert!(workspace.show_split_launch_menu.is_some()); + assert_eq!(pane_group.as_ref(ctx).visible_pane_ids(), panes_before); + }); + workspace.update(&mut app, |workspace, ctx| { + workspace.split_launch_menu.update(ctx, |_, ctx| { + ctx.emit(MenuEvent::Close { + via_select_item: false, + }); + }); + }); + } + }); +} + +#[test] +fn split_agent_launch_recaptures_its_target_until_the_source_pane_closes() { + App::test((), |mut app| async move { + initialize_app(&mut app); + let workspace = mock_workspace(&mut app); + + workspace.update(&mut app, |workspace, ctx| { + let pane_group = workspace.active_tab_pane_group().clone(); + let source = pane_group.as_ref(ctx).focused_pane_id(ctx); + let target = pane_group + .as_ref(ctx) + .recapture_split_target(source, Direction::Right) + .expect("the focused pane is visible"); + let split = PendingSplitLaunch { + pane_group: pane_group.clone(), + target, + chosen_shell: None, + inherited_remote_cwd: None, + }; + + // The card may stay open while another pane changes the layout. + pane_group.update(ctx, |group, ctx| { + group.add_terminal_pane(Direction::Down, None, ctx); + }); + let recaptured = workspace + .revalidated_split_launch(split.clone(), ctx) + .expect("the source pane is still visible"); + assert_eq!(recaptured.target.pane_id(), source); + assert_eq!(recaptured.target.direction(), Direction::Right); + assert!(pane_group + .as_ref(ctx) + .split_target_is_valid(recaptured.target)); + + pane_group.update(ctx, |group, ctx| group.close_pane(source, ctx)); + assert!(workspace.revalidated_split_launch(split, ctx).is_none()); + }); + }); +} + #[test] fn test_open_tab_config_with_params_does_not_use_worktree_branch_as_implicit_title() { App::test((), |mut app| async move { diff --git a/docs/release/1.0-readiness.md b/docs/release/1.0-readiness.md index e4e8e760aaa..6d51015b453 100644 --- a/docs/release/1.0-readiness.md +++ b/docs/release/1.0-readiness.md @@ -1,7 +1,7 @@ -# Zaplex 1.1.1 – Release-Bereitschaft +# Zaplex 1.2.0 – Release-Bereitschaft Dieses Dokument ist das Abnahmeprotokoll für das nächste öffentliche Artefakt, -aktuell die noch unveröffentlichte Version 1.1.1. Phase +aktuell die noch unveröffentlichte Version 1.2.0. Phase A prüft den Quellstand und hält jede Laufzeitzeile offen. Erst nach einer frischen Build-Freigabe wird in Phase B die Matrix gegen das erzeugte DMG ausgefüllt. diff --git a/docs/release/1.0-user-guide.md b/docs/release/1.0-user-guide.md index 60c5ac1af81..897cf603bf6 100644 --- a/docs/release/1.0-user-guide.md +++ b/docs/release/1.0-user-guide.md @@ -1,9 +1,9 @@ -# Zaplex 1.1.1 – Installation und Wiederherstellung +# Zaplex 1.2.0 – Installation und Wiederherstellung ## Installation auf macOS 1. Auf der offiziellen [Zaplex-Release-Seite](https://github.com/byte5ai/zaplex/releases) - prüfen, ob `v1.1.1` veröffentlicht ist, und erst dann das zugehörige DMG laden. + prüfen, ob `v1.2.0` veröffentlicht ist, und erst dann das zugehörige DMG laden. 2. Das DMG öffnen und **Zaplex** nach **Programme** ziehen. 3. Zaplex aus **Programme** starten. Das DMG ist mit Developer ID signiert, von Apple notarisiert und benötigt keine Umgehung der macOS-Sicherheitsprüfung. diff --git a/docs/ui/README.md b/docs/ui/README.md index 35cca1d8245..d5374dcb74d 100644 --- a/docs/ui/README.md +++ b/docs/ui/README.md @@ -49,7 +49,7 @@ bereits vorhandene Implementierung. Sie erklärt diese **nicht** nachträglich f | Konten | 5 h und Woche untereinander; Konto-Klick ergänzt/fokussiert Details, übrige Panes bleiben erhalten | Echte Kontenzuordnung, Zustände und Datenherkunft | | Titel | Pane-Titel aus Host und Verzeichnis; Tabtitel folgt der fokussierten Pane, kein erfundener Gruppenname | Explizite Tabtitel, reale Verzeichniswechsel und Restore | | Favoriten | Hostname verbindet direkt; separates `⋯` öffnet seitlich bei sichtbar bleibendem Elternmenü | Einmaliger Verbindungsaufbau, Tastatur, Randplatzierung und Fokusrückgabe | -| Mehrhost-Panes | Pane-Split nach rechts/unten, Zielhost/Lokal wählen; neuer Tab hat volle Höhe | Asynchrones Hostrouting, echte PTYs, Drag & Drop derselben Pane, Fensterwechsel, Neustart-Restore | +| Mehrhost-Panes | Pane-Split in alle vier Richtungen mit demselben Startmenü wie „+“ ([Spec](pane-launch-and-tab-join.html)); neuer Tab hat volle Höhe | Asynchrones Hostrouting, echte PTYs, Drag & Drop derselben Pane, Fensterwechsel, Neustart-Restore | | Dateimanager | Modus pro Session; F2–F8 und F10 immer mit Kurzbeschriftung, bei Platzmangel zwei bzw. vier Zeilen gleich breiter Zellen statt Ausblenden; nur fokussierte Pane aktiv; `..` markiert das verlassene Verzeichnis | Reale lokale/Remote-Dateien, eindeutige Transferziele und sichere Revalidierung | | Reconnect | Recovery-Vorschau über die Entwurfsoptionen; Eingabe bis Bereitschaft gesperrt | Dieselbe Remote-Session/Generation, echter Shell-Ready-Nachweis, Timeout, Retry und Abbruch | diff --git a/docs/ui/cockpit-sidebar-connections.html b/docs/ui/cockpit-sidebar-connections.html index b72076f0ff4..52e38e90a9f 100644 --- a/docs/ui/cockpit-sidebar-connections.html +++ b/docs/ui/cockpit-sidebar-connections.html @@ -74,9 +74,9 @@

Status und Identität

Tabs und Panes

  • #spec P1 Ein Tab ist Container für Terminal-, Dateimanager- und Konto-Panes verschiedener Hosts; er ist selbst kein Host. Keine neue globale Navigation.
  • -
  • #spec P2 Pane-Split erfasst auslösenden Tab, Pane, Rechts/Unten und stabilen Host oder Lokal. Späterer Fokuswechsel lenkt das Ziel nicht um; Abbruch erzeugt nichts.
  • +
  • #spec P2 Pane-Split in jeder der vier Richtungen öffnet dasselbe Startmenü wie „+“ (siehe pane-launch-and-tab-join.html) und erfasst auslösenden Tab, Pane, Richtung und stabilen Host oder Lokal. Späterer Fokuswechsel lenkt das Ziel nicht um; Abbruch erzeugt nichts.
  • #spec P3 Terminalidentität ist Host · Projekt/Verzeichnis. Der automatische Tabtitel folgt der fokussierten Pane; ein expliziter Titel hat Vorrang.
  • -
  • #spec P4 Eine Pane füllt die Arbeitsfläche. Header-Drag verschiebt dieselbe Pane links/rechts/oben/unten oder in einen anderen Tab, ohne Verbindung, PTY oder Agent zu duplizieren.
  • +
  • #spec P4 Eine Pane füllt die Arbeitsfläche. Header-Drag verschiebt dieselbe Pane links/rechts/oben/unten oder in einen anderen Tab, ohne Verbindung, PTY oder Agent zu duplizieren. Umgekehrt fügt ein in den sichtbaren Tab gezogener Tab seine Panes dort ein.
  • #spec P5 Fokus und Restore bewahren Pane-Geometrie, Host-/Daemon-/PTY-/Generationsidentität, Modus und Entwurf. Konto-Klick fügt die Konto-Pane hinzu oder fokussiert sie, ohne andere Panes zu ersetzen.
diff --git a/docs/ui/pane-launch-and-tab-join.html b/docs/ui/pane-launch-and-tab-join.html new file mode 100644 index 00000000000..768aaf170b7 --- /dev/null +++ b/docs/ui/pane-launch-and-tab-join.html @@ -0,0 +1,191 @@ + + + + + + + + UI-Spezifikation — Startmenü für Tab und Pane, Tab als Pane + + + +
+

UI-Spezifikation — Startmenü für Tab und Pane, Tab als Pane

+
+
+

Startmenü (Tab und Pane)

+
    +
  • #spec L1 „+“ (neuer Tab) und jede der vier Split-Richtungen öffnen dasselbe Startmenü. Einziger Unterschied ist das Ziel: neuer Tab bzw. neues Pane in der erfassten Richtung relativ zum auslösenden Pane.
  • +
  • #spec L2 Erste Ebene in dieser Reihenfolge: „Terminal“ (lokal), „Neuer Agent…“ (nur bei aktivem Cockpit), Kopf „Favoriten“ mit allen Host-Favoriten, danach „Weitere Hosts ›“.
  • +
  • #spec L3 „Weitere Hosts“ ist ein Untermenü mit genau den registrierten Hosts, die kein Favorit sind, in Registry-Reihenfolge. Ohne solche Hosts entfällt der Eintrag. Registry-Lesefehler zeigt eine deaktivierte Fehlerzeile statt der Liste.
  • +
  • #spec L4 Ein Favorit verbindet per Klick auf den Namen direkt; „⋯“ öffnet seitlich „Neuer Agent auf <Host>“, „Bearbeiten“, „Aus Favoriten entfernen“. Im Split-Menü zielen Verbindung und Agent auf das Pane.
  • +
  • #spec L5 Tab-spezifische Einträge (Tab-Konfigurationen, Docker-Sandbox, Worktree-Konfiguration, geschlossene Session wieder öffnen) erscheinen nur im „+“-Menü, unterhalb eines Trenners.
  • +
  • #spec L6 Keine Markierung „· Aktuell“ im Menü; die Pane-Kopfzeile nennt den Host. Abbruch (Escape, Klick daneben) erzeugt nichts.
  • +
+
+
+

Neuer Agent ins Pane

+
    +
  • #spec L7 „Neuer Agent…“ aus dem Split-Menü öffnet die bekannte Startkarte. Der Start erzeugt genau eine Session an der erfassten Position; eine Auswahl mehrerer Konten wird mit Hinweis abgelehnt, die Karte bleibt offen.
  • +
  • #spec L8 Konto, Launch-Intent und Prompt-Vorbelegung hängen am neu erzeugten Pane, nie am zufällig fokussierten.
  • +
  • #spec L9 Ist die erfasste Position beim Start nicht mehr gültig (Pane geschlossen, Tab entfernt), startet nichts und ein Hinweis erklärt es.
  • +
+

Tab-Benennung

+
    +
  • #spec N1 Unverändert gemäß #spec P3: Tabtitel folgt dem fokussierten Pane, expliziter Tabtitel hat Vorrang; jedes Pane zeigt Host · Verzeichnis in seiner Kopfzeile (Industriestandard Windows Terminal, iTerm2, tmux).
  • +
+
+
+

Tab als Pane (Maus)

+
    +
  • #spec J1 Ein Klick aktiviert einen Tab beim Loslassen. Wird stattdessen gezogen, bleibt der bisher sichtbare Tab sichtbar.
  • +
  • #spec J2 Innerhalb der Tab-Leiste sortiert Ziehen wie bisher um. Verlässt der Zeiger die Tab-Leiste nach unten, zeigt das Pane unter dem Zeiger eine Drop-Zone über der Hälfte (links/rechts/oben/unten), in die der Tab eingefügt wird. Richtung wie beim Pane-Kopfzeilen-Drag.
  • +
  • #spec J3 Loslassen über einer Drop-Zone fügt alle Panes des gezogenen Tabs mit ihrem Layout als Split an dieser Kante ein; der Quelltab entfällt; Fokus liegt auf dem zuletzt fokussierten Pane des gezogenen Tabs.
  • +
  • #spec J4 Der sichtbare Tab ist nie Ziel seiner selbst: beim Ziehen des sichtbaren Tabs erscheint keine Drop-Zone. Loslassen ohne Drop-Zone ändert höchstens die Reihenfolge.
  • +
  • #spec J5 Überführen startet keine Verbindung, PTY oder Agent-Session neu; Host-Bindung, Verlauf, CWD, Entwurf und Dateimanager-Modus jedes Panes bleiben.
  • +
  • #spec J6 Ein Tab mit noch laufendem provisorischen Daemon-/Managed-Start kann nicht eingefügt werden (wie #spec R1 beim Fenster-Drag).
  • +
  • #spec J7 Gegenrichtung bleibt: Pane-Kopfzeile auf die Tab-Leiste ziehen erzeugt einen eigenen Tab.
  • +
  • #spec J8 Drop-Zone nutzt ausschließlich Theme-Rollen (Akzent als Rand und transparente Fläche); keine dauerhaften Farbränder.
  • +
+
+
+
Vorbilder: Windows Terminal (gleiches Profilmenü für Tab und Pane), VS Code und JetBrains (Tab in den Editorbereich ziehen, Drop-Rückmeldung zeigt die Zielposition), tmux break-pane/join-pane. Löst #spec P2 (Rechts/Unten) und PRODUCT §32 ab. Farben dieses Dokuments illustrieren Theme-Rollen und sind nicht normativ.
+
+ +
+
+
„+“ in der Tab-Leiste — neuer Tab · „Weitere Hosts“ geöffnet
+
+
buildnode · api
+
+ + +
+
L1–L5: gleiche erste Ebene wie im Split-Menü; Tab-spezifische Einträge unterhalb des Trenners.
+
+ +
+
„Pane links teilen“ — dasselbe Menü, Ziel ist ein Pane links vom auslösenden Pane
+
+
buildnode · api
+
+
+
buildnode · api
$ cargo test
running 42 tests…
+
+ +
+
L1, L6: alle vier Richtungen; kein „· Aktuell“, keine Tab-Einträge.
+
+ +
+
Tab „worknode · logs“ wird in den sichtbaren Tab gezogen — Drop-Zone rechte Hälfte
+
+
buildnode · api
worknode · logs
+
+
+
buildnode · api
$ cargo test
running 42 tests…
+
Lokal · zaplex
$ git status
On branch main
+
+
▣ worknode · logs
+
+
+
J1–J2, J8: sichtbarer Tab bleibt; Zeiger in der rechten Hälfte des rechten Panes → Drop-Zone dort.
+
+ +
+
Nach dem Loslassen — Quelltab entfällt, Fokus im eingefügten Pane
+
+
worknode · logs
+
+
+
buildnode · api
$ cargo test
running 42 tests…
+
Lokal · zaplex
$ git status
On branch main
+
worknode · logs
$ tail -f app.log
…
+
+
+
J3, J5, N1: dieselbe Session, Tabtitel folgt dem fokussierten Pane.
+
+
+ + diff --git a/script/windows/windows-installer.iss b/script/windows/windows-installer.iss index 05b093d3a4e..41a5b560381 100644 --- a/script/windows/windows-installer.iss +++ b/script/windows/windows-installer.iss @@ -8,7 +8,7 @@ #define MyAppName "WarpDev" #endif #ifndef MyAppVersion - #define MyAppVersion "1.1.1" + #define MyAppVersion "1.2.0" #endif #ifndef MyAppExeName #define MyAppExeName "dev.exe" diff --git a/specs/GH-160/PRODUCT.md b/specs/GH-160/PRODUCT.md index 189f536b78c..c751dc2785a 100644 --- a/specs/GH-160/PRODUCT.md +++ b/specs/GH-160/PRODUCT.md @@ -234,15 +234,21 @@ The previous static illustrations are superseded, not alternative approved layou 31. **A tab is a pane container, not a host.** Terminal panes for local and different remote hosts may coexist with File Manager and account-detail panes in one tab. No automatic host group or - global feature tab is added. A favorite-host click still opens a new tab; a pane-local split is - the explicit path for adding a session to the current tab. - -32. **Pane-local split launch is explicit.** The initiating pane offers Right and Down followed by - the existing registered hosts and Local. The captured target is the initiating tab, pane, + global feature tab is added. A favorite-host click in the `+` menu still opens a new tab; a + pane-local split, or dragging an existing tab into the visible tab, is the explicit path for + adding a session to the current tab. + +32. **One launch menu for tabs and panes.** `+` and all four split directions (Left, Right, Up, + Down) open the same launch menu: Terminal (local), New agent… (Cockpit enabled), the favorite + hosts, then a "More hosts" submenu holding exactly the registered hosts that are not favorites. + Tab-only entries (tab configs, Docker sandbox, worktree config, reopen closed session) appear + only in the `+` menu. From a split, every entry targets the captured initiating tab, pane, direction, and stable host reference; a later focus change cannot redirect it. Cancel creates nothing. A valid selection creates exactly one session at that position and focuses usable input only after readiness. Same-host launch may inherit its working directory; cross-host - launch uses the destination profile and never reinterprets a foreign path. + launch uses the destination profile and never reinterprets a foreign path. New agent… from a + split binds account, launch intent, and prompt prefill to the new pane and refuses a + multi-account batch with a notice. See `docs/ui/pane-launch-and-tab-join.html`. 33. **Terminal identity is short, real, and pane-local.** The automatic pane title is `Host · project-or-directory`, with full host/path available accessibly. Missing metadata uses @@ -255,8 +261,12 @@ The previous static illustrations are superseded, not alternative approved layou 34. **Pane geometry preserves session identity.** A sole pane fills the available workspace. Splitting affects only the chosen pane. Dragging a pane header to a target edge moves that existing pane left, right, above, or below without opening a connection, PTY, or agent. Moving - to another tab remains supported. Invalid or cancelled drops leave the layout intact, and - file drag, text selection, splitter resize, and header-button clicks are not pane moves. + to another tab remains supported. Dragging an inactive tab out of the tab bar onto a pane of + the visible tab inserts all of its panes, with their layout, at the indicated edge; the source + tab closes and no connection, PTY, or agent restarts. Tabs activate on click so the visible tab + stays visible while another tab is dragged. Invalid or cancelled drops leave the layout + intact, and file drag, text selection, splitter resize, and header-button clicks are not pane + moves. 35. **Focus and restore are exact.** Each tab retains its last valid focused pane. Closing, moving, reconnecting, and restoring preserve layout, host/daemon/PTY/generation identity, working