diff --git a/apps/desktop/src/app/chat/close-tab.ts b/apps/desktop/src/app/chat/close-tab.ts
index d5a5a0bcae14..098e59108aea 100644
--- a/apps/desktop/src/app/chat/close-tab.ts
+++ b/apps/desktop/src/app/chat/close-tab.ts
@@ -2,17 +2,25 @@ import { closeActiveTerminal } from '@/app/right-sidebar/terminal/terminals'
import { closeWorkspaceTab } from '@/components/pane-shell/tree/store'
import { isFocusWithin } from '@/lib/keybinds/combo'
import { $filePreviewTabs, $previewTarget, closeActiveRightRailTab } from '@/store/preview'
+import { closeSessionTile, nextSessionTileForWorkspace } from '@/store/session-states'
/**
* ⌘W — close the tab of the context you're in, by precedence:
* 1. a focused terminal → its active terminal tab,
* 2. right-rail tabs (live preview and/or file peeks),
* 3. the MAIN zone → its active tab (a session tile stacked into the workspace).
+ * 4. the MAIN (workspace) tab itself, when session tabs are stacked with it:
+ * the workspace can't close, so ⌘W shifts the NEXT session tab into main
+ * (loads it as the primary + drops its now-redundant tile).
* Returns false when nothing closes, so ⌘W is a no-op — it never closes the
* window (a bare workspace stays put). Shared by the keyboard path (Win/Linux)
* and the macOS menu-accelerator IPC.
+ *
+ * `loadSessionIntoWorkspace` carries the app's route-based "load this session
+ * into main" (the two call sites have router access); omitting it disables the
+ * step-4 promotion (⌘W stays the pre-existing no-op on the main tab).
*/
-export function closeActiveTab(): boolean {
+export function closeActiveTab(loadSessionIntoWorkspace?: (storedSessionId: string) => void): boolean {
if (isFocusWithin('[data-terminal]')) {
closeActiveTerminal()
@@ -28,5 +36,27 @@ export function closeActiveTab(): boolean {
return closeActiveRightRailTab()
}
- return closeWorkspaceTab()
+ // A closeable main-zone tab (a session tile that's the active tab) closes
+ // outright; the uncloseable workspace tab returns false and falls through.
+ if (closeWorkspaceTab()) {
+ return true
+ }
+
+ // The main (workspace) tab is active and can't be closed — but if session
+ // tabs are stacked with it, ⌘W shifts the next one into the main tab: drop
+ // its tile (the session stays alive, no busy-close prompt) and load it into
+ // main. Order matters — close the tile FIRST so the selection homes to the
+ // workspace instead of re-fronting the tile.
+ if (loadSessionIntoWorkspace) {
+ const next = nextSessionTileForWorkspace()
+
+ if (next) {
+ closeSessionTile(next)
+ loadSessionIntoWorkspace(next)
+
+ return true
+ }
+ }
+
+ return false
}
diff --git a/apps/desktop/src/app/chat/session-tile.tsx b/apps/desktop/src/app/chat/session-tile.tsx
index e1e149a29d60..5a542d12bfca 100644
--- a/apps/desktop/src/app/chat/session-tile.tsx
+++ b/apps/desktop/src/app/chat/session-tile.tsx
@@ -21,6 +21,7 @@ import { useEffect, useMemo, useRef } from 'react'
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import { useModelControls } from '@/app/session/hooks/use-model-controls'
+import { resolveStoredSession } from '@/app/session/hooks/use-session-actions/utils'
import { blobToDataUrl } from '@/app/session/hooks/use-prompt-actions/utils'
import { ModelMenuPanel } from '@/app/shell/model-menu-panel'
import { formatRefValue } from '@/components/assistant-ui/directive-text'
@@ -201,6 +202,53 @@ export function SessionTilePane({ storedSessionId }: { storedSessionId: string }
const resumingRef = useRef(false)
const view = useMemo(() => buildTileView(storedSessionId), [storedSessionId])
+ // A tab-strip "+"/⌘T tab is created UNLISTED — its session stays out of
+ // $sessions (no sidebar clutter) until it's actually used, so the tab shows
+ // "New session". The moment this tile has a message, pull its row into
+ // $sessions via the lightweight by-id lookup so the tab (and a sidebar row)
+ // resolve the real title. `resolveStoredSession` no-ops when it's already
+ // listed, and 404s harmlessly for an in-memory draft that hasn't persisted a
+ // turn yet — so we retry across that brief persist lag and stop as soon as it
+ // lands (a global turn-complete refresh may beat us to it).
+ const hasMessages = useStore(view.$messagesEmpty) === false
+
+ useEffect(() => {
+ const alreadyListed = () => $sessions.get().some(s => sessionMatchesStoredId(s, storedSessionId))
+
+ if (!runtimeId || !hasMessages || alreadyListed()) {
+ return
+ }
+
+ let cancelled = false
+ let timer: number | undefined
+
+ const attempt = (remaining: number) => {
+ if (cancelled || alreadyListed()) {
+ return
+ }
+
+ void resolveStoredSession(storedSessionId)
+ .then(resolved => {
+ if (cancelled || resolved || remaining <= 0) {
+ return
+ }
+
+ timer = window.setTimeout(() => attempt(remaining - 1), 500)
+ })
+ .catch(() => undefined)
+ }
+
+ attempt(6)
+
+ return () => {
+ cancelled = true
+
+ if (timer !== undefined) {
+ window.clearTimeout(timer)
+ }
+ }
+ }, [hasMessages, runtimeId, storedSessionId])
+
// Same gating as the primary's route resume (use-route-resume): never fire
// session.resume before the gateway is OPEN. Persisted tiles mount at boot
// while it's still connecting — an ungated resume rejected there and
@@ -300,7 +348,9 @@ export function tileStoredRow(storedSessionId: string): SessionInfo | undefined
function tileTitle(storedSessionId: string): string {
const stored = tileStoredRow(storedSessionId)
- return stored ? sessionTitle(stored) : 'Session'
+ // A tab-strip "+" tab is unlisted until its first turn persists, so it isn't
+ // in $sessions yet — label it "New session" rather than a bare "Session".
+ return stored ? sessionTitle(stored) : 'New session'
}
/** The tab's lead-dot color — the tile's session resolved through the SAME
diff --git a/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts b/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts
index d0af49665906..a1cd3cdfc469 100644
--- a/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts
+++ b/apps/desktop/src/app/contrib/hooks/use-desktop-integrations.ts
@@ -155,10 +155,12 @@ export function useDesktopIntegrations({
// OS-standard window close, esp. secondary windows). The Win/Linux keyboard
// path is the `view.closeTab` keybind (use-keybinds), sharing closeActiveTab.
useEffect(() => {
- const unsubscribe = window.hermesDesktop?.onClosePreviewRequested?.(() => void closeActiveTab())
+ const unsubscribe = window.hermesDesktop?.onClosePreviewRequested?.(() =>
+ void closeActiveTab(id => navigate(sessionRoute(id)))
+ )
return () => unsubscribe?.()
- }, [])
+ }, [navigate])
// Another window mutated the shared session list -> re-pull the sidebar.
useEffect(() => {
diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx
index e10a998bf32f..ca59bdabdf9f 100644
--- a/apps/desktop/src/app/contrib/wiring.tsx
+++ b/apps/desktop/src/app/contrib/wiring.tsx
@@ -14,6 +14,7 @@ import { type CSSProperties, lazy, type ReactNode, Suspense, useCallback, useEff
import { useLocation, useNavigate } from 'react-router-dom'
import { formatRefValue } from '@/components/assistant-ui/directive-text'
+import { $newSessionTabAction } from '@/components/pane-shell/tree/store'
import { BootFailureOverlay } from '@/components/boot-failure-overlay'
import { DesktopInstallOverlay } from '@/components/desktop-install-overlay'
import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overlay'
@@ -709,15 +710,33 @@ export function ContribWiring({ children }: { children: ReactNode }) {
}
}, [])
+ // The tab-strip "+" and ⌘T share one action: open a new session as its own
+ // tab (stacked into the workspace zone) WITHOUT polluting the session list.
+ // Created `listed: false`, so each new tab's in-memory session stays out of
+ // the sidebar until its first message persists a turn and a refresh surfaces
+ // it — Cursor-style. Every click opens a fresh "New session" tab (multiple
+ // empty tabs are fine since none touch the session list).
+ const openNewSessionTab = useCallback(() => {
+ void openNewSessionTile('center', { listed: false })
+ }, [openNewSessionTile])
+
// Single global listener for every rebindable hotkey plus the on-screen
// keybind editor's capture mode (same as DesktopController).
useKeybinds({
- openNewSessionTab: () => void openNewSessionTile('center'),
+ openNewSessionTab,
startFreshSession: startFreshSessionDraft,
toggleCommandCenter,
toggleSelectedPin
})
+ // Register the tab-strip "+" action (the generic renderer stays
+ // session-agnostic; null until wired hides the glyph).
+ useEffect(() => {
+ $newSessionTabAction.set(openNewSessionTab)
+
+ return () => $newSessionTabAction.set(null)
+ }, [openNewSessionTab])
+
// The controller's entire callback surface, gathered into the stable
// `actions` bag. `nextActions` is TS-checked against WiringActions each
// render; its fields are copied into the ref object so `actions` keeps one
diff --git a/apps/desktop/src/app/hooks/use-keybinds.ts b/apps/desktop/src/app/hooks/use-keybinds.ts
index 2c46da2d2c62..e4c84963d6b5 100644
--- a/apps/desktop/src/app/hooks/use-keybinds.ts
+++ b/apps/desktop/src/app/hooks/use-keybinds.ts
@@ -181,10 +181,12 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
'view.closeTerminal': () => $terminalTakeover.get() && closeActiveTerminal(),
'view.flipPanes': togglePanesFlipped,
// ⌘W: close the focused tab (terminal / preview target / zone tree tab).
- // On macOS the menu accelerator owns ⌘W and routes through the same
+ // On the main tab with session tabs stacked, it shifts the next one in —
+ // the loader navigates to that session's route (loads it into main). On
+ // macOS the menu accelerator owns ⌘W and routes through the same
// closeActiveTab via IPC (see use-desktop-integrations); this binding is
// the Win/Linux path where ⌘W reaches the renderer directly.
- 'view.closeTab': () => void closeActiveTab(),
+ 'view.closeTab': () => void closeActiveTab(id => navigate(sessionRoute(id))),
'view.reopenTab': reopenLastClosedTile,
'appearance.toggleMode': () => setMode(resolvedMode === 'dark' ? 'light' : 'dark'),
diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts
index e0ca3084cd08..d1775f4846b1 100644
--- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts
+++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts
@@ -457,10 +457,19 @@ export function useSessionActions({
)
/** Create a fresh session and open it as a tile — leaves the primary chat alone.
- * Used by the New session row's "Open in split" menu (and any future
- * "new chat beside" affordance). */
+ * Used by the New session row's "Open in split" menu and the tab-strip "+".
+ *
+ * `listed` (default true) controls sidebar visibility. A brand-new backend
+ * session is IN-MEMORY only until its first turn persists a row, so
+ * `listSessions(min_messages=1)` already hides an unused one — the sidebar
+ * pollution comes solely from the optimistic upsert here. The tab-strip "+"
+ * passes `listed: false` so an unused new tab never clutters the session
+ * list (Cursor-style draft tab); it surfaces on the next refresh once the
+ * first message persists a turn. "Open in split" keeps the listed behavior. */
const openNewSessionTile = useCallback(
- async (dir: TileDock = 'right') => {
+ async (dir: TileDock = 'right', options?: { listed?: boolean }) => {
+ const listed = options?.listed ?? true
+
try {
// Fresh tile → the resolved new-session cwd (project/default), not the
// primary composer's live cwd.
@@ -476,17 +485,25 @@ export function useSessionActions({
}
createdThisRun.add(stored)
- // Seed the sidebar + per-runtime cache, but DON'T steal the primary
- // selection — this session lives in the tile. Prime it with the create
- // runtime so the tile skips a redundant resume.
- upsertOptimisticSession(created, stored, null, null)
+
+ // Seed the per-runtime cache so the tile renders immediately without a
+ // redundant resume. Only add the row to the SIDEBAR when `listed` — an
+ // unlisted (draft) tab stays out of the session list until its first
+ // turn persists and a refresh surfaces it.
+ if (listed) {
+ upsertOptimisticSession(created, stored, null, null)
+ }
+
const runtimeInfo = applyRuntimeInfo(created.info)
updateSessionState(created.session_id, state => (runtimeInfo ? { ...state, ...runtimeInfo } : state), stored)
openSessionTile(stored, dir)
patchSessionTile(stored, { runtimeId: created.session_id })
revealTreePane(`session-tile:${stored}`)
- broadcastSessionsChanged()
+
+ if (listed) {
+ broadcastSessionsChanged()
+ }
} catch (error) {
notifyError(error, copy.createSessionFailed)
}
diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx
index 37bd9a8f01c6..16e2672bbf3e 100644
--- a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx
+++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx
@@ -37,6 +37,7 @@ import {
$hiddenTreePanes,
$layoutTree,
$narrowViewport,
+ $newSessionTabAction,
$treeDragging,
activateTreePane,
closeTreePane,
@@ -162,6 +163,7 @@ export function TreeGroup({
const hiddenPanes = useStore($hiddenTreePanes)
const narrow = useStore($narrowViewport)
+ const newSessionTabAction = useStore($newSessionTabAction)
const paneFor = (id: string) => panes.find(p => p.id === id)
@@ -485,6 +487,23 @@ export function TreeGroup({
// tile tab); the wrapper needs the key since it's the root.
return {chrome.tabWrap ? chrome.tabWrap(tab) : tab}
})}
+
+ {/* Plain "+" after the last tab of the MAIN strip (the workspace
+ zone) — always shown, no tab/button chrome, just the glyph.
+ Creates a new session tab (mirrors ⌘T) via the app-registered
+ action; hidden when unwired or the zone is minimized. */}
+ {node.panes.includes('workspace') && newSessionTabAction && !node.minimized && (
+
+ )}
{minimizable && (