Add terminals to workspace + New Workspace from QuickSwitcher (#790)
* Add terminals to workspace + New Workspace from QuickSwitcher
Two entry points share a single multi-select picker that lets the
user add Local Terminal + any combination of hosts into a workspace:
1. Focus-mode sidebar "+" button appends the selected targets to the
active workspace as new panes.
2. QuickSwitcher "New Workspace" button (small inline action next to
the Jump To hint) spins up a brand-new workspace tab populated
with the selected targets.
## Changes
### domain/workspace.ts
- pruneWorkspaceNode now rebalances surviving siblings to EQUAL
sizes after removal, instead of re-normalising the prior skew.
Matches the "auto-redistribute on close" expectation.
- New appendPaneToWorkspaceRoot(root, sessionId, direction='vertical'):
if root already splits in the requested direction, pushes the new
pane onto its children and resets sizes to equal; otherwise wraps
root + new pane in a new 0.5/0.5 split. Flattens long chains of
appends instead of producing degenerate nested trees.
### application/state/useSessionState.ts
- appendHostToWorkspace(workspaceId, host, direction?) — atomic
"build a session for this host and append it to the root", keeps
activeTab on the workspace and focuses the new pane.
- appendLocalTerminalToWorkspace(workspaceId, options?, direction?)
— mirror of the above for local shells.
- createWorkspaceFromTargets(targets, name?) — accepts a mixed list
of {kind:'local',...} / {kind:'host',host} and creates a new
workspace with one pane per target. Defaults viewMode to 'focus'
so the QuickSwitcher flow lands in the sidebar layout.
- All three exported from the hook.
### components/workspace/AddToWorkspaceDialog.tsx (new)
QuickSwitcher-styled multi-select picker:
- Fixed top-center overlay, same chrome as QuickSwitcher (border,
shadow, rounded-xl, borderless search input, bg-primary/15 cursor).
- Two sections: Local Shells (currently just Local Terminal) and
Hosts. Hover follows keyboard cursor.
- Toggle rows with click or Space / Enter; ⌘/Ctrl+Enter submits;
Esc closes. Right-side Check marks visible items.
- Thin footer bar with Cancel + "Add N" button.
### App.tsx
- Root-mounted single instance of AddToWorkspaceDialog with a
discriminated-union state:
{ mode: 'append'; workspaceId } | { mode: 'create' } | null.
- onAdd dispatches based on mode — append loops through the picker
targets calling the two append helpers; create calls
createWorkspaceFromTargets once.
- TerminalLayer's focus "+" now sends an onRequestAddToWorkspace
(workspaceId) up to App instead of owning its own dialog.
- QuickSwitcher's onCreateWorkspace callback repurposed to open the
dialog in create mode (replaces the older CreateWorkspaceDialog
route for this specific flow).
### components/TerminalLayer.tsx
- Dropped the inline AddToWorkspaceDialog + addHostPanelOpen state;
replaced the two append callbacks with a single
onRequestAddToWorkspace prop wired to the "+" button.
- Focus-sidebar header: replaced the "Terminals · N" counter with an
immersive borderless search input (bg-transparent, shadow-none,
termFg color) for filtering the terminal list; "+" and Columns2
buttons moved to the right.
- Session list filtered client-side by the search term across
hostLabel / hostname / username.
### components/QuickSwitcher.tsx
- Re-introduced onCreateWorkspace prop (was removed as unused).
- "New Workspace" inline button (Plus icon + label) sits on the
right of the Jump To hint row: border, rounded, hover bg. Click
fires onCreateWorkspace then closes QS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Add configurable New Workspace shortcut
Mirrors QuickSwitcher's "+ New Workspace" button via a keyboard
binding so the dialog can open in one keystroke without passing
through QS.
- domain/models.ts: new DEFAULT_KEY_BINDINGS entry id=new-workspace,
action=newWorkspace, default ⌘+Shift+J (Mac) / Ctrl+Shift+J (PC).
Audited the defaults — only quick-switch uses J (⌘+J), so the
shifted combo is free. The binding sits in the 'app' category so
it shows up in Settings → Shortcuts and can be rebound by the user.
- application/state/useGlobalHotkeys.ts: wire newWorkspace into the
HotkeyActions interface, getAppLevelActions() allowlist, and the
global keydown switch so the scheme-driven handler dispatches it.
- App.tsx: handle case 'newWorkspace' inside executeHotkeyAction by
calling setAddToWorkspaceDialog({ mode: 'create' }) — same entry
as QuickSwitcher's button, just without having to open QS first.
- application/i18n/locales/zh-CN.ts: add '新建工作区' translation for
settings.shortcuts.binding.new-workspace. English falls back to
the KeyBinding.label field ("New Workspace"), so no en.ts change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Address codex P1: don't check setState flag after the updater returns
Codex flagged that appendHostToWorkspace / appendLocalTerminalToWorkspace
were racy: both flipped an `inserted` flag inside setWorkspaces'
updater and then read it synchronously to decide whether to commit
the matching session via setSessions. React does NOT guarantee
updaters run synchronously (concurrent rendering, StrictMode
double-invoke, etc.), so the flag could still be false at the read
site even though the workspace exists. In that case setSessions was
skipped while the queued workspace update could still insert a new
pane referencing newSessionId — leaving a pane with no backing
session in state.
Fix: add a workspacesRef kept in sync with the workspaces state on
every render, and perform the existence check synchronously *before*
queuing any setState. Once we've confirmed the workspace exists on
the latest committed state, both setWorkspaces and setSessions are
called unconditionally, so they can never diverge.
The ref approach also correctly handles the multi-target append
loop path — React batches the updaters and applies them in sequence,
so sibling pane/session writes land in matching order.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Address codex P1+P2: narrow prune rebalance; append in root direction
### P1 — pruneWorkspaceNode over-rebalanced ancestor splits
The equal-sizes rebalance was unconditional during the recursive
walk, so closing a pane deep in one branch also rewrote unrelated
ancestor ratios (e.g., a root 0.8/0.2 vertical split got normalised
to 0.5/0.5 when a grand-child horizontal pane closed).
Now each split level tracks whether it actually lost a DIRECT
child. Only splits where a direct child disappeared get their
siblings reset to equal sizes. Ancestors whose direct children all
survived keep their original ratios (defensively re-normalised in
case a descendant subtree collapsed shape).
### P2 — Append path ignored the root's current direction
onAdd in App.tsx called the two append helpers without a direction,
so both defaulted to 'vertical'. appendPaneToWorkspaceRoot only
flattens into the root split when the directions match; if the
workspace root was horizontal (e.g., user split top/bottom earlier),
each append wrapped the entire existing tree into one side of a new
vertical split — existing panes crammed into one branch, new pane
hoarding half the space.
Read the current root direction out of the target workspace and
pass it down so new panes become peers of the existing root
siblings regardless of horizontal vs vertical.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Address codex P2: allow serial hosts in create-workspace picker
The picker used to filter out every host with protocol='serial'
regardless of mode. That was correct for append mode (the
appendHostToWorkspace helper has no serial path and early-returns)
but a regression for create mode — the old createWorkspaceWithHosts
flow passed serial hosts through and createWorkspaceFromTargets
still builds a SerialConfig-backed session for them, so there was
no reason to block them in the "+ New Workspace" entry.
Move the filter from the dialog up to App.tsx:
- AddToWorkspaceDialog drops the serial filter; selectableHosts is
simply the hosts prop.
- App.tsx passes `hosts.filter(h => h.protocol !== 'serial')` when
mode is 'append', and the full list when mode is 'create'.
Result: users can once again build a workspace from serial hosts
via QuickSwitcher's "+ New Workspace" button or the ⌘/Ctrl+Shift+J
hotkey, while append-to-existing keeps its earlier safe behaviour.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Address codex P2: don't commit session when append target disappears
Follow-up to the earlier ref-based guard. The ref check eliminates
the common "workspace already gone" case but still leaves a small
race: if closeWorkspace runs between the ref read and setWorkspaces'
updater firing, prev.map returns the unchanged workspaces but
setSessions / setActiveTabId still execute — leaving an orphan
session whose workspaceId points at a deleted workspace and jumping
activeTabId to a closed tab.
Nest setSessions + setActiveTabId inside the setWorkspaces updater
so the writes are gated on the same authoritative match used for
the tree update. The setSessions updater also de-dupes by newSessionId
so React 18 StrictMode's dev-time double-invoke of the outer updater
doesn't append the same row twice. Same pattern applied to
appendLocalTerminalToWorkspace.
The existing closeSession already uses the nested-setState shape, so
this matches the codebase convention.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
68
App.tsx
68
App.tsx
@@ -44,6 +44,7 @@ import { Label } from './components/ui/label';
|
||||
import { ToastProvider, toast } from './components/ui/toast';
|
||||
import { VaultView, VaultSection } from './components/VaultView';
|
||||
import { QuickAddSnippetDialog } from './components/QuickAddSnippetDialog';
|
||||
import { AddToWorkspaceDialog } from './components/workspace/AddToWorkspaceDialog';
|
||||
import { KeyboardInteractiveModal, KeyboardInteractiveRequest } from './components/KeyboardInteractiveModal';
|
||||
import { PassphraseModal, PassphraseRequest } from './components/PassphraseModal';
|
||||
import { cn } from './lib/utils';
|
||||
@@ -178,6 +179,15 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
|
||||
const [isQuickSwitcherOpen, setIsQuickSwitcherOpen] = useState(false);
|
||||
const [isCreateWorkspaceOpen, setIsCreateWorkspaceOpen] = useState(false);
|
||||
// Combined state for the AddToWorkspaceDialog. null = closed; mode
|
||||
// determines whether picking targets appends them to an existing
|
||||
// workspace (focus sidebar "+") or spins up a brand-new workspace
|
||||
// tab (QuickSwitcher's New Workspace button).
|
||||
const [addToWorkspaceDialog, setAddToWorkspaceDialog] = useState<
|
||||
| { mode: 'append'; workspaceId: string }
|
||||
| { mode: 'create' }
|
||||
| null
|
||||
>(null);
|
||||
const [quickSearch, setQuickSearch] = useState('');
|
||||
// Protocol selection dialog state for QuickSwitcher
|
||||
const [protocolSelectHost, setProtocolSelectHost] = useState<Host | null>(null);
|
||||
@@ -292,6 +302,9 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
createWorkspaceWithHosts,
|
||||
createWorkspaceFromSessions,
|
||||
addSessionToWorkspace,
|
||||
appendHostToWorkspace,
|
||||
appendLocalTerminalToWorkspace,
|
||||
createWorkspaceFromTargets,
|
||||
updateSplitSizes,
|
||||
splitSession,
|
||||
toggleWorkspaceViewMode,
|
||||
@@ -1232,6 +1245,12 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
case 'commandPalette':
|
||||
setIsQuickSwitcherOpen(true);
|
||||
break;
|
||||
case 'newWorkspace':
|
||||
// Dedicated shortcut to launch the AddToWorkspaceDialog in
|
||||
// create mode — same entry as QuickSwitcher's "New Workspace"
|
||||
// button, but without having to open QS first.
|
||||
setAddToWorkspaceDialog({ mode: 'create' });
|
||||
break;
|
||||
case 'portForwarding':
|
||||
// Navigate to vault and open port forwarding section
|
||||
setActiveTabId('vault');
|
||||
@@ -1800,6 +1819,9 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
onTerminalDataCapture={handleTerminalDataCapture}
|
||||
onCreateWorkspaceFromSessions={createWorkspaceFromSessions}
|
||||
onAddSessionToWorkspace={addSessionToWorkspace}
|
||||
onRequestAddToWorkspace={(workspaceId) =>
|
||||
setAddToWorkspaceDialog({ mode: 'append', workspaceId })
|
||||
}
|
||||
onUpdateSplitSizes={updateSplitSizes}
|
||||
onSetDraggingSessionId={setDraggingSessionId}
|
||||
onToggleWorkspaceViewMode={toggleWorkspaceViewMode}
|
||||
@@ -1856,6 +1878,49 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Root-mounted AddToWorkspaceDialog — triggered by the focus-mode
|
||||
"+" button (mode='append') or QuickSwitcher's "New Workspace"
|
||||
button (mode='create'). Single instance so dialog state and
|
||||
styling stay consistent across entry points. */}
|
||||
{addToWorkspaceDialog && (
|
||||
<AddToWorkspaceDialog
|
||||
open
|
||||
onOpenChange={(open) => { if (!open) setAddToWorkspaceDialog(null); }}
|
||||
// Filter serial hosts only in append mode — appendHostToWorkspace
|
||||
// has no serial code path. Create mode goes through
|
||||
// createWorkspaceFromTargets, which builds a SerialConfig-backed
|
||||
// session for serial hosts, so those should remain pickable.
|
||||
hosts={addToWorkspaceDialog.mode === 'append'
|
||||
? hosts.filter((h) => h.protocol !== 'serial')
|
||||
: hosts}
|
||||
workspaceTitle={
|
||||
addToWorkspaceDialog.mode === 'append'
|
||||
? workspaces.find((w) => w.id === addToWorkspaceDialog.workspaceId)?.title
|
||||
: 'New Workspace'
|
||||
}
|
||||
onAdd={(targets) => {
|
||||
if (addToWorkspaceDialog.mode === 'append') {
|
||||
// Match the workspace root's current split direction so
|
||||
// the new panes peer the existing siblings instead of
|
||||
// wrapping the whole tree into one side of a fresh split
|
||||
// (which would happen if we always passed the helper's
|
||||
// default 'vertical').
|
||||
const ws = workspaces.find((w) => w.id === addToWorkspaceDialog.workspaceId);
|
||||
const rootDir = ws && ws.root.type === 'split' ? ws.root.direction : 'vertical';
|
||||
for (const target of targets) {
|
||||
if (target.kind === 'local') {
|
||||
appendLocalTerminalToWorkspace(addToWorkspaceDialog.workspaceId, undefined, rootDir);
|
||||
} else {
|
||||
appendHostToWorkspace(addToWorkspaceDialog.workspaceId, target.host, rootDir);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
createWorkspaceFromTargets(targets);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isQuickSwitcherOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<LazyQuickSwitcher
|
||||
@@ -1879,7 +1944,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}}
|
||||
onCreateWorkspace={() => {
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setIsCreateWorkspaceOpen(true);
|
||||
setQuickSearch('');
|
||||
setAddToWorkspaceDialog({ mode: 'create' });
|
||||
}}
|
||||
onClose={() => {
|
||||
setIsQuickSwitcherOpen(false);
|
||||
|
||||
Reference in New Issue
Block a user