From d9b4f7b2c1c28cd30e4596eb22059a8618ae99ff Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 17 May 2026 19:50:09 -0700 Subject: [PATCH] Make resource session rows jump to focused terminals - Close the resource popover before activating a terminal session so focus can transfer cleanly. - Preserve worktree reveal behavior and flash the focused pane when a stable pane key matches. - Cover stale mappings, malformed pane keys, and keyboard activation with tests. --- .../status-bar/ResourceUsageStatusSegment.tsx | 36 ++++------ .../resource-session-navigation.test.ts | 72 +++++++++++++++++++ .../status-bar/resource-session-navigation.ts | 40 +++++++++++ 3 files changed, 127 insertions(+), 21 deletions(-) create mode 100644 src/renderer/src/components/status-bar/resource-session-navigation.test.ts create mode 100644 src/renderer/src/components/status-bar/resource-session-navigation.ts diff --git a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx index ebbdd8a460b..4b09a619c59 100644 --- a/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx @@ -40,7 +40,6 @@ import type { AppMemory, UsageValues, Worktree } from '../../../../shared/types' import { ORPHAN_WORKTREE_ID } from '../../../../shared/constants' import { isFolderRepo } from '../../../../shared/repo-kind' import { isWorkspaceOldForCleanup } from '../../../../shared/workspace-cleanup' -import { parsePaneKey } from '../../../../shared/stable-pane-id' import { mergeSnapshotAndSessions, UNATTRIBUTED_REPO_ID, @@ -52,6 +51,10 @@ import { } from './mergeSnapshotAndSessions' import { WorkspaceSpaceCompactPanel } from './WorkspaceSpaceCompactPanel' import { STATUS_BAR_CONTEXT_MENU_EXEMPT_PROPS } from './status-bar-context-menu-policy' +import { + isResourceSessionActivationKey, + navigateResourceSessionToTab +} from './resource-session-navigation' const POLL_MS = 2_000 const SESSIONS_POLL_MS = 10_000 @@ -334,7 +337,7 @@ function SessionRow({ onKeyDown={ clickable ? (e) => { - if (e.key === 'Enter' || e.key === ' ') { + if (isResourceSessionActivationKey(e.key)) { e.preventDefault() handleClick() } @@ -658,7 +661,6 @@ export function ResourceUsageStatusSegment({ const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId) const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) const runtimePaneTitlesByTabId = useAppStore((s) => s.runtimePaneTitlesByTabId) - const setActiveView = useAppStore((s) => s.setActiveView) const openModal = useAppStore((s) => s.openModal) const openSpacePage = useAppStore((s) => s.openSpacePage) const activeView = useAppStore((s) => s.activeView) @@ -949,10 +951,9 @@ export function ResourceUsageStatusSegment({ }) }, []) - // Why: navigation handlers leave the popover open so users can chain - // multiple jumps (e.g. browse worktrees, hop between terminals) without - // having to re-open the popover each time. The popover still closes via - // its own outside-click / Escape handlers. + // Why: worktree navigation leaves the popover open so users can browse the + // tree without reopening it; bound terminal rows close explicitly because + // focus transfer is intentionally suppressed by onFocusOutside below. const navigateToWorktree = useCallback((worktreeId: string): void => { if (worktreeId === ORPHAN_WORKTREE_ID || worktreeId.startsWith(`${UNATTRIBUTED_REPO_ID}::`)) { return @@ -962,21 +963,14 @@ export function ResourceUsageStatusSegment({ const navigateToTab = useCallback( (tabId: string, paneKey: string | null) => { - // Resolve the tab → worktree from the store so we can also reveal the - // worktree in the sidebar before flipping the active tab. - for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { - if (tabs.some((t) => t.id === tabId)) { - activateAndRevealWorktree(worktreeId) - break - } - } - setActiveView('terminal') - // Why: paneKey suffixes are stable UUID leaf ids after replay/reload. - // Legacy numeric keys degrade to tab-only activation instead of guessing. - const parsed = paneKey ? parsePaneKey(paneKey) : null - activateTabAndFocusPane(tabId, parsed?.tabId === tabId ? parsed.leafId : null) + navigateResourceSessionToTab(tabId, paneKey, { + tabsByWorktree, + setOpen, + activateAndRevealWorktree, + activateTabAndFocusPane + }) }, - [tabsByWorktree, setActiveView] + [tabsByWorktree] ) const deleteWorktree = useCallback((worktreeId: string): void => { diff --git a/src/renderer/src/components/status-bar/resource-session-navigation.test.ts b/src/renderer/src/components/status-bar/resource-session-navigation.test.ts new file mode 100644 index 00000000000..d63dea4b78f --- /dev/null +++ b/src/renderer/src/components/status-bar/resource-session-navigation.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest' +import { + isResourceSessionActivationKey, + navigateResourceSessionToTab, + type ResourceSessionNavigationDeps +} from './resource-session-navigation' + +const TAB_ID = 'tab-1' +const OTHER_TAB_ID = 'tab-2' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' + +function makeDeps(events: string[] = []): ResourceSessionNavigationDeps { + return { + tabsByWorktree: { + 'wt-1': [{ id: TAB_ID }], + 'wt-2': [{ id: OTHER_TAB_ID }] + }, + setOpen: vi.fn((open: boolean) => events.push(`open:${open}`)), + activateAndRevealWorktree: vi.fn((worktreeId: string) => { + events.push(`worktree:${worktreeId}`) + }), + activateTabAndFocusPane: vi.fn((tabId: string, leafId: string | null) => { + events.push(`tab:${tabId}:${leafId ?? 'null'}`) + }) + } +} + +describe('resource session navigation', () => { + it('closes the popover first, reveals the owning worktree, and focuses a matching pane', () => { + const events: string[] = [] + const deps = makeDeps(events) + + navigateResourceSessionToTab(TAB_ID, `${TAB_ID}:${LEAF_ID}`, deps) + + expect(events).toEqual([`open:false`, 'worktree:wt-1', `tab:${TAB_ID}:${LEAF_ID}`]) + expect(deps.activateTabAndFocusPane).toHaveBeenCalledWith(TAB_ID, LEAF_ID, { + flashFocusedPane: true + }) + }) + + it('degrades malformed and mismatched pane keys to tab-only activation', () => { + const malformed = makeDeps() + navigateResourceSessionToTab(TAB_ID, `${TAB_ID}:1`, malformed) + expect(malformed.activateTabAndFocusPane).toHaveBeenCalledWith(TAB_ID, null, { + flashFocusedPane: true + }) + + const mismatched = makeDeps() + navigateResourceSessionToTab(TAB_ID, `${OTHER_TAB_ID}:${LEAF_ID}`, mismatched) + expect(mismatched.activateTabAndFocusPane).toHaveBeenCalledWith(TAB_ID, null, { + flashFocusedPane: true + }) + }) + + it('still activates the tab when the worktree mapping is stale', () => { + const deps = makeDeps() + deps.tabsByWorktree = {} + + navigateResourceSessionToTab(TAB_ID, `${TAB_ID}:${LEAF_ID}`, deps) + + expect(deps.activateAndRevealWorktree).not.toHaveBeenCalled() + expect(deps.activateTabAndFocusPane).toHaveBeenCalledWith(TAB_ID, LEAF_ID, { + flashFocusedPane: true + }) + }) + + it('keeps keyboard activation equivalent to click for Enter and Space only', () => { + expect(isResourceSessionActivationKey('Enter')).toBe(true) + expect(isResourceSessionActivationKey(' ')).toBe(true) + expect(isResourceSessionActivationKey('Escape')).toBe(false) + }) +}) diff --git a/src/renderer/src/components/status-bar/resource-session-navigation.ts b/src/renderer/src/components/status-bar/resource-session-navigation.ts new file mode 100644 index 00000000000..b8251ab32cb --- /dev/null +++ b/src/renderer/src/components/status-bar/resource-session-navigation.ts @@ -0,0 +1,40 @@ +import { parsePaneKey } from '../../../../shared/stable-pane-id' + +type TabLookup = Record + +export type ResourceSessionNavigationDeps = { + tabsByWorktree: TabLookup + setOpen: (open: boolean) => void + activateAndRevealWorktree: (worktreeId: string) => unknown + activateTabAndFocusPane: ( + tabId: string, + leafId: string | null, + opts: { flashFocusedPane: true } + ) => void +} + +export function isResourceSessionActivationKey(key: string): boolean { + return key === 'Enter' || key === ' ' +} + +export function navigateResourceSessionToTab( + tabId: string, + paneKey: string | null, + deps: ResourceSessionNavigationDeps +): void { + deps.setOpen(false) + + for (const [worktreeId, tabs] of Object.entries(deps.tabsByWorktree)) { + if (tabs.some((tab) => tab.id === tabId)) { + deps.activateAndRevealWorktree(worktreeId) + break + } + } + + // Why: paneKey suffixes are stable UUID leaf ids after replay/reload. + // Legacy numeric keys degrade to tab-only activation instead of guessing. + const parsed = paneKey ? parsePaneKey(paneKey) : null + deps.activateTabAndFocusPane(tabId, parsed?.tabId === tabId ? parsed.leafId : null, { + flashFocusedPane: true + }) +}