hermes-agent/apps/desktop/src/app/chat/close-tab.test.ts
Brooklyn Nicholson c7b021ca48 fix(desktop): closing the last main tab lands on New session
The workspace pane can't leave the tree, so "close the main tab" only ever had
one answer wired: shift the next stacked session in. With main as the only tab
there was nothing to shift and ⌘W dead-ended on the tab the user was looking
at.

closeWorkspaceTab is now the one answer for every entry point — stacked
session still wins, and with nothing stacked main drops to a fresh New session
draft. A blank draft and a full-page view stay no-ops: a blank draft already
IS the post-close state.
2026-07-30 21:43:15 -05:00

138 lines
4.3 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const closeFocusedSessionTab = vi.fn(() => false)
const nextSessionTileForWorkspace = vi.fn<() => null | string>(() => null)
const closeSessionTile = vi.fn()
const requestFreshSession = vi.fn()
vi.mock('@/components/pane-shell/tree/store', () => ({
closeFocusedSessionTab: () => closeFocusedSessionTab()
}))
vi.mock('@/store/session-states', () => ({
closeSessionTile: (...args: unknown[]) => closeSessionTile(...args),
nextSessionTileForWorkspace: () => nextSessionTileForWorkspace()
}))
vi.mock('@/store/profile', () => ({
requestFreshSession: () => requestFreshSession()
}))
import { $rightRailActiveTabId } from '@/store/layout'
import { $previewTabs, closeRightRail, openPreview, type PreviewTarget } from '@/store/preview'
import { $activeSessionId, $selectedStoredSessionId } from '@/store/session'
import { $workspaceIsPage } from '../routes'
import { closeActiveTab, closeWorkspaceTab } from './close-tab'
function fileTarget(path: string): PreviewTarget {
return {
kind: 'file',
label: path,
path,
previewKind: 'text',
source: path,
url: `file://${path}`
}
}
/** Main is holding a loaded chat and nothing else is stacked with it. */
function loadedMainOnly() {
$selectedStoredSessionId.set('stored-a')
$activeSessionId.set('runtime-a')
}
beforeEach(() => {
vi.stubGlobal('document', { activeElement: null })
closeRightRail()
window.localStorage.clear()
$selectedStoredSessionId.set(null)
$activeSessionId.set(null)
$workspaceIsPage.set(false)
closeFocusedSessionTab.mockReturnValue(false)
nextSessionTileForWorkspace.mockReturnValue(null)
vi.clearAllMocks()
})
afterEach(() => {
vi.unstubAllGlobals()
closeRightRail()
window.localStorage.clear()
})
describe('closeActiveTab', () => {
it('closes the active file preview tab (⌘W happy path)', () => {
openPreview(fileTarget('/work/notes.md'), 'manual')
expect($previewTabs.get()).toHaveLength(1)
expect($rightRailActiveTabId.get()).toBe('file:file:///work/notes.md')
expect(closeActiveTab()).toBe(true)
expect($previewTabs.get()).toHaveLength(0)
})
it('closes the visible tab when the active selection points at a tab that is gone', () => {
// The rail falls back to tabs[0] until React syncs the selection, so ⌘W has
// to act on what is actually on screen rather than no-op'ing.
openPreview(fileTarget('/work/notes.md'), 'manual')
$rightRailActiveTabId.set('file:file:///work/stale.md')
expect($previewTabs.get()).toHaveLength(1)
expect(closeActiveTab()).toBe(true)
expect($previewTabs.get()).toHaveLength(0)
})
})
/**
* The main tab's own close. The workspace pane can never leave the tree, so
* every answer here is about what FILLS it — a stacked session, or an empty
* draft. The gesture used to dead-end whenever main was the only tab.
*/
describe('closeWorkspaceTab', () => {
it('shifts the next stacked session into main', () => {
loadedMainOnly()
nextSessionTileForWorkspace.mockReturnValue('stored-b')
const load = vi.fn()
expect(closeWorkspaceTab(load)).toBe(true)
expect(closeSessionTile).toHaveBeenCalledWith('stored-b')
expect(load).toHaveBeenCalledWith('stored-b')
// Promotion refills main — it must not ALSO blank it.
expect(requestFreshSession).not.toHaveBeenCalled()
})
it('drops a lone loaded main to a fresh draft', () => {
loadedMainOnly()
expect(closeWorkspaceTab(vi.fn())).toBe(true)
expect(requestFreshSession).toHaveBeenCalledTimes(1)
})
it('empties main even with no session loader wired', () => {
loadedMainOnly()
expect(closeWorkspaceTab()).toBe(true)
expect(requestFreshSession).toHaveBeenCalledTimes(1)
})
it('is a no-op on a blank draft — that IS the post-close state', () => {
expect(closeWorkspaceTab(vi.fn())).toBe(false)
expect(requestFreshSession).not.toHaveBeenCalled()
})
it('is a no-op over a full-page view, which owns no chat tab', () => {
loadedMainOnly()
$workspaceIsPage.set(true)
expect(closeWorkspaceTab(vi.fn())).toBe(false)
expect(requestFreshSession).not.toHaveBeenCalled()
})
it('⌘W reaches it once the terminal, rail and zone tabs pass', () => {
loadedMainOnly()
expect(closeActiveTab(vi.fn())).toBe(true)
expect(requestFreshSession).toHaveBeenCalledTimes(1)
})
})