Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/site/content/docs/notifications.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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' })
)
})
})
2 changes: 2 additions & 0 deletions src/renderer/src/app-shell/app-command-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -155,6 +156,7 @@ export function createAppCommandHandlers(
: false

return new Map<KeybindingActionId, () => boolean>([
['worktree.jumpToLatestAttention', () => runLatestAttention(creationLayoutActive, claim)],
[
'worktree.history.back',
() => {
Expand Down
20 changes: 20 additions & 0 deletions src/renderer/src/app-shell/latest-attention-command.ts
Original file line number Diff line number Diff line change
@@ -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))
}
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ export function buildActivityTabHostIndex(
return index
}

function resolveActivityExecutionHostId(
export function resolveActivityExecutionHostId(
context: ActivityTabContext,
entry: AgentStatusEntry,
terminalPtyId: string | null | undefined,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<WorktreeAgentActivationOutcome>((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<typeof stateForThread>
let gate: ReturnType<typeof deferredGate>

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 }
)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
Loading