From 468315f489289361960971c7f4809ae845e413de Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 7 Jun 2026 23:32:45 -0700 Subject: [PATCH 1/6] Turn a single workspace into a group (promote in place) Grouping previously always synthesized a fresh empty anchor workspace, so "New Group from Workspace" on a single workspace produced a junk empty anchor with the original as a child, and ungrouping left two workspaces. Add a promote path: an existing ungrouped workspace becomes the anchor of a new single-member group in place (no extra workspace), inheriting its title as the group name and its pinned state to keep its sidebar position. The existing ungroup is the exact inverse, so workspace -> group -> ungroup round-trips back to the same single normal workspace. - TabManager.makeWorkspaceGroupFromWorkspace(anchorWorkspaceId:name:) - Sidebar "New Group from Workspace" (single, ungrouped) routes here; multi-select "New Group from Selection" / Cmd-Shift-G keep the documented fresh-anchor create contract; grouped rows still show Move/Remove only. - Socket workspace.group.from_workspace + CLI `workspace group from-workspace` (non-focus, mirrors create's payload). - Round-trip + no-op + header-render unit tests; docs updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLI/cmux.swift | 31 ++++++++- Resources/Localizable.xcstrings | 17 +++++ Sources/TabItemView+WorkspaceGroups.swift | 45 +++++++++---- Sources/TabManager.swift | 63 ++++++++++++++++++ Sources/TerminalController.swift | 51 +++++++++++++++ cmuxTests/WorkspaceGroupTests.swift | 78 +++++++++++++++++++++++ docs/workspace-groups.md | 17 +++-- 7 files changed, 283 insertions(+), 19 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index f10d7bb79877..ad03bfe4ea1e 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -7194,7 +7194,7 @@ struct CMUXCLI { windowOverride: String? ) throws { guard let sub = commandArgs.first?.lowercased() else { - throw CLIError(message: "workspace-group requires a subcommand. Try: list, create, ungroup, delete, rename, collapse, expand, pin, unpin, add, remove, set-anchor, new-workspace, set-color, set-icon, move, focus") + throw CLIError(message: "workspace-group requires a subcommand. Try: list, create, from-workspace, ungroup, delete, rename, collapse, expand, pin, unpin, add, remove, set-anchor, new-workspace, set-color, set-icon, move, focus") } let rest = Array(commandArgs.dropFirst()) var params: [String: Any] = [:] @@ -7256,6 +7256,29 @@ struct CMUXCLI { print("OK") } + case "from-workspace": + // Turn an existing workspace into a group: the workspace itself + // becomes the anchor/header (no fresh anchor is created). Inverse + // of `ungroup`. + let (nameOpt, rem0) = parseOption(rest, name: "--name") + let (wsOpt, rem1) = parseOption(rem0, name: "--workspace") + // Strip --window before scanning for a positional so a `--window + // ` pair never gets parsed as the workspace id. + let (_, rem2) = parseOption(rem1, name: "--window") + guard let wsId = wsOpt ?? rem2.first(where: { !$0.hasPrefix("--") }) else { + throw CLIError(message: "from-workspace requires --workspace ") + } + params["workspace_id"] = wsId + if let nameOpt { params["name"] = nameOpt } + let response = try client.sendV2(method: "workspace.group.from_workspace", params: params) + if jsonOutput { + print(jsonString(formatIDs(response, mode: idFormat))) + } else if let group = response["group"] as? [String: Any] { + print("OK \(textHandle(group, idFormat: idFormat))") + } else { + print("OK") + } + case "ungroup": params["group_id"] = try resolveGroupId(in: rest) let resp = try client.sendV2(method: "workspace.group.ungroup", params: params) @@ -13508,6 +13531,12 @@ struct CMUXCLI { create [--name ] [--cwd ] [--from ,...] Defaults --from to the active sidebar selection / caller workspace when omitted. + from-workspace --workspace [--name ] + Turn an existing workspace into a group: + the workspace itself becomes the anchor + (no fresh anchor is created). Inverse of + `ungroup`. Defaults the group name to the + workspace's title. ungroup Dissolve a group, preserving all members delete Delete a group AND close every workspace inside it. Destructive. Use `ungroup` to diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index b110ef05c49a..0ce400aaf6e8 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -166998,6 +166998,23 @@ } } }, + "workspaceGroup.error.workspaceAlreadyGrouped": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Workspace is already in a group; remove it first" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このワークスペースはすでにグループに属しています。先に削除してください" + } + } + } + }, "workspaceGroup.autoName.numbered": { "extractionState": "manual", "localizations": { diff --git a/Sources/TabItemView+WorkspaceGroups.swift b/Sources/TabItemView+WorkspaceGroups.swift index 8c5d6e308636..c46ace4ab7c7 100644 --- a/Sources/TabItemView+WorkspaceGroups.swift +++ b/Sources/TabItemView+WorkspaceGroups.swift @@ -23,24 +23,37 @@ extension TabItemView { }() let hasAnyGroupedTarget = eligibleTargets.contains { $0.groupId != nil } - let groupSelectedShortcut = KeyboardShortcutSettings.shortcut(for: .groupSelectedWorkspaces) - let groupSelectedLabel = isMulti - ? String( + if isMulti { + // Multi-select grouping creates a fresh anchor above the + // selection (the documented `create` contract). ⌘⇧G drives + // this same path. + let groupSelectedShortcut = KeyboardShortcutSettings.shortcut(for: .groupSelectedWorkspaces) + let groupSelectedLabel = String( localized: "contextMenu.workspaceGroup.newFromSelection", defaultValue: "New Group from Selection" ) - : String( - localized: "contextMenu.workspaceGroup.newFromWorkspace", - defaultValue: "New Group from Workspace" - ) - if let key = groupSelectedShortcut.keyEquivalent { - Button(groupSelectedLabel) { - promptNewWorkspaceGroup(workspaceIds: eligibleTargetIds) + if let key = groupSelectedShortcut.keyEquivalent { + Button(groupSelectedLabel) { + promptNewWorkspaceGroup(workspaceIds: eligibleTargetIds) + } + .keyboardShortcut(key, modifiers: groupSelectedShortcut.eventModifiers) + } else { + Button(groupSelectedLabel) { + promptNewWorkspaceGroup(workspaceIds: eligibleTargetIds) + } } - .keyboardShortcut(key, modifiers: groupSelectedShortcut.eventModifiers) - } else { - Button(groupSelectedLabel) { - promptNewWorkspaceGroup(workspaceIds: eligibleTargetIds) + } else if let target = eligibleTargets.first, target.groupId == nil { + // Single ungrouped workspace: turn it into a group by making + // the workspace itself the anchor (no phantom empty anchor). + // Hidden for workspaces already in a group — those use + // "Move to Group" / "Remove from Group" below. + Button( + String( + localized: "contextMenu.workspaceGroup.newFromWorkspace", + defaultValue: "New Group from Workspace" + ) + ) { + promptWorkspaceGroupFromWorkspace(workspaceId: target.id) } } @@ -80,4 +93,8 @@ extension TabItemView { guard !workspaceIds.isEmpty else { return } tabManager.createWorkspaceGroup(name: "", childWorkspaceIds: workspaceIds) } + + func promptWorkspaceGroupFromWorkspace(workspaceId: UUID) { + tabManager.makeWorkspaceGroupFromWorkspace(anchorWorkspaceId: workspaceId) + } } diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 39ae663431ba..3ff842d4f8b0 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -4233,6 +4233,69 @@ class TabManager: ObservableObject { return group.id } + /// Turn an existing workspace into a group by promoting it to be the + /// anchor (the sidebar header) of a brand-new single-member group. + /// + /// Unlike `createWorkspaceGroup`, this does NOT synthesize a fresh anchor + /// workspace: the passed workspace becomes the group header in place, so + /// no extra workspace is created and the row keeps its position. The exact + /// inverse is `ungroupWorkspaceGroup`, which dissolves the group and + /// returns the (former) anchor to a regular ungrouped workspace at the + /// same sidebar spot — so workspace → group → ungroup round-trips back to + /// the original single workspace. + /// + /// No-op (returns nil) when the workspace doesn't exist or is already in a + /// group; grouped workspaces use `addWorkspaceToGroup` / + /// `removeWorkspaceFromGroup` instead. + @discardableResult + func makeWorkspaceGroupFromWorkspace( + anchorWorkspaceId: UUID, + name: String = "" + ) -> UUID? { + guard let anchorTab = tabs.first(where: { $0.id == anchorWorkspaceId }), + anchorTab.groupId == nil else { return nil } + + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + // Inherit the workspace's own display name so "turn THIS workspace + // into a group" keeps its identity in the header instead of an + // anonymous "Group N". Mirrors the workspace display-name precedence + // (customTitle → title). Reads only — never mutates the workspace's + // title, so Ungroup restores the original row name. Falls back to the + // auto group name only when the workspace has no title at all. + let resolvedName: String + if !trimmedName.isEmpty { + resolvedName = trimmedName + } else { + let custom = anchorTab.customTitle?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let title = anchorTab.title.trimmingCharacters(in: .whitespacesAndNewlines) + let inherited = custom.isEmpty ? title : custom + resolvedName = inherited.isEmpty ? nextAutoWorkspaceGroupName() : inherited + } + + // Inherit the workspace's pinned state so a pinned workspace stays in + // the pinned sidebar tier (group tier is read from `group.isPinned`, + // not the anchor's `Workspace.isPinned`) and Ungroup restores it as a + // pinned row. + let group = WorkspaceGroup( + id: UUID(), + name: resolvedName, + isCollapsed: false, + isPinned: anchorTab.isPinned, + anchorWorkspaceId: anchorWorkspaceId, + customColor: nil, + iconSymbol: nil + ) + workspaceGroups.append(group) + assignGroup(workspaceId: anchorWorkspaceId, groupId: group.id) + // The anchor is already at its sidebar position; normalize keeps it + // there (`sidebarTopLevelWorkspaceIds` maps the now-grouped workspace + // back to its anchor's slot) while syncing the workspaceGroups order + // to the tabs order. + normalizeWorkspaceGroupContiguity() + postWorkspaceOrderDidChange(movedWorkspaceIds: [anchorWorkspaceId]) + return group.id + } + /// Create a brand-new workspace inheriting the anchor's cwd, attach it /// to the group, and position it within the group's tabs[] range per /// `placement`. Returns the new workspace. diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 02b462e5070d..c6ce0ec65c67 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1822,6 +1822,8 @@ class TerminalController { return v2Result(id: id, self.v2WorkspaceGroupList(params: params)) case "workspace.group.create": return v2Result(id: id, self.v2WorkspaceGroupCreate(params: params)) + case "workspace.group.from_workspace": + return v2Result(id: id, self.v2WorkspaceGroupFromWorkspace(params: params)) case "workspace.group.ungroup": return v2Result(id: id, self.v2WorkspaceGroupUngroup(params: params)) case "workspace.group.delete": @@ -2327,6 +2329,7 @@ class TerminalController { "workspace.rename", "workspace.group.list", "workspace.group.create", + "workspace.group.from_workspace", "workspace.group.ungroup", "workspace.group.delete", "workspace.group.rename", @@ -4889,6 +4892,54 @@ class TerminalController { ]) } + /// Turn an existing workspace into a group: the workspace itself becomes + /// the new group's anchor (no fresh anchor is synthesized, unlike + /// `workspace.group.create`). The inverse is `workspace.group.ungroup`. + /// Not a focus-intent method, so the active workspace is left unchanged. + private func v2WorkspaceGroupFromWorkspace(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let wsId = v2UUID(params, "workspace_id") else { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + } + let name = (params["name"] as? String) ?? "" + // Distinguish "no such workspace" from "already grouped" so scripts + // get an actionable error instead of a bare not-created. + let knownState: (exists: Bool, grouped: Bool) = v2MainSync { + guard let tab = tabManager.tabs.first(where: { $0.id == wsId }) else { + return (false, false) + } + return (true, tab.groupId != nil) + } + guard knownState.exists else { + return .err(code: "not_found", message: "Workspace not found in target window", data: [ + "workspace_id": wsId.uuidString + ]) + } + guard !knownState.grouped else { + return .err( + code: "invalid_state", + message: String( + localized: "workspaceGroup.error.workspaceAlreadyGrouped", + defaultValue: "Workspace is already in a group; remove it first" + ), + data: ["workspace_id": wsId.uuidString] + ) + } + var createdGroupId: UUID? + v2MainSync { + createdGroupId = tabManager.makeWorkspaceGroupFromWorkspace(anchorWorkspaceId: wsId, name: name) + } + guard let gid = createdGroupId, + let group = v2MainSync({ tabManager.workspaceGroups.first(where: { $0.id == gid }) }) else { + return .err(code: "not_created", message: "Group was not created", data: nil) + } + return .ok([ + "group": v2MainSync { v2WorkspaceGroupPayload(group, tabManager: tabManager) } + ]) + } + private func v2WorkspaceGroupUngroup(params: [String: Any]) -> V2CallResult { guard let tabManager = v2ResolveTabManager(params: params) else { return .err(code: "unavailable", message: "TabManager not available", data: nil) diff --git a/cmuxTests/WorkspaceGroupTests.swift b/cmuxTests/WorkspaceGroupTests.swift index 6d0b9e7981f3..8d1eaba51ae6 100644 --- a/cmuxTests/WorkspaceGroupTests.swift +++ b/cmuxTests/WorkspaceGroupTests.swift @@ -850,4 +850,82 @@ struct WorkspaceGroupTests { #expect(manager.resolvedWorkspaceDisplayTitle(for: member) == memberTitle) } + + // Turning a single workspace into a group promotes that workspace to be + // the anchor (no fresh anchor is synthesized), keeps it at the same + // sidebar position, inherits its title as the group name, and ungroup is + // the exact inverse: the same single workspace at the same spot. + @Test func makeWorkspaceGroupFromWorkspacePromotesInPlaceAndRoundTrips() throws { + let manager = makeTabManager() + manager.addWorkspace(autoWelcomeIfNeeded: false) + let originalIds = manager.tabs.map(\.id) + #expect(originalIds.count == 3) + let targetId = originalIds[1] + let target = try #require(manager.tabs.first { $0.id == targetId }) + target.title = "api-server" + let countBefore = manager.tabs.count + + let groupId = try #require(manager.makeWorkspaceGroupFromWorkspace(anchorWorkspaceId: targetId)) + + // No phantom anchor workspace was created. + #expect(manager.tabs.count == countBefore) + let group = try #require(manager.workspaceGroups.first { $0.id == groupId }) + // The workspace itself is the anchor and the sole member. + #expect(group.anchorWorkspaceId == targetId) + #expect(target.groupId == groupId) + #expect(manager.tabs.filter { $0.groupId == groupId }.map(\.id) == [targetId]) + // Position is unchanged. + #expect(manager.tabs.map(\.id) == originalIds) + // The header inherits the workspace's title rather than "Group N". + #expect(group.name == "api-server") + #expect(manager.resolvedWorkspaceDisplayTitle(for: target) == "api-server") + + // Ungroup returns the anchor to a regular ungrouped workspace in place. + manager.ungroupWorkspaceGroup(groupId: groupId) + #expect(manager.workspaceGroups.contains { $0.id == groupId } == false) + #expect(target.groupId == nil) + #expect(manager.tabs.count == countBefore) + #expect(manager.tabs.map(\.id) == originalIds) + #expect(manager.resolvedWorkspaceDisplayTitle(for: target) == "api-server") + } + + // Promote is for ungrouped workspaces only. A workspace that already + // belongs to a group must use add/remove, so the call is a no-op. + @Test func makeWorkspaceGroupFromWorkspaceNoOpsForGroupedWorkspace() throws { + let manager = makeTabManager() + let memberId = manager.tabs[1].id + let groupId = try #require( + manager.createWorkspaceGroup(name: "Group 1", childWorkspaceIds: [memberId]) + ) + let groupCountBefore = manager.workspaceGroups.count + let tabCountBefore = manager.tabs.count + + #expect(manager.makeWorkspaceGroupFromWorkspace(anchorWorkspaceId: memberId) == nil) + #expect(manager.workspaceGroups.count == groupCountBefore) + #expect(manager.tabs.count == tabCountBefore) + #expect(manager.tabs.first { $0.id == memberId }?.groupId == groupId) + } + + // A promoted single-workspace group has its anchor as the only member, so + // it renders as a header with no child rows. Sanity-check that the anchor + // is excluded from the rendered workspace rows (it is the header). + @Test func singleWorkspaceGroupRendersOnlyAsHeader() throws { + let manager = makeTabManager() + let targetId = manager.tabs[0].id + + let groupId = try #require(manager.makeWorkspaceGroupFromWorkspace(anchorWorkspaceId: targetId)) + let groupsById = Dictionary(uniqueKeysWithValues: manager.workspaceGroups.map { ($0.id, $0) }) + let items = SidebarWorkspaceRenderItem.renderItems(tabs: manager.tabs, groupsById: groupsById) + + let headerGroupIds: [UUID] = items.compactMap { + if case let .groupHeader(group, _) = $0 { return group.id } + return nil + } + let workspaceRowIds: [UUID] = items.compactMap { + if case let .workspace(workspace) = $0 { return workspace.id } + return nil + } + #expect(headerGroupIds.contains(groupId)) + #expect(workspaceRowIds.contains(targetId) == false) + } } diff --git a/docs/workspace-groups.md b/docs/workspace-groups.md index 56dae9ebeb13..aa39fdae0fac 100644 --- a/docs/workspace-groups.md +++ b/docs/workspace-groups.md @@ -8,9 +8,11 @@ Workspace groups let you nest workspaces into collapsible named sections in the Every group is owned by exactly one workspace called the **anchor**. The group header in the sidebar IS the anchor's representation — there is no separate row for it. Clicking the header name area focuses the anchor's panels. Clicking the chevron toggles collapse. -Anchors are always brand new when a group is created. They are never promoted from an existing workspace. The anchor's working directory is inherited from the first selected workspace (when grouping a selection) or from the active workspace (when creating via the CLI without `--cwd`). +When you create a group from a **selection** (or via `create`), the anchor is brand new: a fresh workspace is inserted above the selected members, inheriting its working directory from the first selected workspace (or from the active workspace when creating via the CLI without `--cwd`). -Closing the anchor workspace **dissolves the group**: every other member loses its `groupId` and stays in the tabs list as an ungrouped workspace. Nothing is closed besides the anchor itself. The app shows a confirm dialog with a "Don't ask again" toggle before this happens. +When you **turn a single workspace into a group**, that existing workspace becomes the anchor itself: no fresh workspace is inserted, the row keeps its position, and the group name defaults to the workspace's own title. This is the exact inverse of ungrouping, so workspace → group → ungroup round-trips back to the same single workspace. + +Closing the anchor workspace **dissolves the group**: every other member loses its `groupId` and stays in the tabs list as an ungrouped workspace. Nothing is closed besides the anchor itself. The app shows a confirm dialog with a "Don't ask again" toggle before this happens. Ungrouping a single-workspace group simply turns the anchor back into a regular workspace at the same spot. ### Group identity @@ -34,7 +36,7 @@ Single-tab groups are not created from the shortcut. Use the workspace context m ### From a workspace context menu -Right-click any workspace in the sidebar, choose **New Group from Workspace** (or **New Group from Selection** when multiple workspaces are selected). Same auto-naming behavior as the shortcut. +Right-click an ungrouped workspace in the sidebar and choose **New Group from Workspace** to turn it into a group (the workspace becomes the anchor; the group takes the workspace's name). With multiple workspaces selected, the entry becomes **New Group from Selection**, which inserts a fresh auto-named anchor above them. Workspaces already in a group show **Move to Group** / **Remove from Group** instead. ### From the group header context menu @@ -55,6 +57,7 @@ All group operations are scriptable via `cmux workspace-group `. The ```bash cmux workspace-group list [--json] cmux workspace-group create --name "manaflow" [--cwd ~/projects/manaflow] [--from ,] +cmux workspace-group from-workspace --workspace [--name "manaflow"] cmux workspace-group ungroup cmux workspace-group delete # destructive: closes every member workspace cmux workspace-group rename --name "new name" @@ -68,7 +71,7 @@ cmux workspace-group set-anchor --group --workspace cmux workspace-group new-workspace [--placement afterCurrent|top|end] ``` -`create` returns a group handle (`workspace_group:N` by default). Pass `--json` for the full structured payload. +`create` and `from-workspace` return a group handle (`workspace_group:N` by default). Pass `--json` for the full structured payload. ### Examples @@ -78,6 +81,12 @@ Group the three currently selected workspaces under a name: cmux workspace-group create --name manaflow ``` +Turn an existing workspace into a group (the workspace becomes the anchor): + +```bash +cmux workspace-group from-workspace --workspace workspace:2 +``` + Spin up a new workspace inside an existing group (e.g. wired to a worktree script): ```bash From 84cdbbb36d11192806c5f1163b646104ce59af5b Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 7 Jun 2026 23:38:18 -0700 Subject: [PATCH 2/6] Test pinned workspace stays in pinned tier on promote/ungroup Co-Authored-By: Claude Opus 4.8 (1M context) --- cmuxTests/WorkspaceGroupTests.swift | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/cmuxTests/WorkspaceGroupTests.swift b/cmuxTests/WorkspaceGroupTests.swift index 8d1eaba51ae6..556c9f286450 100644 --- a/cmuxTests/WorkspaceGroupTests.swift +++ b/cmuxTests/WorkspaceGroupTests.swift @@ -906,6 +906,34 @@ struct WorkspaceGroupTests { #expect(manager.tabs.first { $0.id == memberId }?.groupId == groupId) } + // A pinned workspace turned into a group must stay in the pinned sidebar + // tier (group tier reads `group.isPinned`, not the anchor's own pin), so + // it does not get demoted below other pinned rows. Guards the + // `isPinned: anchorTab.isPinned` inheritance. + @Test func makeWorkspaceGroupFromWorkspaceKeepsPinnedWorkspaceInPinnedTier() throws { + let manager = makeTabManager() + manager.addWorkspace(autoWelcomeIfNeeded: false) + manager.addWorkspace(autoWelcomeIfNeeded: false) + let ids = manager.tabs.map(\.id) + #expect(ids.count == 4) + // Pin the first two so they form the pinned tier, in order. Promoting + // the FIRST pinned workspace without inheriting its pin would demote + // it below the second pinned workspace (pinned-first reordering). + manager.tabs[0].isPinned = true + manager.tabs[1].isPinned = true + + let groupId = try #require(manager.makeWorkspaceGroupFromWorkspace(anchorWorkspaceId: ids[0])) + let group = try #require(manager.workspaceGroups.first { $0.id == groupId }) + + #expect(group.isPinned) + #expect(manager.tabs.map(\.id) == ids) + + // Ungroup restores the anchor as a still-pinned row in the same spot. + manager.ungroupWorkspaceGroup(groupId: groupId) + #expect(manager.tabs.first { $0.id == ids[0] }?.isPinned == true) + #expect(manager.tabs.map(\.id) == ids) + } + // A promoted single-workspace group has its anchor as the only member, so // it renders as a header with no child rows. Sanity-check that the anchor // is excluded from the rendered workspace rows (it is the header). From 9b38aa9706eac5df47972db12b5aad82710025c1 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sun, 7 Jun 2026 23:53:58 -0700 Subject: [PATCH 3/6] Cmd-Shift-G turns the focused workspace into a group When there's no 2+ sidebar selection, Cmd-Shift-G now turns the single focused workspace into a group in place, instead of doing nothing. It defers to React Grab whenever React Grab would act on the current focus (browser focused, or a single browser reachable from the focused terminal), so React Grab keeps the chord on browser-bearing workspaces; in a plain terminal (where it previously just beeped) it promotes. Extracts a shared reactGrabShortcutRouteForCurrentFocus() so the gate and the React Grab action use one route-resolution path. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/AppDelegate.swift | 35 ++++++++++++++++++++++++----------- Sources/TabManager.swift | 26 ++++++++++++++++++++++---- docs/workspace-groups.md | 6 ++++-- 3 files changed, 50 insertions(+), 17 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index c9c225cb244f..608b19066ee6 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -13169,8 +13169,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if matchConfiguredShortcut(event: event, action: .groupSelectedWorkspaces) { // Only consume the event when grouping actually happened; otherwise // fall through so the dispatcher reaches the later - // `.toggleReactGrab` check (default ⌘⇧G collides with React Grab - // and grouping returns false when no multi-selection exists). + // `.toggleReactGrab` check (default ⌘⇧G collides with React Grab). + // Grouping returns false when React Grab would act on the current + // focus or there's nothing to group, so React Grab still wins on + // browser-bearing workspaces. if handleGroupSelectedWorkspacesShortcut( preferredWindow: commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow ) { @@ -14498,15 +14500,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let orderedSelectedIds: [UUID] = selectedSet.isEmpty ? [] : tabManager.tabs.compactMap { selectedSet.contains($0.id) ? $0.id : nil } - // Only consume the shortcut when there's an explicit sidebar - // multi-selection. Anything ≤ 1 falls through so ⌘⇧G keeps working as - // React Grab's default in browser/terminal contexts. A single-tab - // group can still be created via right-click → New Group from - // Workspace. `sidebarSelectedWorkspaceIds` is normally synced to the - // focused workspace (clearSidebarMultiSelection sets it to a - // singleton after keyboard nav), so the singleton case must be - // treated the same as "no selection." - guard orderedSelectedIds.count >= 2 else { return false } + // Two or more sidebar-selected workspaces → group them under a fresh + // anchor (the original multi-select behavior below). + // + // A single focused workspace (or no explicit multi-selection) → turn + // that workspace itself into a group in place, but only when React + // Grab would not act on the current focus. React Grab keeps ⌘⇧G on + // browser-bearing workspaces; in a plain terminal the chord otherwise + // just beeps, so we use it to promote the focused workspace. + // `sidebarSelectedWorkspaceIds` is normally synced to the focused + // workspace (a singleton after keyboard nav), so ≤ 1 is treated as + // "no multi-selection." + guard orderedSelectedIds.count >= 2 else { + guard !tabManager.reactGrabWouldHandleCurrentFocus(), + let focusedId = tabManager.selectedTabId, + let focused = tabManager.tabs.first(where: { $0.id == focusedId }), + focused.groupId == nil else { + return false + } + return tabManager.makeWorkspaceGroupFromWorkspace(anchorWorkspaceId: focusedId) != nil + } let candidateIds: [UUID] = orderedSelectedIds // Match the workspace context-menu eligibility filter so the shortcut // doesn't silently create an anchor-only group when every selected diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 3ff842d4f8b0..f791cf13e2d8 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -6272,9 +6272,13 @@ class TabManager: ObservableObject { } @discardableResult - func toggleReactGrabFromCurrentFocus() -> Bool { - guard let workspace = selectedWorkspace else { return false } - + /// Resolve the React Grab shortcut route for the currently-focused + /// workspace, if any. Read-only; used both to decide whether ⌘⇧G should + /// defer to React Grab and to actually run it. + private func reactGrabShortcutRouteForCurrentFocus() + -> (workspace: Workspace, route: ReactGrabShortcutRoute, browserPanel: BrowserPanel)? + { + guard let workspace = selectedWorkspace else { return nil } let snapshots = workspace.panels.values.map { panel in ReactGrabShortcutPanelSnapshot( id: panel.id, @@ -6284,8 +6288,22 @@ class TabManager: ObservableObject { } guard let route = resolveReactGrabShortcutRoute(panels: snapshots), let browserPanel = workspace.browserPanel(for: route.browserPanelId) else { - return false + return nil } + return (workspace, route, browserPanel) + } + + /// True when ⌘⇧G would activate React Grab for the current focus (a + /// browser is focused, or a single browser panel is reachable from the + /// focused terminal). The "turn this workspace into a group" shortcut + /// defers to React Grab in that case so React Grab keeps ⌘⇧G on + /// browser-bearing workspaces. + func reactGrabWouldHandleCurrentFocus() -> Bool { + reactGrabShortcutRouteForCurrentFocus() != nil + } + + func toggleReactGrabFromCurrentFocus() -> Bool { + guard let (workspace, route, browserPanel) = reactGrabShortcutRouteForCurrentFocus() else { return false } if let returnTerminalPanelId = route.returnTerminalPanelId { browserPanel.armReactGrabRoundTrip(returnTo: returnTerminalPanelId) diff --git a/docs/workspace-groups.md b/docs/workspace-groups.md index aa39fdae0fac..ed8a9d8aac7f 100644 --- a/docs/workspace-groups.md +++ b/docs/workspace-groups.md @@ -30,9 +30,11 @@ The sidebar layout, top to bottom: ### From the keyboard (`⌘⇧G`) -Select two or more workspaces in the sidebar, press `⌘⇧G`. A fresh anchor workspace is inserted above the selection; all selected workspaces become children. The group is auto-named `Group 1`, `Group 2`, … (rename anytime via the header context menu). `⌘⇧G` collides with React Grab's default; the group handler only consumes the chord when there is an explicit sidebar multi-selection of at least two workspaces, so React Grab still fires in single-selection and browser/terminal contexts. Rebind in Settings → Keyboard if you'd rather the two not share a key. +Select two or more workspaces in the sidebar, press `⌘⇧G`. A fresh anchor workspace is inserted above the selection; all selected workspaces become children. The group is auto-named `Group 1`, `Group 2`, … (rename anytime via the header context menu). -Single-tab groups are not created from the shortcut. Use the workspace context menu's **New Group from Workspace** entry for that. +With a single focused workspace (no multi-selection), `⌘⇧G` **turns that workspace into a group in place** (the workspace becomes the anchor, named after itself). `⌘⇧G` collides with React Grab's default, so the group handler defers to React Grab whenever React Grab would act on the current focus (a browser is focused, or a single browser panel is reachable from the focused terminal). In a plain terminal workspace — where `⌘⇧G` would otherwise just beep — it promotes the workspace instead. Rebind in Settings → Keyboard if you'd rather the two not share a key. + +You can also turn a single workspace into a group from the workspace context menu's **New Group from Workspace** entry, which works regardless of focus. ### From a workspace context menu From 5630220771042bad0f12895c86078e1ac81834cc Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 9 Jun 2026 01:46:41 -0700 Subject: [PATCH 4/6] Fix sidebar not re-rendering when a workspace becomes/leaves a group in place Turning the focused workspace into a single-member group (and ungrouping it) changed the data model correctly but the sidebar didn't update: the group header never appeared on create, and a stale header lingered after ungroup. The sidebar list is a LazyVStack + ForEach over render items where a top-level slot switches between a `.workspace` row and a `.groupHeader` (different view types). LazyVStack mis-diffs that in-place type change, so the row view wasn't swapped. Multi-select grouping never hit this because it inserts a brand-new anchor workspace rather than transforming an existing row. Key the rows on a signature of the group-anchor SET so the list rebuilds cleanly whenever a workspace starts or stops being a group header. It is sorted, so reorders and renames (which don't change the anchor set) don't trigger a rebuild and drag-reorder is unaffected. Verified visually via cua screenshots: keyboard Cmd-Shift-G on the focused workspace now renders the header, and ungroup reverts it to a plain row. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/ContentView.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 98e428feb631..654375b2f314 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -12370,6 +12370,18 @@ struct VerticalTabsSidebar: View { // drag mutations at 60fps invalidate only the rows/overlays that // read them, never this sidebar body. See SidebarDragState and // https://github.com/manaflow-ai/cmux/issues/2586. + // Force a clean row rebuild whenever the SET of group anchors changes + // (a workspace becoming, or ceasing to be, a group header). A sidebar + // row that switches in place between a `.workspace` row and a + // `.groupHeader` is mis-diffed by LazyVStack: turning the focused + // workspace into a single-member group left the header invisible, and + // ungrouping left a stale header. Keying on the anchor SET (sorted, so + // it ignores reorders) flips identity on group create/ungroup/anchor + // changes only, so neither drag-reorder nor rename triggers a rebuild. + let groupAnchorSignature = renderContext.workspaceGroups + .map { $0.anchorWorkspaceId.uuidString } + .sorted() + .joined(separator: ",") let rows = LazyVStack(spacing: tabRowSpacing) { ForEach(renderItems, id: \.id) { item in switch item { @@ -12391,6 +12403,7 @@ struct VerticalTabsSidebar: View { } .padding(.vertical, SidebarWorkspaceListMetrics.rowVerticalPadding) .frame(maxWidth: .infinity, alignment: .leading) + .id(groupAnchorSignature) // Gate ONLY the per-row frame-anchor *reader* (the virtualization-defeating // work) behind the drag-active check, and keep the Bonsplit drop-capture From f50a194865ceee7aa914e43c43686fb59a041e3d Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 9 Jun 2026 01:54:43 -0700 Subject: [PATCH 5/6] Bump Swift file length budgets for the workspace-group changes Merge with main raised these mega-files; the feature's small additions (socket handler, CLI subcommand, TabManager primitive, sidebar render fix, tests) push them a little further. Splitting them is out of scope. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/swift-file-length-budget.tsv | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 7b7f7925b71a..bb110614afe2 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -1,15 +1,15 @@ # cmux-owned Swift file length budget. # Format: max_linesrelative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. -32547 CLI/cmux.swift -21983 Sources/TerminalController.swift +32576 CLI/cmux.swift +22034 Sources/TerminalController.swift 19875 Sources/Workspace.swift -19052 Sources/ContentView.swift -17860 Sources/AppDelegate.swift +19065 Sources/ContentView.swift +17873 Sources/AppDelegate.swift 16503 Sources/GhosttyTerminalView.swift 13589 Sources/Panels/BrowserPanel.swift 11756 cmuxTests/AppDelegateShortcutRoutingTests.swift -9914 Sources/TabManager.swift +9995 Sources/TabManager.swift 8494 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift 7737 Sources/Panels/BrowserPanelView.swift 7214 cmuxTests/WorkspaceUnitTests.swift @@ -82,6 +82,7 @@ 1006 cmuxTests/CmuxSSHURLRequestTests.swift 1000 cmuxTests/CmuxTopSnapshotScopeTests.swift 969 cmuxUITests/TerminalCmdClickUITests.swift +959 cmuxTests/WorkspaceGroupTests.swift 949 Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift 947 Sources/TerminalNotificationPolicy.swift 945 Sources/SessionIndexRegisteredAgents.swift @@ -97,7 +98,6 @@ 866 Sources/CommandPalette/CommandPaletteSettingsToggle.swift 863 Sources/Panels/TerminalPanel.swift 856 Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift -853 cmuxTests/WorkspaceGroupTests.swift 846 cmuxTests/AgentSessionAutoResumeSettingsTests.swift 845 cmuxTests/SSHStartupSignalLifecycleTests.swift 842 Sources/Panels/MarkdownWebRenderer.swift From 7bcf7fdcdc2493ae557c2407a5089cad52c04e26 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 9 Jun 2026 02:52:09 -0700 Subject: [PATCH 6/6] =?UTF-8?q?Group=20shortcuts:=20=E2=8C=98=E2=87=A7G=20?= =?UTF-8?q?toggle,=20=E2=8C=98=E2=87=A7P=20rename=20group,=20=E2=8C=98?= =?UTF-8?q?=E2=87=A7R=20rename=20both?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⌘⇧G now toggles for a single focused workspace: first press makes it a single-member group, pressing again on that solo group ungroups it. A workspace inside a multi-member group (its anchor or a child) is left untouched so the toggle never dissolves a group with other members. ⌘⇧P (new .renameFocusedWorkspaceGroup action) renames the focused workspace's group. It shares the chord with commandPalettePrevious, which only acts while the palette is open (in-palette nav, dispatched earlier), so they don't collide in practice. Full shortcut wiring: KeyboardShortcut Settings case/label/default, dispatch + handler, cmux.json schema, docs, localized label. ⌘⇧R (rename workspace) now also renames the group when the focused workspace is a group anchor, since the group name is what the header displays — hooked once in applyRenameFlow so every rename entrypoint (palette, context menu, shortcut) renames both. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/swift-file-length-budget.tsv | 6 +-- Resources/Localizable.xcstrings | 17 +++++++++ Sources/AppDelegate.swift | 52 ++++++++++++++++++++++++-- Sources/ContentView.swift | 7 ++++ Sources/KeyboardShortcutSettings.swift | 9 +++++ docs/workspace-groups.md | 6 ++- web/data/cmux.schema.json | 1 + 7 files changed, 91 insertions(+), 7 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index bb110614afe2..920952672f09 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -4,8 +4,8 @@ 32576 CLI/cmux.swift 22034 Sources/TerminalController.swift 19875 Sources/Workspace.swift -19065 Sources/ContentView.swift -17873 Sources/AppDelegate.swift +19072 Sources/ContentView.swift +17919 Sources/AppDelegate.swift 16503 Sources/GhosttyTerminalView.swift 13589 Sources/Panels/BrowserPanel.swift 11756 cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -38,7 +38,7 @@ 2654 Sources/Panels/CmuxWebView.swift 2545 cmuxTests/WorkspaceManualUnreadTests.swift 2513 cmuxTests/CommandPaletteSearchEngineTests.swift -2491 Sources/KeyboardShortcutSettings.swift +2500 Sources/KeyboardShortcutSettings.swift 2378 Sources/TerminalNotificationStore.swift 2327 cmuxTests/CJKIMEInputTests.swift 2290 Sources/FileExplorerView.swift diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 54f1cdeb5fa5..a508971ab674 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -166856,6 +166856,23 @@ } } }, + "shortcut.renameFocusedWorkspaceGroup.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rename Focused Workspace’s Group" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォーカス中のワークスペースのグループ名を変更" + } + } + } + }, "shortcut.toggleFocusedWorkspaceGroupCollapsed.label": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 7870b4e72d61..0ccaa6d607c4 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -13215,6 +13215,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } + if matchConfiguredShortcut(event: event, action: .renameFocusedWorkspaceGroup) { + // Only consume when the focused workspace is actually in a group. + // Otherwise fall through (the default chord is shared with + // commandPalettePrevious, which is handled earlier while the + // palette is open). + if handleRenameFocusedWorkspaceGroupShortcut( + preferredWindow: commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow + ) { + return true + } + } + if matchConfiguredShortcut(event: event, action: .editWorkspaceDescription) { #if DEBUG cmuxDebugLog( @@ -14508,6 +14520,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + @discardableResult + func handleRenameFocusedWorkspaceGroupShortcut(preferredWindow: NSWindow? = nil) -> Bool { + let targetWindow = preferredWindow ?? NSApp.keyWindow ?? NSApp.mainWindow + let resolvedTabManager: TabManager? = contextForMainWindow(targetWindow)?.tabManager ?? self.tabManager + guard let tabManager = resolvedTabManager else { return false } + guard let focusedId = tabManager.selectedTabId, + let groupId = tabManager.tabs.first(where: { $0.id == focusedId })?.groupId, + let group = tabManager.workspaceGroups.first(where: { $0.id == groupId }) else { + // Focused workspace isn't in a group — let the chord propagate. + return false + } + presentSidebarWorkspaceGroupRenamePrompt( + tabManager: tabManager, + groupId: group.id, + currentName: group.name + ) + return true + } + @discardableResult func handleGroupSelectedWorkspacesShortcut(preferredWindow: NSWindow? = nil) -> Bool { // Resolve the TabManager for the preferred/key/main window first so @@ -14536,13 +14567,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // workspace (a singleton after keyboard nav), so ≤ 1 is treated as // "no multi-selection." guard orderedSelectedIds.count >= 2 else { + // Single focused workspace. React Grab keeps ⌘⇧G in browser + // contexts; everything below is terminal-context only. guard !tabManager.reactGrabWouldHandleCurrentFocus(), let focusedId = tabManager.selectedTabId, - let focused = tabManager.tabs.first(where: { $0.id == focusedId }), - focused.groupId == nil else { + let focused = tabManager.tabs.first(where: { $0.id == focusedId }) else { return false } - return tabManager.makeWorkspaceGroupFromWorkspace(anchorWorkspaceId: focusedId) != nil + // Ungrouped → turn it into a single-member group. + if focused.groupId == nil { + return tabManager.makeWorkspaceGroupFromWorkspace(anchorWorkspaceId: focusedId) != nil + } + // Already grouped: ⌘⇧G toggles OFF only when this workspace is the + // anchor of a SOLO group (itself, no other members), so the chord + // round-trips create<->ungroup. A workspace inside a multi-member + // group (its anchor or a child) is left untouched — consume the + // chord so it doesn't beep or fall through to React Grab. + if let group = tabManager.workspaceGroups.first(where: { $0.id == focused.groupId }), + group.anchorWorkspaceId == focusedId, + tabManager.tabs.filter({ $0.groupId == group.id }).count == 1 { + tabManager.ungroupWorkspaceGroup(groupId: group.id) + } + return true } let candidateIds: [UUID] = orderedSelectedIds // Match the workspace context-menu eligibility filter so the shortcut diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 654375b2f314..6df5db533fab 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -9772,6 +9772,13 @@ struct ContentView: View { switch target.kind { case .workspace(let workspaceId): tabManager.setCustomTitle(tabId: workspaceId, title: normalizedName) + // A group anchor's displayed name IS its group's name, so renaming + // a "group workspace" must rename the group too or the header + // wouldn't change. Skip on clear (groups can't be unnamed). + if let normalizedName, + let anchoredGroup = tabManager.workspaceGroups.first(where: { $0.anchorWorkspaceId == workspaceId }) { + tabManager.renameWorkspaceGroup(groupId: anchoredGroup.id, name: normalizedName) + } case .tab(let workspaceId, let panelId): guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { NSSound.beep() diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 53bc68ec069b..39cdd8e3ebd5 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -107,6 +107,7 @@ enum KeyboardShortcutSettings { case closeWorkspace case groupSelectedWorkspaces case toggleFocusedWorkspaceGroupCollapsed + case renameFocusedWorkspaceGroup case reopenClosedBrowserPanel case newSurface case toggleTerminalCopyMode @@ -207,6 +208,7 @@ enum KeyboardShortcutSettings { case .closeWorkspace: return String(localized: "shortcut.closeWorkspace.label", defaultValue: "Close Workspace") case .groupSelectedWorkspaces: return String(localized: "shortcut.groupSelectedWorkspaces.label", defaultValue: "Group Selected Workspaces") case .toggleFocusedWorkspaceGroupCollapsed: return String(localized: "shortcut.toggleFocusedWorkspaceGroupCollapsed.label", defaultValue: "Toggle Focused Workspace's Group Collapse") + case .renameFocusedWorkspaceGroup: return String(localized: "shortcut.renameFocusedWorkspaceGroup.label", defaultValue: "Rename Focused Workspace's Group") case .reopenClosedBrowserPanel: return String(localized: "menu.history.reopenLastClosed", defaultValue: "Reopen Last Closed") case .newSurface: return String(localized: "shortcut.newSurface.label", defaultValue: "New Surface") case .toggleTerminalCopyMode: return String(localized: "shortcut.toggleTerminalCopyMode.label", defaultValue: "Toggle Terminal Copy Mode") @@ -366,6 +368,13 @@ enum KeyboardShortcutSettings { // mnemonic. No-ops gracefully when the focused workspace // isn't in a group. return StoredShortcut(key: ".", command: true, shift: false, option: false, control: true) + case .renameFocusedWorkspaceGroup: + // Cmd+Shift+P. Shares the chord with commandPalettePrevious, + // but that only acts while the command palette is OPEN (it's + // in-palette navigation, dispatched earlier), so this fires + // only when the palette is closed. No-ops gracefully when the + // focused workspace isn't in a group. Rebind in Settings. + return StoredShortcut(key: "p", command: true, shift: true, option: false, control: false) case .reopenClosedBrowserPanel: return StoredShortcut(key: "t", command: true, shift: true, option: false, control: false) case .focusLeft: diff --git a/docs/workspace-groups.md b/docs/workspace-groups.md index ed8a9d8aac7f..eec2e15a387b 100644 --- a/docs/workspace-groups.md +++ b/docs/workspace-groups.md @@ -32,10 +32,14 @@ The sidebar layout, top to bottom: Select two or more workspaces in the sidebar, press `⌘⇧G`. A fresh anchor workspace is inserted above the selection; all selected workspaces become children. The group is auto-named `Group 1`, `Group 2`, … (rename anytime via the header context menu). -With a single focused workspace (no multi-selection), `⌘⇧G` **turns that workspace into a group in place** (the workspace becomes the anchor, named after itself). `⌘⇧G` collides with React Grab's default, so the group handler defers to React Grab whenever React Grab would act on the current focus (a browser is focused, or a single browser panel is reachable from the focused terminal). In a plain terminal workspace — where `⌘⇧G` would otherwise just beep — it promotes the workspace instead. Rebind in Settings → Keyboard if you'd rather the two not share a key. +With a single focused workspace (no multi-selection), `⌘⇧G` **toggles** that workspace into and out of a group in place: the first press turns the workspace into a single-member group (the workspace becomes the anchor, named after itself), and pressing `⌘⇧G` again on that solo group ungroups it back into a normal workspace. A workspace that is part of a multi-member group (its anchor or a child) is left untouched, so the toggle never dissolves a group that has other members. `⌘⇧G` collides with React Grab's default, so the group handler defers to React Grab whenever React Grab would act on the current focus (a browser is focused, or a single browser panel is reachable from the focused terminal). Rebind in Settings → Keyboard if you'd rather the two not share a key. You can also turn a single workspace into a group from the workspace context menu's **New Group from Workspace** entry, which works regardless of focus. +### Renaming from the keyboard + +When the focused workspace is in a group, `⌘⇧P` opens the group rename prompt (it shares the chord with the command palette's previous-selection navigation, which only applies while the palette is open). `⌘⇧R` renames the focused workspace; when that workspace is a **group anchor**, it renames both the workspace and its group, since the group name is what the header displays. Both are rebindable in Settings → Keyboard. + ### From a workspace context menu Right-click an ungrouped workspace in the sidebar and choose **New Group from Workspace** to turn it into a group (the workspace becomes the anchor; the group takes the workspace's name). With multiple workspaces selected, the entry becomes **New Group from Selection**, which inserts a fresh auto-named anchor above them. Workspaces already in a group show **Move to Group** / **Remove from Group** instead. diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index 5a0d13c5e3cd..63f8448e36da 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -1132,6 +1132,7 @@ "closeWorkspace", "groupSelectedWorkspaces", "toggleFocusedWorkspaceGroupCollapsed", + "renameFocusedWorkspaceGroup", "reopenClosedBrowserPanel", "newSurface", "toggleTerminalCopyMode",