mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
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:
parent
5c4d358a7e
commit
067cb9e033
14 changed files with 198 additions and 16 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
/** ⌘1…⌘9: 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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -2362,6 +2362,7 @@ export const ja = defineLocale({
|
|||
closeOthers: '他を閉じる',
|
||||
closeToRight: '右側を閉じる',
|
||||
closeAll: 'すべて閉じる',
|
||||
newSessionTab: '新しいセッションタブ',
|
||||
split: dir => `${dir}に分割`,
|
||||
move: dir => `${dir}へ移動`,
|
||||
dirUp: '上',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2290,6 +2290,7 @@ export const zhHant = defineLocale({
|
|||
closeOthers: '關閉其他',
|
||||
closeToRight: '關閉右側',
|
||||
closeAll: '全部關閉',
|
||||
newSessionTab: '新增工作階段分頁',
|
||||
split: dir => `向${dir}分割`,
|
||||
move: dir => `向${dir}移動`,
|
||||
dirUp: '上',
|
||||
|
|
|
|||
|
|
@ -2604,6 +2604,7 @@ export const zh: Translations = {
|
|||
closeOthers: '关闭其他',
|
||||
closeToRight: '关闭右侧',
|
||||
closeAll: '全部关闭',
|
||||
newSessionTab: '新建会话标签',
|
||||
split: dir => `向${dir}拆分`,
|
||||
move: dir => `向${dir}移动`,
|
||||
dirUp: '上',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue