feat(desktop): shift next tab into main on ⌘W + always-shown new-session tab

- ⌘W on the main tab promotes the next stacked session tab into main
- '+' / ⌘T open a new 'New session' tab, unlisted until first message
  (no sidebar pollution); multiple new-session tabs allowed
- tile resolves its own row via by-id lookup once it has a message
This commit is contained in:
Brooklyn Nicholson 2026-07-22 22:18:31 -05:00
parent 5c4d358a7e
commit 067cb9e033
14 changed files with 198 additions and 16 deletions

View file

@ -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
}

View file

@ -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

View file

@ -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(() => {

View file

@ -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

View file

@ -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'),

View file

@ -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)
}

View file

@ -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 <Fragment key={paneId}>{chrome.tabWrap ? chrome.tabWrap(tab) : tab}</Fragment>
})}
{/* 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 && (
<button
aria-label={t.zones.newSessionTab}
className="grid size-7 shrink-0 place-items-center self-center bg-transparent text-(--ui-text-quaternary) transition-colors hover:text-foreground [-webkit-app-region:no-drag]"
onClick={() => newSessionTabAction()}
onPointerDown={e => e.stopPropagation()}
title={t.zones.newSessionTab}
type="button"
>
<Codicon name="add" size="0.8125rem" />
</button>
)}
</div>
{minimizable && (
<button

View file

@ -343,6 +343,13 @@ export function treePanesWithPrefix(prefix: string): string[] {
return tree ? allPaneIds(tree).filter(id => id.startsWith(prefix)) : []
}
/** The main tab strip's "+": open a new session as its own tab (reusing an
* already-open unused tab when one exists, so repeated clicks don't pile up
* empty sessions). The app wiring registers the concrete action so this
* generic renderer stays session-agnostic; null until wired (the "+" hides).
* An atom so the strip re-renders when the action becomes available. */
export const $newSessionTabAction = atom<(() => void) | null>(null)
/** 19: activate the Nth tab of the FOCUSED zone (the interaction tracker's
* group), but only when it's a real tab strip (2 panes). Returns false so the
* caller falls back to its default (profile switch) the number keys mean

View file

@ -2429,6 +2429,7 @@ export const en: Translations = {
closeOthers: 'Close others',
closeToRight: 'Close to the right',
closeAll: 'Close all',
newSessionTab: 'New session tab',
split: dir => `Split ${dir}`,
move: dir => `Move ${dir}`,
dirUp: 'up',

View file

@ -2362,6 +2362,7 @@ export const ja = defineLocale({
closeOthers: '他を閉じる',
closeToRight: '右側を閉じる',
closeAll: 'すべて閉じる',
newSessionTab: '新しいセッションタブ',
split: dir => `${dir}に分割`,
move: dir => `${dir}へ移動`,
dirUp: '上',

View file

@ -2048,6 +2048,7 @@ export interface Translations {
closeOthers: string
closeToRight: string
closeAll: string
newSessionTab: string
split: (dir: string) => string
move: (dir: string) => string
dirUp: string

View file

@ -2290,6 +2290,7 @@ export const zhHant = defineLocale({
closeOthers: '關閉其他',
closeToRight: '關閉右側',
closeAll: '全部關閉',
newSessionTab: '新增工作階段分頁',
split: dir => `${dir}分割`,
move: dir => `${dir}移動`,
dirUp: '上',

View file

@ -2604,6 +2604,7 @@ export const zh: Translations = {
closeOthers: '关闭其他',
closeToRight: '关闭右侧',
closeAll: '全部关闭',
newSessionTab: '新建会话标签',
split: dir => `${dir}拆分`,
move: dir => `${dir}移动`,
dirUp: '上',

View file

@ -540,6 +540,37 @@ export function openSessionTile(
}
}
/** W on the MAIN tab: the next session tab stacked WITH the workspace, to
* shift into main. Walks the workspace group's strip from the workspace tab
* outward (the tab after it first, then wrapping to the ones before), and
* returns the first session tile's stored id. Null when the workspace has no
* session tab stacked beside it (W then stays the no-op it was). */
export function nextSessionTileForWorkspace(): null | string {
const tree = $layoutTree.get()
const group = tree ? findGroupOfPane(tree, 'workspace') : null
if (!group) {
return null
}
const tiles = $sessionTiles.get()
const idx = group.panes.indexOf('workspace')
// After the workspace tab first, then the ones before it (nearest-out).
const ordered = [...group.panes.slice(idx + 1), ...group.panes.slice(0, idx).reverse()]
for (const paneId of ordered) {
if (paneId.startsWith(TILE_PANE_PREFIX)) {
const storedSessionId = paneId.slice(TILE_PANE_PREFIX.length)
if (tiles.some(t => t.storedSessionId === storedSessionId)) {
return storedSessionId
}
}
}
return null
}
/** If a session is already ON SCREEN an open tile OR the one loaded in main
* front its tab (and focus its zone) and return true. A sidebar click on an
* already-open chat JUMPS to its tab instead of reloading it; `false` means the