feat: flash focused pane from agent row (#2116)

This commit is contained in:
Jinjing
2026-05-16 15:37:10 -07:00
committed by GitHub
parent 17c6f8330e
commit f0ce24fa21
9 changed files with 232 additions and 6 deletions
+31
View File
@@ -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;
@@ -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)
}
@@ -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<string>()
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)
})
})
@@ -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<ManagedPane, 'id' | 'leafId' | 'container'>[]
setActivePane(paneId: number, opts?: { focus?: boolean }): void
}
type FocusTerminalPaneEventDeps = {
tabId: string
manager: Pick<PaneManager, 'getNumericIdForLeaf' | 'getPanes' | 'setActivePane'> | 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])
}
@@ -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<string>()
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)
})
})
@@ -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<HTMLElement, ReturnType<typeof setTimeout>>()
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)
}
+2
View File
@@ -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 = {
@@ -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<FocusTerminalPaneDetail>(FOCUS_TERMINAL_PANE_EVENT, {
@@ -23,7 +23,7 @@ export type PaneKeyResolution =
export type PaneKeyResolutionManager = {
getNumericIdForLeaf(leafId: string): number | null
getPanes(): ManagedPane[]
getPanes(): Pick<ManagedPane, 'id' | 'leafId'>[]
}
export function resolvePaneKeyForManager(