diff --git a/src/main/agent-hooks/server/server-row-ownership.ts b/src/main/agent-hooks/server/server-row-ownership.ts index a1836e5777f..6a778ee2d22 100644 --- a/src/main/agent-hooks/server/server-row-ownership.ts +++ b/src/main/agent-hooks/server/server-row-ownership.ts @@ -104,10 +104,14 @@ export abstract class AgentHookServerRowOwnership extends AgentHookServerListene if (!emit || semanticRowsEqual(before, after)) { return false } - const mutation: AgentHookStatusRowMutation = { + this.notifyStatusRowMutationListeners({ before: toMutationIdentity(before), after: toMutationIdentity(after) - } + }) + return true + } + + private notifyStatusRowMutationListeners(mutation: AgentHookStatusRowMutation): void { for (const listener of this.statusRowMutationListeners) { try { listener(mutation) @@ -115,6 +119,5 @@ export abstract class AgentHookServerRowOwnership extends AgentHookServerListene console.error('[agent-hooks] status-row mutation listener threw', error) } } - return true } } diff --git a/src/main/runtime/terminal/output/terminal-derived-agent-facts.ts b/src/main/runtime/terminal/output/terminal-derived-agent-facts.ts index 4a64e96c417..9d3d7ac4ff9 100644 --- a/src/main/runtime/terminal/output/terminal-derived-agent-facts.ts +++ b/src/main/runtime/terminal/output/terminal-derived-agent-facts.ts @@ -10,6 +10,12 @@ import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree/id' import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path' import type { ProcessedAgentStatusChunk } from '../../../../shared/agent-status-osc' import { mapExplicitAgentStateToRuntimeTerminalStatus } from '../../runtime-worktree-status-projection' +import type { RuntimeTerminalAgentStatusEvent } from '../../runtime-terminal-contracts' + +type TerminalAgentStatusTarget = Pick< + RuntimeTerminalAgentStatusEvent, + 'source' | 'paneKey' | 'tabId' | 'worktreeId' | 'connectionId' | 'terminalHandle' +> export abstract class OrcaRuntimeWithCreateTerminalSideEffectCommandCodeDetector extends OrcaRuntimeWithApplyTrackedPtyTitle { abstract getAgentStatusTerminalHandleForPaneKey(paneKey: string): string | undefined @@ -94,17 +100,41 @@ export abstract class OrcaRuntimeWithCreateTerminalSideEffectCommandCodeDetector if (chunk.payloads.length === 0) { return } - const targets = new Map< - string, - { - source: 'mounted-leaf' | 'pty-record' - paneKey: string - tabId?: string - worktreeId?: string - connectionId?: string | null - terminalHandle?: string + const targets = this.resolveTerminalAgentStatusTargets(ptyId) + for (const payload of chunk.payloads) { + // Why not gated on a listener: the prompt lifecycle is main's own state, read by + // terminal waits that run with no status consumer attached. + this.recordAgentPromptLifecycleState( + ptyId, + mapExplicitAgentStateToRuntimeTerminalStatus(payload.state) + ) + for (const target of targets) { + if (!this.onTerminalAgentStatus) { + continue + } + try { + this.onTerminalAgentStatus({ + ptyId, + ...target, + payload, + ...provenance + }) + } catch (err) { + console.error('[runtime] terminal agent status listener threw', { + ptyId, + paneKey: target.paneKey, + state: payload.state, + agentType: payload.agentType, + err + }) + } } - >() + } + } + + /** The panes a PTY's in-band status belongs to: its mounted leaves, else its own pane record. */ + private resolveTerminalAgentStatusTargets(ptyId: string): TerminalAgentStatusTarget[] { + const targets = new Map() const pty = this.ptysById.get(ptyId) const connectionId = pty?.connectionId ?? null for (const leaf of this.getLeavesForPty(ptyId)) { @@ -136,34 +166,6 @@ export abstract class OrcaRuntimeWithCreateTerminalSideEffectCommandCodeDetector } } } - for (const payload of chunk.payloads) { - // Why not gated on a listener: the prompt lifecycle is main's own state, read by - // terminal waits that run with no status consumer attached. - this.recordAgentPromptLifecycleState( - ptyId, - mapExplicitAgentStateToRuntimeTerminalStatus(payload.state) - ) - for (const target of targets.values()) { - if (!this.onTerminalAgentStatus) { - continue - } - try { - this.onTerminalAgentStatus({ - ptyId, - ...target, - payload, - ...provenance - }) - } catch (err) { - console.error('[runtime] terminal agent status listener threw', { - ptyId, - paneKey: target.paneKey, - state: payload.state, - agentType: payload.agentType, - err - }) - } - } - } + return [...targets.values()] } } diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index 321b68d483b..cd5b0027ca3 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -29,9 +29,9 @@ import { useTabStripPointerActivation } from './tab-strip-pointer-activation' import { TerminalTabLeadingIcon } from './TerminalTabLeadingIcon' import { isTerminalTabActivityLive, - resolveTerminalTabActivityStatus, terminalTabHasUnreadActivity } from './terminal-tab-activity-status' +import { useTerminalTabActivity } from './use-terminal-tab-activity' import { CLOSE_ALL_CONTEXT_MENUS_EVENT } from '@/lib/close-all-context-menus' import type { NativeChatTabSession } from './tab-session-history-switch' @@ -115,17 +115,7 @@ export default function SortableTab({ unreadAgentCompletionPanes: s.unreadAgentCompletionPanes }) ) - // Why: resolver returns a primitive so unrelated agent updates can't repaint this tab (pane bucketing memoized per snapshot). - const activityStatus = useAppStore((s) => - resolveTerminalTabActivityStatus({ - tab, - agentStatusByPaneKey: s.agentStatusByPaneKey, - agentStatusEpoch: s.agentStatusEpoch, - runtimePaneTitlesByTabId: s.runtimePaneTitlesByTabId, - ptyIdsByTabId: s.ptyIdsByTabId, - terminalLayout: s.terminalLayoutsByTabId?.[tab.id] - }) - ) + const { activityStatus } = useTerminalTabActivity(tab) // Why: shellOverride is stamped at create time, so changing the default shell later won't repaint existing tabs. const shellForIcon = tab.shellOverride diff --git a/src/renderer/src/components/tab-bar/use-terminal-tab-activity.ts b/src/renderer/src/components/tab-bar/use-terminal-tab-activity.ts new file mode 100644 index 00000000000..f169c8bb43d --- /dev/null +++ b/src/renderer/src/components/tab-bar/use-terminal-tab-activity.ts @@ -0,0 +1,28 @@ +import { useAppStore } from '../../store' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import { + resolveTerminalTabActivityStatus, + type TerminalTabActivityStatus +} from './terminal-tab-activity-status' + +type TerminalTabActivity = { + activityStatus: TerminalTabActivityStatus +} + +/** A terminal tab's status glyph, as a primitive so unrelated agent updates can't repaint it. */ +export function useTerminalTabActivity( + tab: Pick +): TerminalTabActivity { + // Pane bucketing is memoized per snapshot, so every mounted tab shares one pass. + const activityStatus = useAppStore((s) => + resolveTerminalTabActivityStatus({ + tab, + agentStatusByPaneKey: s.agentStatusByPaneKey, + agentStatusEpoch: s.agentStatusEpoch, + runtimePaneTitlesByTabId: s.runtimePaneTitlesByTabId, + ptyIdsByTabId: s.ptyIdsByTabId, + terminalLayout: s.terminalLayoutsByTabId?.[tab.id] + }) + ) + return { activityStatus } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/confirmed-shell-agent-title.ts b/src/renderer/src/components/terminal-pane/pty-connection/confirmed-shell-agent-title.ts new file mode 100644 index 00000000000..fb975a43cf0 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/confirmed-shell-agent-title.ts @@ -0,0 +1,26 @@ +import { useAppStore } from '@/store' +import { titleAfterConfirmedShell } from '../../../../../shared/terminal-pane-title-reset' +import type { ConnectPanePtySession } from './connect-pane-pty-session' + +// Why: a plain `codex`/`grok` sets its OSC title and the shell never repaints +// it on exit, so a confirmed return-to-shell must clear a title that still +// names an agent — otherwise the tab reads "grok" over a bare prompt. Only +// reset an agent-named title; user/shell-set titles are left untouched. +export function clearStaleAgentTabTitleOnConfirmedShell(session: ConnectPanePtySession): void { + const state = useAppStore.getState() + const currentTitle = state.runtimePaneTitlesByTabId?.[session.deps.tabId]?.[session.pane.id] + const tab = (state.tabsByWorktree[session.deps.worktreeId] ?? []).find( + (entry) => entry.id === session.deps.tabId + ) + const neutralTitle = titleAfterConfirmedShell( + currentTitle ?? tab?.title, + session.neutralTerminalTitle() + ) + if (neutralTitle === null) { + return + } + session.deps.setRuntimePaneTitle(session.deps.tabId, session.pane.id, neutralTitle) + if (session.manager.getActivePane()?.id === session.pane.id) { + session.deps.updateTabTitle(session.deps.tabId, neutralTitle) + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts index 14b7e28d784..4f7e9ebfa7e 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts @@ -13,7 +13,6 @@ import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspec import { dispatchTerminalCommandFinishedEvent } from '@/hooks/terminal-command-finished-event' import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' import type { TuiAgent } from '../../../../../shared/tui-agent' -import { titleAfterConfirmedShell } from '../../../../../shared/terminal-pane-title-reset' import { isTuiAgent, TUI_AGENT_CONFIG } from '../../../../../shared/tui-agent-config' import { @@ -22,6 +21,7 @@ import { type PaneLaunchAgentPaneSlice } from './pane-launch-agent-candidate' import type { ConnectPanePtySession } from './connect-pane-pty-session' +import { clearStaleAgentTabTitleOnConfirmedShell } from './confirmed-shell-agent-title' /** Pane agent identity, foreground-agent sampling, and command lifecycle handling. */ export function installPaneAgentIdentity(session: ConnectPanePtySession): void { @@ -71,28 +71,8 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void { isTuiAgent(registeredLaunchAgent) ) } - // Why: a plain `codex`/`grok` sets its OSC title and the shell never repaints - // it on exit, so a confirmed return-to-shell must clear a title that still - // names an agent — otherwise the tab reads "grok" over a bare prompt. Only - // reset an agent-named title; user/shell-set titles are left untouched. - session.clearStaleAgentTabTitleOnConfirmedShell = (): void => { - const state = useAppStore.getState() - const currentTitle = state.runtimePaneTitlesByTabId?.[session.deps.tabId]?.[session.pane.id] - const tab = (state.tabsByWorktree[session.deps.worktreeId] ?? []).find( - (entry) => entry.id === session.deps.tabId - ) - const neutralTitle = titleAfterConfirmedShell( - currentTitle ?? tab?.title, - session.neutralTerminalTitle() - ) - if (neutralTitle === null) { - return - } - session.deps.setRuntimePaneTitle(session.deps.tabId, session.pane.id, neutralTitle) - if (session.manager.getActivePane()?.id === session.pane.id) { - session.deps.updateTabTitle(session.deps.tabId, neutralTitle) - } - } + session.clearStaleAgentTabTitleOnConfirmedShell = () => + clearStaleAgentTabTitleOnConfirmedShell(session) session.deferredCommandFinishedStatusDrop = null session.deferredConfirmedShellReconcile = null /** The pane's foreground was proven to be a shell, so its agent exited. Unlike a user dismissal, diff --git a/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts b/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts index 6cafff23d50..530a5b86405 100644 --- a/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts +++ b/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts @@ -10,7 +10,7 @@ import { parsePaneKey } from '../../../../shared/stable-pane-id' import { resolveAgentStatusTerminalTitle } from '@/lib/agent-status-terminal-title' import { track } from '@/lib/telemetry' import { resolveAgentPaneAuthorityKey } from '@/store/slices/agent-pane-authority' -import type { AgentStatusBatchUpdate, AgentStatusUpdate } from '@/store/slices/agent-status' +import type { AgentStatusUpdate } from '@/store/slices/agent-status' import { observeAgentHookCompletionForNotification } from '../agent-hook-completion-notifications' import { useAppStore } from '../../store' import { @@ -35,6 +35,7 @@ import { normalizeAgentStatusEvent, normalizeAgentStatusMetadata } from './normalize-agent-status-event' +import { applyProviderSessionOnlyUpdate } from './agent-status-partial-updates' export function createAgentStatusEventApplicator(args: { pendingAgentStatusEvents: PendingAgentStatusEvent[] @@ -154,34 +155,17 @@ export function createAgentStatusEventApplicator(args: { return 'dropped' } if (data.providerSessionOnly) { - if (!data.providerSession || data.agentType !== 'pi') { - return 'dropped' - } - const providerSessionUpdate: AgentStatusBatchUpdate = { - kind: 'providerSession', + return applyProviderSessionOnlyUpdate({ + store, + batch: options?.batch, + data, paneKey, - agent: 'pi', - providerSession: data.providerSession, - timing: { updatedAt: data.receivedAt }, routing: { tabId: ownerTabId, worktreeId: data.worktreeId ?? owningWorktreeId, ...(ownershipConnectionId !== undefined ? { connectionId: ownershipConnectionId } : {}) - }, - metadata: data.launchToken ? { launchToken: data.launchToken } : undefined - } - if (options?.batch) { - return options.batch.transaction.apply(providerSessionUpdate) ? 'applied' : 'dropped' - } - store.recordAgentProviderSession( - providerSessionUpdate.paneKey, - providerSessionUpdate.agent, - providerSessionUpdate.providerSession, - providerSessionUpdate.timing, - providerSessionUpdate.routing, - providerSessionUpdate.metadata - ) - return 'applied' + } + }) } const resolvedPayload = resolveHookPayloadAgentType(payload, identityTitle ?? title) const statusPayload = data.orchestration diff --git a/src/renderer/src/hooks/ipc-events/agent-status-partial-updates.ts b/src/renderer/src/hooks/ipc-events/agent-status-partial-updates.ts new file mode 100644 index 00000000000..8f257352170 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/agent-status-partial-updates.ts @@ -0,0 +1,40 @@ +import type { AgentStatusIpcPayload } from '../../../../shared/agent-status-types' +import type { AppState } from '@/store/types' +import type { AgentProviderSessionUpdate } from '@/store/slices/agent-status' +import type { AgentStatusApplyResult, AgentStatusBatchContext } from './agent-status-bridge-types' + +// Events that patch one aspect of an already-routed row; they never create, re-time or notify one. + +export function applyProviderSessionOnlyUpdate(args: { + store: AppState + batch: AgentStatusBatchContext | undefined + data: AgentStatusIpcPayload + paneKey: string + routing: NonNullable +}): AgentStatusApplyResult { + const { store, batch, data, paneKey, routing } = args + if (!data.providerSession || data.agentType !== 'pi') { + return 'dropped' + } + const update: AgentProviderSessionUpdate = { + kind: 'providerSession', + paneKey, + agent: 'pi', + providerSession: data.providerSession, + timing: { updatedAt: data.receivedAt }, + routing, + metadata: data.launchToken ? { launchToken: data.launchToken } : undefined + } + if (batch) { + return batch.transaction.apply(update) ? 'applied' : 'dropped' + } + store.recordAgentProviderSession( + update.paneKey, + update.agent, + update.providerSession, + update.timing, + update.routing, + update.metadata + ) + return 'applied' +}