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
7 changes: 7 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1338,6 +1338,13 @@ function openMainWindow(): BrowserWindow {
) {
driveSyntheticTitleFromHook(paneKey, payload.state, profile)
}
// Why: headless --serve has no renderer graph sync, so without this republish remote/
// mobile clients never receive HTTP-hook-only agent status (e.g. OpenCode) until unrelated
// PTY/title churn happens. Mirrors the OSC 9999 / synthetic-title push paths (#7970).
const agentHookPtyId = getPtyIdForPaneKey(paneKey)
if (agentHookPtyId) {
runtime?.touchMobileSessionSnapshotsForPty(agentHookPtyId)
}
}
)
agentHookServer.setPaneStatusClearListener((clear) => {
Expand Down
199 changes: 199 additions & 0 deletions src/main/runtime/headless-hook-agent-status.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
/**
* Headless agent-status synchronization: on a `--serve` runtime with no renderer
* graph sync, HTTP-hook-only agents (e.g. OpenCode) record status only in the
* agent-hook cache. The session.tabs projection must surface that status to
* remote/mobile clients, and a hook status change must republish — mirroring the
* OSC 9999 path (#7970) for agents that never emit OSC.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type {
RuntimeMobileSessionTabsResult,
RuntimeMobileSessionTabsSnapshot,
RuntimeMobileSessionTerminalClientTab
} from '../../shared/runtime-types'
import type { WorkspaceSessionState } from '../../shared/types'
import type { AgentStatusIpcPayload } from '../../shared/agent-status-types'
import { OrcaRuntimeService } from './orca-runtime'

const WT = 'repo-1::/tmp/worktree-a'
const TAB = 'tab-1'
const LEAF = '33333333-3333-4333-8333-333333333333'
const PANE_KEY = `${TAB}:${LEAF}`
const PTY = 'pty-1'

const storeBase = {
getRepo: () => ({
id: 'repo-1',
path: '/tmp/repo',
displayName: 'repo',
badgeColor: 'blue',
addedAt: 1
}),
getRepos: () => [storeBase.getRepo()],
addRepo: () => {},
updateRepo: () => undefined as never,
getAllWorktreeMeta: () => ({}),
getWorktreeMeta: () => undefined,
getGitHubCache: () => ({ pr: {}, issue: {} }),
setWorktreeMeta: () => undefined as never,
removeWorktreeMeta: () => {},
getSettings: () => ({
workspaceDir: '/tmp/workspaces',
nestWorkspaces: false,
refreshLocalBaseRefOnWorktreeCreate: false,
branchPrefix: 'none',
branchPrefixCustom: ''
})
}

function makeSession(): WorkspaceSessionState {
return {
activeRepoId: 'repo-1',
activeWorktreeId: WT,
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {}
}
}

/** A renderer mobile-session snapshot whose terminal tab carries NO graph-synced
* agentStatus — the headless condition where the host has no renderer to populate it. */
function makeHeadlessSnapshot(): RuntimeMobileSessionTabsSnapshot {
return {
worktree: WT,
publicationEpoch: 'renderer:test-epoch',
snapshotVersion: 1,
activeGroupId: 'group-1',
activeTabId: `${TAB}::${LEAF}`,
activeTabType: 'terminal',
tabs: [
{
type: 'terminal',
id: `${TAB}::${LEAF}`,
parentTabId: TAB,
leafId: LEAF,
ptyId: PTY,
title: 'Terminal 1',
isActive: true
}
]
}
}

function createRuntime(hookSnapshot: () => AgentStatusIpcPayload[]) {
const runtime = new OrcaRuntimeService(
{ ...storeBase, getWorkspaceSession: () => makeSession(), setWorkspaceSession: () => {} },
undefined,
{ getAgentStatusSnapshot: hookSnapshot }
)
const events: RuntimeMobileSessionTabsResult[] = []
const unsubscribe = runtime.onMobileSessionTabsChanged((snapshot) => events.push(snapshot))
const syncGraph = (): void => {
runtime.syncWindowGraph(1, {
tabs: [{ tabId: TAB, worktreeId: WT, title: 'Terminal', activeLeafId: LEAF, layout: null }],
leaves: [{ tabId: TAB, worktreeId: WT, leafId: LEAF, paneRuntimeId: 1, ptyId: PTY }],
mobileSessionTabs: [makeHeadlessSnapshot()]
})
}
return { runtime, events, syncGraph, unsubscribe }
}

function firstTerminalTab(
result: RuntimeMobileSessionTabsResult | undefined
): RuntimeMobileSessionTerminalClientTab | undefined {
const tab = result?.tabs.find((t) => t.type === 'terminal')
return tab?.type === 'terminal' ? tab : undefined
}

describe('headless hook-only agent status over session.tabs', () => {
beforeEach(() => {
vi.useFakeTimers()
})

afterEach(() => {
vi.useRealTimers()
})

it('surfaces hook-reported working status when the tab has no graph-synced agentStatus', () => {
const now = Date.now()
const { events, syncGraph } = createRuntime(() => [
{
paneKey: PANE_KEY,
worktreeId: WT,
tabId: TAB,
state: 'working',
prompt: 'ship it',
agentType: 'opencode',
connectionId: null,
receivedAt: now,
stateStartedAt: now - 100
}
])

syncGraph()
vi.advanceTimersByTime(60)

expect(firstTerminalTab(events[0])).toMatchObject({
agentStatus: expect.objectContaining({
state: 'working',
agentType: 'opencode',
prompt: 'ship it'
})
})
})

it('does not surface stale hook status past the freshness window', () => {
const stale = Date.now() - 31 * 60 * 1000
const { events, syncGraph } = createRuntime(() => [
{
paneKey: PANE_KEY,
worktreeId: WT,
tabId: TAB,
state: 'working',
prompt: 'stale',
agentType: 'opencode',
connectionId: null,
receivedAt: stale,
stateStartedAt: stale
}
])

syncGraph()
vi.advanceTimersByTime(60)

expect(firstTerminalTab(events[0])?.agentStatus).toBeUndefined()
})

it('republishes session.tabs when touchMobileSessionSnapshotsForPty fires after a hook change', () => {
let hookState: AgentStatusIpcPayload[] = []
const { runtime, events, syncGraph } = createRuntime(() => hookState)

syncGraph()
vi.advanceTimersByTime(60)
expect(firstTerminalTab(events[0])?.agentStatus).toBeUndefined()

// Why: simulates the onAgentStatus listener resolving the pane PTY and republishing
// after an HTTP hook POST lands in the agent-hook cache.
const now = Date.now()
hookState = [
{
paneKey: PANE_KEY,
worktreeId: WT,
tabId: TAB,
state: 'working',
prompt: 'now working',
agentType: 'opencode',
connectionId: null,
receivedAt: now,
stateStartedAt: now
}
]
events.length = 0
runtime.touchMobileSessionSnapshotsForPty(PTY)
vi.advanceTimersByTime(120)

expect(firstTerminalTab(events[0])).toMatchObject({
agentStatus: expect.objectContaining({ state: 'working', prompt: 'now working' })
})
})
})
77 changes: 71 additions & 6 deletions src/main/runtime/orca-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
isClaudeManagementTitle,
isCursorAgentTitle,
isCursorNativeAgentTitle,
isOpenCodeNativeTitle,
isShellProcess,
normalizeTerminalTitle
} from '../../shared/agent-detection'
Expand Down Expand Up @@ -5083,10 +5084,11 @@ export class OrcaRuntimeService {
this.notifyMobileSessionTabsChanged(worktreeId)
}

private touchMobileSessionSnapshotsForPty(
ptyId: string,
options: { immediate?: boolean } = {}
): void {
/** Bump and coalesce-republish mobile session snapshots for worktrees whose
* tabs back `ptyId`. Called from the PTY/title/OSC paths, and from the
* agent-hook listener so HTTP-hook-only agents (OpenCode) republish to
* remote/mobile clients on status change — mirroring the OSC 9999 path. */
touchMobileSessionSnapshotsForPty(ptyId: string, options: { immediate?: boolean } = {}): void {
for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) {
const hasPtyBackedTab = snapshot.tabs.some(
(tab) =>
Expand Down Expand Up @@ -26986,7 +26988,7 @@ export class OrcaRuntimeService {
}
}

/** Retained OSC 9999 hook row for this mobile tab if still fresh; looked up by pane identity, then PTY ownership (legacy `pane:N` ids can drift). */
/** Retained OSC 9999 hook row for this mobile tab if still fresh; looked up by pane identity, then PTY ownership (legacy `pane:N` ids can drift). Falls back to the agent-hook cache so headless hook-only agents (e.g. OpenCode) still publish status without a renderer graph sync. */
private getFreshRetainedAgentStatusForMobileTab(
paneKey: string,
pty: RuntimePtyWorktreeRecord | null,
Expand All @@ -27006,12 +27008,70 @@ export class OrcaRuntimeService {
}
}
}
// Why: headless --serve has no renderer graph sync, so for most agents OSC 9999
// (latestAgentStatusByPaneKey) is the only runtime-side retained row. But OpenCode and
// other HTTP-hook-only agents never emit OSC 9999 — their status lives only in the
// agent-hook cache (getAgentStatusSnapshotFn). Mirror the retained lookup against that
// cache so remote/mobile clients see their working/idle state on a headless server.
if (!retained) {
retained = this.getFreshHookAgentStatusForMobileTab(paneKey)
}
if (!retained || Date.now() - retained.updatedAt > AGENT_STATUS_STALE_AFTER_MS) {
return null
}
return retained
}

private getFreshHookAgentStatusForMobileTab(paneKey: string): RuntimeAgentRowSnapshot | null {
const entries = this.getAgentStatusSnapshotFn?.() ?? []
let best: AgentStatusIpcPayload | null = null
for (const entry of entries) {
if (entry.paneKey !== paneKey || entry.providerSessionOnly) {
continue
}
if (!best || entry.receivedAt > best.receivedAt) {
best = entry
}
}
if (!best) {
return null
}
const {
state,
prompt,
agentType,
model,
toolName,
toolInput,
interactivePrompt,
lastAssistantMessage,
interrupted,
subagents
} = best
const payload: ParsedAgentStatusPayload = {
state,
prompt,
...(agentType ? { agentType } : {}),
...(model ? { model } : {}),
...(toolName ? { toolName } : {}),
...(toolInput ? { toolInput } : {}),
...(interactivePrompt ? { interactivePrompt } : {}),
...(lastAssistantMessage ? { lastAssistantMessage } : {}),
...(interrupted ? { interrupted } : {}),
...(subagents ? { subagents } : {})
}
return {
paneKey,
// Why: hook cache rows aren't pty-keyed; buildPtyMobileAgentStatus reads payload/timings, not ptyId.
ptyId: '',
...(best.worktreeId ? { worktreeId: best.worktreeId } : {}),
...(best.tabId ? { tabId: best.tabId } : {}),
payload,
stateStartedAt: best.stateStartedAt,
updatedAt: best.receivedAt
}
}

private findPtyForMobileTerminalTab(
worktreeId: string,
tab: RuntimeMobileSessionTerminalTab,
Expand Down Expand Up @@ -33166,7 +33226,12 @@ function classifyAgentTitle(title: string | null): 'agent' | 'management' | 'neu
if (isClaudeManagementTitle(title)) {
return 'management'
}
return detectAgentStatusFromTitle(title) !== null ? 'agent' : 'neutral'
// Why: OpenCode's native `OC | <task>` session title carries identity, not a status glyph,
// so detectAgentStatusFromTitle misses it and would classify the pane as a reclaimed shell,
// suppressing hook-derived working status for an agent that still owns the pane.
return detectAgentStatusFromTitle(title) !== null || isOpenCodeNativeTitle(title)
? 'agent'
: 'neutral'
}

function terminalTitleBlocksExplicitAgentStatus(title: string | null): boolean {
Expand Down
5 changes: 1 addition & 4 deletions src/renderer/src/components/right-sidebar/FileExplorer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,7 @@ import {
buildAddProjectFromFolderModalData,
canShowAddAsProjectAction
} from './file-explorer-add-project-action'
import {
isRenameHotspotTarget,
resolveDirToggleTiming
} from './file-explorer-dir-toggle-timing'
import { isRenameHotspotTarget, resolveDirToggleTiming } from './file-explorer-dir-toggle-timing'
import type { TreeNode } from './file-explorer-types'
import { useFileExplorerSelection } from './useFileExplorerSelection'
import { useFileExplorerVisibleRowProjection } from './useFileExplorerVisibleRowProjection'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,7 @@ function renderRenameRow(): {
const onSubmit = vi.fn()
const view = render(
<div>
<InlineInputRow
depth={1}
inlineInput={renameInput}
onSubmit={onSubmit}
onCancel={vi.fn()}
/>
<InlineInputRow depth={1} inlineInput={renameInput} onSubmit={onSubmit} onCancel={vi.fn()} />
{/* Rows are Radix context-menu triggers, so a genuine click on a neighbour
used to be indistinguishable from Radix restoring focus after a close. */}
<button type="button" data-slot="context-menu-trigger">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,15 @@ describe('resolveCycledWorktreeId', () => {
expect(
resolveCycledWorktreeId({ worktreeIds, activeWorktreeId: 'hidden', direction: 'up' })
).toBe('c')
expect(resolveCycledWorktreeId({ worktreeIds, activeWorktreeId: null, direction: 'down' })).toBe(
'a'
)
expect(
resolveCycledWorktreeId({ worktreeIds, activeWorktreeId: null, direction: 'down' })
).toBe('a')
})

it('has nothing to cycle to when every group is collapsed', () => {
expect(resolveCycledWorktreeId({ worktreeIds: [], activeWorktreeId: 'a', direction: 'down' })).toBe(
null
)
expect(
resolveCycledWorktreeId({ worktreeIds: [], activeWorktreeId: 'a', direction: 'down' })
).toBe(null)
})
})

Expand Down Expand Up @@ -131,7 +131,10 @@ describe('getCyclableWorktreeIds', () => {

describe('WorktreeList keyboard cycling', () => {
it('cycles over the rendered rows instead of rebuilding a parallel layout', () => {
const source = readFileSync(fileURLToPath(new URL('./WorktreeList.tsx', import.meta.url)), 'utf8')
const source = readFileSync(
fileURLToPath(new URL('./WorktreeList.tsx', import.meta.url)),
'utf8'
)
const navigateWorktree = source.slice(
source.indexOf('const navigateWorktree = useCallback('),
source.indexOf('const handleContainerKeyDown = useCallback(')
Expand Down