From 067cb9e033101bc576d5d32603bded3a3f13eab9 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 22 Jul 2026 22:18:31 -0500 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20shift=20next=20tab=20into=20ma?= =?UTF-8?q?in=20on=20=E2=8C=98W=20+=20always-shown=20new-session=20tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ⌘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 --- apps/desktop/src/app/chat/close-tab.ts | 34 +++++++++++- apps/desktop/src/app/chat/session-tile.tsx | 52 ++++++++++++++++++- .../contrib/hooks/use-desktop-integrations.ts | 6 ++- apps/desktop/src/app/contrib/wiring.tsx | 21 +++++++- apps/desktop/src/app/hooks/use-keybinds.ts | 6 ++- .../hooks/use-session-actions/index.ts | 33 +++++++++--- .../pane-shell/tree/renderer/tree-group.tsx | 19 +++++++ .../src/components/pane-shell/tree/store.ts | 7 +++ apps/desktop/src/i18n/en.ts | 1 + apps/desktop/src/i18n/ja.ts | 1 + apps/desktop/src/i18n/types.ts | 1 + apps/desktop/src/i18n/zh-hant.ts | 1 + apps/desktop/src/i18n/zh.ts | 1 + apps/desktop/src/store/session-states.ts | 31 +++++++++++ 14 files changed, 198 insertions(+), 16 deletions(-) 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 && (