From eb545ddea9f5b6b59011eef0e743ca1be826b1f1 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 1 Aug 2026 02:15:02 -0500 Subject: [PATCH 1/2] =?UTF-8?q?feat(desktop):=20=E2=8C=98-click=20closes?= =?UTF-8?q?=20a=20terminal=20rail=20tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same gesture the pane tabs already carry — isMetaClose hoists from pane-tab.tsx into lib/middle-click.ts beside its sibling middle-click gesture, so the two surfaces share one predicate instead of drifting. --- .../src/app/right-sidebar/terminal/rail.test.tsx | 16 ++++++++++++++++ .../src/app/right-sidebar/terminal/rail.tsx | 5 +++-- apps/desktop/src/components/ui/pane-tab.tsx | 8 +------- apps/desktop/src/lib/middle-click.ts | 6 ++++++ 4 files changed, 26 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/app/right-sidebar/terminal/rail.test.tsx b/apps/desktop/src/app/right-sidebar/terminal/rail.test.tsx index 60b2113916..baf5874bb3 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/rail.test.tsx +++ b/apps/desktop/src/app/right-sidebar/terminal/rail.test.tsx @@ -33,4 +33,20 @@ describe('TerminalRail', () => { expect(label?.classList.contains('inline-flex')).toBe(true) expect(label?.classList.contains('flex')).toBe(false) }) + + it('⌘-click closes the tab; a plain click selects it', () => { + $terminals.set([ + ...$terminals.get(), + { auto: true, cwd: 'C:\\repo', id: 'term-2', kind: 'user', title: 'zsh' } + ]) + + render() + + fireEvent.click(screen.getByRole('tab', { name: '2. zsh' }), { metaKey: true }) + expect($terminals.get().map(term => term.id)).toEqual(['term-1']) + + fireEvent.click(screen.getByRole('tab', { name: '1. PowerShell' })) + expect($activeTerminalId.get()).toBe('term-1') + expect($terminals.get()).toHaveLength(1) + }) }) diff --git a/apps/desktop/src/app/right-sidebar/terminal/rail.tsx b/apps/desktop/src/app/right-sidebar/terminal/rail.tsx index 7b5aee0ffa..d5e654b31b 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/rail.tsx +++ b/apps/desktop/src/app/right-sidebar/terminal/rail.tsx @@ -11,7 +11,7 @@ import { import { Tip, TipHintLabel } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' import { formatCombo } from '@/lib/keybinds/combo' -import { middleClickHandlers } from '@/lib/middle-click' +import { isMetaClose, middleClickHandlers } from '@/lib/middle-click' import { cn } from '@/lib/utils' import { $bindings } from '@/store/keybinds' @@ -132,7 +132,8 @@ function TerminalRailItem({ active, canCloseOthers, index, term, toggleHint }: T : 'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground' )} {...middleClickHandlers(() => closeTerminal(term.id))} - onClick={() => selectTerminal(term.id)} + // ⌘-click closes (the pane-tab gesture); a plain click selects. + onClick={event => (isMetaClose(event) ? closeTerminal(term.id) : selectTerminal(term.id))} role="tab" type="button" > diff --git a/apps/desktop/src/components/ui/pane-tab.tsx b/apps/desktop/src/components/ui/pane-tab.tsx index 87545f018c..47aad2614d 100644 --- a/apps/desktop/src/components/ui/pane-tab.tsx +++ b/apps/desktop/src/components/ui/pane-tab.tsx @@ -1,6 +1,6 @@ import * as React from 'react' -import { middleClickHandlers } from '@/lib/middle-click' +import { isMetaClose, middleClickHandlers } from '@/lib/middle-click' import { cn } from '@/lib/utils' /** Inset stroke for a vertical tab rail — content-facing edge. */ @@ -43,12 +43,6 @@ interface PaneTabProps extends React.ComponentProps<'div'> { side?: 'left' | 'right' } -/** ⌘-click (metaKey + primary button) — the Mac has no middle button, so this - * is the trackpad equivalent of middle-click-to-close. Guarded on metaKey so - * it never collides with left-click (activate/drag) or ⌃-click (macOS context - * menu). */ -const isMetaClose = (event: { button: number; metaKey: boolean }) => event.button === 0 && event.metaKey - /** * Editor tab shell — preview rail + zone headers + collapsed vertical rails. * diff --git a/apps/desktop/src/lib/middle-click.ts b/apps/desktop/src/lib/middle-click.ts index bba5e58743..5d31cdac13 100644 --- a/apps/desktop/src/lib/middle-click.ts +++ b/apps/desktop/src/lib/middle-click.ts @@ -3,6 +3,12 @@ import type * as React from 'react' /** `MouseEvent.button` for the middle (wheel) button. */ const MIDDLE_BUTTON = 1 +/** ⌘-click (metaKey + primary button) — the Mac has no middle button, so this + * is the trackpad equivalent of middle-click-to-close. Guarded on metaKey so + * it never collides with left-click (activate/drag) or ⌃-click (macOS context + * menu). */ +export const isMetaClose = (event: { button: number; metaKey: boolean }) => event.button === 0 && event.metaKey + /** Where the current middle press started. One pointer holds one button, so a * single slot is the whole state, and it's only ever compared by identity in * the pointerup right after — a value left behind by a press released From c450fb931d4610c0aeb6946e9a3a18c6e0470d8e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 1 Aug 2026 02:15:02 -0500 Subject: [PATCH 2/2] feat(desktop): switching sessions re-selects the terminal tab in its cwd A $currentCwd listener in the terminal store picks the user tab whose live shell cwd (restoreCwd, falling back to launch dir) matches the session's workspace. Selection only: no tab is created, closed, or revealed; detached sessions and unmatched cwds leave the rail alone, and an already-matching active tab keeps focus. --- .../right-sidebar/terminal/terminals.test.ts | 71 ++++++++++++++++++- .../app/right-sidebar/terminal/terminals.ts | 42 ++++++++++- 2 files changed, 110 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/app/right-sidebar/terminal/terminals.test.ts b/apps/desktop/src/app/right-sidebar/terminal/terminals.test.ts index eb99e7a782..9a35fcc926 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/terminals.test.ts +++ b/apps/desktop/src/app/right-sidebar/terminal/terminals.test.ts @@ -4,11 +4,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const STORAGE_KEY = 'hermes.desktop.terminals.v1' async function loadTerminalStore() { + const $currentCwd = atom('/workspace') + vi.doMock('@/store/session', () => ({ - $currentCwd: atom('/workspace') + $currentCwd })) - return import('./terminals') + return { ...(await import('./terminals')), $currentCwd } } describe('terminal store persistence', () => { @@ -121,3 +123,68 @@ describe('terminal store persistence', () => { expect($terminals.get().find(term => term.id === userId)?.restoreCwd).toBeUndefined() }) }) + +describe('session cwd → terminal tab linking', () => { + beforeEach(() => { + window.localStorage.clear() + vi.resetModules() + }) + + it('re-selects the tab already pointed at the new session cwd (trailing slash tolerated)', async () => { + const { $activeTerminalId, $currentCwd, createTerminal } = await loadTerminalStore() + + const repoTab = createTerminal('/repo') + const otherTab = createTerminal('/elsewhere') + expect($activeTerminalId.get()).toBe(otherTab) + + $currentCwd.set('/repo/') + expect($activeTerminalId.get()).toBe(repoTab) + }) + + it('matches the live shell cwd (restoreCwd) over the launch dir', async () => { + const { $activeTerminalId, $currentCwd, createTerminal, updateTerminalRestoreCwd } = await loadTerminalStore() + + const movedTab = createTerminal('/repo') + updateTerminalRestoreCwd(movedTab, '/repo/packages/api') + const otherTab = createTerminal('/elsewhere') + expect($activeTerminalId.get()).toBe(otherTab) + + $currentCwd.set('/repo/packages/api') + expect($activeTerminalId.get()).toBe(movedTab) + + // The launch dir no longer describes where that shell lives. + $currentCwd.set('/repo') + expect($activeTerminalId.get()).toBe(movedTab) + }) + + it('leaves the active tab alone when no tab lives in the session cwd or the cwd is empty', async () => { + const { $activeTerminalId, $currentCwd, createTerminal } = await loadTerminalStore() + + createTerminal('/repo') + const activeTab = createTerminal('/elsewhere') + + $currentCwd.set('/unrelated') + expect($activeTerminalId.get()).toBe(activeTab) + + $currentCwd.set('') + expect($activeTerminalId.get()).toBe(activeTab) + }) + + it('stays put when the active tab already lives in the target cwd, and never matches agent tabs', async () => { + const { $activeTerminalId, $currentCwd, createTerminal, ensureAgentTerminal, selectTerminal } = + await loadTerminalStore() + + const first = createTerminal('/repo') + const second = createTerminal('/repo') + ensureAgentTerminal('proc-1', 'background task') + selectTerminal(second) + + // Both tabs match; the one already active keeps focus (no first-match steal). + $currentCwd.set('/repo') + expect($activeTerminalId.get()).toBe(second) + + selectTerminal(first) + $currentCwd.set('/repo') + expect($activeTerminalId.get()).toBe(first) + }) +}) diff --git a/apps/desktop/src/app/right-sidebar/terminal/terminals.ts b/apps/desktop/src/app/right-sidebar/terminal/terminals.ts index c86f57aeea..19b560f49d 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/terminals.ts +++ b/apps/desktop/src/app/right-sidebar/terminal/terminals.ts @@ -17,7 +17,8 @@ export interface TerminalEntry { /** Working directory, snapshotted once at creation. Terminals live outside * session/project state — the only thing they inherit is this initial cwd * (the project root if opened in one, else the backend's default). Switching - * sessions never moves or recreates a terminal. */ + * sessions never moves or recreates a terminal; at most it re-SELECTS a tab + * already pointed at the session's cwd (see the $currentCwd listener). */ cwd: string /** Last observed working directory of the live shell (tracked via the PTY * cwd probe / OSC 7). Used to reopen the tab where the user last `cd`'d @@ -223,6 +224,45 @@ export function selectTerminal(id: string): void { } } +// Compare-ready form of a directory path: trimmed, trailing separators dropped +// (keeping a bare root intact) so `/repo/` and `/repo` are the same place. +const normalizePath = (value: string) => { + const trimmed = value.trim() + + return trimmed.length > 1 ? trimmed.replace(/[\\/]+$/, '') || trimmed : trimmed +} + +/** The directory a tab points at right now — the live shell cwd once observed + * (survives a `cd`), else the launch dir. */ +const terminalCwd = (term: TerminalEntry) => normalizePath(term.restoreCwd || term.cwd) + +// Session ↔ terminal linking. Entering a session whose cwd already has a user +// terminal pointed at it re-selects that tab, so the terminal pane follows the +// workspace you're in. Selection ONLY — it never creates a shell, never closes +// one, and never reveals the pane; a detached session (empty cwd) or a cwd no +// tab lives in leaves the tabs exactly where they were. `listen` (not +// `subscribe`) so boot keeps the persisted active tab. +$currentCwd.listen(cwd => { + const target = normalizePath(cwd) + + if (!target) { + return + } + + const list = $terminals.get() + const active = list.find(term => term.id === $activeTerminalId.get()) + + if (active?.kind === 'user' && terminalCwd(active) === target) { + return + } + + const match = list.find(term => term.kind === 'user' && terminalCwd(term) === target) + + if (match) { + $activeTerminalId.set(match.id) + } +}) + /** Move the active tab by `direction` (+1 next / -1 prev), wrapping around. */ export function cycleTerminal(direction: 1 | -1): void { const list = $terminals.get()