mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
refactor: extract helpers that agent-status files need room for (#30260)
No behavior change. Moves cohesive pieces out of files at their line limit so the OSC 7501 program-status work on top reads as additions: - pane-agent-identity: the confirmed-shell tab-title reset - SortableTab: its activity-status selector, into useTerminalTabActivity - agent-status-event-applicator: the Pi provider-session partial update - terminal-derived-agent-facts: resolving a PTY's status target panes - server-row-ownership: notifying row-mutation listeners
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, TerminalAgentStatusTarget>()
|
||||
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()]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<TerminalTab, 'id' | 'title' | 'launchAgent'>
|
||||
): 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 }
|
||||
}
|
||||
+26
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<AgentProviderSessionUpdate['routing']>
|
||||
}): 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'
|
||||
}
|
||||
Reference in New Issue
Block a user