From f0ce24fa21bf94d4fa4d0f92f009cded80a48115 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 16 May 2026 15:37:10 -0700 Subject: [PATCH] feat: flash focused pane from agent row (#2116) --- src/renderer/src/assets/terminal.css | 31 ++++++ .../components/sidebar/WorktreeCardAgents.tsx | 5 +- .../focus-terminal-pane-event.test.ts | 100 ++++++++++++++++++ .../focus-terminal-pane-event.ts | 17 ++- .../focused-pane-rim-flash.test.ts | 53 ++++++++++ .../terminal-pane/focused-pane-rim-flash.ts | 23 ++++ src/renderer/src/constants/terminal.ts | 2 + .../src/lib/activate-tab-and-focus-pane.ts | 5 +- .../lib/pane-manager/pane-key-resolution.ts | 2 +- 9 files changed, 232 insertions(+), 6 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/focus-terminal-pane-event.test.ts create mode 100644 src/renderer/src/components/terminal-pane/focused-pane-rim-flash.test.ts create mode 100644 src/renderer/src/components/terminal-pane/focused-pane-rim-flash.ts diff --git a/src/renderer/src/assets/terminal.css b/src/renderer/src/assets/terminal.css index d0b32989339..05931b02b59 100644 --- a/src/renderer/src/assets/terminal.css +++ b/src/renderer/src/assets/terminal.css @@ -128,6 +128,37 @@ opacity: 0.4 !important; } +.pane.pane-focus-rim-flash::before { + content: ''; + position: absolute; + inset: 1px; + z-index: 30; + pointer-events: none; + border: 1px solid color-mix(in srgb, var(--ring) 82%, transparent); + border-radius: 3px; + box-shadow: + inset 0 0 0 1px color-mix(in srgb, var(--ring) 44%, transparent), + 0 0 0 2px color-mix(in srgb, var(--ring) 28%, transparent), + 0 0 18px color-mix(in srgb, var(--ring) 28%, transparent); + animation: pane-focus-rim-flash 1.5s ease-out forwards; +} + +@keyframes pane-focus-rim-flash { + 0%, + 72% { + opacity: 1; + } + 100% { + opacity: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .pane.pane-focus-rim-flash::before { + animation: none; + } +} + /* Suppress pointer events on terminals during pane drag so overlay works */ .is-pane-dragging .pane { pointer-events: none; diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx index 8499ea1de4c..6c51309fd3c 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx @@ -106,7 +106,10 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ activateAndRevealWorktree(worktreeId) const tabs = useAppStore.getState().tabsByWorktree[worktreeId] ?? [] if (tabs.some((t) => t.id === tabId)) { - activateTabAndFocusPane(tabId, parsed.leafId, { ackPaneKeyOnSuccess: paneKey }) + activateTabAndFocusPane(tabId, parsed.leafId, { + ackPaneKeyOnSuccess: paneKey, + flashFocusedPane: true + }) } else { dismissStaleAgentRowByKey(paneKey) } diff --git a/src/renderer/src/components/terminal-pane/focus-terminal-pane-event.test.ts b/src/renderer/src/components/terminal-pane/focus-terminal-pane-event.test.ts new file mode 100644 index 00000000000..5253c9e28fe --- /dev/null +++ b/src/renderer/src/components/terminal-pane/focus-terminal-pane-event.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, vi } from 'vitest' +import type { TerminalLeafId } from '../../../../shared/stable-pane-id' +import { handleFocusTerminalPaneDetail } from './focus-terminal-pane-event' + +const LEAF_ID = '11111111-1111-4111-8111-111111111111' as TerminalLeafId +const OTHER_LEAF_ID = '22222222-2222-4222-8222-222222222222' as TerminalLeafId + +class MockClassList { + private classes = new Set() + + add(value: string): void { + this.classes.add(value) + } + + remove(value: string): void { + this.classes.delete(value) + } + + contains(value: string): boolean { + return this.classes.has(value) + } +} + +function createPaneElement(): HTMLElement { + return { classList: new MockClassList() } as unknown as HTMLElement +} + +function createManager(args?: { numericPaneId?: number | null; leafId?: TerminalLeafId }) { + const container = createPaneElement() + const numericPaneId = args?.numericPaneId ?? 7 + const leafId = args?.leafId ?? LEAF_ID + return { + container, + manager: { + getNumericIdForLeaf: vi.fn(() => numericPaneId), + getPanes: vi.fn(() => [ + { + id: 7, + leafId, + container + } + ]), + setActivePane: vi.fn() + } + } +} + +describe('handleFocusTerminalPaneDetail', () => { + it('focuses and flashes only after the target leaf resolves', () => { + const { container, manager } = createManager() + const acknowledgeAgents = vi.fn() + const surfaceStaleAgentRow = vi.fn() + + handleFocusTerminalPaneDetail( + { + tabId: 'tab-1', + leafId: LEAF_ID, + ackPaneKeyOnSuccess: `tab-1:${LEAF_ID}`, + flashFocusedPane: true + }, + { + tabId: 'tab-1', + manager, + acknowledgeAgents, + surfaceStaleAgentRow + } + ) + + expect(manager.setActivePane).toHaveBeenCalledWith(7, { focus: true }) + expect(container.classList.contains('pane-focus-rim-flash')).toBe(true) + expect(acknowledgeAgents).toHaveBeenCalledWith([`tab-1:${LEAF_ID}`]) + expect(surfaceStaleAgentRow).not.toHaveBeenCalled() + }) + + it('does not focus, flash, or ack when the numeric pane no longer owns the leaf', () => { + const { container, manager } = createManager({ leafId: OTHER_LEAF_ID }) + const acknowledgeAgents = vi.fn() + const surfaceStaleAgentRow = vi.fn() + + handleFocusTerminalPaneDetail( + { + tabId: 'tab-1', + leafId: LEAF_ID, + ackPaneKeyOnSuccess: `tab-1:${LEAF_ID}`, + flashFocusedPane: true + }, + { + tabId: 'tab-1', + manager, + acknowledgeAgents, + surfaceStaleAgentRow + } + ) + + expect(manager.setActivePane).not.toHaveBeenCalled() + expect(container.classList.contains('pane-focus-rim-flash')).toBe(false) + expect(acknowledgeAgents).not.toHaveBeenCalled() + expect(surfaceStaleAgentRow).toHaveBeenCalledWith('tab-1', LEAF_ID) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/focus-terminal-pane-event.ts b/src/renderer/src/components/terminal-pane/focus-terminal-pane-event.ts index a55c40c12d9..72497950d4d 100644 --- a/src/renderer/src/components/terminal-pane/focus-terminal-pane-event.ts +++ b/src/renderer/src/components/terminal-pane/focus-terminal-pane-event.ts @@ -1,10 +1,17 @@ import type { FocusTerminalPaneDetail } from '@/constants/terminal' -import type { PaneManager } from '@/lib/pane-manager/pane-manager' +import type { ManagedPane } from '@/lib/pane-manager/pane-manager' import { resolveLeafIdForManager } from '@/lib/pane-manager/pane-key-resolution' +import { flashFocusedPaneRim } from './focused-pane-rim-flash' + +type FocusTerminalPaneManager = { + getNumericIdForLeaf(leafId: string): number | null + getPanes(): Pick[] + setActivePane(paneId: number, opts?: { focus?: boolean }): void +} type FocusTerminalPaneEventDeps = { tabId: string - manager: Pick | null + manager: FocusTerminalPaneManager | null acknowledgeAgents: (paneKeys: string[]) => void surfaceStaleAgentRow: (tabId: string, leafId: string) => void } @@ -33,6 +40,12 @@ export function handleFocusTerminalPaneDetail( return } manager.setActivePane(resolution.numericPaneId, { focus: true }) + if (detail.flashFocusedPane) { + const pane = manager.getPanes().find((candidate) => candidate.id === resolution.numericPaneId) + if (pane) { + flashFocusedPaneRim(pane.container) + } + } if (detail.ackPaneKeyOnSuccess) { acknowledgeAgents([detail.ackPaneKeyOnSuccess]) } diff --git a/src/renderer/src/components/terminal-pane/focused-pane-rim-flash.test.ts b/src/renderer/src/components/terminal-pane/focused-pane-rim-flash.test.ts new file mode 100644 index 00000000000..3293cfe8424 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/focused-pane-rim-flash.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { flashFocusedPaneRim, FOCUSED_PANE_FLASH_MS } from './focused-pane-rim-flash' + +class MockClassList { + private classes = new Set() + + add(value: string): void { + this.classes.add(value) + } + + remove(value: string): void { + this.classes.delete(value) + } + + contains(value: string): boolean { + return this.classes.has(value) + } +} + +function createPaneElement(): HTMLElement { + return { classList: new MockClassList() } as unknown as HTMLElement +} + +describe('flashFocusedPaneRim', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('removes the rim flash class after the highlight duration', () => { + vi.useFakeTimers() + const pane = createPaneElement() + + flashFocusedPaneRim(pane) + + expect(pane.classList.contains('pane-focus-rim-flash')).toBe(true) + vi.advanceTimersByTime(FOCUSED_PANE_FLASH_MS) + expect(pane.classList.contains('pane-focus-rim-flash')).toBe(false) + }) + + it('resets the removal timer when the same pane flashes again', () => { + vi.useFakeTimers() + const pane = createPaneElement() + + flashFocusedPaneRim(pane) + vi.advanceTimersByTime(800) + flashFocusedPaneRim(pane) + vi.advanceTimersByTime(600) + + expect(pane.classList.contains('pane-focus-rim-flash')).toBe(true) + vi.advanceTimersByTime(900) + expect(pane.classList.contains('pane-focus-rim-flash')).toBe(false) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/focused-pane-rim-flash.ts b/src/renderer/src/components/terminal-pane/focused-pane-rim-flash.ts new file mode 100644 index 00000000000..d12e5d6511c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/focused-pane-rim-flash.ts @@ -0,0 +1,23 @@ +const FOCUSED_PANE_FLASH_CLASS = 'pane-focus-rim-flash' +export const FOCUSED_PANE_FLASH_MS = 1_500 + +const flashTimersByPane = new WeakMap>() + +export function flashFocusedPaneRim(paneElement: HTMLElement): void { + const existingTimer = flashTimersByPane.get(paneElement) + if (existingTimer) { + clearTimeout(existingTimer) + } + + // Why: remove before add restarts the CSS animation when the same agent row + // is clicked repeatedly while the previous rim flash is still active. + paneElement.classList.remove(FOCUSED_PANE_FLASH_CLASS) + void paneElement.offsetWidth + paneElement.classList.add(FOCUSED_PANE_FLASH_CLASS) + + const timer = setTimeout(() => { + paneElement.classList.remove(FOCUSED_PANE_FLASH_CLASS) + flashTimersByPane.delete(paneElement) + }, FOCUSED_PANE_FLASH_MS) + flashTimersByPane.set(paneElement, timer) +} diff --git a/src/renderer/src/constants/terminal.ts b/src/renderer/src/constants/terminal.ts index 86b35c48dd1..9590f0d15d2 100644 --- a/src/renderer/src/constants/terminal.ts +++ b/src/renderer/src/constants/terminal.ts @@ -31,6 +31,8 @@ export type FocusTerminalPaneDetail = { leafId: string | null /** Optional paneKey to ack only after the target leaf resolves and focuses. */ ackPaneKeyOnSuccess?: string + /** Briefly lights the resolved pane rim after focus for click-to-locate flows. */ + flashFocusedPane?: boolean } export type PasteTerminalTextDetail = { diff --git a/src/renderer/src/lib/activate-tab-and-focus-pane.ts b/src/renderer/src/lib/activate-tab-and-focus-pane.ts index 2a8ef07106a..f8987285511 100644 --- a/src/renderer/src/lib/activate-tab-and-focus-pane.ts +++ b/src/renderer/src/lib/activate-tab-and-focus-pane.ts @@ -4,7 +4,7 @@ import { FOCUS_TERMINAL_PANE_EVENT, type FocusTerminalPaneDetail } from '@/const export function activateTabAndFocusPane( tabId: string, leafId: string | null, - opts?: { ackPaneKeyOnSuccess?: string } + opts?: { ackPaneKeyOnSuccess?: string; flashFocusedPane?: boolean } ): void { useAppStore.getState().setActiveTab(tabId) if (leafId === null) { @@ -16,7 +16,8 @@ export function activateTabAndFocusPane( const detail: FocusTerminalPaneDetail = { tabId, leafId, - ...(opts?.ackPaneKeyOnSuccess ? { ackPaneKeyOnSuccess: opts.ackPaneKeyOnSuccess } : {}) + ...(opts?.ackPaneKeyOnSuccess ? { ackPaneKeyOnSuccess: opts.ackPaneKeyOnSuccess } : {}), + ...(opts?.flashFocusedPane ? { flashFocusedPane: true } : {}) } window.dispatchEvent( new CustomEvent(FOCUS_TERMINAL_PANE_EVENT, { diff --git a/src/renderer/src/lib/pane-manager/pane-key-resolution.ts b/src/renderer/src/lib/pane-manager/pane-key-resolution.ts index a8ebe9094f0..8647ed21944 100644 --- a/src/renderer/src/lib/pane-manager/pane-key-resolution.ts +++ b/src/renderer/src/lib/pane-manager/pane-key-resolution.ts @@ -23,7 +23,7 @@ export type PaneKeyResolution = export type PaneKeyResolutionManager = { getNumericIdForLeaf(leafId: string): number | null - getPanes(): ManagedPane[] + getPanes(): Pick[] } export function resolvePaneKeyForManager(