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/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() 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