mirror of
https://github.com/stablyai/orca.git
synced 2026-09-24 16:02:41 +00:00
feat: flash focused pane from agent row (#2116)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user