From 445c6d4c8ed4d77869de1eb5953309ed41bd0cf0 Mon Sep 17 00:00:00 2001 From: Mirksen <52635146+Mirksen@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:38:15 +0200 Subject: [PATCH 1/3] feat(shortcuts): jump to the latest agent needing input --- docs/site/content/docs/notifications.mdx | 12 + ...-command-handlers-latest-attention.test.ts | 138 ++++++++++ .../src/app-shell/app-command-handlers.ts | 2 + .../src/app-shell/latest-attention-command.ts | 20 ++ .../activity-event-builder-context.ts | 2 +- .../activity/activity-thread-actions.ts | 92 ++++--- .../activity/latest-attention-thread.test.ts | 247 ++++++++++++++++++ .../activity/latest-attention-thread.ts | 164 ++++++++++++ .../keybindings-latest-attention.test.ts | 50 ++++ src/shared/keybindings/definitions-core-4.ts | 17 ++ src/shared/keybindings/types.ts | 1 + src/shared/plugins/plugin-command-actions.ts | 1 + tests/e2e/jump-to-latest-attention.spec.ts | 118 +++++++++ 13 files changed, 816 insertions(+), 48 deletions(-) create mode 100644 src/renderer/src/app-shell/app-command-handlers-latest-attention.test.ts create mode 100644 src/renderer/src/app-shell/latest-attention-command.ts create mode 100644 src/renderer/src/components/activity/latest-attention-thread.test.ts create mode 100644 src/renderer/src/components/activity/latest-attention-thread.ts create mode 100644 src/shared/keybindings-latest-attention.test.ts create mode 100644 tests/e2e/jump-to-latest-attention.spec.ts diff --git a/docs/site/content/docs/notifications.mdx b/docs/site/content/docs/notifications.mdx index 8d5e02866f75..4ec24f43f24d 100644 --- a/docs/site/content/docs/notifications.mdx +++ b/docs/site/content/docs/notifications.mdx @@ -14,6 +14,18 @@ The header bell shows unread notifications across all worktrees. Clicking a noti On macOS, the same unread count is mirrored as a badge on the Dock icon, so you can spot pending agent pings without bringing Orca to the foreground. +## Jump to the latest input request + +Press `Cmd+Shift+U` on macOS or `Ctrl+Shift+U` on Windows/Linux to open the newest agent +still waiting for input or blocked on a request, including its tab and split pane. Remap +**Jump to latest agent needing input** in Settings → Shortcuts. + +The action uses fresh agent status, rather than older notifications or completed turns. +Reading a request does not resolve it: repeated presses return to the same newest pending +request until the agent resumes or another request arrives. When nothing needs input, +the shortcut passes through to the focused terminal. It also works with folder workspaces, +floating terminals, structured chats, and SSH/remote workspaces. + ## Mark unread Right-click a notification to mark unread — useful when you've triaged something but want to come back to it later. diff --git a/src/renderer/src/app-shell/app-command-handlers-latest-attention.test.ts b/src/renderer/src/app-shell/app-command-handlers-latest-attention.test.ts new file mode 100644 index 000000000000..d53e703da6a6 --- /dev/null +++ b/src/renderer/src/app-shell/app-command-handlers-latest-attention.test.ts @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentPaneThread } from '../components/activity/activity-thread-types' +import type { AppShortcutState, ShortcutDispatchInput } from './app-command-handlers' +import { + makeRepo, + makeTab, + makeWorktree +} from '../components/activity/ActivityPrototypePage-test-fixtures' + +const mocks = vi.hoisted(() => ({ + activate: vi.fn(), + resolve: vi.fn<() => AgentPaneThread | null>(), + notify: vi.fn(), + overlay: false, + store: { activeModal: 'none' } +})) + +vi.mock('../store', () => ({ + useAppStore: Object.assign(vi.fn(), { getState: () => mocks.store }) +})) +vi.mock('../components/activity/latest-attention-thread', () => ({ + resolveLatestAttentionThread: mocks.resolve +})) +vi.mock('../components/activity/activity-thread-actions', () => ({ + activateActivityThreadTarget: mocks.activate +})) +vi.mock('@/lib/floating-workspace-terminal-actions', () => ({ + isFloatingWorkspacePanelFocused: () => false +})) +vi.mock('@/lib/visible-overlay', () => ({ hasVisibleOverlay: () => mocks.overlay })) +vi.mock('@/lib/terminal-shortcut-capture-notification', () => ({ + showTerminalShortcutCaptureNotification: mocks.notify +})) + +import { createAppCommandHandlers } from './app-command-handlers' + +function shortcutState(): AppShortcutState { + return { + activeView: 'terminal', + activeWorktreeId: 'current', + actions: { + toggleSidebar: vi.fn(), + toggleRightSidebar: vi.fn(), + setRightSidebarOpen: vi.fn(), + setRightSidebarTab: vi.fn(), + showRightSidebarFiles: vi.fn(), + showRightSidebarSearch: vi.fn(), + openDiffNotesSendMenuForActiveWorktree: vi.fn() + }, + creationLayoutActive: false, + floatingTerminalEnabled: false, + floatingTerminalOpen: false, + floatingVisibleTabCount: 0, + keybindings: {}, + openFloatingWorkspaceMaximized: vi.fn(), + pluginCommands: [], + setFloatingTerminalOpen: vi.fn(), + terminalShortcutPolicy: 'terminal-first', + workspaceChromeActive: true + } +} + +function pendingThread(): AgentPaneThread { + return { + paneKey: 'tab-1:11111111-1111-4111-8111-111111111111', + paneTitle: 'Synthetic pending agent', + worktree: makeWorktree(), + repo: makeRepo(), + tab: makeTab(), + agentType: 'codex', + currentAgentState: 'waiting', + currentAgentEntry: null, + responsePreview: '', + latestTimestamp: 1_000, + latestEvent: null, + events: [], + unread: true + } +} + +function run(state = shortcutState(), context: 'app' | 'terminal' = 'terminal') { + const input: ShortcutDispatchInput = { + target: null, + defaultPrevented: false, + preventDefault: vi.fn() + } + const handled = createAppCommandHandlers(state, input, context).get( + 'worktree.jumpToLatestAttention' + )?.() + return { handled, input } +} + +describe('latest attention app command', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolve.mockReturnValue(pendingThread()) + mocks.overlay = false + mocks.store.activeModal = 'none' + }) + + it('claims the shortcut and reuses exact activity pane activation under terminal-first policy', () => { + const { handled, input } = run() + expect(handled).toBe(true) + expect(input.preventDefault).toHaveBeenCalledOnce() + expect(mocks.activate).toHaveBeenCalledWith(mocks.resolve.mock.results[0]?.value) + expect(mocks.notify).not.toHaveBeenCalled() + }) + + it('passes the chord through when no current request exists', () => { + mocks.resolve.mockReturnValue(null) + const { handled, input } = run() + expect(handled).toBe(false) + expect(input.preventDefault).not.toHaveBeenCalled() + expect(mocks.activate).not.toHaveBeenCalled() + }) + + it('does not interrupt an open dialog', () => { + mocks.store.activeModal = 'settings' + expect(run().handled).toBe(false) + expect(mocks.resolve).not.toHaveBeenCalled() + expect(mocks.activate).not.toHaveBeenCalled() + }) + + it('does not interrupt a local overlay or workspace creation', () => { + mocks.overlay = true + expect(run().handled).toBe(false) + mocks.overlay = false + expect(run({ ...shortcutState(), creationLayoutActive: true }).handled).toBe(false) + expect(mocks.resolve).not.toHaveBeenCalled() + }) + + it('reports capture through the existing orca-first notification policy', () => { + expect(run({ ...shortcutState(), terminalShortcutPolicy: 'orca-first' }).handled).toBe(true) + expect(mocks.notify).toHaveBeenCalledWith( + expect.objectContaining({ actionId: 'worktree.jumpToLatestAttention' }) + ) + }) +}) diff --git a/src/renderer/src/app-shell/app-command-handlers.ts b/src/renderer/src/app-shell/app-command-handlers.ts index b38ff32c8fe1..1be9a4bfbf78 100644 --- a/src/renderer/src/app-shell/app-command-handlers.ts +++ b/src/renderer/src/app-shell/app-command-handlers.ts @@ -17,6 +17,7 @@ import { resolveHoveredWorkspaceDeleteTarget } from '../components/sidebar/hovered-workspace-delete' import { useAppStore } from '../store' +import { runLatestAttention } from './latest-attention-command' import type { usePluginCommands } from '@/store/plugin-panels' import { isGitRepoKind } from '../../../shared/repo-kind' import type { @@ -155,6 +156,7 @@ export function createAppCommandHandlers( : false return new Map boolean>([ + ['worktree.jumpToLatestAttention', () => runLatestAttention(creationLayoutActive, claim)], [ 'worktree.history.back', () => { diff --git a/src/renderer/src/app-shell/latest-attention-command.ts b/src/renderer/src/app-shell/latest-attention-command.ts new file mode 100644 index 000000000000..23c0a7c03d2e --- /dev/null +++ b/src/renderer/src/app-shell/latest-attention-command.ts @@ -0,0 +1,20 @@ +import { hasVisibleOverlay } from '@/lib/visible-overlay' +import { useAppStore } from '../store' +import { resolveLatestAttentionThread } from '../components/activity/latest-attention-thread' +import { activateActivityThreadTarget } from '../components/activity/activity-thread-actions' +import type { KeybindingActionId } from '../../../shared/keybindings' + +export function runLatestAttention( + creationLayoutActive: boolean, + claim: (actionId: KeybindingActionId, run: () => void) => boolean +): boolean { + const store = useAppStore.getState() + if (creationLayoutActive || store.activeModal !== 'none' || hasVisibleOverlay()) { + return false + } + const thread = resolveLatestAttentionThread(store) + if (!thread) { + return false + } + return claim('worktree.jumpToLatestAttention', () => activateActivityThreadTarget(thread)) +} diff --git a/src/renderer/src/components/activity/activity-event-builder-context.ts b/src/renderer/src/components/activity/activity-event-builder-context.ts index c630e4e8752d..ca181dc7da0f 100644 --- a/src/renderer/src/components/activity/activity-event-builder-context.ts +++ b/src/renderer/src/components/activity/activity-event-builder-context.ts @@ -109,7 +109,7 @@ export function buildActivityTabHostIndex( return index } -function resolveActivityExecutionHostId( +export function resolveActivityExecutionHostId( context: ActivityTabContext, entry: AgentStatusEntry, terminalPtyId: string | null | undefined, diff --git a/src/renderer/src/components/activity/activity-thread-actions.ts b/src/renderer/src/components/activity/activity-thread-actions.ts index 30ec951a16d7..13e8a2596d79 100644 --- a/src/renderer/src/components/activity/activity-thread-actions.ts +++ b/src/renderer/src/components/activity/activity-thread-actions.ts @@ -47,6 +47,50 @@ export function hasActivityThreadWorkspace( ) } +export function activateActivityThreadTarget(thread: AgentPaneThread): void { + const isFloatingTerminal = thread.worktree.id === FLOATING_TERMINAL_WORKTREE_ID + const executionHostId = getActivityThreadExecutionHostId( + thread, + getSettingsFocusedExecutionHostId(useAppStore.getState().settings) + ) + // Why the full sequence (not bare setActiveWorktree): a cold-parked thread — the normal + // state of an SSH session that was never revived — has no resident tab until + // resumeSleepingAgentSessionsForWorktree/ensureWorktreeHasInitialTerminal run inside here. + // Probing tab residency first is what made a remote row click a silent no-op (#16731). + if ( + !isFloatingTerminal && + activateAndRevealWorkspace(thread.worktree.id, { + executionHostId, + revealInSidebar: false, + clearSidebarFilters: false + }) === false + ) { + return + } + if (activateStructuredAgentSessionTab({ worktreeId: thread.worktree.id, tabId: thread.tab.id })) { + return + } + // Read post-activation: the tab this thread points at may have only just been revived. + const activated = useAppStore.getState() + const liveTabs = activated.tabsByWorktree[thread.worktree.id] ?? [] + if (!liveTabs.some((tab) => tab.id === thread.tab.id)) { + // Retained threads outlive their tab; the workspace is still activated, but there is + // no pane to focus and focusing a sibling would be worse than focusing nothing. + return + } + // Floating tabs have no catalog workspace; reveal their panel without changing the main workspace. + if (isFloatingTerminal) { + revealFloatingWorkspacePanel(activated) + } + activated.setActiveTabType('terminal', thread.worktree.id) + const parsed = parsePaneKey(thread.paneKey) + activateTabAndFocusPane( + thread.tab.id, + parsed && parsed.tabId === thread.tab.id ? parsed.leafId : null, + { flashFocusedPane: true, scrollToBottomIfOutputSinceLastView: true } + ) +} + export function createActivityThreadActions({ getMarkAllReadThreads, acknowledgeAgents, @@ -86,55 +130,9 @@ export function createActivityThreadActions({ const markThreadUnread = (thread: AgentPaneThread): void => markThreadsUnread([thread]) - const activateThreadTarget = (thread: AgentPaneThread): void => { - const isFloatingTerminal = thread.worktree.id === FLOATING_TERMINAL_WORKTREE_ID - const executionHostId = getActivityThreadExecutionHostId( - thread, - getSettingsFocusedExecutionHostId(useAppStore.getState().settings) - ) - // Why the full sequence (not bare setActiveWorktree): a cold-parked thread — the normal - // state of an SSH session that was never revived — has no resident tab until - // resumeSleepingAgentSessionsForWorktree/ensureWorktreeHasInitialTerminal run inside here. - // Probing tab residency first is what made a remote row click a silent no-op (#16731). - if ( - !isFloatingTerminal && - activateAndRevealWorkspace(thread.worktree.id, { - executionHostId, - revealInSidebar: false, - clearSidebarFilters: false - }) === false - ) { - return - } - if ( - activateStructuredAgentSessionTab({ worktreeId: thread.worktree.id, tabId: thread.tab.id }) - ) { - return - } - // Read post-activation: the tab this thread points at may have only just been revived. - const activated = useAppStore.getState() - const liveTabs = activated.tabsByWorktree[thread.worktree.id] ?? [] - if (!liveTabs.some((tab) => tab.id === thread.tab.id)) { - // Retained threads outlive their tab; the workspace is still activated, but there is - // no pane to focus and focusing a sibling would be worse than focusing nothing. - return - } - // Floating tabs have no catalog workspace; reveal their panel without changing the main workspace. - if (isFloatingTerminal) { - revealFloatingWorkspacePanel(activated) - } - activated.setActiveTabType('terminal', thread.worktree.id) - const parsed = parsePaneKey(thread.paneKey) - activateTabAndFocusPane( - thread.tab.id, - parsed && parsed.tabId === thread.tab.id ? parsed.leafId : null, - { flashFocusedPane: true, scrollToBottomIfOutputSinceLastView: true } - ) - } - const selectThread = (thread: AgentPaneThread): void => { setSelectedPaneKey(thread.paneKey) - activateThreadTarget(thread) + activateActivityThreadTarget(thread) } const jumpToWorkspace = (thread: AgentPaneThread): void => { diff --git a/src/renderer/src/components/activity/latest-attention-thread.test.ts b/src/renderer/src/components/activity/latest-attention-thread.test.ts new file mode 100644 index 000000000000..a292935a04ac --- /dev/null +++ b/src/renderer/src/components/activity/latest-attention-thread.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it } from 'vitest' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status-types' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { findKnownWorktreeById } from '@/store/slices/worktrees/listing/detected-worktree-meta' +import { resolveLatestAttentionThread, type LatestAttentionSource } from './latest-attention-thread' +import { + LEAF_ID, + LEAF_ID_2, + makeRepo, + makeTabWithIds, + makeWorktreeWithId +} from './ActivityPrototypePage-test-fixtures' + +const NOW = 100_000 + +function request( + tabId: string, + startedAt: number, + state: AgentStatusEntry['state'] = 'waiting' +): AgentStatusEntry { + return { + paneKey: makePaneKey(tabId, LEAF_ID), + worktreeId: `wt-${tabId}`, + state, + stateStartedAt: startedAt, + updatedAt: NOW, + prompt: 'Synthetic input request', + terminalTitle: 'Synthetic agent', + agentType: 'codex', + stateHistory: [] + } +} + +function sourceFor(...entries: AgentStatusEntry[]): LatestAttentionSource { + const worktrees = entries.map((entry) => makeWorktreeWithId(entry.worktreeId ?? 'wt-missing')) + const source: LatestAttentionSource = { + agentStatusByPaneKey: Object.fromEntries(entries.map((entry) => [entry.paneKey, entry])), + runtimeAgentOrchestrationByPaneKey: {}, + tabsByWorktree: Object.fromEntries( + worktrees.map((wt, index) => [ + wt.id, + [makeTabWithIds(entries[index]!.paneKey.split(':')[0]!, wt.id)] + ]) + ), + unifiedTabsByWorktree: {}, + terminalLayoutsByTabId: {}, + sleepingAgentSessionsByPaneKey: {}, + repos: [makeRepo()], + worktreesByRepo: { 'repo-1': worktrees }, + detectedWorktreesByRepo: {}, + folderWorkspaces: [], + floatingWorkspacePath: '/synthetic/floating', + getKnownWorktreeById: (id, host) => findKnownWorktreeById(source, id, host), + getFreshFolderWorkspacePathStatus: () => null, + acknowledgedAgentsByPaneKey: {}, + agentsShowChildAgents: false, + settings: null + } + return source +} + +describe('latest outstanding agent request', () => { + it('selects the newest waiting or blocked request, regardless of heartbeat order', () => { + const old = { ...request('older', 90_000, 'blocked'), updatedAt: NOW + 100 } + const latest = request('latest', 99_000) + const source = sourceFor(old, latest, request('completed', NOW, 'done')) + + expect(resolveLatestAttentionThread(source, NOW)?.paneKey).toBe(latest.paneKey) + }) + + it('keeps a read request eligible until the agent resumes', () => { + const entry = request('pending', 99_000) + const source = sourceFor(entry) + source.acknowledgedAgentsByPaneKey[entry.paneKey] = NOW + + expect(resolveLatestAttentionThread(source, NOW)?.paneKey).toBe(entry.paneKey) + source.agentStatusByPaneKey[entry.paneKey] = { ...entry, state: 'working' } + expect(resolveLatestAttentionThread(source, NOW)).toBeNull() + }) + + it('ignores historical requests after an answer or completion', () => { + const entry = request('answered', 99_000, 'working') + entry.stateHistory = [{ state: 'waiting', startedAt: 98_000, prompt: 'Old question' }] + expect(resolveLatestAttentionThread(sourceFor(entry), NOW)).toBeNull() + expect(resolveLatestAttentionThread(sourceFor(request('done', NOW, 'done')), NOW)).toBeNull() + }) + + it('ignores stale, restored-unconfirmed, and invalid status timestamps', () => { + const stale = { ...request('stale', 1), updatedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 1 } + const unconfirmed = { ...request('unconfirmed', 99_000), restoredUnconfirmed: true } + const invalid = request('invalid', Infinity) + expect(resolveLatestAttentionThread(sourceFor(stale, unconfirmed, invalid), NOW)).toBeNull() + }) + + it('skips a deleted workspace and reaches an older pending request', () => { + const older = request('older', 90_000) + const latest = request('deleted', 99_000) + const source = sourceFor(older, latest) + source.worktreesByRepo['repo-1'] = [makeWorktreeWithId('wt-older')] + expect(resolveLatestAttentionThread(source, NOW)?.paneKey).toBe(older.paneKey) + }) + + it('ignores archived workspaces', () => { + const entry = request('archived', 99_000) + const source = sourceFor(entry) + source.worktreesByRepo['repo-1'] = [{ ...makeWorktreeWithId('wt-archived'), isArchived: true }] + expect(resolveLatestAttentionThread(source, NOW)).toBeNull() + }) + + it('skips a confirmed removed split pane', () => { + const older = request('older', 90_000) + const removed = request('removed', 99_000) + const source = sourceFor(older, removed) + source.terminalLayoutsByTabId.removed = { + root: { type: 'leaf', leafId: LEAF_ID_2 }, + activeLeafId: LEAF_ID_2, + expandedLeafId: null + } + expect(resolveLatestAttentionThread(source, NOW)?.paneKey).toBe(older.paneKey) + }) + + it('allows a cold remote request before its tab or layout is resident', () => { + const entry = { ...request('cold', 99_000), connectionId: 'test-host' } + const source = sourceFor(entry) + source.worktreesByRepo['repo-1'] = [ + { ...makeWorktreeWithId('wt-cold'), hostId: 'ssh:test-host' } + ] + source.tabsByWorktree = {} + const target = resolveLatestAttentionThread(source, NOW) + expect(target?.paneKey).toBe(entry.paneKey) + expect(target?.worktree.hostId).toBe('ssh:test-host') + }) + + it('does not replace a vanished remote owner with a local workspace with the same ID', () => { + const older = request('older', 90_000) + const remote = { ...request('shared', 99_000), connectionId: 'test-host' } + const source = sourceFor(older, remote) + expect(resolveLatestAttentionThread(source, NOW)?.paneKey).toBe(older.paneKey) + }) + + it('skips a closed terminal tab while another request remains reachable', () => { + const older = request('older', 90_000) + const closed = request('closed', 99_000) + const source = sourceFor(older, closed) + source.tabsByWorktree['wt-closed'] = [] + expect(resolveLatestAttentionThread(source, NOW)?.paneKey).toBe(older.paneKey) + }) + + it('skips a closed floating terminal', () => { + const entry = { ...request('floating', 99_000), worktreeId: FLOATING_TERMINAL_WORKTREE_ID } + const source = sourceFor(entry) + source.tabsByWorktree = {} + expect(resolveLatestAttentionThread(source, NOW)).toBeNull() + }) + + it('keeps a present floating terminal reachable', () => { + const entry = { ...request('floating', 99_000), worktreeId: FLOATING_TERMINAL_WORKTREE_ID } + expect(resolveLatestAttentionThread(sourceFor(entry), NOW)?.paneKey).toBe(entry.paneKey) + }) + + it('includes a folder workspace without a Git repository', () => { + const entry = { ...request('folder', 99_000), worktreeId: 'folder:synthetic' } + const source = sourceFor(entry) + source.repos = [] + source.worktreesByRepo = {} + source.folderWorkspaces = [ + { + id: 'synthetic', + projectGroupId: 'group', + name: 'Synthetic folder', + folderPath: '/synthetic/folder', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + createdAt: 0, + updatedAt: 0 + } + ] + expect(resolveLatestAttentionThread(source, NOW)?.worktree.id).toBe('folder:synthetic') + source.getFreshFolderWorkspacePathStatus = () => ({ + path: '/synthetic/folder', + exists: false, + reason: 'missing' + }) + expect(resolveLatestAttentionThread(source, NOW)).toBeNull() + }) + + it('respects the child-agent visibility setting', () => { + const parent = request('parent', 90_000, 'working') + const child = { + ...request('child', 99_000), + orchestration: { taskId: 'task', dispatchId: 'dispatch', parentPaneKey: parent.paneKey } + } + const source = sourceFor(parent, child) + expect(resolveLatestAttentionThread(source, NOW)).toBeNull() + source.agentsShowChildAgents = true + expect(resolveLatestAttentionThread(source, NOW)?.paneKey).toBe(child.paneKey) + }) + + it('ignores malformed pane identities', () => { + const entry = { ...request('invalid-key', 99_000), paneKey: 'malformed' } + expect(resolveLatestAttentionThread(sourceFor(entry), NOW)).toBeNull() + }) + + it('includes structured agent sessions even when a terminal layout has no matching leaf', () => { + const entry = request('chat', 99_000) + const source = sourceFor(entry) + source.tabsByWorktree = {} + source.unifiedTabsByWorktree['wt-chat'] = [ + { + id: 'chat', + entityId: 'session', + groupId: 'group', + worktreeId: 'wt-chat', + contentType: 'agent-session', + label: 'Synthetic chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + } + ] + source.terminalLayoutsByTabId.chat = { + root: { type: 'leaf', leafId: LEAF_ID_2 }, + activeLeafId: LEAF_ID_2, + expandedLeafId: null + } + expect(resolveLatestAttentionThread(source, NOW)?.paneKey).toBe(entry.paneKey) + }) + + it('uses a deterministic tie-break when requests have the same timestamp', () => { + const a = request('a', 99_000) + const b = request('b', 99_000) + expect(resolveLatestAttentionThread(sourceFor(b, a), NOW)?.paneKey).toBe(a.paneKey) + expect(resolveLatestAttentionThread(sourceFor(a, b), NOW)?.paneKey).toBe(a.paneKey) + }) + + it('passes an empty inventory through without a target', () => { + expect(resolveLatestAttentionThread(sourceFor(), NOW)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/activity/latest-attention-thread.ts b/src/renderer/src/components/activity/latest-attention-thread.ts new file mode 100644 index 000000000000..18db4f0ea169 --- /dev/null +++ b/src/renderer/src/components/activity/latest-attention-thread.ts @@ -0,0 +1,164 @@ +import { getRepoMapFromState, getWorktreeMapFromState } from '@/store/selectors' +import type { AppState } from '@/store/types' +import { + getSettingsFocusedExecutionHostId, + getWorktreeExecutionHostId, + parseExecutionHostId +} from '../../../../shared/execution-host' +import { blocksFolderWorkspaceActivation } from '../../../../shared/folder-workspace-path-status' +import { parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { parsePaneKey } from '../../../../shared/stable-pane-id' +import { layoutContainsLeafId } from '../../../../shared/workspace-layout/terminal-pane-tree' +import { buildActivityEvents } from './activity-event-builder' +import { + buildActivityTabHostIndex, + resolveActivityExecutionHostId, + type ActivityTabHostIndex +} from './activity-event-builder-context' +import { buildAgentPaneThreads } from './activity-thread-builder' +import { collectChildAgentPaneKeys } from './activity-thread-child-agent' +import { hasActivityThreadWorkspace } from './activity-thread-actions' +import type { AgentPaneThread } from './activity-thread-types' + +export type LatestAttentionSource = Pick< + AppState, + | 'agentStatusByPaneKey' + | 'runtimeAgentOrchestrationByPaneKey' + | 'tabsByWorktree' + | 'unifiedTabsByWorktree' + | 'terminalLayoutsByTabId' + | 'sleepingAgentSessionsByPaneKey' + | 'repos' + | 'worktreesByRepo' + | 'detectedWorktreesByRepo' + | 'folderWorkspaces' + | 'floatingWorkspacePath' + | 'getKnownWorktreeById' + | 'getFreshFolderWorkspacePathStatus' + | 'acknowledgedAgentsByPaneKey' + | 'agentsShowChildAgents' + | 'settings' +> + +function canReachThread( + thread: AgentPaneThread, + source: LatestAttentionSource, + hostIndex: ActivityTabHostIndex +): boolean { + if (thread.worktree.isArchived) { + return false + } + const parsed = parsePaneKey(thread.paneKey) + if (!parsed || parsed.tabId !== thread.tab.id) { + return false + } + const entry = thread.currentAgentEntry + if (!entry) { + return false + } + const requestedHost = resolveActivityExecutionHostId( + { worktreeId: thread.worktree.id, tab: thread.tab }, + entry, + thread.tab.ptyId, + hostIndex + ) + // The feed can fall back to a display row; navigation must retain the request's host. + if (requestedHost && !source.getKnownWorktreeById(thread.worktree.id, requestedHost)) { + return false + } + const scope = parseWorkspaceKey(thread.worktree.id) + if (scope?.type === 'folder') { + const host = parseExecutionHostId( + getWorktreeExecutionHostId( + thread.worktree, + thread.repo ?? undefined, + getSettingsFocusedExecutionHostId(source.settings) + ) + ) + const pathStatus = source.getFreshFolderWorkspacePathStatus( + { scope: 'folder-workspace', folderWorkspaceId: scope.folderWorkspaceId }, + { runtimeEnvironmentId: host?.kind === 'runtime' ? host.environmentId : null } + ) + if (blocksFolderWorkspaceActivation(pathStatus)) { + return false + } + } + const structured = source.unifiedTabsByWorktree[thread.worktree.id]?.some( + (tab) => tab.id === thread.tab.id && tab.contentType === 'agent-session' + ) + if (structured) { + return hasActivityThreadWorkspace(thread, { + ...source, + defaultHostId: getSettingsFocusedExecutionHostId(source.settings) + }) + } + const layout = source.terminalLayoutsByTabId[thread.tab.id]?.root + // A missing snapshot is unknown (cold remote tab); a present tree proves a removed pane. + if (layout && !layoutContainsLeafId(layout, parsed.leafId)) { + return false + } + if (thread.worktree.id === FLOATING_TERMINAL_WORKTREE_ID) { + return ( + source.tabsByWorktree[thread.worktree.id]?.some((tab) => tab.id === thread.tab.id) ?? false + ) + } + const workspaceTabs = source.tabsByWorktree[thread.worktree.id] + const sleeping = source.sleepingAgentSessionsByPaneKey[thread.paneKey] + if (workspaceTabs && !workspaceTabs.some((tab) => tab.id === thread.tab.id) && !sleeping) { + return false + } + return hasActivityThreadWorkspace(thread, { + ...source, + defaultHostId: getSettingsFocusedExecutionHostId(source.settings) + }) +} + +/** Select the newest outstanding request, independent of whether it has already been read. */ +export function resolveLatestAttentionThread( + source: LatestAttentionSource, + now = Date.now() +): AgentPaneThread | null { + const activity = buildActivityEvents({ + agentStatusByPaneKey: source.agentStatusByPaneKey, + runtimeAgentOrchestrationByPaneKey: source.runtimeAgentOrchestrationByPaneKey, + // History and migration warnings cannot establish a currently pending request. + retainedAgentsByPaneKey: {}, + tabsByWorktree: source.tabsByWorktree, + unifiedTabsByWorktree: source.unifiedTabsByWorktree, + worktreeMap: getWorktreeMapFromState(source), + repoMap: getRepoMapFromState(source), + repos: source.repos, + resolveWorktree: source.getKnownWorktreeById, + acknowledgedAgentsByPaneKey: source.acknowledgedAgentsByPaneKey, + now + }) + const threads = buildAgentPaneThreads(activity) + const hostIndex = buildActivityTabHostIndex(source.unifiedTabsByWorktree) + const childPaneKeys = source.agentsShowChildAgents + ? new Set() + : collectChildAgentPaneKeys(threads) + let latest: AgentPaneThread | null = null + for (const thread of threads) { + const entry = thread.currentAgentEntry + if ( + !entry || + (thread.currentAgentState !== 'waiting' && thread.currentAgentState !== 'blocked') || + !Number.isFinite(entry.stateStartedAt) || + childPaneKeys.has(thread.paneKey) || + !canReachThread(thread, source, hostIndex) + ) { + continue + } + // Heartbeats update updatedAt; only a new request should change which agent wins. + if ( + !latest || + entry.stateStartedAt > (latest.currentAgentEntry?.stateStartedAt ?? 0) || + (entry.stateStartedAt === latest.currentAgentEntry?.stateStartedAt && + thread.paneKey < latest.paneKey) + ) { + latest = thread + } + } + return latest +} diff --git a/src/shared/keybindings-latest-attention.test.ts b/src/shared/keybindings-latest-attention.test.ts new file mode 100644 index 000000000000..8911dbba1cbb --- /dev/null +++ b/src/shared/keybindings-latest-attention.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { findKeybindingConflicts, keybindingMatchesAction } from './keybindings' +import { PLUGIN_COMMAND_ALIAS_ACTION_IDS } from './plugins/plugin-command-actions' + +const ACTION = 'worktree.jumpToLatestAttention' + +describe('latest attention shortcut', () => { + it.each(['darwin', 'linux', 'win32'] as const)( + 'matches the platform modifier on %s, including terminal-first policy', + (platform) => { + const input = { + key: 'U', + code: 'KeyU', + metaKey: platform === 'darwin', + ctrlKey: platform !== 'darwin', + shiftKey: true + } + expect( + keybindingMatchesAction( + ACTION, + input, + platform, + {}, + { context: 'terminal', terminalShortcutPolicy: 'terminal-first' } + ) + ).toBe(true) + expect(keybindingMatchesAction(ACTION, { ...input, shiftKey: false }, platform)).toBe(false) + expect(findKeybindingConflicts(platform, {}, { relevantActionIds: [ACTION] })).toEqual([]) + } + ) + + it('honors a remapped or disabled binding', () => { + const original = { code: 'KeyU', key: 'U', metaKey: true, shiftKey: true } + expect(keybindingMatchesAction(ACTION, original, 'darwin', { [ACTION]: [] })).toBe(false) + const overrides = { [ACTION]: ['Mod+Alt+J'] } + expect(keybindingMatchesAction(ACTION, original, 'darwin', overrides)).toBe(false) + expect( + keybindingMatchesAction( + ACTION, + { code: 'KeyJ', key: 'j', metaKey: true, altKey: true }, + 'darwin', + overrides + ) + ).toBe(true) + }) + + it('registers the action with the shared app command dispatcher', () => { + expect(PLUGIN_COMMAND_ALIAS_ACTION_IDS).toContain(ACTION) + }) +}) diff --git a/src/shared/keybindings/definitions-core-4.ts b/src/shared/keybindings/definitions-core-4.ts index f8c733311ff3..989d2d8782da 100644 --- a/src/shared/keybindings/definitions-core-4.ts +++ b/src/shared/keybindings/definitions-core-4.ts @@ -2,6 +2,23 @@ import type { KeybindingDefinition } from './types' import { platformBindings } from './definitions-support' export const KEYBINDING_DEFINITION_CORE_4: readonly KeybindingDefinition[] = [ + { + id: 'worktree.jumpToLatestAttention', + title: 'Jump to latest agent needing input', + group: 'Global', + scope: 'global', + searchKeywords: [ + 'shortcut', + 'agent', + 'attention', + 'notification', + 'latest', + 'waiting', + 'input' + ], + defaultBindings: platformBindings(['Mod+Shift+U']), + allowInTerminal: true + }, { id: 'chat.find', title: 'Find in chat', diff --git a/src/shared/keybindings/types.ts b/src/shared/keybindings/types.ts index 4c3686f61800..dc487ed06bf9 100644 --- a/src/shared/keybindings/types.ts +++ b/src/shared/keybindings/types.ts @@ -28,6 +28,7 @@ export type PluginKeybindingActionId = `plugin:${string}` export type KeybindingActionId = | 'worktree.quickOpen' | 'worktree.palette' + | 'worktree.jumpToLatestAttention' | 'worktree.navigateUp' | 'worktree.navigateDown' | 'app.settings' diff --git a/src/shared/plugins/plugin-command-actions.ts b/src/shared/plugins/plugin-command-actions.ts index d1645c7fcc6c..af7db83a004c 100644 --- a/src/shared/plugins/plugin-command-actions.ts +++ b/src/shared/plugins/plugin-command-actions.ts @@ -5,6 +5,7 @@ import type { KeybindingActionId, PluginKeybindingActionId } from '../keybinding export const PLUGIN_COMMAND_ALIAS_ACTION_IDS = [ 'worktree.history.back', 'worktree.history.forward', + 'worktree.jumpToLatestAttention', 'sidebar.left.toggle', 'sidebar.sleepingWorkspaces.toggle', 'floatingWorkspace.maximize', diff --git a/tests/e2e/jump-to-latest-attention.spec.ts b/tests/e2e/jump-to-latest-attention.spec.ts new file mode 100644 index 000000000000..ff9468e34f88 --- /dev/null +++ b/tests/e2e/jump-to-latest-attention.spec.ts @@ -0,0 +1,118 @@ +import { test, expect } from './helpers/orca-app' +import { waitForActiveWorktree, waitForSessionReady, ensureTerminalVisible } from './helpers/store' +import { + focusActiveTerminalInput, + splitActiveTerminalPane, + waitForPaneCount, + waitForPaneIdentitySnapshot +} from './helpers/terminal' +import { pressShortcut } from './helpers/shortcuts' + +test.use({ launchEnv: { ORCA_BACKGROUND_LAUNCH: '1' } }) + +test('latest input request shortcut opens the exact split pane and keeps the request pending', async ({ + orcaPage, + electronApp +}, testInfo) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await orcaPage.evaluate(async () => { + const state = window.__store!.getState() + await state.updateSettings({ uiLanguage: 'en' }) + state.setAgentActivityDisplayMode('full') + if (!state.worktreeCardProperties.includes('inline-agents')) { + state.setWorktreeCardProperties([...state.worktreeCardProperties, 'inline-agents']) + } + }) + await ensureTerminalVisible(orcaPage) + const worktrees = await orcaPage.evaluate(() => + Object.values(window.__store!.getState().worktreesByRepo) + .flat() + .filter((wt) => !wt.isArchived) + .map((wt) => wt.id) + ) + const [firstId, targetId] = worktrees + if (!firstId || !targetId) { + throw new Error('Expected two synthetic workspaces') + } + const targetRow = orcaPage + .locator( + `[data-worktree-sidebar] [role="option"][data-worktree-id=${JSON.stringify(targetId)}]` + ) + .first() + const firstRow = orcaPage + .locator(`[data-worktree-sidebar] [role="option"][data-worktree-id=${JSON.stringify(firstId)}]`) + .first() + await targetRow.click() + await ensureTerminalVisible(orcaPage) + await splitActiveTerminalPane(orcaPage, 'horizontal') + await waitForPaneCount(orcaPage, 2) + const snapshot = await waitForPaneIdentitySnapshot(orcaPage, 2) + const [olderPane, latestPane] = snapshot.panes + if (!olderPane || !latestPane) { + throw new Error('Expected stable split pane identities') + } + await orcaPage.evaluate( + ({ tabId, olderLeaf, latestLeaf }) => { + const store = window.__store! + const state = store.getState() + const now = Date.now() + state.setAgentStatus( + `${tabId}:${olderLeaf}`, + { state: 'blocked', prompt: 'Older synthetic request', agentType: 'codex' }, + 'Older synthetic agent', + { updatedAt: now, stateStartedAt: now - 2_000 } + ) + state.setAgentStatus( + `${tabId}:${latestLeaf}`, + { state: 'waiting', prompt: 'Latest synthetic request', agentType: 'codex' }, + 'Latest synthetic agent', + { updatedAt: now, stateStartedAt: now - 1_000 } + ) + }, + { tabId: snapshot.tabId, olderLeaf: olderPane.leafId, latestLeaf: latestPane.leafId } + ) + await firstRow.click() + await ensureTerminalVisible(orcaPage) + await focusActiveTerminalInput(orcaPage) + await expect(firstRow).toHaveAttribute('aria-current', 'page') + await orcaPage.evaluate(async () => { + await window.__store!.getState().disableKeybindingAction('worktree.jumpToLatestAttention') + }) + await pressShortcut(orcaPage, 'KeyU', { shift: true }) + await expect(firstRow).toHaveAttribute('aria-current', 'page') + const clip = await orcaPage.evaluate(() => ({ + x: 0, + y: 0, + width: Math.min(900, innerWidth), + height: Math.min(700, innerHeight) + })) + const before = testInfo.outputPath('latest-attention-before.png') + await orcaPage.screenshot({ path: before, clip }) + await testInfo.attach('before', { path: before, contentType: 'image/png' }) + + await orcaPage.evaluate(async () => { + await window.__store!.getState().resetKeybindingOverride('worktree.jumpToLatestAttention') + }) + + await pressShortcut(orcaPage, 'KeyU', { shift: true }) + await expect(targetRow).toHaveAttribute('aria-current', 'page') + const latestInput = orcaPage.locator( + `[data-terminal-tab-id=${JSON.stringify(snapshot.tabId)}] [data-leaf-id=${JSON.stringify(latestPane.leafId)}] .xterm-helper-textarea` + ) + await expect(latestInput).toBeFocused() + await expect(targetRow.getByLabel('Waiting for input', { exact: true }).first()).toBeVisible() + await expect(targetRow.getByLabel('Blocked', { exact: true }).first()).toBeVisible() + const after = testInfo.outputPath('latest-attention-after.png') + await orcaPage.screenshot({ path: after, clip }) + await testInfo.attach('after', { path: after, contentType: 'image/png' }) + await pressShortcut(orcaPage, 'KeyU', { shift: true }) + await expect(latestInput).toBeFocused() + await expect(targetRow).toHaveAttribute('aria-current', 'page') + await expect(targetRow.getByLabel('Waiting for input', { exact: true }).first()).toBeVisible() + expect( + await electronApp.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows().every((window) => !window.isVisible()) + ) + ).toBe(true) +}) From 0041f7a2426390302445bc9b382e653b167491b1 Mon Sep 17 00:00:00 2001 From: Mirksen <52635146+Mirksen@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:46:40 +0200 Subject: [PATCH 2/3] test(shortcuts): stabilize and capture latest attention navigation --- tests/e2e/jump-to-latest-attention.spec.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/tests/e2e/jump-to-latest-attention.spec.ts b/tests/e2e/jump-to-latest-attention.spec.ts index ff9468e34f88..4e1e0bc75cc4 100644 --- a/tests/e2e/jump-to-latest-attention.spec.ts +++ b/tests/e2e/jump-to-latest-attention.spec.ts @@ -14,6 +14,8 @@ test('latest input request shortcut opens the exact split pane and keeps the req orcaPage, electronApp }, testInfo) => { + // Isolated Electron profile setup and teardown can dominate this keyboard journey on loaded hosts. + test.setTimeout(180_000) await waitForSessionReady(orcaPage) await waitForActiveWorktree(orcaPage) await orcaPage.evaluate(async () => { @@ -52,6 +54,7 @@ test('latest input request shortcut opens the exact split pane and keeps the req if (!olderPane || !latestPane) { throw new Error('Expected stable split pane identities') } + await orcaPage.clock.setFixedTime(new Date('2026-01-01T12:00:00Z')) await orcaPage.evaluate( ({ tabId, olderLeaf, latestLeaf }) => { const store = window.__store! @@ -81,12 +84,16 @@ test('latest input request shortcut opens the exact split pane and keeps the req }) await pressShortcut(orcaPage, 'KeyU', { shift: true }) await expect(firstRow).toHaveAttribute('aria-current', 'page') - const clip = await orcaPage.evaluate(() => ({ - x: 0, - y: 0, - width: Math.min(900, innerWidth), - height: Math.min(700, innerHeight) - })) + const sidebar = await orcaPage.locator('[data-worktree-sidebar]').boundingBox() + if (!sidebar) { + throw new Error('Expected visible workspace sidebar') + } + const clip = { + x: sidebar.x, + y: sidebar.y, + width: sidebar.width, + height: Math.min(650, sidebar.height) + } const before = testInfo.outputPath('latest-attention-before.png') await orcaPage.screenshot({ path: before, clip }) await testInfo.attach('before', { path: before, contentType: 'image/png' }) From 3b76d29fd9fc6f31f6cd18aa93f82b039bc72a94 Mon Sep 17 00:00:00 2001 From: Mirksen <52635146+Mirksen@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:33:51 +0200 Subject: [PATCH 3/3] fix(activity): focus sleeping requests after activation --- ...ty-thread-actions-gated-activation.test.ts | 165 ++++++++++++++++++ .../activity/activity-thread-actions.test.ts | 3 + .../activity/activity-thread-actions.ts | 39 ++++- .../src/lib/worktree-agent-activation-gate.ts | 9 +- 4 files changed, 209 insertions(+), 7 deletions(-) create mode 100644 src/renderer/src/components/activity/activity-thread-actions-gated-activation.test.ts diff --git a/src/renderer/src/components/activity/activity-thread-actions-gated-activation.test.ts b/src/renderer/src/components/activity/activity-thread-actions-gated-activation.test.ts new file mode 100644 index 000000000000..f7213471487b --- /dev/null +++ b/src/renderer/src/components/activity/activity-thread-actions-gated-activation.test.ts @@ -0,0 +1,165 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { WorktreeAgentActivationOutcome } from '@/lib/worktree-agent-activation-gate' +import { makeRepo, makeTab, makeWorktree } from './ActivityPrototypePage-test-fixtures' +import type { AgentPaneThread } from './activity-thread-types' + +const mocks = vi.hoisted(() => ({ + getState: vi.fn(), + activateWorkspace: vi.fn(), + pendingGate: vi.fn(), + activateStructured: vi.fn(), + focusPane: vi.fn() +})) +vi.mock('@/store', () => ({ useAppStore: { getState: mocks.getState } })) +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorkspace: mocks.activateWorkspace +})) +vi.mock('@/lib/worktree-agent-activation-gate', () => ({ + getPendingWorktreeAgentActivation: mocks.pendingGate +})) +vi.mock('@/lib/structured-agent-session-tab-activation', () => ({ + activateStructuredAgentSessionTab: mocks.activateStructured +})) +vi.mock('@/lib/activate-tab-and-focus-pane', () => ({ + activateTabAndFocusPane: mocks.focusPane +})) + +import { activateActivityThreadTarget } from './activity-thread-actions' + +const hostId = 'ssh:devbox' as const +const thread: AgentPaneThread = { + paneKey: 'tab-1:11111111-1111-4111-8111-111111111111', + paneTitle: 'Sleeping request', + worktree: { ...makeWorktree(), hostId }, + repo: makeRepo(), + tab: makeTab(), + agentType: 'codex', + currentAgentState: 'waiting', + currentAgentEntry: null, + latestTimestamp: 1_000, + latestEvent: null, + events: [], + unread: true, + responsePreview: '' +} + +function deferredGate() { + let settle!: (outcome: WorktreeAgentActivationOutcome) => void + const promise = new Promise((resolve) => { + settle = resolve + }) + return { promise, settle } +} + +function stateForThread() { + return { + activeWorktreeId: thread.worktree.id, + activeWorkspaceExecutionHostId: hostId, + activeView: 'terminal', + tabsByWorktree: { [thread.worktree.id]: [thread.tab] }, + getKnownWorktreeById: vi.fn<() => typeof thread.worktree | undefined>(() => thread.worktree), + setActiveTabType: vi.fn() + } +} + +describe('activity target focus after gated activation', () => { + let state: ReturnType + let gate: ReturnType + + beforeEach(() => { + vi.resetAllMocks() + state = stateForThread() + state.tabsByWorktree[thread.worktree.id] = [] + gate = deferredGate() + mocks.getState.mockImplementation(() => state) + mocks.activateWorkspace.mockReturnValue({ primaryTabId: null }) + mocks.pendingGate.mockReturnValue(gate.promise) + mocks.activateStructured.mockReturnValue(false) + }) + + it('focuses the requested tab and leaf only after recovery creates the tab', async () => { + activateActivityThreadTarget(thread) + expect(mocks.focusPane).not.toHaveBeenCalled() + expect(mocks.activateStructured).not.toHaveBeenCalled() + state.tabsByWorktree[thread.worktree.id] = [thread.tab] + gate.settle('resumed') + await gate.promise + expect(mocks.focusPane).toHaveBeenCalledExactlyOnceWith( + thread.tab.id, + '11111111-1111-4111-8111-111111111111', + { flashFocusedPane: true, scrollToBottomIfOutputSinceLastView: true } + ) + }) + + it('activates a structured request after its inventory has hydrated', async () => { + activateActivityThreadTarget(thread) + expect(mocks.activateStructured).not.toHaveBeenCalled() + mocks.activateStructured.mockReturnValue(true) + gate.settle('structured') + await gate.promise + expect(mocks.activateStructured).toHaveBeenCalledExactlyOnceWith({ + worktreeId: thread.worktree.id, + tabId: thread.tab.id + }) + expect(mocks.focusPane).not.toHaveBeenCalled() + }) + + it.each(['workspace', 'host', 'view', 'deleted'])( + 'does not steal focus when the target becomes stale through %s', + async (change) => { + activateActivityThreadTarget(thread) + state.tabsByWorktree[thread.worktree.id] = [thread.tab] + if (change === 'workspace') { + state.activeWorktreeId = 'other-workspace' + } + if (change === 'host') { + Object.assign(state, { activeWorkspaceExecutionHostId: 'local' }) + } + if (change === 'view') { + state.activeView = 'settings' + } + if (change === 'deleted') { + state.getKnownWorktreeById.mockReturnValue(undefined) + } + gate.settle('resumed') + await gate.promise + expect(mocks.focusPane).not.toHaveBeenCalled() + expect(mocks.activateStructured).not.toHaveBeenCalled() + } + ) + + it('does not fall back to a sibling tab when the requested tab stays absent', async () => { + activateActivityThreadTarget(thread) + state.tabsByWorktree[thread.worktree.id] = [{ ...thread.tab, id: 'sibling' }] + gate.settle('empty') + await gate.promise + expect(mocks.focusPane).not.toHaveBeenCalled() + }) + + it('does not focus through a blocked recovery gate', async () => { + activateActivityThreadTarget(thread) + state.tabsByWorktree[thread.worktree.id] = [thread.tab] + gate.settle('blocked') + await gate.promise + expect(mocks.focusPane).not.toHaveBeenCalled() + expect(mocks.activateStructured).not.toHaveBeenCalled() + }) + + it('lets a newer target selection supersede an earlier one in the same workspace', async () => { + const newer = { + ...thread, + tab: { ...thread.tab, id: 'tab-2' }, + paneKey: 'tab-2:22222222-2222-4222-8222-222222222222' + } + activateActivityThreadTarget(thread) + activateActivityThreadTarget(newer) + state.tabsByWorktree[thread.worktree.id] = [thread.tab, newer.tab] + gate.settle('resumed') + await gate.promise + expect(mocks.focusPane).toHaveBeenCalledExactlyOnceWith( + newer.tab.id, + '22222222-2222-4222-8222-222222222222', + { flashFocusedPane: true, scrollToBottomIfOutputSinceLastView: true } + ) + }) +}) diff --git a/src/renderer/src/components/activity/activity-thread-actions.test.ts b/src/renderer/src/components/activity/activity-thread-actions.test.ts index 215dbf5454cd..c286db1d4fee 100644 --- a/src/renderer/src/components/activity/activity-thread-actions.test.ts +++ b/src/renderer/src/components/activity/activity-thread-actions.test.ts @@ -22,6 +22,9 @@ vi.mock('@/lib/structured-agent-session-tab-activation', () => ({ vi.mock('@/lib/worktree-activation', () => ({ activateAndRevealWorkspace: mocks.activateAndRevealWorkspace })) +vi.mock('@/lib/worktree-agent-activation-gate', () => ({ + getPendingWorktreeAgentActivation: () => null +})) import { createActivityThreadActions, hasActivityThreadWorkspace } from './activity-thread-actions' diff --git a/src/renderer/src/components/activity/activity-thread-actions.ts b/src/renderer/src/components/activity/activity-thread-actions.ts index 13e8a2596d79..f56a35cce9ba 100644 --- a/src/renderer/src/components/activity/activity-thread-actions.ts +++ b/src/renderer/src/components/activity/activity-thread-actions.ts @@ -2,6 +2,7 @@ import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane' import { revealFloatingWorkspacePanel } from '@/lib/floating-workspace-panel-reveal' import { activateStructuredAgentSessionTab } from '@/lib/structured-agent-session-tab-activation' import { activateAndRevealWorkspace } from '@/lib/worktree-activation' +import { getPendingWorktreeAgentActivation } from '@/lib/worktree-agent-activation-gate' import { jumpToWorktreeFromSidebar } from '@/lib/worktree-jump-navigation' import { useAppStore } from '@/store' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' @@ -47,16 +48,16 @@ export function hasActivityThreadWorkspace( ) } +let latestThreadActivation = 0 + +/** Restore the selected thread before focusing its exact surface; newer navigation wins. */ export function activateActivityThreadTarget(thread: AgentPaneThread): void { + const activation = ++latestThreadActivation const isFloatingTerminal = thread.worktree.id === FLOATING_TERMINAL_WORKTREE_ID const executionHostId = getActivityThreadExecutionHostId( thread, getSettingsFocusedExecutionHostId(useAppStore.getState().settings) ) - // Why the full sequence (not bare setActiveWorktree): a cold-parked thread — the normal - // state of an SSH session that was never revived — has no resident tab until - // resumeSleepingAgentSessionsForWorktree/ensureWorktreeHasInitialTerminal run inside here. - // Probing tab residency first is what made a remote row click a silent no-op (#16731). if ( !isFloatingTerminal && activateAndRevealWorkspace(thread.worktree.id, { @@ -67,10 +68,36 @@ export function activateActivityThreadTarget(thread: AgentPaneThread): void { ) { return } + const pending = isFloatingTerminal ? null : getPendingWorktreeAgentActivation(thread.worktree.id) + if (!pending) { + focusActivityThreadTarget(thread) + return + } + // Sleeping recovery is asynchronous; a residency check before it finishes drops the target. + void pending.then( + (outcome) => { + const state = useAppStore.getState() + if ( + outcome === 'blocked' || + activation !== latestThreadActivation || + state.activeWorktreeId !== thread.worktree.id || + (state.activeWorkspaceExecutionHostId ?? 'local') !== executionHostId || + state.activeView !== 'terminal' || + !state.getKnownWorktreeById(thread.worktree.id, executionHostId) + ) { + return + } + focusActivityThreadTarget(thread) + }, + () => undefined + ) +} + +function focusActivityThreadTarget(thread: AgentPaneThread): void { if (activateStructuredAgentSessionTab({ worktreeId: thread.worktree.id, tabId: thread.tab.id })) { return } - // Read post-activation: the tab this thread points at may have only just been revived. + // Recovery may have just revived the selected tab; retained rows must not focus a sibling. const activated = useAppStore.getState() const liveTabs = activated.tabsByWorktree[thread.worktree.id] ?? [] if (!liveTabs.some((tab) => tab.id === thread.tab.id)) { @@ -79,7 +106,7 @@ export function activateActivityThreadTarget(thread: AgentPaneThread): void { return } // Floating tabs have no catalog workspace; reveal their panel without changing the main workspace. - if (isFloatingTerminal) { + if (thread.worktree.id === FLOATING_TERMINAL_WORKTREE_ID) { revealFloatingWorkspacePanel(activated) } activated.setActiveTabType('terminal', thread.worktree.id) diff --git a/src/renderer/src/lib/worktree-agent-activation-gate.ts b/src/renderer/src/lib/worktree-agent-activation-gate.ts index 37c8c93ab4d8..24e403c8668b 100644 --- a/src/renderer/src/lib/worktree-agent-activation-gate.ts +++ b/src/renderer/src/lib/worktree-agent-activation-gate.ts @@ -282,8 +282,15 @@ export function gateWorktreeAgentActivation( return gate } +/** Observe restoration already in flight without starting another recovery. */ +export function getPendingWorktreeAgentActivation( + worktreeId: string +): Promise | null { + return inFlightByWorktreeId.get(worktreeId) ?? null +} + export function waitForWorktreeAgentActivationGateForTests( worktreeId: string ): Promise { - return inFlightByWorktreeId.get(worktreeId) ?? Promise.resolve(null) + return getPendingWorktreeAgentActivation(worktreeId) ?? Promise.resolve(null) }