mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
Merge pull request #73218 from NousResearch/bb/focused-zone-tabs
fix(desktop): make the tab verbs follow the focused zone like ⌘1-9
This commit is contained in:
commit
e4564586bc
6 changed files with 170 additions and 26 deletions
|
|
@ -0,0 +1,83 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// ⌘W / ⌘T / ⌘⇧T / the strip "+" used to hardcode the workspace's zone while
|
||||
// ⌘1…⌘9 followed the interacted zone — so every tab verb missed a SECOND chat
|
||||
// zone the user was working in. They now resolve the same focused zone.
|
||||
|
||||
describe('focused chat zone drives the tab verbs', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
async function setup() {
|
||||
const tree = await import('@/components/pane-shell/tree/store')
|
||||
const model = await import('@/components/pane-shell/tree/model')
|
||||
const { registry } = await import('@/contrib/registry')
|
||||
|
||||
for (const id of ['workspace', 'files', 'session-tile:a', 'session-tile:b']) {
|
||||
registry.register({
|
||||
area: 'panes',
|
||||
data: id === 'workspace' ? { placement: 'main', uncloseable: true } : { placement: 'main' },
|
||||
id,
|
||||
render: () => null,
|
||||
title: id
|
||||
})
|
||||
}
|
||||
|
||||
// Two chat zones side by side: main holds the workspace, the second holds
|
||||
// its own session tabs (a split-off stack).
|
||||
tree.declareDefaultTree(
|
||||
model.split('row', [
|
||||
model.group(['workspace'], { active: 'workspace', id: 'grp-main' }),
|
||||
model.group(['session-tile:a', 'session-tile:b'], { active: 'session-tile:b', id: 'grp-side' })
|
||||
])
|
||||
)
|
||||
|
||||
return { model, tree }
|
||||
}
|
||||
|
||||
it('⌘T anchors the new tab to the focused zone, not always the workspace', async () => {
|
||||
const { tree } = await setup()
|
||||
|
||||
tree.noteActiveTreeGroup('grp-side')
|
||||
expect(tree.focusedSessionTabAnchor()).toBe('session-tile:b')
|
||||
|
||||
tree.noteActiveTreeGroup('grp-main')
|
||||
expect(tree.focusedSessionTabAnchor()).toBe('workspace')
|
||||
})
|
||||
|
||||
it('a non-chat zone (files/terminal focus) falls back to the workspace', async () => {
|
||||
const { model, tree } = await setup()
|
||||
|
||||
tree.declareDefaultTree(
|
||||
model.split('row', [
|
||||
model.group(['workspace'], { active: 'workspace', id: 'grp-main' }),
|
||||
model.group(['files'], { active: 'files', id: 'grp-files' })
|
||||
])
|
||||
)
|
||||
tree.noteActiveTreeGroup('grp-files')
|
||||
|
||||
expect(tree.focusedSessionTabAnchor()).toBe('workspace')
|
||||
// ⌘W must not close the file tree just because it holds focus.
|
||||
expect(tree.closeFocusedSessionTab()).toBe(false)
|
||||
})
|
||||
|
||||
it('⌘W closes the focused zone active tab and leaves the workspace alone', async () => {
|
||||
const { model, tree } = await setup()
|
||||
|
||||
tree.noteActiveTreeGroup('grp-side')
|
||||
expect(tree.closeFocusedSessionTab()).toBe(true)
|
||||
expect(model.allPaneIds(tree.$layoutTree.get()!)).not.toContain('session-tile:b')
|
||||
|
||||
// Back on main: the uncloseable workspace is a no-op (the caller promotes
|
||||
// a stacked session instead of closing the window).
|
||||
tree.noteActiveTreeGroup('grp-main')
|
||||
expect(tree.closeFocusedSessionTab()).toBe(false)
|
||||
expect(model.allPaneIds(tree.$layoutTree.get()!)).toContain('workspace')
|
||||
})
|
||||
})
|
||||
|
|
@ -39,7 +39,9 @@ import {
|
|||
collapseTreePane,
|
||||
dismissTreePane,
|
||||
isCollapsePane,
|
||||
isSessionStripPane,
|
||||
moveTreePane,
|
||||
noteActiveTreeGroup,
|
||||
restoreTreePane,
|
||||
SESSION_TILE_DRAG,
|
||||
setTreeGroupHeaderHidden,
|
||||
|
|
@ -507,16 +509,26 @@ export function TreeGroup({
|
|||
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 && (
|
||||
{/* Plain "+" after the last tab of a CHAT strip (the workspace
|
||||
zone, or any zone holding session tabs) — always shown, no
|
||||
tab/button chrome, just the glyph. Creates a new session tab
|
||||
(mirrors ⌘T) via the app-registered action; the pointerdown
|
||||
focuses this zone first, so the tab lands in THIS strip.
|
||||
Hidden when unwired or the zone is minimized. */}
|
||||
{shown.some(isSessionStripPane) && 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()}
|
||||
onPointerDown={e => {
|
||||
e.stopPropagation()
|
||||
// The action docks into the FOCUSED chat zone; clicking a
|
||||
// background strip's "+" must make THAT zone the focused
|
||||
// one first, or the tab opens in whichever zone was last
|
||||
// clicked. (pointerdown's own focus tracking would land
|
||||
// after the click handler reads the anchor.)
|
||||
noteActiveTreeGroup(node.id)
|
||||
}}
|
||||
title={t.zones.newSessionTab}
|
||||
type="button"
|
||||
>
|
||||
|
|
@ -716,7 +728,7 @@ function ZoneDropOverlay({ node }: { node: GroupNode }) {
|
|||
// now (stack into its tabs / split its edges); only a CHAT zone's center is
|
||||
// a link-to-chat (the composer overlay owns that visual).
|
||||
const sessionDrag = dragging === SESSION_TILE_DRAG
|
||||
const chatZone = node.panes.some(p => p === 'workspace' || p.startsWith('session-tile:'))
|
||||
const chatZone = node.panes.some(isSessionStripPane)
|
||||
|
||||
const isDragSource = node.panes.includes(dragging)
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
findGroup,
|
||||
findGroupOfPane,
|
||||
groupLeafIds,
|
||||
type GroupNode,
|
||||
insertAtGroup,
|
||||
isLayoutNode,
|
||||
type LayoutNode,
|
||||
|
|
@ -282,12 +283,49 @@ const isUncloseablePane = (paneId: string): boolean =>
|
|||
(registry.getArea('panes').find(c => c.id === paneId)?.data as { uncloseable?: boolean } | undefined)?.uncloseable
|
||||
)
|
||||
|
||||
/** ⌘W "main tabs always": close the MAIN (workspace) zone's active tab, unless
|
||||
* it's the uncloseable workspace itself. Returns false when there's nothing to
|
||||
* close, so ⌘W stays a no-op — it never closes the window. */
|
||||
export function closeWorkspaceTab(): boolean {
|
||||
/** A pane that belongs to a CHAT tab strip — the workspace or a session tile. */
|
||||
export const isSessionStripPane = (paneId: string): boolean =>
|
||||
paneId === 'workspace' || paneId.startsWith('session-tile:')
|
||||
|
||||
/** The zone the session-tab verbs (⌘W / ⌘T / ⌘⇧T / the strip's "+") act on:
|
||||
* the FOCUSED zone when it hosts a chat strip, else the workspace's zone.
|
||||
* Same source ⌘1…⌘9 indexes ($activeTreeGroup), so the number keys and the
|
||||
* tab verbs can't disagree about which strip is "the" strip. Focus parked in
|
||||
* the sidebar / terminal / files must NOT retarget them — those zones fall
|
||||
* back to main rather than letting ⌘W close the file tree. */
|
||||
function focusedSessionGroup(): GroupNode | null {
|
||||
const tree = $layoutTree.get()
|
||||
const active = tree ? findGroupOfPane(tree, 'workspace')?.active : null
|
||||
|
||||
if (!tree) {
|
||||
return null
|
||||
}
|
||||
|
||||
const groupId = $activeTreeGroup.get()
|
||||
const focused = groupId ? findGroup(tree, groupId) : null
|
||||
|
||||
return focused?.panes.some(isSessionStripPane) ? focused : findGroupOfPane(tree, 'workspace')
|
||||
}
|
||||
|
||||
/** The pane a NEW session tab should dock beside (⌘T): the focused chat zone's
|
||||
* active session pane, else its first. Null when no zone hosts a chat strip —
|
||||
* the caller falls back to the workspace. */
|
||||
export function focusedSessionTabAnchor(): null | string {
|
||||
const group = focusedSessionGroup()
|
||||
|
||||
if (!group) {
|
||||
return null
|
||||
}
|
||||
|
||||
const active = group.active
|
||||
|
||||
return active && isSessionStripPane(active) ? active : (group.panes.find(isSessionStripPane) ?? null)
|
||||
}
|
||||
|
||||
/** ⌘W: close the FOCUSED chat zone's active tab, unless it's the uncloseable
|
||||
* workspace itself. Returns false when there's nothing to close, so ⌘W stays a
|
||||
* no-op — it never closes the window. */
|
||||
export function closeFocusedSessionTab(): boolean {
|
||||
const active = focusedSessionGroup()?.active
|
||||
|
||||
if (!active || isUncloseablePane(active)) {
|
||||
return false
|
||||
|
|
@ -416,7 +454,7 @@ export function cycleTreeTabInFocusedZone(direction: 1 | -1): boolean {
|
|||
const group = groupId && tree ? findGroup(tree, groupId) : null
|
||||
const panes = group ? shownPanesInGroup(group) : []
|
||||
|
||||
if (panes.length < 2 || !panes.some(id => id === 'workspace' || id.startsWith('session-tile:'))) {
|
||||
if (panes.length < 2 || !panes.some(isSessionStripPane)) {
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue