diff --git a/src/main/index.ts b/src/main/index.ts index 9f7e559cb880..fce82ab74294 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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) => { diff --git a/src/main/runtime/headless-hook-agent-status.test.ts b/src/main/runtime/headless-hook-agent-status.test.ts new file mode 100644 index 000000000000..1ebe27e7196e --- /dev/null +++ b/src/main/runtime/headless-hook-agent-status.test.ts @@ -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' }) + }) + }) +}) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index dac1b9f0ffd6..d89cf0f2b9df 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -6,6 +6,7 @@ import { isClaudeManagementTitle, isCursorAgentTitle, isCursorNativeAgentTitle, + isOpenCodeNativeTitle, isShellProcess, normalizeTerminalTitle } from '../../shared/agent-detection' @@ -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) => @@ -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, @@ -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, @@ -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 | ` 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 { diff --git a/src/renderer/src/components/right-sidebar/FileExplorer.tsx b/src/renderer/src/components/right-sidebar/FileExplorer.tsx index 045073f8186e..81a41e20aea3 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorer.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorer.tsx @@ -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' diff --git a/src/renderer/src/components/right-sidebar/file-explorer-inline-input-outside-click.test.tsx b/src/renderer/src/components/right-sidebar/file-explorer-inline-input-outside-click.test.tsx index c5543965db4b..af7aa4b07266 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-inline-input-outside-click.test.tsx +++ b/src/renderer/src/components/right-sidebar/file-explorer-inline-input-outside-click.test.tsx @@ -37,12 +37,7 @@ function renderRenameRow(): { const onSubmit = vi.fn() const view = render(
- + {/* Rows are Radix context-menu triggers, so a genuine click on a neighbour used to be indistinguishable from Radix restoring focus after a close. */}